红茶的个人站点

  • 首页
  • 专栏
  • 开发工具
  • 其它
  • 隐私政策
Awalon
Talk is cheap,show me the code.
  1. 首页
  2. 未分类
  3. 正文

Redis 学习笔记 8:附近店铺

2025年5月12日 7点热度 0人点赞 0条评论

附近店铺

GEO

对于地理坐标(经纬度),Redis 以 GEO 的方式存储,可以通过以下命令查看相关命令:

127.0.0.1:6379> help @geo

添加 GEO 的命令为:

image-20250511143308333

示例,添加北京和南京两个地理位置:

127.0.0.1:6379> GEOADD geo1 116.4074 39.9042 beijing
(integer) 1
127.0.0.1:6379> GEOADD geo1 118.7969 32.0603 nanjing
(integer) 1

比较两个 GEO 坐标的距离可以用以下命令:

image-20250511143808649

示例,比较北京和南京的直线距离:

127.0.0.1:6379> GEODIST geo1 beijing nanjing km
"898.4595"

从 Redis 获取地理位置信息可以用以下命令:

image-20250511144122246

区别是 GEOHASH 会返回一个将经纬度哈希后的字符串,优点是节省存储空间,缺点是可读性不好。GEOPOS 则返回标准的经纬度信息。

示例:

127.0.0.1:6379> GEOHASH geo1 beijing
1) "wx4g0bm6c40"
127.0.0.1:6379> GEOPOS geo1 beijing
1) 1) "116.40740185976028442"
   2) "39.90420012463194865"

地理坐标最常见的应用场景是根据给定位置(通常是用户当前位置),指定一个范围(XX公里),返回在该范围内的地理坐标。比如点评应用最常见的离我最近的饭馆等等。

这个需求可以用以下命令实现:

image-20250511144508905

GEORADIUS 将返回给定坐标(longitude latitude)为圆心,指定距离(radius)为半径范围内的地理坐标。其中 WITHDIST 参数可以同时返回距离信息。COUNT 参数可以用于分页查询,返回前 N 个坐标。ASC DESC 可以用于按距离远近升序或降序排列后返回。STORE 参数可以将结果存储为 KEY-VALUE 结构。

需要注意的是,GEORADIUS 命令已经于 Radis 6.2 版本开始弃用,因此,从新版本开始应当使用以下命令:

image-20250511145216695

GEOSEARCH 与 GEORADIUS 不同的是,除了可以指定以圆心为半径的方式检索外,还可以以某个点作为矩形中心,指定矩形的长宽的方式划定局域进行检索。BYRADIUS 参数指定以圆形区域检索,BYBOX 参数指定以矩形区域检索。其它参数与 GEORADIUS 类似,不再重复说明。

导入店铺地理信息

编写测试用例将店铺的地理位置信息导入 Redis:

 /**
     * 导入店铺的地理位置信息到 Redis
     */
@Test
public void testImportGeoData() {
    // 检查是否已经存在旧数据
    Set<String> keys = stringRedisTemplate.keys(SHOP_GEO_KEY + "*");
    if (keys != null && (!keys.isEmpty())) {
        stringRedisTemplate.delete(keys);
    }
    // 获取店铺信息
    List<Shop> shops = shopService.list();
    // 将店铺按照类型分组
    Map<Long, List<Shop>> groupShops = shops.stream().collect(Collectors.groupingBy(Shop::getTypeId));
    // 按照分组导入 Redis
    GeoOperations<String, String> opsForGeo = stringRedisTemplate.opsForGeo();
    for (Map.Entry<Long, List<Shop>> entry : groupShops.entrySet()) {
        Long typeId = entry.getKey();
        List<Shop> subShops = entry.getValue();
        final String key = SHOP_GEO_KEY + typeId;
        for (Shop shop : subShops) {
            opsForGeo.add(key, new Point(shop.getX(), shop.getY()), shop.getId().toString());
        }
    }
}
  • 因为示例的店铺数据很少,因此采用一次性查询所有店铺信息并导入 Redis 的方式,如果数据量大,可能需要分批查询和导入。

  • 这里使用 Stream 流和 Collectors.groupingBy 收集器进行分组,代码更为简洁。

在这个示例应用中,前端需要按照店铺类型(美食/KTV等)查询离用户最近的商铺,因此这里需要将商铺先按照类型分组,再添加到 GEO 中。

再上边的示例中,每个商铺都会调用一次 GEOADD 进行添加,效率比较低,可以改为批量添加:

List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>(subShops.size());
for (Shop shop : subShops) {
    locations.add(new RedisGeoCommands.GeoLocation<>(shop.getId().toString(), new Point(shop.getX(), shop.getY())));
}
opsForGeo.add(key, locations);

附近商户

查询附近商户的前端调用为:

http://localhost:8080/api/shop/of/type?&typeId=1&current=1&x=120.149993&y=30.334229

其中包含以下参数:

  • typeId 上铺类型

  • current 用于分页(当前页码)

  • x,y 用户的经纬度信息(这里前端写死)

示例项目的 Redis 客户端版本较低,不支持 GEOSEARCH 调用,因此这里排除 Spring 自带的 Redis 客户端,改为指定版本的 Lettuce 客户端和 spring-data-redis API:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
    <exclusions>
        <exclusion>
            <artifactId>lettuce-core</artifactId>
            <groupId>io.lettuce</groupId>
        </exclusion>
        <exclusion>
            <artifactId>spring-data-redis</artifactId>
            <groupId>org.springframework.data</groupId>
        </exclusion>
    </exclusions>
</dependency>
<!-- https://mvnrepository.com/artifact/io.lettuce/lettuce-core -->
<dependency>
    <groupId>io.lettuce</groupId>
    <artifactId>lettuce-core</artifactId>
    <version>6.1.6.RELEASE</version>
</dependency>
<dependency>
    <groupId>org.springframework.data</groupId>
    <artifactId>spring-data-redis</artifactId>
    <version>2.6.2</version>
</dependency>

可以在 Idea 中安装一个 Maven Helper 插件,可以更方便的管理 Maven 依赖,比如排除某个依赖。

实现处理 GEO 信息的内部类:

// ...
public interface IShopService extends IService<Shop> {

	// ...
    /**
     * 商铺地理位置信息
     */
    class RedisShopGeo {
        private final String key;
        private final GeoOperations<String, String> opsForGeo;

        public RedisShopGeo(StringRedisTemplate stringRedisTemplate, long typeId) {
            this.key = SHOP_GEO_KEY + typeId;
            opsForGeo = stringRedisTemplate.opsForGeo();
        }

        /**
         * 分页获取商铺位置信息(按由近到远排列)
         *
         * @param x        当前位置的经度
         * @param y        当前位置的纬度
         * @param distance 查询距离范围
         * @param start    分页起始位置
         * @param limit    返回数据最大条数
         * @return 商铺位置信息列表
         */
        public List<GeoResult<RedisGeoCommands.GeoLocation<Long>>> searchShops(double x, double y, Distance distance, long start, int limit) {
            GeoReference<String> reference = new GeoReference.GeoCoordinateReference<>(x, y);
            RedisGeoCommands.GeoSearchCommandArgs args = RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs();
            args.limit(start + limit);
            args.sort(Sort.Direction.ASC); // 按距离升序排列
            args.includeDistance(); // 返回值包含距离信息
            GeoResults<RedisGeoCommands.GeoLocation<String>> geoResults = opsForGeo.search(key, reference, distance, args);
            // 逻辑分页
            List<GeoResult<RedisGeoCommands.GeoLocation<String>>> results = geoResults.getContent();
            if (results.size() <= start) {
                return Collections.emptyList();
            }
            // 只取最后 limit 个商铺位置信息
            List<GeoResult<RedisGeoCommands.GeoLocation<String>>> collect = results.stream()
                    .skip(start)
                    .collect(Collectors.toList());
            // 将 String 类型的 shopId 转换为 long
            List<GeoResult<RedisGeoCommands.GeoLocation<Long>>> longResults = new ArrayList<>(collect.size());
            for (GeoResult<RedisGeoCommands.GeoLocation<String>> result : collect) {
                RedisGeoCommands.GeoLocation<Long> content = new RedisGeoCommands.GeoLocation<>(Long.valueOf(result.getContent().getName()), result.getContent().getPoint());
                GeoResult<RedisGeoCommands.GeoLocation<Long>> longResult = new GeoResult<>(content, result.getDistance());
                longResults.add(longResult);
            }
            return longResults;
        }
    }
}
  • 代码最后的部分是为了将 String 类型的商铺 id 转换为 Long 类型,这部分不是必须的,这里是考虑到内部类本身是负责处理业务层和 Redis 调用之间的桥梁,因此业务层使用的实际类型和 Redis 使用的 String 类型转换都应当在这里完成。

  • GEOSEARCH 命令只支持一个 LIMIT 参数,即只能指定返回前 N 个元素,不能真正做到分页那样返回从 START 到 END 的元素。因此这里是在 Java 代码中逻辑分页,即获取前 N 个元素后删除掉一部分元素作为分页结果。

GEOSEARCH 调用返回的结构比较复杂,这里解释一下:

GeoResults:GEOSEARCH 查询到的结果

  • content:GeoResult 组成的列表

    • content:GeoLocation 类型

      • name:geo标识,这里是商铺id

      • point:坐标的经纬度信息

    • distance:与查询时给定参考坐标的直线距离

服务层实现:

@Override
public Result queryShopsByType(Integer typeId, Integer current, Double x, Double y) {
    if (x == null || y == null) {
        // 根据类型分页查询
        Page<Shop> page = query()
            .eq("type_id", typeId)
            .page(new Page<>(current, SystemConstants.DEFAULT_PAGE_SIZE));
        // 返回数据
        return Result.ok(page.getRecords());
    }
    // 按照地理坐标远近返回商铺信息
    final int pageSize = 5;
    final int start = (current - 1) * pageSize;
    RedisShopGeo redisShopGeo = new RedisShopGeo(stringRedisTemplate, typeId);
    // 默认查询 10km 范围内的商铺
    final Distance distance = new Distance(10, RedisGeoCommands.DistanceUnit.KILOMETERS);
    List<GeoResult<RedisGeoCommands.GeoLocation<Long>>> geoResults = redisShopGeo.searchShops(x, y, distance, start, pageSize);
    // 查询商铺详情
    List<Long> shopIds = geoResults.stream().map(gr -> gr.getContent().getName()).collect(Collectors.toList());
    String strIds = shopIds.stream().map(Object::toString).collect(Collectors.joining(","));
    String lastSql = String.format("order by field(id,%s)", strIds);
    List<Shop> shops = this.query().in("id", shopIds)
        .last(lastSql).list();
    Map<Long, Shop> shopMap = new HashMap<>();
    for (Shop shop : shops) {
        shopMap.put(shop.getId(), shop);
    }
    // 遍历 geo 查询结果,将距离信息填充到店铺详情
    for (GeoResult<RedisGeoCommands.GeoLocation<Long>> geoResult : geoResults) {
        // 获取店铺id
        Long shopId = geoResult.getContent().getName();
        // 获取距离信息
        Distance distance1 = geoResult.getDistance();
        // 填充距离信息(以米为单位)
        double meters = distance1.in(RedisGeoCommands.DistanceUnit.METERS).getValue();
        shopMap.get(shopId).setDistance(meters);
    }
    return Result.ok(shops);
}

签到

可以使用31个比特位存储某个用户某一月的签到记录:

image-20250511181246830

这样做的好处是最大限度节省空间。对应的,在 Redis 中有一种数据结构 BitMap,对应这种 bit 位数据结构。

SETBIT命令可以设置 BitMap 的指定 bit 位:

image-20250511181643032

示例:

127.0.0.1:6379> SETBIT bm1 3 1
(integer) 0

用图形化客户端查看:

image-20250511181855457

这里的偏移量(offset)是3,所以第四个bit位被设置为1,其它没有被设置过的 bit 位默认为 0。

注意,需要确保查看值的方式是 Binary,否则看到的可能是乱码。

可以用GETBIT命令查看 BitMap 的指定 bit 位的值:

image-20250511182200064

示例:

127.0.0.1:6379> GETBIT bm1 3
(integer) 1
127.0.0.1:6379> GETBIT bm1 2
(integer) 0

可以用BITCOUNT命令统计 BitMap 有多少位 bit 位为1:

image-20250511182355737

示例:

127.0.0.1:6379> BITCOUNT bm1
(integer) 1

可以用BITPOS命令获取第一个是 1 或 0 的比特位的偏移量:

image-20250511182601995

示例:

127.0.0.1:6379> BITPOS bm1 1
(integer) 3

BITFIELD命令有很多用途,但通常用它来获取整个 BitMap 的值:

image-20250511182852217

示例:

127.0.0.1:6379> BITFIELD bm1 GET u1 0
1) (integer) 0
127.0.0.1:6379> BITFIELD bm1 GET u2 0
1) (integer) 0
127.0.0.1:6379> BITFIELD bm1 GET u3 0
1) (integer) 0
127.0.0.1:6379> BITFIELD bm1 GET u4 0
1) (integer) 1
127.0.0.1:6379> BITFIELD bm1 GET u5 0
1) (integer) 2

该命令返回的结果是十进制。type 参数指定返回值的格式,比如 u1 表示无符号长度为1的值(i表示有符号)。这里 BitMap 实际上是00010000,因此 u4 返回的是0001的十进制表示,也就是1,而 u5 返回的是00010,也就是2。

封装签到相关功能:

// ...
public class RedisSign {

    private final ValueOperations<String, String> opsForValue;
    private final StringRedisTemplate stringRedisTemplate;

    public RedisSign(StringRedisTemplate stringRedisTemplate) {
        opsForValue = stringRedisTemplate.opsForValue();
        this.stringRedisTemplate = stringRedisTemplate;
    }

    /**
     * 签到
     *
     * @param userId 用户id
     * @param date   日期
     * @return 成功/失败
     */
    public boolean sign(long userId, LocalDate date) {
        final String key = getKey(userId, date);
        // 判断给定日期是所在月份的第几天
        int dayOfMonth = date.getDayOfMonth();
        Boolean result = opsForValue.setBit(key, dayOfMonth - 1, true);
        return BooleanUtil.isTrue(result);
    }

    /**
     * 返回用于记录单个用户指定月的签到记录的 key,类似于 user:sign:1001:200501
     *
     * @param userId 用户id
     * @param date   日期
     * @return key
     */
    private String getKey(long userId, LocalDate date) {
        YearMonth yearMonth = YearMonth.of(date.getYear(), date.getMonth());
        return getKey(userId, yearMonth);
    }

    private String getKey(long userId, YearMonth yearMonth) {
        String monthStr = yearMonth.format(DateTimeFormatter.ofPattern("yyyyMM"));
        return String.format("%s%d:%s", SIGN_KEY, userId, monthStr);
    }

    /**
     * 用户是否在某一天签到过
     *
     * @param userId 用户id
     * @param date   指定日期
     * @return 是否签到过
     */
    public boolean isSigned(long userId, LocalDate date) {
        String key = getKey(userId, date);
        // 判断记录签到的 BitMap 是否存在
        Long count = stringRedisTemplate.countExistingKeys(Collections.singletonList(key));
        if (count == null || count <= 0) {
            // 不存在,视作没有签到过
            return false;
        }
        Boolean bit = opsForValue.getBit(key, date.getDayOfMonth() - 1);
        return BooleanUtil.isTrue(bit);
    }

    /**
     * 获取用户的月度签到记录
     *
     * @param userId    用户id
     * @param yearMonth 指定月
     * @return 月度签到记录
     */
    public Map<LocalDate, Boolean> getSignRecord(long userId, YearMonth yearMonth) {
        // 判断指定月份有多少天
        int lengthOfMonth = yearMonth.lengthOfMonth();
        // 获取签到信息
        final String key = getKey(userId, yearMonth);
        // 读取无符号正月数据,比如 u31
        BitFieldSubCommands.BitFieldType type = BitFieldSubCommands.BitFieldType.unsigned(lengthOfMonth);
        BitFieldSubCommands subCommands = BitFieldSubCommands.create().get(type).valueAt(0);
        List<Long> longList = opsForValue.bitField(key, subCommands);
        // 月度签到记录
        Map<LocalDate, Boolean> record = new HashMap<>();
        // 初始化
        for (int i = 1; i <= lengthOfMonth; i++) {
            LocalDate date = LocalDate.of(yearMonth.getYear(), yearMonth.getMonth(), i);
            record.put(date, Boolean.FALSE);
        }
        if (longList == null || longList.isEmpty()) {
            return record;
        }
        Long decimal = longList.get(0);
        if (decimal == null || decimal == 0) {
            return record;
        }
        // 按bit位从低到高遍历
        for (int i = lengthOfMonth; i >= 1; i--) {
            if ((decimal & 1) == 1) {
                // 当前bit位是1,说明当天签到了
                LocalDate date = LocalDate.of(yearMonth.getYear(), yearMonth.getMonth(), i);
                record.put(date, Boolean.TRUE);
            }
            // 十进制数右移一位
            decimal = decimal >>> 1;
        }
        return record;
    }

    /**
     * 返回用户已经连续签到的天数
     *
     * @param userId 用户 id
     * @return 已经连续签到的天数
     */
    public int getContinueSignDays(long userId) {
        // 获取当月签到记录
        final LocalDate now = LocalDate.now();
        Map<LocalDate, Boolean> signRecord = this.getSignRecord(userId, YearMonth.of(now.getYear(), now.getMonth()));
        // 从当前天向前遍历统计
        int signedDays = 0;
        LocalDate currentDate = now;
        do {
            Month currentMonth = currentDate.getMonth();
            do {
                Boolean isSigned = signRecord.get(currentDate);
                if (BooleanUtil.isTrue(isSigned)) {
                    signedDays++;
                } else {
                    break;
                }
                currentDate = currentDate.minusDays(1);
            }
            while (currentDate.getMonth().equals(currentMonth));
            if (currentMonth.equals(currentDate.getMonth())){
                // 连续签到没有跨月
                break;
            }
            // 连续签到跨月,重新获取所在月的签到记录
            signRecord = this.getSignRecord(userId, YearMonth.of(currentDate.getYear(), currentDate.getMonth()));
        }
        while (true);
        return signedDays;
    }
}

spring-data-redis 的 API 并没有类似 OpsForBitMap 这样的操作集合,因为 Redis 的 BitMap 类型底层用 String 实现,因此相关操作在 spring-data-redis 中被定义在 OpsForValue 中。

调用示例见这里。

UV 统计

有时候我们需要对网站的网络访问做统计,通常包含两个维度:

  • UV:全称Unique Visitor,也叫独立访客量,是指通过互联网访问、浏览这个网页的自然人。1天内同一个用户多次访问该网站,只记录1次。

  • PV:全称Page View,也叫页面访问量或点击量,用户每访问网站的一个页面,记录1次PV,用户多次打开页面,则记录多次PV。往往用来衡量网站的流量。

因为 UV 的统计需要记录用户是否当天已经访问过,如果用户量巨大(10w+),将这种记录存储就会消耗大量内存。可以利用 Redis 的 HyperLogLog 来记录这些信息并完成统计。

HyperLogLog

Hyperloglog(HLL)是从Loglog算法派生的概率算法,用于确定非常大的集合的基数,而不需要存储其所有值。相关算法原理大家可以参考:https://juejin.cn/post/6844903785744056333#heading-0 Redis中的HLL是基于string结构实现的,单个HLL的内存永远小于16kb,内存占用低的令人发指!作为代价,其测量结果是概率性的,有小于0.81%的误差。不过对于UV统计来说,这完全可以忽略。

HyperLogLog 主要包含以下命令:

image-20250512104558550

  • PFADD 添加指定元素

  • PFCOUNT 统计 HyperLogLog 中的(不重复的)元素个数

  • PFMERGE 将多个 HyperLogLog 进行合并

编写一个测试用例插入 100w 条数据:

@Test
public void testHyperLogLog() {
    // 模拟100w条数据的插入
    final int ALL_COUNT = 1000000; // 总插入数据数
    final int COUNT = 1000; // 单次插入数据数
    final String KEY = "test:hll2";
    int userId = 1;
    do {
        // 分批插入,每次插入1000条
        String[] values = new String[COUNT];
        for (int i = 0; i < COUNT; i++) {
            values[i] = "user" + userId;
            userId++;
        }
        stringRedisTemplate.opsForHyperLogLog().add(KEY, values);
    }
    while (userId <= ALL_COUNT);
    // 打印结果
    Long size = stringRedisTemplate.opsForHyperLogLog().size(KEY);
    log.info(size);
}

实际的执行结果并不是 100w,有 1% 左右的误差。

对比插入前后的 Redis 内存占用情况:

image-20250512110657470

image-20250512110701841

可以看到内存占用是很少的。

实现

利用 HyperLogLog 实现 UV 统计:

// ...
public class RedisUV {

    private final HyperLogLogOperations<String, String> opsForHyperLogLog;
    private final StringRedisTemplate stringRedisTemplate;

    public RedisUV(StringRedisTemplate stringRedisTemplate) {
        opsForHyperLogLog = stringRedisTemplate.opsForHyperLogLog();
        this.stringRedisTemplate = stringRedisTemplate;
    }

    /**
     * 记录一次用户请求
     *
     * @param userId 用户id
     */
    public void recordUserCall(long userId) {
        // 获取当天记录 uv 的 HyperLogLog 的key
        final String KEY = getKey(LocalDateTime.now().toLocalDate());
        opsForHyperLogLog.add(KEY, Long.toString(userId));
    }

    /**
     * 获取指定日期对应的 HyperLogLog 的 key
     *
     * @param date 日期
     * @return key
     */
    private String getKey(LocalDate date) {
        String dateStr = date.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
        return UV_KEY + dateStr;
    }

    /**
     * 获取指定日期的 uv
     *
     * @param date 日期
     * @return uv
     */
    public long getUV(LocalDate date) {
        final String KEY = getKey(date);
        // 不需要考虑染回值是 null 的情况,缺少 key 时自动返回 0
        return opsForHyperLogLog.size(KEY);
    }

    /**
     * 统计指定月份的 uv 之和
     *
     * @param yearMonth 指定月份
     * @return uv 之和
     */
    public long getUV(YearMonth yearMonth) {
        // 利用模糊查询获取相关 HyperLogLog 的 key
        final String PATTERN = UV_KEY + yearMonth.format(DateTimeFormatter.ofPattern("yyyy:MM")) + ":*";
        Set<String> keys = stringRedisTemplate.keys(PATTERN);
        if (keys == null || keys.isEmpty()) {
            return 0;
        }
        // 合计
        long allSize = 0;
        for (String key : keys) {
            Long size = opsForHyperLogLog.size(key);
            allSize += size;
        }
        return allSize;
    }
}

调用示例可以看这里。

本文的完整示例代码可以从这里获取。

The End.

参考资料

  • 黑马程序员Redis入门到实战教程

本作品采用 知识共享署名 4.0 国际许可协议 进行许可
标签: geo HyperLogLog redis uv
最后更新:2025年5月12日

魔芋红茶

加一点PHP,加一点Go,加一点Python......

点赞
< 上一篇

文章评论

取消回复

*

code

COPYRIGHT © 2021 icexmoon.cn. ALL RIGHTS RESERVED.
本网站由提供CDN加速/云存储服务

Theme Kratos Made By Seaton Jiang

宁ICP备2021001508号

宁公网安备64040202000141号