做后端这些年,Redis 里 String 和 Hash 是每天打交道的基础,真正让系统省心省力的,反而是 Stream、Geospatial、HyperLogLog、Bitmaps、Bitfields 这些平时用得少、但关键时刻能顶上大用的高级数据类型。我接手过一个签到系统改造,原来每天几千万条签到明细直接往 MySQL 里堆,后来改用 Bitmaps 做签到标记、HyperLogLog 做去重统计,存储开销砍了两个数量级,查询从秒级掉到毫秒级。这篇文章就把这五类高级数据类型逐个拆开讲清楚:它们解决什么问题、底层大概长什么样、关键命令怎么用、什么场景可以放心上、什么场景千万别硬塞。内容偏实操,适合正在做技术选型,或者觉得“Redis 只会用 String 存缓存”的朋友参考。
1. Redis Stream:从 List 的消息队列进化
1.1 Stream 的核心数据模型:追加日志与自动 ID
在 Redis 5.0 之前,用 Redis 做消息队列基本只有两条路:用 List 的 LPUSH 配合 BRPOP 实现一个简单的任务队列,或者用 Pub/Sub 做广播通知。前者的问题是消息消费后立刻没了,不存在“消费确认”和“重新投递”的机制;后者更脆弱,消费者一旦掉线,发布期间的实时消息直接丢失。Stream 从设计上就是为了补这两个洞:既能持久化存储,又能像消息中间件一样管理消费状态。
Stream 本质上是一个追加型日志结构,新消息永远往尾部写,每条消息由唯一 ID 标识。XADD 是写入命令,通常这样用:
bash复制XADD events * user_id 1024 action login
这里的 * 表示让 Redis 自动生成 ID。它生成的 ID 格式是“毫秒时间戳-序号”,比如 1716941930821-0,同一毫秒内有多条消息时,序号递增。因为 ID 天然跟时间相关,所以用 XRANGE 按 ID 范围查询时,返回结果天然是有序的,这就是能够实现断点续传、增量拉取的基础。
bash复制XRANGE events - + COUNT 5
XLEN events
XDEL events 1716941930821-0
- 和 + 分别指最小 ID 和最大 ID,实际业务里可以用 XREAD STREAMS events 1716941930821-0 从指定 ID 之后继续读,模拟“我上次读到这里,这次从这往后拉”的效果。Stream 还提供了 XTRIM,可以用 MAXLEN 或 MINID 控制消息数量:
bash复制XADD events MAXLEN ~ 1000 * user_id 1024 action login
~ 是模糊裁剪的意思,允许 Redis 在超过上限后再统一清理,避免每次追加都去精确删除,性能上稳定很多。消息不会无限增长,对长期运行的生产环境来说,这个参数基本是必须配的,否则内存迟早被撑爆。
1.2 消费者组:让消费流程变得可靠
Stream 最吸引我的功能是消费者组。如果不分组,多个客户端直接 XREAD,每条消息只会被其中一个客户端拿到,但消费状态没有任何保障,一个客户端拿到消息后崩了,消息就丢了。消费者组机制在这之上加了一层“被读取但未确认”的管理能力。
创建消费者组用 XGROUP:
bash复制XGROUP CREATE events group_1 0
0 表示从 Stream 第一条消息开始消费,也可以填 $,表示只消费组创建之后新到的消息。消费者读取消息:
bash复制XREADGROUP GROUP group_1 consumer_1 COUNT 10 BLOCK 5000 STREAMS events >
这里的 > 是最关键的地方,它表示只读取“这个组之前没有分配给任何消费者的新消息”。BLOCK 5000 是阻塞等待 5 秒,没有新消息就返回空。每次读取后,消息不会自动消失,会进入组的待确认列表 PEL(Pending Entries List),必须由消费者处理完后显式确认:
bash复制XACK events group_1 1716941930821-0
这个设计跟 Kafka 的 offset 提交有几分相似,但更轻量。如果一个消费者读了消息却挂掉了,可以用 XPENDING 查看堆积:
bash复制XPENDING events group_1
XRANGE events 1716941930821-0 1716941930821-0
再配合 XCLAIM 把 pending 中的超时消息转给另一个消费者接管,就构成了一个简易的“失败重试”闭环。实际项目里没必要完全照搬 Kafka 那套,但 Stream 这种“手动 ACK + PEL 重试”的机制,确实能把消息丢失的概率降到非常低。
1.3 Stream 的使用边界与常见坑
Stream 并不适合所有消息场景。如果吞吐量到百万级每秒、要求严格分区顺序和消息保留策略,还是去用专业的消息中间件更稳妥。Stream 更适合中小规模、希望少维护一套组件、又需要消息持久化的业务。比如订单状态变更记录、用户行为事件流、定时任务调度队列,这套东西都够用。
常见的坑有两个。第一是忘记 XACK。我有一次排查线上消息越积越多,看 XPENDING 发现几千条全部处于 pending 状态,查代码才发现消费者处理完消息后没有 ACK。消息不会因为没 ACK 而消失,只会越攒越多,最后消费又被重新投递导致重复处理。第二是消费者组和消息保留时长配合的问题。Stream 不像 Kafka 那样有独立的消息过期时间,如果你用 XTRIM 裁剪旧消息,组里的 pending 消息也可能被一起清掉,这时候 XPENDING 里还会留着 ID,再去 XCLAIM 会查不到数据。设计时要想清楚:消息保留多久、组内失败重试多久,这两套策略需要一致。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Geospatial:用 Redis 做地理位置计算
2.1 底层原理:Sorted Set 上的坐标编码
Geospatial 类型的底层实现非常巧妙。你以为 Redis 单独为地理坐标设计了一套存储结构,实际上它复用 Sorted Set。每个地理位置是一个元素,Score 存的是“经纬度经过 geohash 编码后的 52 位整数”。这么设计的好处是:不需要新造一套数据结构,地理位置的排序、范围查找、去重能力,全都能继承过来。
geohash 的思想是把地球看成二维平面,按经纬度递归二分,得到一个一维编码。编码上相邻位置,在空间上往往也相邻。Redis 将这个编码作为 sorted set 的 score,所以做地理范围查询时,实际上是在 sorted set 上做 score 区间扫描,天然支持索引,性能非常好。
常用命令有这五个:
bash复制GEOADD cities 116.397 39.908 beijing 121.473 31.230 shanghai
GEOPOS cities beijing
GEODIST cities beijing shanghai km
GEOSEARCH cities FROMLONLAT 116.397 39.908 BYRADIUS 100 km ASC
GEODIST 算两地距离,单位可以是 m、km、mi、ft。GEOSEARCH 是 Redis 6.2 引入的新命令,用来替代老的 GEORADIUS,按圆心、半径搜索范围内的元素,支持按距离排序。FROMLONLAT 表示从指定坐标开始找,也可以换成 FROMMEMBER,从某个已有元素的坐标开始找。
2.2 核心命令与附近的人实战
做“附近的人”、“附近的店铺”这类功能时,流程通常是这样:用户登录时把他的位置写进 GEO key,查询时用 GEOSEARCH 拿到以当前坐标为中心、半径 N 公里内的所有 ID,再按距离排序返回。
bash复制GEOADD user_location 116.397 39.908 user_1001
GEOADD user_location 121.473 31.230 user_1002
GEOSEARCH user_location FROMLONLAT 116.397 39.908 BYRADIUS 100 km ASC
返回结果按距离从近到远,如果业务方只要 ID 信息,就在 GEOSEARCH 后加 WITHDIST,还能把距离值一起返回。做外卖派单时,我见过一个方案是把每个配送员的位置实时更新到一个 GEO key 里,新订单进入时直接半径搜索最近的配送员,再配合 ZUNIONSTORE 把多个筛选条件合在一起。这种做法的好处是查询很快,毫秒级返回,比在 MySQL 里用三角函数做经纬度距离计算快太多,而且天然支持数据量增长——有上百万个位置点也能扛。
2.3 精度限制与实际项目里的取舍
这里必须给 GEO 泼点冷水。Redis 的 GEO 假设地球是一个标准球体,计算距离用的是近似球面距离公式,官方文档里给的数据是:距离在几百公里内,误差约 0.5%,超出 800 公里误差会更大。做城市内地理位置匹配绰绰有余,但要是做跨洋航运、卫星定位这类高精度场景,还是得用专业 GIS 引擎。
还有一个容易踩的坑:经纬度参数传反。GEOADD 的格式是经度在前、纬度在后,跟很多人习惯的“纬度, 经度”相反。我见过不止一次因为传反坐标,把上海附近的用户定位到了太平洋里。排查办法很简单,用 GEOPOS 查一下返回坐标是否符合直觉。
半径值也要控制。GEOSEARCH 的 BYRADIUS 如果给得过大,比如一次搜几千公里的半径,Redis 要扫描的 score 区间会变得很大,CPU 消耗会显著上升。实际业务里一般限制在 5 到 50 公里,超过这个范围的分页或者不走 GEO。
3. HyperLogLog:海量数据里的基数“估算师”
3.1 统计原理:12KB 如何搞定亿级去重
初次接触 HyperLogLog 的人都会觉得不可思议:固定内存只有 12KB,却能统计几十亿条数据的去重基数,标准误差还控制在 0.81% 左右。我第一次看到官方文档时的反应是“这也太神了”。理解它,先理解“基数”这个词:一组数据里不重复元素的个数。比如一千万次访问,里面只有 20 万个不同用户,基数就是 20 万。
HyperLogLog 的做法非常聪明。它不给每个元素分配一个独立空间存储,而是对每个元素做哈希,然后观察这个哈希值“低位连续 0 的个数”。随机生成的哈希值,末尾连续 0 的数量越多,说明这个数据点在随机分布中越稀有;如果多个哈希值里出现了比较长的连续 0,就可以推测数据量足够大。实际操作比这个描述复杂一点:Redis 把哈希值分成多个桶,每个桶保留各自的“最长连续 0 记录”,最后将所有桶的记录聚合起来估算整体基数,所以误差比较稳定。
正因为不存原始数据,HyperLogLog 有一个天然限制:只能告诉你“有多少个”,没法告诉你“有哪些”。你不能从里面取出某个具体元素,也不能判断某个元素是否被重复加入,这跟 Set 是完全不同的。
3.2 常用命令与 PV/UV 场景落地
用到 HyperLogLog 最常见的场景就是独立访客统计。每天的日活、独立访问 IP、独立用户数,这些需求要是用精确 Set 去重,几百万活跃用户就得占用几十 MB 甚至上 GB 内存;用 HyperLogLog 基本不随数据量增长而增长,一个 key 永远是 12KB 左右。
bash复制PFADD traffic:day:20240101 user_1001 user_1002 user_1003
PFCOUNT traffic:day:20240101
PFMERGE traffic:week1 traffic:day:20240101 traffic:day:20240102 traffic:day:20240103
PFADD 往里塞元素,PFCOUNT 读取去重后的估计值。PFMERGE 特别适合做周活、月活这类跨天汇总:把一周里的 7 个日 PV key 合并到一起,就能拿到周的独立用户数估计值,不需要把所有明细重新入库。
我们项目里实际是配合 Bitmaps 一起用的。Bitmaps 记录每天哪些用户签到,HyperLogLog 记录每天的独立访客。查询日活时直接 PFCOUNT,1 秒内返回结果;运营报表需要的误差精度 0.81% 完全够用,没必要为了一个精确值去承担昂贵的明细计算成本。
3.3 使用 HyperLogLog 要克制的地方
HyperLogLog 适合“只关心近似数”的场景,不适合对账、财务、计数等必须精确的场景。凡是需要倒推“具体是哪些用户”的报表,也不要用它,换成 Set 或者存明细。我踩过的一个坑是拿它统计商品详情页的去重点击用户,产品经理问“能不能把点击过的用户列表导出”,我瞬间就傻了——HLL 里啥也导不出来。
另一个问题是同一个 key 塞了不同维度的数据。比如一个 key 既加 UV 又加某个活动的参与人数,算出来的结果不是理论上两部分数据的并集,因为你污染了同一个哈希统计状态。正确做法是每个维度一个独立 key,最后用 PFMERGE 合并。
还有一点容易被忽略:PFCOUNT 在 key 不存在时,返回 0;但如果这个 key 是 HyperLogLog 类型但内部数据量特别少,估算误差可能比 0.81% 大。所以要习惯用“误差边界”而不是“精确值”去理解它。有时候产品会拿需求文档来 challenge “为什么昨天日活明明是 1.1 万,你统计出来是 1.09 万”,这时候要解释清楚 HLL 是概率算法,0.81% 的误差是设计特性。如果产品非要精确值,那只能换方案。
4. Bitmaps 与 Bitfields:位级别的高效存储
4.1 Bitmaps 的本质:用数组位图做标记
Bitmaps 不是独立的数据结构,底层就是普通字符串,只不过把每个字节拆成 8 个二进制位来用。你可以把它看成一个巨大的位数组:每一位的值是 0 或者 1,offset 就是位下标。SETBIT 和 GETBIT 的时间复杂度都是 O(1),以为着不管你的位图有多大,读写单个位都极快。
这种结构特别适合表达“是/否”这种二元状态,而且空间极其省。一个用户一年 365 天的签到记录,如果一天用一个 bit,总共才 46 字节左右;一亿用户一年的签到数据用 Bitmaps 也只要不到 600MB。换成 MySQL 一张明细表,几个亿行数据,查询和存储负担完全是两个级别。
常用命令:
bash复制GETBIT key offset
SETBIT key offset value
BITCOUNT key [start end]
BITOP operation destkey key [key ...]
BITCOUNT 统计一个位图里有几个 1,也就是“签到了几天”。BITOP 可以对多个位图做 AND、OR、XOR 等运算,结果是复合状态。
4.2 签到与在线状态的完整例子
以签到功能为例。用户 ID 1001 在 2024 年第 100 天签到,可以这样记录:
bash复制SETBIT sign:2024:1001 100 1
SETBIT sign:2024:1001 101 1
BITCOUNT sign:2024:1001
第二天产品想看本月签到人数,可以直接 BITCOUNT。要判断某一天是否签到,GETBIT 返回 1 就是签过。我见过一个更精细的方案:把“连续签到 7 天发奖励”这类规则,直接用一段 Lua 脚本配合 GETBIT 逐日检查,能免掉多次 Redis 往返。
在线状态场景同理。给每个在线用户用一个全局位图标记心跳,偏移就是用户 ID。比如用户 ID 5000 在线,就 SETBIT online_users 5000 1。统计在线总数直接用 BITCOUNT,半小时后清空一次位图或者生成新的 key,就能得到每个时间窗口的在线趋势。用户的 ID 不适合从 0 起步的场景,可以先做一个 ID 到连续整数的映射,避免位图过大。
这个方案要注意 offset 的上限。Redis 字符串最大 512MB,偏移上限是 2^32-1。如果用户 ID 特别大,比如 65 位雪花 ID,那不能直接当 offset 用,否则一个位图就爆了。先做 ID 映射或对小型业务做内部短 ID 转换是常见解法。
4.3 BITFIELD:把 Redis 字符串当整数寄存器用
Bitfields 相对于 Bitmaps 更进一步。Bitmaps 只操作单个 bit,Bitfields 则允许把字符串的某一段位区间当成一个整数来读写,可以是 8 位、16 位、32 位甚至 64 位,带符号或无符号都行。最关键的是 SET、INCRBY 这类操作都是原子的,单个命令内部不会被打断,天然适合高并发的计数场景。
命令格式:
bash复制BITFIELD key SET u8 0 50
BITFIELD key INCRBY u32 8 1
第一个命令把 key 值中从 offset 0 开始的 8 位无符号整数设为 50。第二个命令把 offset 8 开始的 32 位无符号整数加 1。你可以想象把一个 Redis 字符串切成了几个“小格子”,每个格子当作一个独立的计数器用。这种紧凑布局适合多类指标合一个 key 的场景。
举一个实际例子:抢购活动的库存扣减。传统做法是先 GET 再扣再 SET,三个步骤之间没法保证原子性,需要加分布式锁。用 BITFIELD 可以一条命令修掉:
bash复制BITFIELD seckill:item_1001 INCRBY u16 0 -1
把库存看成 offset 0 处一个 16 位无符号数,每次抢购直接减 1。Redis 单命令执行是原子的,天然不会出现超卖。要限制最低只能减到 0,配合 OVERFLOW SAT 或 FAIL 控制溢出行为。
4.4 Bitfield 溢出控制与注意事项
BITFIELD 的默认溢出行为是 WRAP,也就是像一个循环计数器一样绕回。比如 u8 的最大值是 255,再加 1 会变成 0。这个默认行为对支付、库存这类业务很危险,所以必须显式设置溢出策略:
bash复制BITFIELD seckill:item_1001 OVERFLOW SAT INCRBY u16 0 -1
SAT 是饱和运算:有符号整数到达最小值或最大值后保持不变,这样库存减到 0 就不会变成负数。另一种是 FAIL:超出范围时命令返回空,由业务侧处理。
字节序问题也容易踩坑。BITFIELD 默认按大端序(big-endian)解析位,也就是高位在前。如果其他团队用 C 语言的小端序结构体往同一个 key 写值,两边读出来的整数会不一致。跨语言协作时要约好统一使用 BITFIELD 的默认大端序,或者干脆各自独立读写的字段分开偏移。
使用 Bitfield 时要注意单个命令能操作的位宽限制。Redis 支持最大 64 位的整数类型,如果你需要表达更大范围,比如 128 位的计数器,就得自己拆成多个字段处理。另外 BITFIELD 是操作字符串型 key,如果 key 不存在会先自动创建空字符串;对非常大的 offset 操作会导致底层字符串膨胀,所以对 key 的字段布局要提前规划好。
5. 数据类型的选型对照与排查备忘
5.1 五种类型怎么选:一张表看懂
经常有人问“这个需求到底用哪种类型”,我的建议是先看本质:你要的是有序消息、地理范围、去重计数、位标记,还是紧凑整数。下面这张表是我整理过多次的判断参考:
| 数据类型 | 底层依赖 | 本质作用 | 典型场景 | 内存消耗特征 |
|---|---|---|---|---|
| Stream | 独立日志结构 | 消息持久化、消费组管理 | 任务队列、事件流、日志采集 | 随消息量线性增长,支持裁剪 |
| Geospatial | Sorted Set | 经纬度编码与距离计算 | 附近的人、外卖派单、LBS 推荐 | 同 Sorted Set,约等于成员数量 |
| HyperLogLog | 概率结构 | 基数估算、去重计数 | UV 统计、独立 IP 数、渠道去重 | 固定约 12KB,几乎不随数据量增长 |
| Bitmaps | 字符串位图 | 单个 bit 的开关状态 | 签到、在线状态、功能开关 | 等于最大偏移量对应的字节数 |
| Bitfields | 字符串位段 | 紧凑整数原子计算 | 库存、限流、计数器、多指标聚合 | 等于最大 offset 所在的字节数 |
选型时要反向问自己三个问题:数据量有多大?能接受误差吗?需要随机访问吗?如果数据量不大,用 Set 和 Hash 更简单直接;如果能接受 0.81% 误差,HyperLogLog 就是最优解;如果消息量小且要求不高,List 队列反而比 Stream 省心。技术选型没有银弹,很简单的东西用复杂类型反而是过度设计。
5.2 运维排查时的几个实用姿势
高级类型用久了,排查问题的方法也可以沉淀下来。第一个习惯是学会用 MEMORY USAGE 检查单个 key 的真实内存占用:
bash复制MEMORY USAGE events
MEMORY USAGE traffic:day:20240101
这在定位“某个 Stream 是不是傍着整个 Redis 内存跑”时特别有用。我之前排查过一次 Redis 内存告警,先跑 SCAN 找到所有 stream 开头的大 key,再用 MEMORY USAGE 看具体占用,最后发现是测试环境一个没有配 XTRIM 的 Stream 堆了几百万条消息。XTRIM 一清,内存立刻下来。
第二个习惯是留意 key 的过期时间和 TTL。Bitmap 和 BITFIELD 操作即使只写一个 bit,也是在改整个底层字符串,如果把这类 key 设成永久不过期,并且业务不断写入新偏移量,内存会缓慢增长。每天生成的签到 key 和 UV key,最好统一带 7 天或 30 天过期时间,让键自然回收。
第三个坑是主从复制和持久化。Redis 默认 RDB 快照和 AOF 都会保存高级类型的数据,没问题;但如果你用主从复制架构,很老的 slave 版本可能不支持新命令。GEOSEARCH 是 6.2 才有的,Stream 是 5.0 才有的,BITFIELD 是 3.2 才有的。升级主节点前先确认从节点版本,否则从库同步时报错会拖垮整个集群。我遇到过 Redis Desktop Manager 连上旧版本节点,执行新版命令直接报错的情况,排查到最后其实是版本不兼容。
5.3 关于高级类型的一些个人心得
做技术选型时,我越来越觉得“用对场景比会用命令更重要”。Stream 是用来解决消息可靠性的,不是用来炫技的;Bitmaps 在签到场景很高效,放历史变更记录里就是灾难;HyperLogLog 图表都很好用,但产品非要精确数就只能换方案。Redis 这些高级数据类型其实很省资源,因为它们借用现有的基础结构演化出新的能力,而不是凭空加东西。
如果你正在接触这类数据结构,我建议不要只看命令文档,最好自己用 docker 起一个 Redis 实例,把这篇文章里的命令一条一条跑一遍。看 Stream 消息怎么进 PEL,看 GEO 半径搜索的返回,看 BITFIELD 溢出前后的值变化,比看十遍文档都有用。遇到奇怪的问题时,用 OBJECT ENCODING key 看一下底层编码,再用 MEMORY USAGE 摸一下内存量级,大部分疑难杂症都能定位出来。
最后分享一个我在实际项目里反复用到的习惯:所有高级类型的 key,我都会在命名里带上类型前缀和版本号,比如 stream:event:2024, geo:shop:v2, hll:uv:day。这看起来只是命名规范,但在排查时能帮你一眼判断这个 key 是什么类型、属于哪个业务、能不能安全清理。高级数据类型的坑,很多时候不是命令不会用,而是设计阶段少想了一步,等线上踩进去才开始难受。
