2026年7月12日 · 7 分钟阅读

Redis 实战模式:GEO 附近搜索、BitMap 签到、HyperLogLog UV 与博客点赞

覆盖四种高频业务场景的 Redis 实现方案:博客点赞的 Set 去重与 TopN、GEO 坐标搜索的九宫格原理、BitMap 连续签到计算、HyperLogLog 的 UV 统计与误差分析。

原理讲完总要落得了地。这一篇专门整理四种高频业务场景的 Redis 实现,覆盖 PDF 课件中”实战”一章的全部内容,每一种都给出完整方案和核心命令。


场景一:博客点赞

需求

  • 同一个用户只能点赞一次,再次点击则取消点赞。
  • 如果当前用户已经点赞,点赞按钮高亮。
  • 展示点赞总数。
  • 展示点赞 Top3 的用户信息。

方案:Set

用 Set 实现自然去重——SADD 添加,SREM 取消,SCARD 获取总数,SMEMBERS 遍历。

public class BlogLikeService {
    private static final String LIKE_KEY_PREFIX = "blog:like:";

    // 点赞/取消点赞(切换)
    public boolean toggleLike(Long blogId, Long userId) {
        String key = LIKE_KEY_PREFIX + blogId;
        Boolean isMember = jedis.sismember(key, userId.toString());
        if (isMember) {
            jedis.srem(key, userId.toString());  // 取消点赞
            return false;
        } else {
            jedis.sadd(key, userId.toString());  // 点赞
            return true;
        }
    }

    // 用户是否已点赞
    public boolean isLiked(Long blogId, Long userId) {
        return jedis.sismember(LIKE_KEY_PREFIX + blogId, userId.toString());
    }

    // 点赞总数
    public Long likeCount(Long blogId) {
        return jedis.scard(LIKE_KEY_PREFIX + blogId);
    }

    // 点赞 Top3
    public Set<String> topLikes(Long blogId) {
        // 这里如果要有 Top3,可以在写入时用 ZSet 维护一个全局排行榜
        return jedis.smembers(LIKE_KEY_PREFIX + blogId);
    }
}

如果还需要全局点赞排行榜(所有博客按点赞数排序),可以用 ZSet:

// 点赞时同时维护排行榜
jedis.sadd("blog:like:" + blogId, userId.toString());
jedis.zincrby("blog:rank", 1, blogId.toString());

// 取消时
jedis.srem("blog:like:" + blogId, userId.toString());
jedis.zincrby("blog:rank", -1, blogId.toString());

// 获取 Top3
Set<String> top3 = jedis.zrevrange("blog:rank", 0, 2);

场景二:附近的商店(GEO)

需求

  • 用户打开地图,加载方圆 5 公里内的店铺。
  • 按与用户的距离排序。
  • 支持实时刷新。

方案:GEO

Redis 从 3.2 版本开始支持 GEO(Geolocation)。底层其实是用 ZSet 实现的,score 是 geohash 编码。

核心命令

命令说明
GEOADD key lng lat member添加一个位置的坐标
GEODIST key member1 member2 [unit]返回两个位置之间的距离
GEOPOS key member返回指定成员的经纬度
GEOHASH key member返回 geohash 字符串
GEORADIUS key lng lat radius [unit]以给定坐标找附近元素(6.2 已废弃)
GEOSEARCH key FROMLONLAT lng lat BYRADIUS radius [unit]6.2 新接口,推荐使用
GEOSEARCHSTORE将搜索结果写入新 key

业务实现

public class NearbyShopService {
    private static final String GEO_KEY = "shop:geo";

    // 添加店铺坐标
    public void addShop(Long shopId, double lng, double lat) {
        jedis.geoadd(GEO_KEY, lng, lat, shopId.toString());
    }

    // 查找附近店铺
    public List<Map<String, Object>> searchNearby(double lng, double lat, int radiusKm) {
        GeoRadiusParam param = GeoRadiusParam.geoRadiusParam()
            .withDist()     // 返回距离
            .withCoord()    // 返回坐标
            .sortAscending(); // 按距离升序

        List<GeoRadiusResponse> results = jedis.georadius(
            GEO_KEY, lng, lat, radiusKm, GeoUnit.KM, param);

        List<Map<String, Object>> list = new ArrayList<>();
        for (GeoRadiusResponse res : results) {
            Map<String, Object> item = new HashMap<>();
            item.put("shopId", res.getMemberByString());
            item.put("distance", res.getDistance());
            list.add(item);
        }
        return list;
    }
}

附近搜索的底层原理

GEO 搜索基于 geohash 编码。核心流程:

  1. 把地球看作一个二维平面,递归二分切成网格。
  2. 每个网格有一个唯一的二进制编码(geohash)。
  3. geohash 编码越长的两个点,在 ZSet 中的 score 越接近,物理距离也越近。

搜索时,Redis 的做法是:

1. 根据搜索半径计算需要几级网格
2. 确定九宫格(中心 + 周围 8 个网格)的 geohash 范围
3. 在 ZSet 中通过 ZRANGEBYSCORE 快速取出候选点
4. 逐一计算候选点与中心点的真实距离,过滤出真正在半径内的
5. 按距离排序返回

之所以取九宫格而不是一个网格,是因为一个网格内不包含所有相邻点(靠近网格边界的点可能落在相邻网格中)。

6.2 后的新命令

GEORADIUS 在 Redis 6.2 已标记为废弃,推荐使用 GEOSEARCH

GEOSEARCH shop:geo FROMLONLAT 116.397 39.908 BYRADIUS 5 KM WITHDIST ASC

语义更清晰,且支持矩形范围搜索(BYBOX)。


场景三:用户签到

需求

  • 用户每天可以签到一次。
  • 查看用户某月的签到日历。
  • 计算连续签到天数。

方案:BitMap

BitMap 本质是 String 类型,把每个 bit 位映射到某一天。

一月最多 31 天,一个月只需要 31 bit(不到 4 字节)。即使一年 365 天也只需要 46 字节。比存日期字符串省两个数量级

核心命令

SETBIT key offset value    # 设置某位的值(0 或 1)
GETBIT key offset          # 获取某位的值
BITCOUNT key [start end]   # 统计值为 1 的位数
BITFIELD key GET type offset  # 操作 bit 数组
BITFIELD key SET type offset value
BITPOS key bit [start end] # 查找第一个 0 或 1 的位置

业务实现

# 用户 1001 在 2026 年 7 月第 5 天签到
SETBIT sign:1001:202607 4 1

# 查询是否签到
GETBIT sign:1001:202607 4

连续签到天数

先拿到本月到今天的全部签到数据:

BITFIELD sign:1001:202607 GET u31 0

然后从今天开始向后遍历每个 bit:

def consecutive_sign_days(key, today):
    # 获取本月所有签到 bit
    result = redis.execute_command("BITFIELD", key, "GET", f"u{today}", "0")
    bits = result[0]  # 得到一个整数
    days = 0
    # 从最后一天往前遍历
    for _ in range(today):
        if bits & 1:  # 最后一位是否为 1
            days += 1
            bits >>= 1  # 右移一位,下一位成为最后一位
        else:
            break
    return days

批量签到统计

如果需要统计一个部门/小组的总体签到率,可以用 BITOP

# 对多个用户的 BitMap 做 OR 操作
BITOP OR team:sign:202607 sign:1001:202607 sign:1002:202607 sign:1003:202607

# 统计团队总签到天数
BITCOUNT team:sign:202607

场景四:UV 统计

需求

  • 统计页面的独立访客数(UV)。
  • 同一个用户一天内多次访问只计一次。
  • 页面访问量(PV)用普通计数器即可,这里只讨论 UV。

方案:HyperLogLog(HLL)

如果用 Set 存储所有 UV,一个日活百万的页面,每天要存一百万个用户 ID——至少几十 MB,还不好做统计汇总。

HyperLogLog 提供了一种概率近似方案:

  • 每个 HLL 固定占用 不到 16KB
  • 标准误差 ≈ 0.81%(百万级 UV 误差约 ±8100,对业务完全可以接受)。
  • 不支持去重后反向查询具体是哪些用户(只能告诉你”大概有多少人”)。

核心命令

PFADD key element [element ...]   # 添加元素(去重统计)
PFCOUNT key [key ...]             # 返回基数估算值
PFMERGE destkey source [source ...] # 合并多个 HLL

业务实现

public class UVService {
    // 记录一次访问
    public void recordUV(String pageId, String userId) {
        jedis.pfadd("uv:" + pageId + ":" + today(), userId);
    }

    // 获取 UV
    public long getUV(String pageId) {
        return jedis.pfcount("uv:" + pageId + ":" + today());
    }

    // 获取本周总 UV(合并 7 天的 HLL)
    public long getWeeklyUV(String pageId) {
        String[] keys = weeklyKeys(pageId);
        return jedis.pfcount(keys);
    }

    // 合并多天 HLL 到月度汇总
    public void mergeToMonth(String pageId) {
        String[] days = monthDays(pageId);
        jedis.pfmerge("uv:" + pageId + ":month", days);
    }
}

HyperLogLog 的底层原理

HLL 基于概率统计。核心思路来自一个掷硬币的观察:在一串随机的 bit 序列中,最长的连续 0 前缀长度 ≈ 去重基数的 log2 值

具体实现:

  1. 对每个元素计算 hash,得到一个 64 位的 bit 串。
  2. 用前若干位(比如前 14 位)决定存储桶的编号(共 2¹⁴ = 16384 个桶)。
  3. 在桶中记录该元素剩余 bit 位中”第一个 1 出现的位置”的最大值。
  4. 最终根据所有桶中的记录做调和平均,得到去重基数估计。

16384 个桶 × 每个桶 6 bit ≈ 12KB——加上一些元数据不到 16KB。

注意事项

特性说明
不精确误差 < 0.81%,百万级约差 8000
不可反查只能查 UV,不能知道”有哪些用户”
可合并多天 HLL 可以直接 PFMERGE 得到总数,误差不会累积
小数据有偏差UV < 1000 时误差可能偏大,但绝对值也很小

四种场景总结

场景数据结构核心优势注意事项
博客点赞Set + ZSetO(1) 判断是否已赞,自然去重单个 Set 元素过多会变 BigKey
附近商家GEO(ZSet + geohash)运算在服务端完成,不需要外部计算引擎geohash 精度选择影响搜索速度
签到日历BitMap一个月签到仅占 4 字节连续签到计算需要位运算
UV 统计HyperLogLog百万 UV 仅占 16KB,误差可接受不能反查具体用户,小 UV 误差偏大

从方案选择的角度看,这四个场景分别代表了 Redis 四种经典的能力:去重(Set)、空间索引(GEO)、位运算(BitMap)、概率统计(HLL)——每一样在其他存储系统中都不好做,但在 Redis 里只是一两个命令的事。