Redis 数据类型全解 - 数据类型选型矩阵:一张表、一棵树、四条线(十二)

一句话:类型选择真正的胜负手不在类型本身,而在「规模 × 访问方式 × 运维能力 × 组合约束」这四条线上。第一反应给你一个起点,这四条线决定这个起点要不要被推翻。

1. 选型错的代价与四类约束

选型错误不像代码 bug:它不报错,只会随数据长大而变贵。修复代价按层放大------改一个字段的写法改动几行代码;改一个命令的写法改动一个函数;改一个 Key 的结构要改数据模型、迁移存量、改所有读写方。所以判断必须前置到「建 Key 之前」,而不是「压测之后」。

四类约束,缺一条结论就不成立:

类型只是第一反应,约束才是推翻条件。 这句话在本篇里会反复出现:第 2 章给第一反应,第 3 章给推翻条件,第 5、6 章给推翻所需的证据(成本与边界)。

把代价换算成工时更直观:改一个字段的读写是几分钟;换一个命令的实现是半天(含回归);改一个 Key 的结构是「数据模型 + 迁移脚本 + 双写 + 切读 + 存量校验」,通常以周计,还要停机窗口或双写期。选型讨论花两小时,能省掉后面两周------这是本篇愿意在「判断」上多花篇幅的唯一理由。

2. 一张映射表

先看第一反应,再看「常见错选」那一列为什么不能写。

三条判读提醒:

一,「不推荐」不等于「绝对不能写」。允许丢消息的预热任务用 List 完全合理,把 List 用在支付回调上才是反模式------反模式的定义是「在要求它做不到的语义」。

二,类型名相同不代表能力相同。Bitmap 的 TYPE 返回 string,它不是独立类型,而是「String 上的位操作」;GEO 也不是独立类型,底层就是 ZSet,score 是 52 位 geohash 整数。

三,映射表给的是「第一反应」,不是「结论」。凡是能进第 1 章第二条(成本)和第四条(组合)的问题,都必须再过一遍第 3 章的推翻条件。

四,Stream 与 List 的差别不是「新与旧」,是「有没有确认语义」。List 是「取走即消失」,Stream 是「读到 PEL 里、ACK 才移除」。所以「这条消息能不能丢」答错,后面所有优化都白做;这也是为什么映射表把「允许丢」写成 List 的唯一前提。

3. 一棵决策树(含两次实例走查)

第一问:这块数据要「整块」还是「一小块」?

整块 → String(含 String+JSON)。要一小块 → 进第二问。

第二问:那一小块是「字段」还是「成员/元素」?

字段 → Hash。成员且要排序 → ZSet。成员且只要去重/求交 → Set。成员且只是一个布尔位 → Bitmap。

第三问:要不要「按值排序」或「按时间重放」?

按值排序 → ZSet。按时间重放、要 ACK → Stream。只是先进先出、允许丢 → List。

三个出口之外还有一条兜底:规模或精度超出单 Key 能力时,不是换类型,是换结构------Top N 进 Redis、其余落库;按业务维度分片;预计算代替实时聚合。换类型解决不了「一个 Key 太大」这个物理问题。

三个推翻条件(任一条成立,第一反应作废):

一,单 Key 会越过编码阈值 。Hash 超 512 字段翻 hashtable,ZSet 超 128 成员或单值超 64 B 翻 skiplist,Set 超 128 成员或超 64 B 元素(7.2 起)翻 hashtable。翻转后每元素开销涨一个量级,此时要么控制规模,要么接受成本,不能假装没翻。

二,访问方式与类型不匹配。只改一个字段却用 String 存整对象(写放大 = 全量读改写,且非原子);只读 Top 10 却用 Set 存全量(没有排序能力)。

三,组合越界。涉及两个 Key 又要原子,而它们不在同一 slot------只能改 Key 设计(加 hash tag),客户端补救不了。

走查一次:100 万用户签到。

第一问:要「某一天这个用户签没签」,是一小块。第二问:它是一个布尔位 → Bitmap,offset 用「用户序号 × 1」或「用户序号」,前提是先建 id → offset 映射。能不能直接拿用户 ID 当 offset?不能------ID 是 8 位长整型,最大值可能到 10^10,SETBIT 会立刻分配 GB 级内存,直接踩第 7 章第 5 条。

推翻条件检查:100 万位 = 125 KB,越不过编码阈值,也不触发运维问题,成立。

但如果需求变成「连续 30 天的签到日历」,答案就变:这是一个「每天一位的定长位段」,用 Bitfield 或「一天一个 Key + BITCOUNT」更省心;如果还要「本月签到榜」,那排序需求出现,主体仍是 Bitmap,另加一个 ZSet 做排行------这就是第 6 章的组合。同一个业务词,加一句需求就换一个答案,这就是决策树存在的意义。

再走查一次:1000 万成员的实时榜单。

第一问:只取前 100 → 是一小块。第二问:成员且要排序 → ZSet。第三问:按值排序 → ZSet,第一反应成立。

但三条推翻条件要逐条查:编码阈值不构成问题(这个规模早已是 skiplist);运维阈值过线 ------单 Key 1000 万,备份、迁移、SCAN、slot 搬移都开始超时;规模阈值也过线。结论是:类型没错,结构错了 。改法有三条,按代价排序:按榜单周期分 Key(rank:2026:11),只保留 Top N,其余落库;按业务维度分片(分区、分段位);预计算代替实时聚合。

注意这里最容易走偏的一步:很多人会因此改用 Set 或 Hash 来「省内存」,结果丢掉排序能力,等于用错了第二问。当推翻原因是规模,答案永远是换结构,不是换类型。

4. 三种对象形态:写放大与读放大

同一个「用户资料」,三种存法。差别不在类型名,在改一个字段要付多少代价。

两条容易踩的边界:

「JSON 更省内存」是错觉。 JSON 里同样存字段名,还多出引号、冒号、花括号与转义。只有当「字段少、值大」时它才更省;字段多、值小时,Hash 的 listpack 编码把每个字段的长度前缀压到 1--2 字节,单位开销反而更低。判断依据不是直觉,是 5.3 的实测口径。

String+JSON 做不到字段级原子更新。 原生 String 没有「改 JSON 里某个 path」的能力,没有 RedisJSON 时,每次改字段都是「读全量 → 改 → 写回」,两次请求之间被别人改过就丢更新。要字段级原子,Hash 是原生答案;要 JSON 的嵌套结构又要局部更新,那得上 RedisJSON(JSON.SET $path),这是另一个选型分支,本篇不展开。

该拆多 Key 的判据 :字段少于 5 个、各字段读频率差异极大、或某个字段要单独过期(EXPIRE 只能作用在整 Key 上)。反过来,字段总要一起读、或必须事务性更新,拆开就是自找跨 Key 一致性问题。

5. 成本与容量:四条口径

5.1 复杂度只看一件事:O(N) 里的 N 是谁

O(1) 的瓶颈在单 Key QPS 与网络往返,不在 CPU;O(log N + M) 随返回条数增长,M 由调用方控制;O(N) 的危险在于 N 是这个 Key 的规模,而 Key 拆不开。

三个易错点:SINTER 是 O(N*M),N 取最小集合的基数,所以「小集合与大集合求交」比「两个大集合求交」便宜;PFCOUNT 多 Key 是 O(N),要临时合并 N 个 HLL;SCAN 保证「全程在集合内的元素一定被返回」,但可能重复返回,调用方必须幂等。

「读一次要多久」可以粗算:一次往返的 RTT 加命令本身的处理时间。O(1) 类命令几乎全是 RTT;O(N) 类命令在大 Key 上是主线程串行处理 ,百万级元素往往对应百毫秒级阻塞------这段时间里这台节点上的所有请求都在排队。所以「有没有 LIMIT」比「用哪个类型」更影响线上体感。

5.2 三层阈值,不要互相代入

这三个数回答的是三个不同问题:能不能省内存、能不能运维、还能不能长。把 512 当成「性能红线」是最常见的误读------它只是编码切换点,不是能力边界;反过来,把 1000 万当成「编码还没翻所以没事」也是误读,那已经是运维问题。

阈值与版本是绑定的:Set 的 listpack 路径自 7.2 才有(7.0 及以前全整数走 intset、否则 hashtable);Hash 的 512 与 ZSet 的 128 是默认值 ,可以 CONFIG SET 改,但改了要同步到所有节点和所有客户端的假设。跨版本比编码,先 CONFIG GET 确认,不要按「我记得的默认值」推算。

5.3 「每元素 XX 字节」不能乘生产规模

任何精确到字节的每元素开销都不成立:它随编码状态、键名长度、分配器、真实数据分布变化。正确做法只有一条:在目标数据分布上抽样,用 INFO memory 的 used_memory 前后差值算。

三条可复现的实测锚点(Redis 7.4.2):

  • 口径错位是常态 :同一个 ZSet,按「112 字节/成员」乘 100 万得出 112 MB,换成整 Key 口径实测就是 145 MB,即每成员 145 B。差的不是谁算错,而是口径不同:112 B 是元素自身(含编码内前缀)的抽样值,145 B 里还要加上 Key 名、redisObject 头、skiplist 的多层指针与分配器按 size class 取整的碎片。所以容量要按「整 Key 实测」算,并默认留 20%--30% 冗余,禁止「每元素乘数量」。
  • SETBIT bm 99999999 1 之后 MEMORY USAGE bm = 12500070,与 1e8/8 = 12,500,000 只差 70 字节。Bitmap 的容量是可算的,因为它是纯位图;这也说明结构开销在小 Key 上不可忽略、在大 Key 上可以忽略。
  • MEMORY USAGE 对 Stream 系统性低估约 3 倍 :1,000 条三字段小消息,MEMORY USAGE 约 39 KB,used_memory 差值约 130 KB。它统计的是 rax + listpack,不含消费者组结构与 PEL,所以 Stream 容量只能把 MEMORY USAGE 当下界。
  • PEL 约 170 B/条 :100 万条未 ACK 的消息额外占约 170 MB,而且 MAXLEN ~ 的近似裁剪可能裁掉未 ACK 的消息 。要可靠就用 MINID 保窗口,或定期 XPENDING 巡检悬空;这两件事要一起做,只设窗口不巡检,等于把问题推到故障时。

5.4 两个精度边界

ZSet 的 score 是 double:2^53 以内可精确表示整数。毫秒时间戳约 1.7×10^12,安全;纳秒约 1.7×10^18 已超限,相邻任务会落到同一分数,先后顺序失效------延迟队列用纳秒做分数,是一个「看起来更精确、实际更不可靠」的选择。

GEO 的 score 是 52 位 geohash 整数 ,不是经纬度、也不是业务分数;距离按球模型算,官方标注误差约 0.5%,所以结果必须二次校验,且不能把 score 当业务排序值用。

6. 组合:五个必写约束

真实需求几乎都是组合:主类型存主体,辅助类型存索引。组合的坑全部集中在「边界」上,而且在单机环境里测不出来。

① 跨 slot → hash tag,不是客户端补救。

Cluster 以 Key 为最小分片单位,多 Key 命令(MGET、集合运算、MULTI、Lua)要求所有 Key 同 slot。同一业务对象拆多 Key 时,用 {user:1001}:cart 与 {user:1001}:rank 把它们钉在一起。注意 hash tag 只解决「同 slot」,不解决「同事务」------事务还要求这些 Key 在同一节点上,tag 恰好把两件事一起解决了。

② 事务的边界是连接,不是命令序列。

MULTI 之后的命令只入队,EXEC 时一起执行;但 Redis 事务不回滚 ------运行期出错只影响出错的那条,其余照常生效。更要紧的是:MULTI、QUEUED、EXEC 必须发在同一条连接上。用脚本管道把这几行一次性喂给 redis-cli,它会为每行各开一次连接,EXEC 直接报 ERR EXEC without MULTI------本地验证脚本里最常见的假故障。

③ 跨 Key 要原子,只能用 Lua 或接受对账。

延迟队列是典型案例:ZRANGEBYSCORE 取出、ZREM 删除,两条命令之间的任何延迟都会让多消费者取到同一批任务。把两步塞进一次 Lua 调用(脚本只碰一个 Key,Cluster 下不会 CROSSSLOT)才真正「取出即删除」。非原子版本只允许单消费者------这不是建议,是它的语义边界。

要说清一件事:非原子版本不是「更快的版本」,是「只允许一个消费者的版本」。只跑一个消费者时它更简单;一旦扩到两个,必须换成 Lua 版。不要用分布式锁去补------锁的粒度与重试会把吞吐拉低一个量级,而 Lua 在服务端执行,没有额外往返。

④ 双写要有降级与对账。

排行榜 = ZSet 排序 + Hash 详情。两侧同 slot 才能进一个事务;详情缺失(用户注销、双写丢过一次)时,读侧必须给占位值,不能让前端拿 null。降级规则要写进代码,不是写进文档。

⑤ 读放大要一次往返解决。

「取 Top 10 再逐条回查详情」是 N+1:延迟随 N 线性上涨,而 N 由榜单长度决定。用 CreateBatch 把 10 次 HGET 压成一次下发。顺序不能反:先 Execute 再等结果,反过来会死等一条永远不发出的命令。

写侧与读侧要分开设计:写侧要原子与顺序 (事务、Lua、同一个 Key),读侧要往返次数(Batch、Pipeline、批量接口)。把两侧混成一套接口,最后一定在某一侧妥协------通常是读侧,因为写侧的错误更早暴露。

7. 上线前对照:十二个反模式与十五项检查

7.1 十二个反模式(按后果排序)

7.2 十五项检查

8. 已经选错了:迁移三步

① 影子结构双写。 新结构(Hash,或分片后的多 Key)与旧结构同时写,读仍走旧结构。这一步不改读路径,风险最低,也最容易回退。

② 抽样比对 + 全量校验。 先抽 1%,用两套读法对同一批 Key,比字段数、比数值;再跑一轮 SCAN 全量比对。差异来源通常是边界写入(并发更新、过期时序),必须归零再进下一步。

③ 切读、保留写、再删旧。 先切读(可秒级回退),观察一个业务周期;再停旧写;最后删旧 Key。回退点只在第 ① 步存在------一旦停写旧结构,回退就等于二次迁移,成本比第一次更高。

三个迁移期硬约束:大 Key 拆分用 SCAN + 分批写,不要 KEYS;Cluster 迁移前演练 slot 搬移与副本追平,别在业务高峰做第一次;双写期容量按两套结构之和算,不然切换前就爆内存。

迁移期要盯三个数:双写失败率 (决定能否继续)、两套结构的差异条数 (决定能否切读)、旧结构 QPS 衰减曲线(决定何时停写)。三个数都在往好的方向走,才叫迁移完成;只看「新结构能读通」就停旧写,是把风险留给下一次故障。

附录 A 八条版本事实

相关推荐
虹科网络安全9 小时前
Redis Radar:在统一视图中掌握 Redis 部署、健康与容量
经验分享·redis·艾体宝·艾体宝it·艾体宝redis
大大大大晴天10 小时前
每天认识一个组件:Redis
redis
虹科网络安全21 小时前
Search on Flex:用分层存储承载更大规模的 Redis 搜索索引
经验分享·redis·艾体宝·艾体宝it
青瓦梦滋21 小时前
Redis主从复制
服务器·数据库·redis·缓存·bootstrap
Joy T1 天前
Redis Lua 原子限流:Lua 脚本、竞态条件与并发控制
数据库·redis·lua·限流·竞态条件·redis scripting
IT规划师1 天前
Redis 数据类型全解 - Stream 深度:Pending 回收、重试死信与消息队列选型(十一)
redis·.net
虹科网络安全1 天前
RDI 多数据源管道:把分散的数据汇入统一 Redis 数据层
经验分享·redis·艾体宝·艾体宝it·艾体宝redis