目录

Redis-03 高级数据类型:Bitmap、HyperLogLog、GEO、Stream

1. 概览

除了五种基本类型,Redis 还提供了几种"高级类型"。要理解的关键是:它们中大部分不是新的底层数据结构,而是建立在已有类型上的命令集

类型 底层实际是 引入版本 解决的问题
Bitmap string(按位操作) 2.2 海量布尔状态的极致压缩存储
Bitfield string(按位域操作) 3.2 在一个 string 里塞多个小整数
HyperLogLog string(稀疏/稠密两种编码) 2.8 用固定 12KB 估算亿级基数
GEO zset(GeoHash 编码为 score) 3.2 地理位置的距离与范围查询
Stream Radix Tree + listpack 5.0 可靠的消息流(真正的新结构)
Vector Set 独立结构 8.0 向量相似度检索(AI 场景)

2. Bitmap 位图

2.1 本质

Bitmap 不是新类型,就是把 string 当成一个巨大的 bit 数组来操作。string 最大 512MB = 2^32 bit,所以一个 Bitmap 最多能表示约 42.9 亿个布尔值。

它的杀手级优势是省内存:1 亿个用户的签到状态,用 set 存 userId 要几百 MB,用 Bitmap 只要 100000000 / 8 = 12.5MB

2.2 命令

SETBIT key offset 0|1      # 设置某一位,返回该位的旧值
GETBIT key offset          # 获取某一位(超出范围返回 0)
BITCOUNT key [start end [BYTE|BIT]]   # 统计值为 1 的位数
BITPOS key 0|1 [start [end [BYTE|BIT]]]  # 找第一个为 0/1 的位的位置
BITOP AND|OR|XOR|NOT destkey key [key ...]   # 位运算,结果存到 destkey

注意 BITCOUNT/BITPOSstart/end 默认单位是字节,7.0+ 可以用 BIT 关键字改成按位:

SETBIT sign 0 1
SETBIT sign 5 1
SETBIT sign 100 1

BITCOUNT sign              # 3(全部)
BITCOUNT sign 0 0          # 2(第 0 个字节,即 bit 0~7 里有 2 个 1)
BITCOUNT sign 0 10 BIT     # 2(bit 0~10 里有 2 个 1,7.0+)
BITPOS sign 1              # 0(第一个为 1 的位)
BITPOS sign 0              # 1(第一个为 0 的位)

2.3 内存分配的坑

SETBIT key 100000000 1 会立即分配 12.5MB 内存(string 会被扩展到能容纳该 offset)。所以:

不要用巨大的、稀疏的 offset。比如用手机号(11 位数字)当 offset:SETBIT k 13800138000 1 会尝试分配 1.7GB 内存,直接把实例撑爆。offset 上限是 2^32-1,超出会报错。

正确做法是把业务 ID 映射到连续的、从 0 开始的小整数(比如用自增主键,或者做一层 ID → 序号的映射)。

2.4 场景一:用户签到

# key 设计:sign:{userId}:{年月},offset 为当月第几天(0-based)
SETBIT sign:1001:202607 0 1     # 7 月 1 日签到
SETBIT sign:1001:202607 1 1     # 7 月 2 日签到
SETBIT sign:1001:202607 4 1     # 7 月 5 日签到

GETBIT sign:1001:202607 1       # 7 月 2 日是否签到 → 1
BITCOUNT sign:1001:202607       # 本月签到总天数 → 3
BITPOS sign:1001:202607 1       # 本月第一次签到是第几天 → 0

计算连续签到天数:取出整个 bitmap,在客户端做位运算。或者用 BITFIELD 一次取出:

# 取出前 31 位作为一个无符号整数(需要 31 <= 64)
BITFIELD sign:1001:202607 GET u31 0
# 客户端拿到整数后从高位(对应第 1 天)开始数连续的 1

统计某天全站签到人数(换个 key 维度):

# key 设计:sign:daily:{日期},offset 为 userId
SETBIT sign:daily:20260729 1001 1
BITCOUNT sign:daily:20260729     # 当天签到人数(O(N) 但常数极小,12.5MB 也就几毫秒)

2.5 场景二:连续活跃 / 留存分析

# 每天一个 bitmap,offset 是 userId
SETBIT active:20260727 1001 1
SETBIT active:20260728 1001 1
SETBIT active:20260729 1001 1

# 连续 3 天都活跃的用户(AND)
BITOP AND active:3days active:20260727 active:20260728 active:20260729
BITCOUNT active:3days

# 3 天里至少活跃 1 天的用户(OR)—— 三日 UV
BITOP OR active:any3 active:20260727 active:20260728 active:20260729
BITCOUNT active:any3

# 昨天活跃今天没来的用户(流失):昨天 AND (NOT 今天)
BITOP NOT tmp:not_today active:20260729
BITOP AND churn active:20260728 tmp:not_today
BITCOUNT churn

# 次日留存率 = (前一天且当天都活跃) / 前一天活跃

BITOP 的性能警告:复杂度是 O(N),N 是最长 string 的字节数。对 12.5MB 的 bitmap 做 AND 大约需要几毫秒到十几毫秒,在主线程执行。做 30 天的批量分析时应该:分批算、放到从节点算、或者干脆离线算。

BITOP NOT 的坑NOT 是按整个字节取反,会把"不存在的用户"(bit 为 0)全部变成 1,导致 BITCOUNT 结果远大于真实用户数。所以要么再 AND 一个"全体用户"的 bitmap 做掩码,要么限定 BITCOUNT 的范围。

2.6 场景三:布隆过滤器的基础

自己实现布隆过滤器时,Bitmap 就是底层位数组:

-- 简化版:k 个哈希函数把元素映射到 m 位的 bitmap
-- 添加:SETBIT bf h1 1; SETBIT bf h2 1; ...
-- 查询:GETBIT 全为 1 才"可能存在"

不过生产上建议直接用 RedisBloom 模块BF.ADDBF.EXISTS),它实现了自动扩容和更优的哈希策略。

2.7 场景四:权限 / 开关位

# 一个用户的 64 个功能开关,用一个 string 存
SETBIT perm:1001 3 1      # 开启第 3 号权限
GETBIT perm:1001 3        # 检查权限

3. Bitfield 位域

BITFIELD(3.2+)把 string 看作一串任意位宽的整数数组,可以在一条命令里读写多个位域,支持有符号/无符号、溢出策略。

BITFIELD key
  [GET type offset]
  [SET type offset value]
  [INCRBY type offset increment]
  [OVERFLOW WRAP|SAT|FAIL]
  • typeu<n> 无符号(n <= 63)或 i<n> 有符号(n <= 64);
  • offset:位偏移。加 # 前缀表示"按 type 宽度的第几个",如 u8 #2 等于 u8 16
  • OVERFLOW 策略(影响后续的 INCRBY/SET):
    • WRAP:回绕(默认,无符号取模,有符号补码回绕);
    • SAT:饱和(超上限就停在上限,低于下限停在下限);
    • FAIL:溢出时返回 nil 不做修改。
# 在一个 key 里存三个 u8 计数器
BITFIELD counters SET u8 #0 100 SET u8 #1 200 SET u8 #2 50
BITFIELD counters GET u8 #0 GET u8 #1 GET u8 #2
# 1) 100  2) 200  3) 50

# 饱和递增:到 255 就不再涨(不会回绕成 0)
BITFIELD counters OVERFLOW SAT INCRBY u8 #0 200
# → 255

# 混合操作,一条命令原子完成
BITFIELD user:1001 INCRBY u16 0 1 GET u8 16 SET u4 24 7

典型用途:在极端省内存的场景里,把很多小整数(如"每个用户的等级(u4) + 状态(u2) + 计数(u10)")打包进一个 string。比如统计一亿个用户的 App 使用次数(每人最多 255 次),用 u8 只要 100MB,比一亿个 string key 省几十倍。


4. HyperLogLog 基数统计

4.1 解决什么问题

统计去重后的数量(基数),比如网站 UV。

传统方案对比:

方案 1 亿 UV 的内存 精度
set 存 userId 数 GB 100% 精确
Bitmap(userId 连续) 12.5MB 100% 精确
HyperLogLog 12KB 标准误差 0.81%

HyperLogLog 用 12KB 固定内存统计任意基数(理论上到 2^64),代价是约 0.81% 的标准误差

4.2 命令

只有三个:

PFADD key element [element ...]   # 添加元素,返回 1 表示基数估算值发生了变化
PFCOUNT key [key ...]             # 返回基数估算值(多个 key 会先做并集)
PFMERGE destkey sourcekey [sourcekey ...]   # 合并多个 HLL
PFADD uv:20260729 user1 user2 user3
PFADD uv:20260729 user1              # 重复元素,返回 0
PFCOUNT uv:20260729                  # 3

PFADD uv:20260728 user3 user4
PFCOUNT uv:20260728 uv:20260729      # 两天的并集去重 → 4

PFMERGE uv:week uv:20260728 uv:20260729   # 合并成周 UV
PFCOUNT uv:week                            # 4

注意还有一个内部调试命令 PFDEBUG GETREG key(查看寄存器)和 PFSELFTEST

4.3 原理

HyperLogLog 是一个概率算法,核心直觉是:

抛硬币直到出现正面。如果你告诉我"最多连续抛出了 10 次反面才见正面",我可以推测你大约抛了 2^10 = 1024 轮。

映射到基数统计:

  1. 对每个元素做哈希,得到一个均匀分布的 64 位值;
  2. 观察这个哈希值从低位起连续 0 的个数(记为 ρ,等价于"连续抛出反面的次数");
  3. 如果在所有元素中观察到的最大 ρ 是 k,那么元素基数大约是 2^k。

单个估算的方差巨大(一个"运气特别好"的元素就能让估算偏离很多),所以 HLL 用分桶平均来降低误差:

  1. 取哈希值的低 14 位作为桶索引 → 2^14 = 16384 个桶(Redis 的选择);
  2. 剩余高位计算 ρ,取该桶的最大值,每个桶用 6 bit 存(能表示 0~63,足够 64 位哈希);
  3. 最终估算用所有桶的调和平均数(harmonic mean)乘以修正常数:
E = alpha_m * m^2 / sum(2^(-M[j]))

内存计算:16384 桶 × 6 bit = 98304 bit = 12KB。误差 = 1.04 / sqrt(16384) ≈ 0.81%

4.4 稀疏与稠密编码

Redis 的实现有个重要优化:

  • 稀疏编码(sparse):基数很小时,大部分桶都是 0,用行程长度编码(RLE)只记录非零桶,只占几十到几百字节
  • 稠密编码(dense):基数变大后转成标准的 16384×6bit 数组,固定 12KB。

转换阈值由 hll-sparse-max-bytes(默认 3000 字节)控制,超过就转稠密,且不可逆

所以「HLL 一定占 12KB」是不准确的——小基数时它非常省。可以验证:

PFADD hll a b c
MEMORY USAGE hll      # 只有几十字节
STRLEN hll            # 稀疏编码,很短

# 塞进大量元素后
for i in $(seq 1 100000); do echo "PFADD hll u$i"; done | redis-cli --pipe
STRLEN hll            # 12304 字节左右(12KB + 16 字节头)

HLL 的底层就是 string,所以 TYPE hll 返回 stringSTRLEN 也能用。但不要用 SET/APPEND 手动改它,会破坏结构(Redis 有 magic header HYLL 校验,损坏时报 WRONGTYPE Key is not a valid HyperLogLog string value.)。

4.5 使用注意

  1. 不能取出元素:HLL 只存"基数的痕迹",无法列出成员,也无法判断某个元素是否存在(没有 PFEXISTS);
  2. 不能删除元素:只能加不能减;
  3. 误差是标准误差:0.81% 意味着约 68% 的情况误差在 0.81% 内、95% 的情况在 1.62% 内,不是硬上界;
  4. PFCOUNT 有写操作:它会把估算结果缓存在 header 里(避免重复计算),所以 PFCOUNT 不能在只读从节点上执行(会报错),也不适合放在 Lua 的只读脚本里;
  5. 精确度要求高的场景不能用:财务数字、需要精确对账的统计都不行。

4.6 场景

# 日 UV
PFADD uv:page:home:20260729 <userId或sessionId>
PFCOUNT uv:page:home:20260729

# 周/月 UV(合并每日 HLL,不需要重新遍历原始日志)
PFMERGE uv:page:home:202607 uv:page:home:202607*
PFCOUNT uv:page:home:202607

# 搜索关键词的去重搜索用户数
PFADD search:kw:redis <userId>

# 独立 IP 数
PFADD ips:20260729 <clientIP>

选型建议

  • 需要精确值且用户 ID 连续 → Bitmap
  • 需要精确值且数据量不大(<100 万) → set
  • 数据量巨大且能接受 1% 误差 → HyperLogLog
  • 需要判断"某个元素是否出现过" → 布隆过滤器(RedisBloom)。

5. GEO 地理位置

5.1 本质

GEO(3.2+)底层就是 zset。它把经纬度用 GeoHash 算法编码成一个 52 位整数,作为 zset 的 score 存储。

因为 GeoHash 的核心性质是位置相近的点,编码后的数值也相近,所以 zset 按 score 排序后,附近的点自然聚集在一起,用 ZRANGEBYSCORE 就能高效找到附近的成员。

5.2 命令

GEOADD key [NX|XX] [CH] longitude latitude member [lon lat member ...]
GEOPOS key member [member ...]          # 查询坐标(有精度损失)
GEODIST key m1 m2 [M|KM|FT|MI]          # 两点距离
GEOHASH key member                      # 返回标准 GeoHash 字符串(11 位 base32)

# 6.2+ 统一的搜索命令(推荐)
GEOSEARCH key
  FROMMEMBER member | FROMLONLAT lon lat
  BYRADIUS radius M|KM|FT|MI | BYBOX width height M|KM|FT|MI
  [ASC|DESC] [COUNT n [ANY]]
  [WITHCOORD] [WITHDIST] [WITHHASH]

GEOSEARCHSTORE dst src ...              # 结果存入新 key

# 旧命令(已废弃但仍可用)
GEORADIUS key lon lat radius unit [...]
GEORADIUSBYMEMBER key member radius unit [...]

5.3 实例

# 添加几个北京的地点(注意顺序是 经度 纬度)
GEOADD cities 116.397128 39.916527 天安门
GEOADD cities 116.405285 39.904989 前门
GEOADD cities 116.322987 39.983424 中关村
GEOADD cities 116.468206 39.995576 望京

# 两点距离
GEODIST cities 天安门 中关村 km        # "8.9847"

# 查坐标
GEOPOS cities 天安门
# 1) 1) "116.39712899923324"
#    2) "39.91652647362980"
# 注意有精度损失(GeoHash 编码只有 52 位,误差约 0.5 米以内)

# GeoHash 字符串(可以用来做前缀匹配或和其他系统交换)
GEOHASH cities 天安门                   # "wx4g0f6xrt0"

# 搜索天安门周边 10 公里内的地点,按距离升序,带距离和坐标
GEOSEARCH cities FROMMEMBER 天安门 BYRADIUS 10 km ASC WITHDIST WITHCOORD
# 1) 1) "\xe5\xa4\xa9\xe5\xae\x89\xe9\x97\xa8"   <- 自己也会返回,距离 0
#    2) "0.0000"
#    3) 1) "116.397..." 2) "39.916..."
# 2) 1) "前门"  2) "1.3140" ...

# 按坐标搜索(更常用:用户当前位置)
GEOSEARCH cities FROMLONLAT 116.40 39.92 BYRADIUS 5 km ASC COUNT 10 WITHDIST

# 矩形范围搜索(6.2+,比圆形更适合地图视口)
GEOSEARCH cities FROMLONLAT 116.40 39.92 BYBOX 10 10 km ASC

# COUNT n ANY:找到 n 个就立即返回,不保证是最近的 n 个,但快很多
GEOSEARCH cities FROMLONLAT 116.40 39.92 BYRADIUS 50 km COUNT 10 ANY

5.4 GEO 就是 zset 的证据

TYPE cities              # zset
ZRANGE cities 0 -1 WITHSCORES
# 1) "中关村"
# 2) "4069885552230465"     <- 这就是 GeoHash 编码的 52 位整数
# ...

ZSCORE cities 天安门        # 直接拿到编码值
ZREM cities 天安门          # GEO 没有 GEODEL,就用 ZREM 删除!
ZCARD cities              # 成员数量

记住:删除 GEO 成员用 ZREM,这是面试常问的小细节。

5.5 GeoHash 原理简述

  1. 二分递归编码:把经度区间 [-180, 180] 二分,点在左半就记 0、右半记 1,再对所在半区继续二分……重复 26 次得到 26 位;纬度区间 [-90, 90] 同样得到 26 位。
  2. 交错合并(interleave):把纬度位和经度位交替穿插,得到 52 位整数(这就是 Redis 存的 score)。这一步是关键——交错让二维空间被映射成一维的 Z 阶曲线(Morton order),保证空间相邻的点编码接近。
  3. Base32 编码GEOHASH 命令返回的字符串是把这 52 位(补齐到 55 位)用 base32 表编码成 11 个字符。前缀相同的字符越多,两点越接近。

GeoHash 的固有缺陷:Z 阶曲线在跨越"格子边界"时会出现跳跃——两个物理上很近的点,如果分别落在编码边界的两侧,编码值可能差很多。Redis 的处理方法是搜索时同时查询目标格子和它周围的 8 个邻居格子(共 9 个),再对每个候选点用 Haversine 公式精确计算实际距离并过滤。这就是为什么 GEOSEARCH 是"先粗筛后精算"。

5.6 场景

# 附近的人(用户位置实时更新)
GEOADD user:locations 116.40 39.92 user1001
GEOSEARCH user:locations FROMMEMBER user1001 BYRADIUS 1 km ASC COUNT 20 WITHDIST

# 附近的门店/骑手
GEOADD shops 116.41 39.93 shop:88
GEOSEARCH shops FROMLONLAT <用户经度> <用户纬度> BYRADIUS 3 km ASC COUNT 10 WITHDIST

# 打车派单:找 2 公里内的空闲司机
GEOSEARCH drivers:idle FROMLONLAT <乘客位置> BYRADIUS 2 km ASC COUNT 5

# 地图视口内的 POI
GEOSEARCH pois FROMLONLAT <中心> BYBOX 20 15 km

生产注意

  1. GEO 数据全在一个 key(一个 zset)里,海量位置数据会形成大 key。要按城市/区域分 key(如 drivers:beijingdrivers:shanghai);
  2. 位置频繁更新(每个骑手每 5 秒上报一次)会带来很高的写 QPS,注意评估;
  3. GEOSEARCH 的复杂度是 O(N + log(M)),范围太大、成员太多时依然慢;
  4. 需要"附近 + 复杂过滤(评分、品类、营业中)“时,Redis 只适合做第一层地理粗筛,精细排序交给业务层或 ES。

6. Stream 消息流

Stream(5.0+)是 Redis 唯一真正为消息队列设计的数据类型,也是唯一在这一篇里全新实现的底层结构(Radix Tree 索引 + listpack 存条目)。

它借鉴了 Kafka 的核心设计:append-only 日志 + 消费组 + offset,弥补了 pub/sub(消息不持久、订阅者掉线就丢)和 list(无消费组、无确认、无回溯)的所有缺陷。

这里给出完整命令,第 9 篇会展开讲消息队列的完整实践。

6.1 生产消息

XADD key [NOMKSTREAM] [MAXLEN|MINID [=|~] threshold [LIMIT n]] *|id field value [field value ...]
# * 表示让 Redis 自动生成 ID
XADD orders * orderId 1001 amount 99.5 userId 2001
# "1721890000123-0"   <- ID 格式:毫秒时间戳-同毫秒内序号

# 自动裁剪,只保留最近 10000 条(~ 表示近似裁剪,性能更好)
XADD orders MAXLEN ~ 10000 * orderId 1002 amount 50

# 只保留 ID 大于某值的(按时间裁剪)
XADD orders MINID ~ 1721800000000 * orderId 1003

# NOMKSTREAM:stream 不存在时不创建,返回 nil
XADD orders NOMKSTREAM * orderId 1004

ID 的规则<毫秒时间戳>-<序号>,单调递增。可以自己指定 ID,但必须比现有最大 ID 大。7.0+ 支持 <ms>-* 让 Redis 自动补序号。

6.2 读取消息

XLEN key                          # 消息条数
XRANGE key start end [COUNT n]    # 按 ID 范围正序读,- 和 + 表示最小/最大
XREVRANGE key end start [COUNT n]  # 倒序
XDEL key id [id ...]              # 删除指定消息(只是标记删除,不回收 listpack 空间)
XTRIM key MAXLEN|MINID [=|~] threshold   # 手动裁剪
XRANGE orders - + COUNT 10           # 最早的 10 条
XREVRANGE orders + - COUNT 1         # 最新的 1 条
XRANGE orders 1721890000000 +        # 某时间点之后的全部
XRANGE orders (1721890000123-0 +     # 6.2+ 用 ( 表示排他(不含该 ID)

6.3 独立消费(无消费组)

XREAD [COUNT n] [BLOCK ms] STREAMS key [key ...] id [id ...]
# 读 ID 大于指定值的消息
XREAD COUNT 10 STREAMS orders 0        # 从头读
XREAD COUNT 10 STREAMS orders 1721890000000-0

# $ 表示"只读此刻之后的新消息",配合 BLOCK 实现阻塞订阅
XREAD BLOCK 0 STREAMS orders $
# 阻塞等待,有新消息就返回。BLOCK 0 = 永久阻塞

# 同时监听多个 stream
XREAD BLOCK 5000 STREAMS orders payments $ $

这种模式类似 pub/sub 的"广播”,但消息是持久化的、可回溯的。缺点是没有消费进度记录(客户端要自己记住上次读到哪),也不能多个消费者分摊负载。

6.4 消费组(核心)

消费组解决两个问题:负载均衡(一条消息只被组内一个消费者处理)和消息确认(未 ACK 的消息可以重新投递)。

# 创建消费组
XGROUP CREATE key groupname id|$ [MKSTREAM] [ENTRIESREAD n]
XGROUP CREATE orders g1 0 MKSTREAM     # 从头开始消费,stream 不存在则创建
XGROUP CREATE orders g1 $              # 只消费此后的新消息

XGROUP SETID orders g1 0               # 重置消费组的位置(重新消费)
XGROUP CREATECONSUMER orders g1 c1     # 显式创建消费者
XGROUP DELCONSUMER orders g1 c1        # 删除消费者(其 pending 消息会丢到组的 PEL 里)
XGROUP DESTROY orders g1               # 删除消费组
# 组内消费
XREADGROUP GROUP group consumer [COUNT n] [BLOCK ms] [NOACK] STREAMS key id

# > 表示"给我这个组从未投递过的新消息"
XREADGROUP GROUP g1 consumer1 COUNT 10 BLOCK 0 STREAMS orders >

# 指定具体 ID(如 0)表示"给我这个消费者已投递但未 ACK 的消息"(用于重启后重试)
XREADGROUP GROUP g1 consumer1 STREAMS orders 0

# NOACK:不进 PEL,投递即忘(性能好但会丢消息)
XREADGROUP GROUP g1 consumer1 NOACK STREAMS orders >
# 确认消息(从 PEL 移除)
XACK key group id [id ...]

6.5 PEL 与消息重投

**PEL(Pending Entries List)**是消费组的核心:每条被 XREADGROUP 投递出去的消息都会进入 PEL,记录"投递给了谁、投递时间、投递次数",直到被 XACK

# 查看待确认消息
XPENDING key group                              # 概要:数量、最小/最大 ID、每个消费者的数量
XPENDING key group [IDLE ms] start end count [consumer]   # 明细
XPENDING orders g1 - + 10                       # 详细列出 10 条
# 1) 1) "1721890000123-0"    <- 消息 ID
#    2) "consumer1"          <- 属于哪个消费者
#    3) (integer) 300000     <- 空闲了多少毫秒
#    4) (integer) 3          <- 已投递次数

当某个消费者崩溃、它的 PEL 消息就"卡住"了。用 XCLAIMXAUTOCLAIM 把这些消息转移给健康的消费者:

# 手动认领:把空闲超过 60 秒的消息转给 consumer2
XCLAIM key group consumer min-idle-time id [id ...]
  [IDLE ms] [TIME ms-ts] [RETRYCOUNT n] [FORCE] [JUSTID]

XCLAIM orders g1 consumer2 60000 1721890000123-0

# 6.2+ 自动认领(推荐,不用先 XPENDING 再 XCLAIM)
XAUTOCLAIM key group consumer min-idle-time start [COUNT n] [JUSTID]
XAUTOCLAIM orders g1 consumer2 60000 0 COUNT 10
# 返回:1) 下一次的起始游标  2) 认领到的消息  3) 已不存在被清理的 ID(7.0+)

死信处理:靠 RETRYCOUNT。如果一条消息投递次数超过阈值(比如 5 次)还没成功,说明它有毒(poison message),应该 XACK 掉并转存到一个专门的"死信 stream"里人工处理。

6.6 监控命令

XINFO STREAM key [FULL]     # stream 概况:长度、radix tree 节点数、首末消息、消费组数
XINFO GROUPS key            # 所有消费组:名称、消费者数、pending 数、last-delivered-id、lag
XINFO CONSUMERS key group   # 组内消费者:名称、pending 数、idle 时间、inactive 时间

XINFO GROUPS 里的 lag(7.0+)是运维最关心的指标:表示这个组还有多少条消息没消费,等价于 Kafka 的 consumer lag。

6.7 一个完整的消费者伪代码

# 1. 启动时先处理自己之前未 ACK 的消息(崩溃恢复)
XREADGROUP GROUP g1 consumer1 COUNT 100 STREAMS orders 0
# → 处理并 XACK,直到返回空

# 2. 进入正常消费循环
while true:
    XREADGROUP GROUP g1 consumer1 COUNT 10 BLOCK 5000 STREAMS orders >
    for msg in result:
        try:
            process(msg)
            XACK orders g1 msg.id
        except:
            log(...)   # 不 ACK,留在 PEL 等重试

# 3. 另有一个巡检任务,认领超时消息
XAUTOCLAIM orders g1 consumer1 60000 0 COUNT 10

6.8 Stream 的底层结构

  • **Radix Tree(基数树 / 压缩前缀树)**作为索引,key 是消息 ID(16 字节:8 字节毫秒 + 8 字节序号)。因为 ID 是时间递增的,前缀高度重复,radix tree 压缩效果极好;
  • 树的叶子节点挂 listpack,每个 listpack 存多条消息。listpack 内部还做了字段名去重(master entry 机制):同一个 listpack 里第一条消息记录完整字段名,后续消息如果字段名相同就只存值,大幅节省内存。

这个设计让 Stream 既能 O(logN) 按 ID 定位、又能顺序扫描、还很省内存。

XDEL 的注意点XDEL 只是给 listpack 里的条目打删除标记,不会立即回收空间XLEN 会减少但内存不一定降。真正回收要靠 XTRIM/MAXLEN(整个 listpack 节点都被裁掉时才释放)。

6.9 Stream 与其他方案对比

能力 pub/sub list Stream Kafka
消息持久化
消费者掉线不丢
消息确认(ACK)
消费组负载均衡 伪(抢)
消息回溯
多消费组独立消费 广播但不持久
消息顺序 分区内
阻塞消费
吞吐量 高(10w+/s) 极高(百万级)
存储上限 - 内存 内存 磁盘(TB 级)
事务/Exactly-once

结论:Stream 适合"中小规模、要求可靠但不需要海量堆积"的场景。数据量大到 TB 级、需要长期保存与重放、需要精确一次语义时,还是要用 Kafka/RocketMQ——因为 Stream 的数据在内存里,堆积就是内存爆炸。


7. 高频面试题

Q1:Bitmap 的底层是什么?能存多少数据?

底层就是 stringSETBIT/GETBIT 是对 string 的按位操作。string 上限 512MB = 2^32 bit,所以一个 Bitmap 最多表示约 42.9 亿个布尔状态(offset 范围 0 ~ 2^32-1)。

最大的坑是内存按最大 offset 分配SETBIT k 4000000000 1 会立即分配 500MB。所以 offset 必须是从 0 开始的连续小整数,绝不能直接用手机号、UUID 哈希这种稀疏值。

Q2:统计 UV 用 set、Bitmap 还是 HyperLogLog?

看三个维度:精度要求、数据量、ID 是否连续

  • set:100% 精确、可以列出成员、可以判断某人是否访问过。但内存最大(1 亿 userId 要几 GB)。适合数据量小(百万以内)或需要成员明细的场景;
  • Bitmap:100% 精确、内存约 maxUserId/8 字节(1 亿用户 12.5MB)、还能用 BITOP 做多天留存分析。但要求 userId 是连续小整数。是精确统计的最优解;
  • HyperLogLog:固定 12KB(小基数时更少),能统计到 2^64,但有 0.81% 标准误差,不能列成员、不能判断存在性。适合亿级 UV 的粗略统计。

实际工程里常常混用:全站 UV 用 HLL(省内存),需要精确留存分析的核心指标用 Bitmap,小范围的(如单个活动参与者)用 set。

Q3:HyperLogLog 的原理是什么?为什么是 12KB?

原理是基于伯努利试验的概率估算 + 分桶调和平均

  1. 元素哈希成 64 位均匀分布的值;
  2. 观察哈希值低位连续 0 的个数 ρ,直觉是"出现 ρ 个连续 0 的概率是 2^(-ρ-1)",所以看到最大 ρ 为 k 就推测有约 2^k 个不同元素;
  3. 单次估算方差太大,于是用低 14 位分成 16384 个桶,每桶记录该桶内的最大 ρ;
  4. 用所有桶的调和平均数做估算并乘修正系数(调和平均对大离群值不敏感,比算术平均稳健)。

内存:16384 × 6 bit = 98304 bit = 12KB。误差:1.04/√16384 ≈ 0.81%

补充加分点:Redis 实现了稀疏编码,小基数时用 RLE 压缩只占几十字节,超过 hll-sparse-max-bytes(默认 3000)才转成 12KB 的稠密编码,且不可逆。

Q4:PFCOUNT 为什么不能在从节点执行?

因为 PFCOUNT 虽然语义上是"读",但它会把计算出的基数缓存到 HLL 的 header 里(HLL 头部有个 card 字段和一个"缓存是否有效"标志位),下次 PFCOUNT 如果没有新的 PFADD 就直接返回缓存值,避免重复遍历 16384 个桶。

这个缓存写入使 PFCOUNT 变成了一个会修改 key 的命令,所以它被标记为写命令,在只读从节点上执行会报错,在 Lua 的只读脚本(redis.set_replEVAL_RO)里也不能用。

Q5:GEO 的底层是什么?怎么删除一个位置?

底层是 zset。GEO 把经纬度用 GeoHash 编码成一个 52 位整数作为 score,member 就是地点名。

因为它就是 zset,所以:

  • 删除用 ZREM(GEO 没有提供 GEODEL 命令);
  • ZCARD 看数量、ZSCORE 看编码值、ZRANGE 遍历都能用;
  • TYPE 返回 zset

Q6:GeoHash 是怎么把二维坐标变成一维数值的?为什么搜索要查 9 个格子?

编码过程:分别对经度 [-180,180] 和纬度 [-90,90] 做 26 次二分(在左/下半记 0,右/上半记 1),然后把经纬度的 bit 交错穿插成 52 位整数。这个交错等价于把二维平面按 Z 阶曲线(Morton order) 展开成一维,使得空间上相邻的点数值也接近。

但 Z 阶曲线有边界跳跃问题:两个物理距离只有几米的点,如果正好落在编码格子的分界两侧,编码值可能差好几个数量级(例如一个在 01111...,一个在 10000...)。

所以 Redis 的 GEOSEARCH 做法是:先根据搜索半径确定合适的格子精度,然后同时扫描目标格子和它周围的 8 个邻居格子(共 9 个),把所有候选点收集起来,再用 Haversine 公式逐个精确计算球面距离并过滤掉超出半径的。这就是"粗筛 + 精算"两阶段。

Q7:Stream 相比 list 做消息队列有什么优势?

list 的缺陷 Stream 全部解决了:

  1. 消费组:list 只能"抢"(一个消息被一个客户端 BRPOP 走),无法做"多个业务系统各自完整消费一遍";Stream 支持多个消费组,每组独立维护进度,组内多消费者负载均衡;
  2. 消息确认(ACK)与重投:list BRPOP 后消息就没了,消费者崩溃消息就丢;Stream 有 PEL 记录未确认消息,可以用 XCLAIM/XAUTOCLAIM 重新投递给其他消费者;
  3. 消息回溯:list 弹出即删除,无法重放;Stream 是 append-only 日志,可以用 XRANGE 按 ID/时间任意读取历史;
  4. 消息 ID 与顺序保证:Stream 的 ID 是"毫秒时间戳-序号",天然有序且能定位时间点;
  5. 一条消息多字段:Stream 的条目是 field-value 结构,不用自己序列化 JSON。

代价是 Stream 更复杂(要管理消费组、PEL、裁剪)、内存开销略高。

Q8:Stream 的 PEL 是什么?消息不 ACK 会怎样?

PEL(Pending Entries List) 是每个消费组维护的"已投递但未确认"消息列表,记录每条消息的:ID、当前归属的消费者、最后投递时间、投递次数。

消息不 XACK 的后果:

  1. 永远留在 PEL 里,占用内存(PEL 只存 ID 和元信息,但消息本体也不能被 XTRIM 安全裁掉);
  2. XPENDING 数量持续增长,XINFO GROUPSlag 看起来正常但 pending 堆积;
  3. 这条消息不会自动重投——必须有人主动 XCLAIM/XAUTOCLAIM(按 min-idle-time 筛超时的),或者消费者用 XREADGROUP ... STREAMS key 0 重新拉自己的 PEL。

所以生产上必须有一个巡检任务周期性执行 XAUTOCLAIM,并且对 RETRYCOUNT 超过阈值的消息做死信处理(ACK 掉 + 转存死信 stream),否则毒消息会被无限重投。

Q9:Stream 会无限增长吗?怎么控制内存?

会。Stream 是 append-only 的,不裁剪就会一直涨到打爆内存。控制手段:

  1. XADD 时带 MAXLEN/MINID 自动裁剪(推荐):XADD s MAXLEN ~ 10000 * f v。用 ~(近似裁剪)而不是 =(精确裁剪),因为近似裁剪只在能整个删掉一个 listpack 节点时才删,性能好得多;
  2. 定期 XTRIMXTRIM s MINID ~ <7天前的时间戳> 按时间保留;
  3. 注意 XDEL 不释放内存,只是打标记;
  4. 裁剪会丢掉还没被消费的消息——如果消费组有滞后,MAXLEN 太小会直接丢消息。所以裁剪阈值要留足余量,并监控 XINFO GROUPSlag

Q10:什么时候用 Stream,什么时候必须上 Kafka?

Stream

  • 消息量中等(每天千万级以内)、堆积可控;
  • 已经有 Redis 不想再引入中间件;
  • 需要低延迟(微秒级)的轻量任务队列;
  • 消息生命周期短(几小时到几天)。

必须用 Kafka/RocketMQ

  • 数据量 TB 级、需要长期(几周到几个月)保存与重放;
  • Stream 数据全在内存,堆积就是内存成本,而 Kafka 是磁盘顺序写,堆积几乎无成本;
  • 需要极高吞吐(百万级 TPS)和水平扩展的分区模型;
  • 需要事务消息、Exactly-Once 语义、Schema 管理、流处理生态(Kafka Streams/Flink 集成);
  • 需要严格的持久化保证(Redis 的 AOF everysec 仍可能丢 1 秒数据,且 Stream 在主从异步复制下主节点宕机可能丢消息)。

一句话:Stream 是"够用的轻量 MQ",不是 Kafka 的替代品。

Q11:BITCOUNTBITOP 会阻塞主线程吗?

会。两者复杂度都是 O(N),N 是字符串的字节数。

好在常数极小(底层用查表法 + SIMD 友好的字节遍历),实测 12.5MB 的 bitmap 做 BITCOUNT 约 1~5ms,BITOP AND 类似。单次操作可以接受。

但风险在批量场景:要算 30 天留存做 30 次 BITOP,累计上百毫秒的阻塞就会造成明显的延迟毛刺。生产建议:

  1. BITCOUNT key start end 限定范围减少扫描量;
  2. 把批量分析任务放到从节点执行(BITCOUNT 是只读的,BITOP 是写的所以不行);
  3. 大规模离线分析用数据仓库,Redis 只做实时的单日/少量天数查询。

小结

  • Bitmap = string 的按位视图,1 亿布尔状态只要 12.5MB;核心坑是内存按最大 offset 分配,offset 必须连续小整数;BITOP 做留存分析很强但要注意 O(N) 阻塞和 NOT 的全字节取反问题。
  • Bitfield 把 string 当成任意位宽的整数数组,OVERFLOW SAT/WRAP/FAIL 三种溢出策略,用于极致压缩小整数。
  • HyperLogLog = 分桶(16384 桶 × 6bit = 12KB)+ 调和平均的概率算法,误差 0.81%;小基数用稀疏编码更省;只能加不能删、不能查成员、PFCOUNT 是写命令(从节点不能执行)。
  • GEO = zset + GeoHash(52 位整数作 score);删除用 ZREM;搜索是"查 9 个格子粗筛 + Haversine 精算";生产要按区域分 key 避免大 key。
  • Stream 是唯一为 MQ 设计的类型:Radix Tree + listpack,支持消费组、PEL、ACK、XAUTOCLAIM 重投、消息回溯;必须配 MAXLEN ~/XTRIM 裁剪 + 巡检任务 + 死信处理;数据在内存所以不能替代 Kafka。
  • 记住哪些是"伪类型":Bitmap/Bitfield/HLL 底层是 string,GEO 底层是 zset,只有 Stream 是全新结构。