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

扩展数据类型

HyperLogLog / Bitmap / GEO / Stream —— 用极小代价解决特定问题的专用武器

Redis 学习笔记 · 第 04 篇 · 大纲 · 前置:第 03 篇

总览与定位

五大基础类型(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)。

想象一排 1 亿个灯泡,每个灯泡只有亮(1)和灭(0)两种状态。Bitmap 就是高效控制和查询这些灯泡的遥控器。用一个 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}用户 ID1 亿用户 ≈ 12MB/天
功能开关feature:{name}用户 ID灰度放量标记
在线状态online用户 ID1 亿用户 ≈ 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"

你能反推我掷了多少次吗?

核心规律

在一个随机伯努利过程中,观察到的最极端事件的稀有程度,可以反推总体试验次数。这就是 HLL 从 hash 值反推基数的数学根基。

第二步:用 Hash 把"元素"变成"硬币序列"

Hash 函数的输出是均匀分布的伪随机比特串。把每个 bit 看作一次硬币:0 = 正面,1 = 反面,那么 hash 值的二进制"前导零个数"就是"连续正面的次数"。

hash("user_1") = 00110101 11000010 ...
                  ↑↑
                  前导零 = 2(遇到第一个 1 之前有几个连续的 0)

对于一个好的 hash 函数,前导零个数与概率的关系:

前导零个数概率大约需要多少个不同元素才能见到一次
0(第一位就是 1)1/22⁰ = 1
1(01...)1/42¹ = 2
2(001...)1/82² = 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]) 求和)
估计有偏怎么办?常数修正 + 大小基数分段处理
对比SetHyperLogLog
1 亿元素占用~1.6 GB12 KB
精确度100% 精确标准误差 0.81%
能否获取具体元素✅ SMEMBERS❌ 不可
能否判断某元素是否存在✅ SISMEMBER❌ 不可

2.4 应用场景

场景key 设计说明
页面 UVuv:{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"

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门店 IDGEOSEARCH BYRADIUS
打车派单geo:drivers:{city}司机 IDGEOSEARCH + WITHDIST
外卖配送范围geo:riders骑手 IDGEOSEARCH BYRADIUS 3 KM
地理围栏geo:devices设备 IDGEODIST 判断是否超出范围

GEO 的局限

① 底层是单个 ZSet,不支持按区域分片——如果存全国所有门店(百万级),查询时仍需扫描整个集合。实践中按城市拆 key(如 geo:stores:beijing)。② 不支持多边形范围搜索(只有圆形和矩形)。③ 删除用 ZREM(因为底层是 ZSet)。

4. Stream(消息流)

Stream 是 Redis 5.0 引入的日志型数据结构,专为消息队列设计。相比用 List 做队列的方案,Stream 原生支持:

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)StreamKafka
消费模式点对点(取走即没)消费者组 + 广播消费者组 + 广播
消息确认✅ XACK✅ offset commit
消息持久化消费即删保留(可设 MAXLEN)保留(按时间/容量)
消息回溯✅ XRANGE✅ seek offset
吞吐量上限万级/s万级/s百万级/s
运维复杂度低(Redis 自带)高(独立集群)
适用定位简单临时队列轻中量级可靠队列大规模流式处理

选择原则

① 如果已经用了 Redis 且消息量不大(万级 QPS 以下),Stream 可以替代 MQ 减少架构复杂度;② 如果需要百万级吞吐、跨数据中心复制、精确一次语义,选 Kafka;③ Stream 的消息堆积能力有限(受 Redis 内存约束),不适合大量消息长时间积压。

5. 四种类型选型对比

需求选择理由
统计"有多少"(不关心"有哪些")HyperLogLog12KB 固定内存,亿级基数
海量布尔状态(签到/在线/开关)Bitmap1bit/状态,支持位运算
"附近的XX"GEO原生经纬度存储 + 范围搜索
可靠消息队列(需 ACK/消费组)Stream原生消费者组,消息可回溯
精确去重 + 需要查具体元素SetHLL 不行时的 fallback
简单任务队列(不需 ACK)ListStream 杀鸡用牛刀时

快速回顾

类型核心命令记忆要点
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 怎么做?时间复杂度是多少?