签到、统计 UV、找附近的人,三个需求看起来风马牛不相及,Redis 各给了一个专门的结构:Bitmap、HyperLogLog、GEO。
但它们其实都不是新类型。用 TYPE 看一眼:
127.0.0.1:6379> TYPE sign:user:1:2025
"string"
127.0.0.1:6379> TYPE uv:2025-09-30
"string"
127.0.0.1:6379> TYPE nearby
"zset"
Bitmap 和 HyperLogLog 的 TYPE 都是 string,底层就是 SDS;GEO 的 TYPE 是 zset,底层是跳表。它们是 Redis 在已有类型上做的一层包装,把同一份数据换一种角度解释。搞清这层关系,用起来就不会觉得它们是天上掉下来的东西。
Bitmap
位图解决的是"一个元素只有两种状态"的场景,签到了/没签到、在线/离线、点过赞/没点过赞。
它就是把一个 String 的字节数组当成一大排 bit 来用:
# 第 5 天签到,offset 从 0 开始,所以是 4
SETBIT sign:user:1:2025 4 1
(integer) 0
# 查第 5 天签没签
GETBIT sign:user:1:2025 4
(integer) 1
# 统计这一年签到多少次
BITCOUNT sign:user:1:2025
(integer) 1
SETBIT 的 offset 是 bit 的下标。按"一年 365 天"设计,offset 最多用到 364,整个 key 只需要 365 个 bit,也就是 46 个字节左右。一个用户一整年的签到记录,46 字节存完,比什么结构都省。
内存是按最大 offset 分配的
Bitmap 省不省,取决于 offset 密不密。
需要 365 个 bit → 365 / 8 ≈ 46 字节
需要 1 亿个 bit → 100000000 / 8 = 12.5 MB
Redis 分配内存是看你用到的最大的那个 offset。如果 offset 是用户 ID,而用户 ID 是那种从 9 位、10 位随机生成的大数,第一个 SETBIT 就把字节数组撑到几十 MB,中间全是大片的 0。用 Bitmap 的前提是 offset 连续、密集,最适合的是"日期偏移""序号从 0 开始"这类。
offset 的上限是 2^32 - 1,就是 4G 个 bit,对应 String 512MB 的上限(512MB × 8 = 2^32 bit)。
几个常用命令
SETBIT key offset value 置某一位
GETBIT key offset 取某一位
BITCOUNT key [start end] 数 1 的个数
BITPOS key bit [start end] 找第一个 0 或 1 的位置
BITOP AND/OR/XOR/NOT dest k1 k2 ... 多个位图做位运算
BITCOUNT 的用法有个坑。BITCOUNT key 0 0 里的 0 0 是字节范围,不是 bit 范围,写 0 0 是统计第一个字节(8 个 bit)。Redis 7.0 之后可以显式指定单位:BITCOUNT key 0 7 BIT。
BITOP 拿来做多个位图的运算很实用。比如"连续两天都活跃的用户",把两天的活跃位图做 AND;"整个月的活跃用户",把每天的位图做 OR(BITOP OR dest d1 d2 ... ,注意 key 太多要分批)。
Bitmap 本质就是 String,
SETBIT往高位写的时候会自动把 SDS 撑大,读的时候GETBIT直接算字节下标和位下标。所以它的所有限制(512MB、二进制安全)都是 String 的限制,它自己没有额外的数据结构。
HyperLogLog
UV 要统计"有多少个不同的用户来过"。精确做法是用 Set,每个用户 ID 塞进去,SCARD 就是答案。问题是量大:1000 万个 UV,一个 Set 在内存里要几百 MB。
HyperLogLog 用 12KB 给出这个数的估算值,误差在 0.81% 以内。
PFADD uv:2025-09-30 user1 user2 user3
(integer) 1
PFADD uv:2025-09-30 user3 user4
(integer) 1
PFCOUNT uv:2025-09-30
(integer) 4
# 合并多天的去重 UV
PFMERGE uv:total uv:09-28 uv:09-29 uv:09-30
12KB 是怎么来的
HyperLogLog 把元素先哈希成一个 64 位的值,然后:
- 拿其中一部分 bit 选一个"桶",一共有 16384 (2^14) 个桶
- 剩下的 bit 里,数"从某个位置开始,第一个 1 出现在第几位"
元素越多,观察到的"第一个 1 的位置"就倾向于越大。用这些位置的最大值就能反推出大概有多少个不同的元素。单个桶的估计非常粗糙,但 16384 个桶各估各的,再取调和平均,误差就压下来了,标准误差大约是 1.04 / √16384 = 0.81%。
桶数固定,所以内存也固定:
16384 个桶 × 6 bit/桶 = 98304 bit = 12288 字节 = 12KB
HyperLogLog 不存元素本身,只存一堆极其模糊的"有多少种哈希形态出现过"的计数。你没法问它"user1 来过吗",它答不了,因为它根本没记 user1。它只能回答"大概有多少个不同的"。这就是 12KB 换 0.81% 误差的交易。
元素少的时候,Redis 用的是一种稀疏表示,比 12KB 小很多,等稀疏表示涨到一定大小(3000 字节)才转成固定的 12KB 稠密表示。所以一个刚建、只加了几条数据的 HLL key,STRLEN 看到的是几十上百字节,不是 12KB。
限制
- 不能取元素。 只能
PFCOUNT拿个数,拿不到具体是谁。想去重后还要遍历元素,只能用 Set - 不精确。 0.81% 是标准误差,实际会上下浮动。做报表、看趋势可以,做账单不行
- 不能删单个元素。
PFADD只能往里加,没有PFREM。多个 HLL 可以PFMERGE合并,但合完不能再拆开
判断标准很简单:只要这个数不要求精确,就能用 HyperLogLog 省下几十倍的内存。
GEO
"附近的人"看起来是个二维空间查询,Redis 用的是 ZSet 加跳表。
GEOADD nearby 116.397 39.908 "tiananmen"
GEOADD nearby 116.407 39.915 "wangfujing"
# 查两个点的球面距离
GEODIST nearby tiananmen wangfujing km
# "1.2"
# 找 tiananmen 附近 3 公里内的人,按距离近到远
GEOSEARCH nearby FROMMEMBER tiananmen BYRADIUS 3 km ASC
经纬度怎么变成一个 score
ZSet 的 score 是一个 double,GEO 把经纬度编码成一个整数塞进去,用的是 geohash 的思路。
经纬度其实是两个浮点数,范围分别是经度 [-180, 180]、纬度 [-85.05, 85.05]。把每个数各自的取值范围不断二分,二分一次记一个 bit,转成二进制。然后把两个二进制串交错排列:
经度 bits: b1 b2 b3 b4 ...
纬度 bits: c1 c2 c3 c4 ...
交错后: b1 c1 b2 c2 b3 c3 b4 c4 ...
得到的是一个一维的 52 位整数(26 位经度 + 26 位纬度),这就是 score。
交错编码的关键性质是:二维空间里相邻的两个区域,编码成整数之后也相邻。所以"查附近"这个二维的范围查询,就变成了一维的 ZRANGEBYSCORE,正好用上跳表的范围扫描。这就是 GEO 复用一个 ZSet 就能干活的原因。
52 位的精度:地球赤道一圈约 40075017 米,2^26 = 67108864,所以每一格大约 40075017 / 67108864 ≈ 0.6 米。存个门店、用户位置绰绰有余。
两个注意点
纬度的范围不是 ±90。 [-85.05112878, 85.05112878],超出范围的纬度 GEOADD 会报错。这个范围来自 Web 地图(墨卡托投影)的惯例,Redis 沿用了它。
结果是矩形,不是圆形。 geohash 编码划分出来的区域是矩形,所以按范围筛出来的候选点是矩形区域内的。GEOSEARCH ... BYRADIUS 内部会拿这些候选点再按真实球面距离过滤一遍,所以最终结果是对的,但如果你自己去扫 ZSet 的 score,注意拿到的可能是矩形范围。
三者对照
| 需求 | 结构 | TYPE | 底层 | 内存 | 精度 |
|---|---|---|---|---|---|
| 签到 / 状态标记 | Bitmap | string | SDS | 取决于最大 offset,密集时极省 | 精确 |
| UV 估算 | HyperLogLog | string | SDS | 最大 12KB,固定 | 约 0.81% 误差 |
| 附近的人 | GEO | zset | 跳表 | 每个点是一个跳表节点 | 约 0.6 米 |
选型的判断:
- 状态是"有/没有"两种,且能对应到密集的整数下标,用 Bitmap
- 只要个数、不要集合内容,且能容忍误差,用 HyperLogLog
- 要按空间距离查询,用 GEO
反过来用错的代价很直接:拿 Set 存 UV,内存几十倍;拿 HyperLogLog 去查"某个人在不在",根本查不了;拿 Bitmap 存稀疏的用户 ID,中间那一片 0 白占内存。