总览与定位
五大基础类型(String/List/Hash/Set/ZSet)覆盖了大部分通用场景. 但某些特定问题如果用基础类型硬做, 要么内存爆炸, 要么功能缺失. Redis 为此提供了四种"专用武器":
| 类型 | 解决什么问题 | 核心优势 | 代价 |
|---|---|---|---|
| Bitmap | 海量布尔状态(是/否) | 每个状态仅占 1 bit | 仅适用于 ID 为连续整数的场景 |
| HyperLogLog | 海量基数统计(去重计数) | 固定 12KB 统计任意量级 | 有 0.81% 标准误差, 不能获取具体元素 |
| GEO | 地理位置存储与范围查询 | 原生支持经纬度, 距离, 半径搜索 | 底层是 ZSet, 大数据量需分片 |
| Stream | 消息队列(支持消费者组) | 原生 ACK, 消费组, 持久化 | 比 Kafka 轻量但能力上限低 |
Bitmap = 打孔机(只做"打孔/不打孔"这一件事但极快极省).
HyperLogLog = 人流量估算器(不认识每个人但能告诉你"大约来了多少人").
GEO = 地图标尺.
Stream = 一条有回执的传送带.
1. Bitmap(位图)
Bitmap 不是独立的数据类型, 本质是 String 的按位操作接口. 一个 String key 最大 512MB = 2³² 个 bit 位, 每个 bit 存储一个布尔值(0 或 1).
Bitmap = 一排 1 亿个灯泡, 每个灯泡只有亮(1)和灭(0)两种状态. 用一个 bit 记录"用户 X 是否签到了", 比用 Set 存 user_id 字符串省内存几十倍.
1.1 基础操作
SETBIT / GETBIT
SETBIT key offset value / GETBIT key offset
SETBIT 设置第 offset 位的值(0 或 1). GETBIT 获取第 offset 位的值. offset 从 0 开始.
|
1.2 批量统计与位运算
BITCOUNT
BITCOUNT key [start end [BYTE | BIT]]
统计值为 1 的 bit 数量. 可指定字节/bit 范围(Redis 7.0+ 支持 BIT 模式精确到位). 不加范围则统计全部.
BITOP
BITOP operation destkey key [key ...]
对多个 Bitmap 做位运算, 结果存入 destkey. operation 可选 AND / OR / XOR / NOT.
BITPOS
BITPOS key bit [start [end [BYTE | BIT]]]
查找第一个值为 bit(0 或 1)的位的位置. 用于快速定位"第一次签到是哪天"等场景.
|
1.3 应用场景
| 场景 | key 设计 | offset 含义 | 内存估算 |
|---|---|---|---|
| 用户签到 | sign:{uid}:{month} |
日期(0~30) | 每人每月 4 字节 |
| 日活统计 | active:{date} |
用户 ID | 1 亿用户 ≈ 12MB/天 |
| 功能开关 | feature:{name} |
用户 ID | 灰度放量标记 |
| 在线状态 | online |
用户 ID | 1 亿用户 ≈ 12MB |
| 布隆过滤器(手动) | 多个 Bitmap + 多 hash 函数 | hash 值 | 可控 |
Bitmap 的限制
offset 必须是非负整数. 如果用户 ID 是 UUID 或字符串, 不能直接当 offset 用--要么维护一个 ID→数字的映射, 要么改用 Set/HyperLogLog.
另外, 如果用户 ID 非常稀疏(如最大 ID 是 10 亿但活跃只有 1 万), Bitmap 反而浪费空间.
2. HyperLogLog(基数估算)
HyperLogLog(简称 HLL)用于解决**基数统计(Cardinality)**问题--统计一个集合中不重复元素的数量. 典型需求: UV(独立访客数), 搜索关键词去重计数.
为什么不用 Set? 因为当数据量到千万, 亿级时, Set 需要存储每个元素, 内存消耗巨大. 而 HLL 固定只占 12KB, 就能估算 2⁶⁴ 个不同元素的基数.
2.1 基础操作
PFADD
PFADD key element [element ...]
添加元素到 HLL 结构. 如果内部估算值有变化返回 1, 否则返回 0. 元素不可取回, 只影响计数.
PFCOUNT
PFCOUNT key [key ...]
返回一个或多个 HLL 的基数估算值. 多 key 时返回并集基数. 标准误差 0.81%.
PFMERGE
PFMERGE destkey sourcekey [sourcekey ...]
将多个 HLL 合并为一个(并集), 结果存入 destkey. 用于汇总多个时间段的数据.
|
2.2 原理直觉
想象在河边扔石头(添加元素), 观察水花溅起的最高高度(hash 值中前导零的最大长度). 如果只扔了几块石头, 最高水花不会太高; 如果扔了一百万块, 统计学上极大概率会出现一些"超级高的水花". 通过观察到的最大极端值来反推总体数量--这就是 HyperLogLog 的核心思想.
2.3 原理深度拆解
第一步: 掷硬币实验(概率直觉的数学化)
隔着帘子掷硬币, 不告诉你掷了多少次, 只告诉你一个信息: "最长连续正面的次数是 5".
能反推掷了多少次吗?
- 连续 5 次正面的概率 = (1/2)⁵ = 1/32
- 要稳定地看到"5 连正"这个极端事件, 大约需要掷 2⁵ = 32 次
- 同理: 最长 10 连正 → 大约掷了 2¹⁰ = 1024 次
核心规律
在一个随机伯努利过程中, 观察到的最极端事件的稀有程度, 可以反推总体试验次数. 这就是 HLL 从 hash 值反推基数的数学根基.
第二步: 用 Hash 把"元素"变成"硬币序列"
Hash 函数的输出是均匀分布的伪随机比特串. 把每个 bit 看作一次硬币: 0 = 正面, 1 = 反面, 那么 hash 值的二进制"前导零个数"就是"连续正面的次数".
|
对于一个好的 hash 函数, 前导零个数与概率的关系:
| 前导零个数 | 概率 | 大约需要多少个不同元素才能见到一次 |
|---|---|---|
| 0(第一位就是 1) | 1/2 | 2⁰ = 1 |
| 1(01...) | 1/4 | 2¹ = 2 |
| 2(001...) | 1/8 | 2² = 4 |
| k | (1/2)^(k+1) | 2^k |
所以: 如果已见过 N 个不同元素, 它们中最大的前导零大约在 log₂(N) 左右.
第三步: 单计数器的问题--方差太大
如果只用一个计数器记录"迄今为止最大的前导零", 会遇到致命问题:
|
单计数器的方差大到了不可用的程度. 解决办法: 多个计数器, 取平均.
第四步: 分桶策略(Stochastic Averaging)
取 hash 值的前 14 位用作桶编号(2¹⁴ = 16384 个桶), 剩余 50 位用来数前导零:
|
每个桶只记住"这个桶里见过的最大前导零", 不存储任何具体元素. 16384 个桶就是 16384 个独立的小实验, 彼此互不干扰.
这就像雇了 16384 个人同时掷硬币, 每个人负责自己的那组结果. 最后汇总时, 不是看"谁掷出了最长的正面", 而是看"每个人各自的最长正面", 再取平均值. 这样偶尔某个人的极端好运就不会绑架全局结论.
第五步: 为什么用调和平均而不是算术平均?
算术平均的问题是: 一个异常值就能毁掉整个估算.
|
调和平均天然压制极端值--因为它是对倒数求均值再取倒数:
|
所以 HLL 不使用 2^M[j] 的算术平均(被极端值绑架), 而使用 2^(-M[j]) 的调和平均--极端值被倒数自动压制.
第六步: 完整的估算公式
经过上述设计, HLL 的基数估算公式为:
HLL 基数估算公式
E = α_m × m² / Σ(2^(-M[j]))
其中: m = 桶数(16384), M[j] = 第 j 个桶的最大前导零, α_m = 偏差修正常数.
α_m 通过大量实验确定, 用于修正小基数时的系统性偏差. 当 m ≥ 128 时:
|
对 m=16384, α₁₆₃₈₄ ≈ 0.7213.
第七步: 为什么误差恰好是 0.81%?
这是概率论严格推导出的结果. HLL 的标准误差为:
|
第八步: 为什么刚好 12KB?
|
不多不少, 恰好 12KB. 不管统计的是 100 个还是一万亿个不同元素, 内存开销恒定.
走一个完整例子
假设 m=8 个桶(为演示缩到极小), 用 hash 的前 3 位做桶号:
|
各桶最终值:
|
Redis 实现的偏差修正
上述公式在大基数下很准, 但在极端情况下有偏. Redis 的实现加入了分段修正:
| 区间 | 策略 | 原因 |
|---|---|---|
| 小基数(E < 2.5m) | Linear Counting(数空桶数量线性估算) | 桶未填满时调和平均有偏 |
| 正常基数 | 标准 HLL 公式 | 误差稳定在 0.81% |
| 超大基数(E > 2³²/30) | 修正公式防止溢出 | 接近 hash 空间上限时会有偏 |
原理总结
| 问题 | 解法 |
|---|---|
| 怎么把元素变成可统计的量? | Hash → 看作随机比特串 |
| 怎么从 hash 反推数量? | 前导零的长度 ≈ log₂(基数) |
| 单计数器方差太大怎么办? | 多桶 → 随机平均(Stochastic Averaging) |
| 用哪种平均压制异常值? | 调和平均(对 2^(-M[j]) 求和) |
| 估计有偏怎么办? | 常数修正 + 大小基数分段处理 |
| 对比 | Set | HyperLogLog |
|---|---|---|
| 1 亿元素占用 | ~1.6 GB | 12 KB |
| 精确度 | 100% 精确 | 标准误差 0.81% |
| 能否获取具体元素 | ✅ SMEMBERS | ❌ 不可 |
| 能否判断某元素是否存在 | ✅ SISMEMBER | ❌ 不可 |
2.4 应用场景
| 场景 | key 设计 | 说明 |
|---|---|---|
| 页面 UV | uv:{page}:{date} |
每日独立访客数 |
| 搜索关键词去重 | search:keywords:{date} |
今天有多少不同的搜索词 |
| 注册 IP 去重 | register:ips:{date} |
从多少不同 IP 注册 |
| 商品浏览人数 | product:{id}:viewers |
有多少人看过(非 PV) |
什么时候用 HLL vs Set
如果**只需要"有多少个"**而不需要"有哪些", 且数据量大到 Set 存不起, 用 HLL. 如果需要判断某元素是否存在, 需要精确值, 数据量可控, 用 Set.
一般来说数据量 < 10 万用 Set 无压力, > 100 万开始考虑 HLL.
3. GEO(地理位置)
GEO 提供了存储地理坐标(经纬度)并进行距离计算, 范围搜索的能力. 底层实现是 Sorted Set--将经纬度通过 GeoHash 编码为 52 位整数作为 score, member 是地点标识.
GEO = 在一张世界地图上插图钉(GEOADD), 然后可以问: "这两个图钉之间多远?"(GEODIST) "以某点为圆心 5 公里内有哪些图钉?"(GEOSEARCH).
底层用 GeoHash 把二维坐标压缩成一维数值, 使得"附近的点"在数值上也接近, 从而可用 ZSet 的范围查询高效实现.
3.1 基础操作
GEOADD
GEOADD key [NX | XX] longitude latitude member [longitude latitude member ...]
添加地理位置. 经度范围 [-180, 180], 纬度范围 -85.05, 85.05.
GEOPOS / GEODIST
GEOPOS key member [member ...] / GEODIST key member1 member2 [M|KM|MI|FT]
GEOPOS 返回经纬度. GEODIST 计算两点之间的距离(默认米, 可选 KM/MI/FT).
|
3.2 范围搜索
GEOSEARCH(Redis 6.2+)
GEOSEARCH key FROMMEMBER member | FROMLONLAT lon lat BYRADIUS radius M|KM|MI|FT | BYBOX width height M|KM|MI|FT [ASC | DESC] [COUNT count] [WITHCOORD] [WITHDIST]
以指定点/成员为中心, 按圆形半径或矩形范围搜索. 可返回坐标, 距离, 可排序. 取代了旧的 GEORADIUS 和 GEORADIUSBYMEMBER.
|
GEOSEARCHSTORE
GEOSEARCHSTORE dest src ... (同 GEOSEARCH 参数)
与 GEOSEARCH 相同逻辑, 但将结果存入新的 ZSet(dest), 而非直接返回. 适合结果集需要后续处理的场景.
3.3 应用场景
| 场景 | key 设计 | member | 关键命令 |
|---|---|---|---|
| 附近的人/店 | geo:stores |
门店 ID | GEOSEARCH BYRADIUS |
| 打车派单 | geo:drivers:{city} |
司机 ID | GEOSEARCH + WITHDIST |
| 外卖配送范围 | geo:riders |
骑手 ID | GEOSEARCH BYRADIUS 3 KM |
| 地理围栏 | geo:devices |
设备 ID | GEODIST 判断是否超出范围 |
GEO 的局限
- 底层是单个 ZSet, 不支持按区域分片--如果存全国所有门店(百万级), 查询时仍需扫描整个集合. 实践中按城市拆 key(如
geo:stores:beijing) - 不支持多边形范围搜索(只有圆形和矩形)
- 删除用
ZREM(因为底层是 ZSet)
4. Stream(消息流)
Stream 是 Redis 5.0 引入的日志型数据结构, 专为消息队列设计. 相比用 List 做队列的方案, Stream 原生支持:
- 消费者组(Consumer Group): 多个消费者分摊消息, 不重复消费
- 消息确认(ACK): 处理完才确认, 失败可重试
- 消息持久化: 消息不会因被读取而消失
- 消息 ID 有序: 天然按时间排序, 可回溯
List 队列 = 取号机 -- 号码纸取走就没了(LPOP), 且只能一个人取.
Stream = 一本公告簿 -- 公告写上去不会消失, 多个部门(消费者组)各自用书签标记"看到哪了", 看完还要签字确认(ACK).
4.1 基础操作
XADD
XADD key [NOMKSTREAM] [MAXLEN | MINID [= | ~] threshold] * | id field value [field value ...]
向 Stream 追加一条消息. * 表示自动生成 ID(时间戳-序号格式). MAXLEN 限制 Stream 长度(自动裁剪旧消息).
XLEN / XRANGE / XREVRANGE
XLEN key / XRANGE key start end [COUNT count] / XREVRANGE key end start [COUNT count]
XLEN 消息数量. XRANGE 按 ID 范围正序读取(- 和 + 表示最小/最大 ID). XREVRANGE 逆序.
|
XREAD
XREAD [COUNT count] [BLOCK milliseconds] STREAMS key [key ...] id [id ...]
从一个或多个 Stream 读取新消息. BLOCK 0 表示阻塞等待(类似 BLPOP). id 传 $ 表示只读取调用之后产生的新消息.
|
4.2 消费者组(Consumer Group)
消费者组是 Stream 相比 List 的杀手级特性--多个消费者共同消费同一个 Stream, 每条消息只会被组内一个消费者处理.
XGROUP CREATE
XGROUP CREATE key groupname id | $ [MKSTREAM]
创建消费者组. id 指定从哪条消息开始消费: 0 = 从头消费全部; $ = 只消费新消息.
XREADGROUP
XREADGROUP GROUP group consumer [COUNT count] [BLOCK ms] STREAMS key [key ...] id [id ...]
以消费者身份从组中读取消息. id 传 > 表示读取从未分配过的新消息. 消息读取后进入 PEL(Pending Entries List), 等待 ACK.
XACK
XACK key group id [id ...]
确认消息已被成功处理. ACK 后消息从 PEL 中移除. 未 ACK 的消息可以被重新分配(用于故障恢复).
|
XPENDING / XCLAIM
XPENDING key group [start end count] / XCLAIM key group consumer min-idle-time id [id ...]
XPENDING 查看待确认的消息列表. XCLAIM 将超时未确认的消息转移给其他消费者重新处理(故障恢复).
|
4.3 应用场景与对比
| 场景 | Stream 优势 |
|---|---|
| 轻量消息队列 | 无需引入 Kafka/RabbitMQ, 减少架构复杂度 |
| 事件溯源 | 消息持久化, 可回溯, 按时间范围查询 |
| 多消费者协作 | 消费者组 + ACK + 故障转移 |
| 异步任务分发 | 比 List 更可靠(消息不丢失, 可重试) |
Stream vs List vs Kafka
| 能力 | List(BLPOP) | Stream | Kafka |
|---|---|---|---|
| 消费模式 | 点对点(取走即没) | 消费者组 + 广播 | 消费者组 + 广播 |
| 消息确认 | ❌ | ✅ XACK | ✅ offset commit |
| 消息持久化 | 消费即删 | 保留(可设 MAXLEN) | 保留(按时间/容量) |
| 消息回溯 | ❌ | ✅ XRANGE | ✅ seek offset |
| 吞吐量上限 | 万级/s | 万级/s | 百万级/s |
| 运维复杂度 | 无 | 低(Redis 自带) | 高(独立集群) |
| 适用定位 | 简单临时队列 | 轻中量级可靠队列 | 大规模流式处理 |
选择原则
- 如果已经用了 Redis 且消息量不大(万级 QPS 以下), Stream 可以替代 MQ 减少架构复杂度
- 如果需要百万级吞吐, 跨数据中心复制, 精确一次语义, 选 Kafka
- Stream 的消息堆积能力有限(受 Redis 内存约束), 不适合大量消息长时间积压
5. 四种类型选型对比
| 需求 | 选择 | 理由 |
|---|---|---|
| 统计"有多少"(不关心"有哪些") | HyperLogLog | 12KB 固定内存, 亿级基数 |
| 海量布尔状态(签到/在线/开关) | Bitmap | 1bit/状态, 支持位运算 |
| "附近的XX" | GEO | 原生经纬度存储 + 范围搜索 |
| 可靠消息队列(需 ACK/消费组) | Stream | 原生消费者组, 消息可回溯 |
| 精确去重 + 需要查具体元素 | Set | HLL 不行时的 fallback |
| 简单任务队列(不需 ACK) | List | Stream 杀鸡用牛刀时 |
快速回顾
| 类型 | 核心命令 | 记忆要点 |
|---|---|---|
| Bitmap | SETBIT / GETBIT |
按位设置/读取 |
BITCOUNT |
统计 1 的个数 | |
BITOP |
多 Bitmap 位运算(AND/OR) | |
| HyperLogLog | PFADD / PFCOUNT |
添加/估算基数, 固定 12KB |
PFMERGE |
合并多个 HLL(时间维度聚合) | |
| GEO | GEOADD / GEODIST |
添加坐标 / 算距离 |
GEOSEARCH |
范围搜索(圆形/矩形) | |
| Stream | XADD / XREAD |
生产消息 / 消费消息 |
XREADGROUP |
消费者组读取(消息不重复分配) | |
XACK / XCLAIM |
确认 / 故障转移未确认消息 |
动手练习
- Bitmap 签到: 为
sign:1001:2024-06设置第 0, 2, 3, 5 天签到, 用 BITCOUNT 统计总签到天数, 用 BITPOS 找第一天未签到是哪天. - Bitmap 活跃统计: 创建两个 Bitmap 代表两天的活跃用户(用 SETBIT 标记若干用户 ID), 用 BITOP AND 求连续两天都活跃的用户, 用 BITCOUNT 统计数量.
- HyperLogLog UV: 向
uv:page1添加 100 个不同用户 + 50 个重复用户, 验证 PFCOUNT ≈ 100(允许小误差). 再创建uv:page2, 用 PFMERGE 合并后查看并集基数. - GEO 附近搜索: GEOADD 添加 5 个城市的坐标, 用 GEOSEARCH 找出距离某城市 500KM 以内的其他城市, 用 GEODIST 验证距离.
- Stream 消费者组: XADD 推入 5 条消息; 创建消费者组, 两个消费者交替 XREADGROUP; 对其中一条消息 XACK, 用 XPENDING 查看剩余未确认消息.
- 思考题: 如果要实现"最近 7 天连续签到"的判断, 用 Bitmap 怎么做? 时间复杂度是多少?