主题
Redis 高级数据类型:Bitmap、HyperLogLog 与 GEO 的原理与业务场景
为什么需要高级数据类型
Redis 的五种基本类型(String、Hash、List、Set、ZSet)能解决大部分缓存场景,但遇到"亿级用户签到统计""UV 去重""附近的人"这类需求时,直接用基本类型要么内存爆炸,要么性能不够。BitMap、HyperLogLog、GEO 就是 Redis 针对这三个典型场景给出的专用解法。面试也常把它们串成一套题:"Redis 有哪些数据结构?UV 怎么统计?附近的人怎么实现?"
Bitmap:位图并非独立类型,而是 String 的位操作
本质
Bitmap 不是 Redis 的独立数据类型,它底层就是 String(即 SDS),只不过 Redis 在 String 上暴露了一套位级操作接口。一个 String 最长 512MB,所以 Bitmap 最多能存 2^32 个位(512MB × 8 位)。
核心命令
| 命令 | 作用 | 复杂度 |
|---|---|---|
SETBIT key offset value | 将第 offset 位设为 0/1 | O(1) |
GETBIT key offset | 获取第 offset 位的值 | O(1) |
BITCOUNT key [start end] | 统计 1 的个数 | O(n) |
BITOP op dest key [key ...] | 位运算(AND/OR/XOR/NOT) | O(n) |
BITPOS key bit [start end] | 查找第一个 0 或 1 的位置 | O(n) |
业务场景
用户签到
java
// 签到:用户 1001 在 2026-09-09 签到
// 用当天的第几天作为 offset,比如 9 月 9 日 = 一年中第 252 天
jedis.setbit("sign:2026:1001", 251, 1); // offset 从 0 开始
// 查询今天是否签到
boolean signed = jedis.getbit("sign:2026:1001", 251);
// 统计本月签到天数
// 假设 9 月,offset 范围 243-272(9 月 1 日 = 第 243 天)
long count = jedis.bitcount("sign:2026:1001", 243, 272);
// 注意:BITCOUNT 的 start/end 是字节范围,不是位范围,需要换算
// 243 位 ÷ 8 = 第 30 字节起,272 位 ÷ 8 = 第 34 字节止字节偏移这个坑容易踩——BITCOUNT 的 start/end 是字节索引(byte offset),不是位索引。要统计 243~272 位,需要算 243/8=30.375 取整 30,272/8=34,结果是粗略近似。精确统计需要自己切片或配合 BITFIELD。
亿级用户在线状态
一个用户占 1 位,1 亿用户 ≈ 12MB(1 亿 bit ÷ 8 ÷ 1024 ÷ 1024 ≈ 11.9MB)。内存成本极低,单机就能存下全量用户状态。
java
// 用户上线
jedis.setbit("online:20260909", userId, 1);
// 用户下线
jedis.setbit("online:20260909", userId, 0);
// 统计当天在线人数
long onlineCount = jedis.bitcount("online:20260909");
// 连续 7 天都在线的用户
jedis.bitop(BitOP.AND, "online:7days", "online:20260903", "online:20260904",
"online:20260905", "online:20260906", "online:20260907", "online:20260908", "online:20260909");什么时候别用 Bitmap:用户 ID 稀疏的场景。如果你的用户 ID 是 UUID 或自增步长不连续,用 Bitmap 会浪费大量空间。比如只有 100 个活跃用户,但 ID 分布到 1 亿号段,那 1 亿位数组里只有 100 个 1,内存浪费 99.9999%。这时应该用 Set 或 HyperLogLog。
HyperLogLog:12KB 搞定亿级 UV
原理一句话
HyperLogLog 是概率性数据结构,用调和平均 + 分桶估算基数。核心思想:一个好的哈希函数输出分布均匀,那么"哈希值二进制表示中前缀 0 的个数"服从伯努利分布。统计每个桶里最大前缀 0 长度,再取调和平均,就能以 0.81% 的标准误差估算出总基数。
空间固定
无论你往里面塞了 1 个元素还是 10 亿个元素,HyperLogLog 始终占用 12KB(16384 个桶 × 6 位)。这是它最迷人的特性——内存固定。
核心命令
bash
PFADD uv:20260909 user:1001 user:1002 user:1003
PFCOUNT uv:20260909 # 返回近似基数
PFMERGE uv:total uv:20260908 uv:20260909 # 合并多天的 UVJava 使用
java
// 记录一次 UV
jedis.pfadd("uv:page:home", "user_1001", "user_1002");
// 获取 UV 统计
long uv = jedis.pfcount("uv:page:home");
// 合并多日 UV(去重)
jedis.pfmerge("uv:total", "uv:day1", "uv:day2", "uv:day3");选型对比
| 方案 | 内存 | 精确度 | 查询复杂度 | 适合场景 |
|---|---|---|---|---|
| Set | 高(1 亿个 ID ≈ 800MB) | 精确 | O(1) | 小规模精确去重 |
| Bitmap | 低(1 亿位 ≈ 12MB) | 精确 | O(n) | ID 连续的全量位图 |
| HyperLogLog | 极低(固定 12KB) | 近似(0.81% 误差) | O(1) | 亿级 UV,接受误差 |
什么时候别用 HyperLogLog:
- 需要精确去重(比如实际用户数不能差 0.81%)→ 用 Set 或 Bitmap
- 做标签、交集、并集需要精确结果 → HyperLogLog 的 PFMERGE 只是近似合并
- 需要判断"某个元素是否存在" → HyperLogLog 不支持元素查询,用布隆过滤器
GEO:附近的人功能的数据基础
原理
GEO 底层就是一个 ZSet(有序集合)。Redis 将经纬度通过GeoHash 算法编码成一个 52 位的整数,作为 ZSet 的 score。
GeoHash 做法:把地球平面二分,经度范围 [-180, 180] 二分得到 0/1,纬度范围 [-90, 90] 二分得到 0/1,交替编码——经度取一位、纬度取一位,依次类推。编码越长,位置越精确(约 5 位 = 4.9km 精度,12 位 ≈ 几厘米)。最后把这个二进制串 Base32 编码成字符串,就是 GeoHash 字符串。
关键问题:GeoHash 的边界上,两个很近的点可能落在不同网格,编码前缀完全不同。所以 Redis 在做 GEORADIUS 时,查询目标网格的同时会查周围 8 个邻域网格,合并结果再按距离排序。
核心命令
bash
# 添加门店位置
GEOADD shops:beijing 116.397128 39.916527 "shop:1001"
GEOADD shops:beijing 116.407128 39.926527 "shop:1002"
# 查询附近门店(返回 5km 内的门店)
GEORADIUS shops:beijing 116.400000 39.920000 5 km WITHDIST WITHCOORD ASC
# 计算两个门店距离
GEODIST shops:beijing "shop:1001" "shop:1002" km
# 获取门店经纬度
GEOPOS shops:beijing "shop:1001"Java 实现附近的人
java
// 添加用户位置
Map<String, Object> memberMap = new HashMap<>();
memberMap.put("user:1001", new Point(116.397128, 39.916527));
jedis.geoadd("users:nearby", memberMap);
// 查询附近 3km 的人
GeoRadiusParam param = GeoRadiusParam.geoRadiusParam()
.withDist() // 返回距离
.withCoord() // 返回坐标
.sortAscending() // 按距离升序
.count(50); // 最多返回 50 个
List<GeoRadiusResponse> results = jedis.georadius(
"users:nearby", // key
116.400000, 39.920000, // 中心点
3, GeoUnit.KM, // 半径 3km
param
);
for (GeoRadiusResponse r : results) {
System.out.println(r.getMemberByString() + " - " + r.getDistance() + "km");
}GEO 的坑
- GEOADD 内部是 ZSet,所以 ZSet 的通用命令也可以操作(
ZREM删除、ZRANGE遍历),但别乱用,Redis 6.2 以后有GEOADD专门支持多元素批量添加。 - Redis 6.2 之前一次只能加一个成员,6.2 之后支持
GEOADD key [NX|XX|CH] longitude latitude member [longitude latitude member ...]。 - GEORADIUS 在 6.2 之后标记为废弃,改用
GEORADIUS_RO(只读)和GEOSEARCH。哨兵/集群模式下,只读命令可以走副本,降低主库压力。 - 精度问题:极地附近(纬度靠近 90°)GeoHash 编码会失真,但国内业务几乎不受影响。
什么时候别用 GEO:需要存储完整的地理围栏形状(多边形、路径)时,GEO 只支持点级别,不支持多边形判断。这时可以用 Redis 的 GEOADD 配合 GEORADIUSBYMEMBER 做近似,但严格来说需要引入 PostGIS 或 Elasticsearch 的地理功能。
总结
| 类型 | 解决什么问题 | 本质 | 空间 | 注意 |
|---|---|---|---|---|
| Bitmap | 签到、在线状态、布尔统计 | String 的位操作 | 1 亿位 ≈ 12MB | 稀疏 ID 别用,BITCOUNT 字节偏移 |
| HyperLogLog | 亿级 UV 去重 | 概率性基数估算 | 固定 12KB,无关数据量 | 0.81% 误差,不支持元素查询 |
| GEO | 附近的人、门店 | ZSet + GeoHash 编码 | 与 ZSet 相同 | 边界跳变,6.2 后 GEORADIUS_RO |
面试时不要只背命令,能结合场景讲清楚"为什么选它不选别的"才是区分度所在。