阶段二 · 进阶数据结构与应用场景

扩展数据类型:HyperLogLog / Bitmap / GEO / Stream

总览与定位

五大基础类型(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 开始.

# 用户签到: bit 位 = 日期偏移量(0=1号, 1=2号...)
# key = sign:{uid}:{yyyy-MM}

# 用户 1001 在 6 月 1 日签到
SETBIT sign:1001:2024-06 0 1 → (integer) 0 ← 返回旧值

# 6 月 3 日签到
SETBIT sign:1001:2024-06 2 1 → (integer) 0

# 查询 6 月 2 日是否签到
GETBIT sign:1001:2024-06 1 → (integer) 0 ← 未签到

# 查询 6 月 3 日
GETBIT sign:1001:2024-06 2 → (integer) 1 ← 已签到

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)的位的位置. 用于快速定位"第一次签到是哪天"等场景.

# 本月签到了几天
BITCOUNT sign:1001:2024-06 → (integer) 2

# 连续签到判断: 找第一个为 0 的位(即第一天未签到)
BITPOS sign:1001:2024-06 0 → (integer) 1 ← 第 2 天断签

# 多用户活跃统计: 哪些用户在周一和周二都活跃了?
# active:2024-06-01 → 用户ID为offset, 活跃则设为1
# active:2024-06-02 → 同上
BITOP AND active:both active:2024-06-01 active:2024-06-02
BITCOUNT active:both → 连续两天都活跃的用户数

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. 用于汇总多个时间段的数据.

# 统计页面 UV
PFADD page:home:uv "user_1" "user_2" "user_3" → (integer) 1
PFADD page:home:uv "user_2" "user_4" → (integer) 1 ← user_2重复但不影响
PFADD page:home:uv "user_2" → (integer) 0 ← 无变化

PFCOUNT page:home:uv → (integer) 4 ← 去重后 4 个独立用户

# 按天统计, 合并为周 UV
PFADD uv:2024-06-01 "u1" "u2" "u3"
PFADD uv:2024-06-02 "u2" "u3" "u4" "u5"

PFMERGE uv:week1 uv:2024-06-01 uv:2024-06-02
PFCOUNT uv:week1 → (integer) 5 ← 两天去重后的并集

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("user_1") = 00110101 11000010 ...
↑↑
前导零 = 2(遇到第一个 1 之前有几个连续的 0)

对于一个好的 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) 左右.

第三步: 单计数器的问题--方差太大

如果只用一个计数器记录"迄今为止最大的前导零", 会遇到致命问题:

你只添加了 1 个元素, 但它的 hash 恰好是 000000001...
↑ 8 个前导零!
→ 估计: 2⁸ = 256 个不同元素
→ 实际: 仅 1 个

一个极端值就能让估算彻底失效.

单计数器的方差大到了不可用的程度. 解决办法: 多个计数器, 取平均.

第四步: 分桶策略(Stochastic Averaging)

取 hash 值的前 14 位用作桶编号(2¹⁴ = 16384 个桶), 剩余 50 位用来数前导零:

hash("user_1") = [01101011100101] [000101011...]
└── 前14位 ──┘ └── 剩余50位 ──┘
桶编号 = 6874 数这部分的前导零 = 3

→ 桶 6874 的当前值 = max(桶旧值, 3)

每个桶只记住"这个桶里见过的最大前导零", 不存储任何具体元素. 16384 个桶就是 16384 个独立的小实验, 彼此互不干扰.

这就像雇了 16384 个人同时掷硬币, 每个人负责自己的那组结果. 最后汇总时, 不是看"谁掷出了最长的正面", 而是看"每个人各自的最长正面", 再取平均值. 这样偶尔某个人的极端好运就不会绑架全局结论.

第五步: 为什么用调和平均而不是算术平均?

算术平均的问题是: 一个异常值就能毁掉整个估算.

桶的原始 M[j] 值: [5, 5, 6, 4, 5, 18, 5, 5]
↑ 撞大运的异常值

算术平均: (5+5+6+4+5+18+5+5)/8 ≈ 6.6 → 2^6.6 ≈ 97
去掉异常: (5+5+6+4+5+5+5)/7 = 5.0 → 2^5.0 = 32

一个桶撞大运, 估算从 32 变成 97 -- 差了 3 倍.

调和平均天然压制极端值--因为它是对倒数求均值再取倒数:

极端值 18 的倒数是 1/18 ≈ 0.056 → 几乎不影响总和
正常值 5 的倒数是 1/5 = 0.200 → 主导贡献

所以 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 ≈ 0.7213 / (1 + 1.079/m)

对 m=16384, α₁₆₃₈₄ ≈ 0.7213.

第七步: 为什么误差恰好是 0.81%?

这是概率论严格推导出的结果. HLL 的标准误差为:

σ ≈ 1.04 / √m

代入 m = 16384 = 2¹⁴:
σ = 1.04 / √16384 = 1.04 / 128 ≈ 0.008125 = 0.8125% ≈ 0.81%

为什么是 16384?
桶数翻 4 倍 → 误差减半, 但内存也翻 4 倍.
16384 是精度与内存之间的 sweet spot -- 0.81% 误差 + 12KB 内存.

第八步: 为什么刚好 12KB?

每个桶需要记录的最大前导零 ≈ log₂(2^64) = 64(实际很少超过 50)
所以每个桶用 6 bit(2⁶ = 64)

16384 个桶 × 6 bit = 98304 bit = 12288 byte = 12 KB

不多不少, 恰好 12KB. 不管统计的是 100 个还是一万亿个不同元素, 内存开销恒定.

走一个完整例子

假设 m=8 个桶(为演示缩到极小), 用 hash 的前 3 位做桶号:

元素1: hash = [010]0011...  → 桶2, 前导零=2 → 桶2记录 2
元素2: hash = [110]00010... → 桶6, 前导零=3 → 桶6记录 3
元素3: hash = [010]00001... → 桶2, 前导零=4 → 桶2更新为 max(2,4)=4
元素4: hash = [001]1... → 桶1, 前导零=0 → 桶1记录 0
元素5: hash = [111]000100.. → 桶7, 前导零=3 → 桶7记录 3
元素6: hash = [110]01... → 桶6, 前导零=1 → 桶6保持 max(3,1)=3

各桶最终值:

桶:   0    1    2    3    4    5    6    7
M: [0, 0, 4, 0, 0, 0, 3, 3]

Σ(2^(-M[j])) = 1 + 1 + 1/16 + 1 + 1 + 1 + 1/8 + 1/8
= 6 + 0.0625 + 0.25 = 6.3125

E = 0.72 × 64 / 6.3125 ≈ 7.3 ← 估计 7 个, 实际 6 个(m 很小, 已经很接近)

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).

# 存储几家门店的位置
GEOADD stores 116.397128 39.916527 "beijing_store"
GEOADD stores 121.473701 31.230416 "shanghai_store"
GEOADD stores 113.264385 23.129112 "guangzhou_store"
GEOADD stores 116.407526 39.904030 "tiananmen"

# 查看坐标
GEOPOS stores "beijing_store"
1) "116.39712780714035034"
2) "39.91652647362980844"

# 计算北京门店到天安门的距离
GEODIST stores "beijing_store" "tiananmen" KM
→ "1.3850" ← 约 1.4 公里

# 北京到上海
GEODIST stores "beijing_store" "shanghai_store" KM
→ "1067.5980"

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]

以指定点/成员为中心, 按圆形半径或矩形范围搜索. 可返回坐标, 距离, 可排序. 取代了旧的 GEORADIUSGEORADIUSBYMEMBER.

# 以天安门为中心, 搜索 5 公里内的门店
GEOSEARCH stores FROMMEMBER "tiananmen" BYRADIUS 5 KM ASC WITHCOORD WITHDIST
1) 1) "tiananmen"
2) "0.0000"
3) 1) "116.40752649307250977"
2) "39.90403009498505874"
2) 1) "beijing_store"
2) "1.3850"
3) 1) "116.39712780714035034"
2) "39.91652647362980844"

# 以经纬度为中心搜索(不依赖已有 member)
GEOSEARCH stores FROMLONLAT 116.40 39.91 BYRADIUS 2 KM ASC COUNT 3 WITHDIST
1) 1) "tiananmen"
2) "0.8245"
2) 1) "beijing_store"
2) "0.7410"

# 矩形范围搜索
GEOSEARCH stores FROMLONLAT 116.40 39.91 BYBOX 10 10 KM ASC

GEOSEARCHSTORE

GEOSEARCHSTORE dest src ... (同 GEOSEARCH 参数)

与 GEOSEARCH 相同逻辑, 但将结果存入新的 ZSet(dest), 而非直接返回. 适合结果集需要后续处理的场景.

GEOHASH

GEOHASH key member [member ...]

返回 GeoHash 编码字符串(11 个字符). 可粘贴到 geohash.org 在地图上查看对应位置.

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 的局限

  1. 底层是单个 ZSet, 不支持按区域分片--如果存全国所有门店(百万级), 查询时仍需扫描整个集合. 实践中按城市拆 key(如 geo:stores:beijing)
  2. 不支持多边形范围搜索(只有圆形和矩形)
  3. 删除用 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 逆序.

# 生产者: 发送订单事件
XADD orders * user_id 1001 action "create" amount 299
→ "1717200000000-0" ← 自动生成的消息 ID

XADD orders * user_id 1002 action "pay" amount 599
→ "1717200000001-0"

XADD orders * user_id 1001 action "cancel" amount 299
→ "1717200000002-0"

# 查看消息数
XLEN orders → (integer) 3

# 读取全部消息
XRANGE orders - +
1) 1) "1717200000000-0"
2) 1) "user_id" 2) "1001" 3) "action" 4) "create" 5) "amount" 6) "299"
2) 1) "1717200000001-0"
2) 1) "user_id" 2) "1002" 3) "action" 4) "pay" 5) "amount" 6) "599"
3) 1) "1717200000002-0"
2) 1) "user_id" 2) "1001" 3) "action" 4) "cancel" 5) "amount" 6) "299"

# 限制 Stream 最多保留 1000 条(近似裁剪用 ~)
XADD orders MAXLEN ~ 1000 * user_id 1003 action "create" amount 199

XREAD

XREAD [COUNT count] [BLOCK milliseconds] STREAMS key [key ...] id [id ...]

从一个或多个 Stream 读取新消息. BLOCK 0 表示阻塞等待(类似 BLPOP). id 传 $ 表示只读取调用之后产生的新消息.

# 消费者: 阻塞等待新订单(类似 BLPOP)
XREAD BLOCK 0 STREAMS orders $
# ... 阻塞等待中, 有新消息时立即返回

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 的消息可以被重新分配(用于故障恢复).

# 创建消费者组(从头开始消费)
XGROUP CREATE orders order_processors 0
OK

# 消费者 worker_1 读取消息
XREADGROUP GROUP order_processors worker_1 COUNT 1 STREAMS orders >
1) 1) "orders"
2) 1) 1) "1717200000000-0"
2) 1) "user_id" 2) "1001" 3) "action" 4) "create" 5) "amount" 6) "299"

# 消费者 worker_2 读取消息(获得下一条, 不重复)
XREADGROUP GROUP order_processors worker_2 COUNT 1 STREAMS orders >
1) 1) "orders"
2) 1) 1) "1717200000001-0"
2) 1) "user_id" 2) "1002" 3) "action" 4) "pay" 5) "amount" 6) "599"

# worker_1 处理完毕, 确认
XACK orders order_processors "1717200000000-0"
→ (integer) 1

# 查看未确认消息(PEL)
XPENDING orders order_processors - + 10
1) 1) "1717200000001-0"
2) "worker_2"
3) (integer) 30000 ← 距上次投递已过 30 秒
4) (integer) 1 ← 投递次数

XPENDING / XCLAIM

XPENDING key group [start end count] / XCLAIM key group consumer min-idle-time id [id ...]

XPENDING 查看待确认的消息列表. XCLAIM 将超时未确认的消息转移给其他消费者重新处理(故障恢复).

# worker_2 挂了, 30 秒后将其未确认消息转给 worker_3
XCLAIM orders order_processors worker_3 30000 "1717200000001-0"
→ 1) 1) "1717200000001-0"
2) 1) "user_id" 2) "1002" ...

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 自带) 高(独立集群)
适用定位 简单临时队列 轻中量级可靠队列 大规模流式处理

选择原则

  1. 如果已经用了 Redis 且消息量不大(万级 QPS 以下), Stream 可以替代 MQ 减少架构复杂度
  2. 如果需要百万级吞吐, 跨数据中心复制, 精确一次语义, 选 Kafka
  3. 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 确认 / 故障转移未确认消息

动手练习

  1. Bitmap 签到: 为 sign:1001:2024-06 设置第 0, 2, 3, 5 天签到, 用 BITCOUNT 统计总签到天数, 用 BITPOS 找第一天未签到是哪天.
  2. Bitmap 活跃统计: 创建两个 Bitmap 代表两天的活跃用户(用 SETBIT 标记若干用户 ID), 用 BITOP AND 求连续两天都活跃的用户, 用 BITCOUNT 统计数量.
  3. HyperLogLog UV: 向 uv:page1 添加 100 个不同用户 + 50 个重复用户, 验证 PFCOUNT ≈ 100(允许小误差). 再创建 uv:page2, 用 PFMERGE 合并后查看并集基数.
  4. GEO 附近搜索: GEOADD 添加 5 个城市的坐标, 用 GEOSEARCH 找出距离某城市 500KM 以内的其他城市, 用 GEODIST 验证距离.
  5. Stream 消费者组: XADD 推入 5 条消息; 创建消费者组, 两个消费者交替 XREADGROUP; 对其中一条消息 XACK, 用 XPENDING 查看剩余未确认消息.
  6. 思考题: 如果要实现"最近 7 天连续签到"的判断, 用 Bitmap 怎么做? 时间复杂度是多少?