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 编码。核心流程:
- 把地球看作一个二维平面,递归二分切成网格。
- 每个网格有一个唯一的二进制编码(geohash)。
- 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 值。
具体实现:
- 对每个元素计算 hash,得到一个 64 位的 bit 串。
- 用前若干位(比如前 14 位)决定存储桶的编号(共 2¹⁴ = 16384 个桶)。
- 在桶中记录该元素剩余 bit 位中”第一个 1 出现的位置”的最大值。
- 最终根据所有桶中的记录做调和平均,得到去重基数估计。
16384 个桶 × 每个桶 6 bit ≈ 12KB——加上一些元数据不到 16KB。
注意事项
| 特性 | 说明 |
|---|---|
| 不精确 | 误差 < 0.81%,百万级约差 8000 |
| 不可反查 | 只能查 UV,不能知道”有哪些用户” |
| 可合并 | 多天 HLL 可以直接 PFMERGE 得到总数,误差不会累积 |
| 小数据有偏差 | UV < 1000 时误差可能偏大,但绝对值也很小 |
四种场景总结
| 场景 | 数据结构 | 核心优势 | 注意事项 |
|---|---|---|---|
| 博客点赞 | Set + ZSet | O(1) 判断是否已赞,自然去重 | 单个 Set 元素过多会变 BigKey |
| 附近商家 | GEO(ZSet + geohash) | 运算在服务端完成,不需要外部计算引擎 | geohash 精度选择影响搜索速度 |
| 签到日历 | BitMap | 一个月签到仅占 4 字节 | 连续签到计算需要位运算 |
| UV 统计 | HyperLogLog | 百万 UV 仅占 16KB,误差可接受 | 不能反查具体用户,小 UV 误差偏大 |
从方案选择的角度看,这四个场景分别代表了 Redis 四种经典的能力:去重(Set)、空间索引(GEO)、位运算(BitMap)、概率统计(HLL)——每一样在其他存储系统中都不好做,但在 Redis 里只是一两个命令的事。