主题
Redis 数据结构
一句话:Redis 用「逻辑类型 + 内部编码」两层设计,小数据量压成连续内存、大数据量切成哈希表或跳表,换来可预测的延迟和内存;代价是编码只升不降,阈值调错会把小对象变成大 Key。
适用版本:Redis 7.x / 8.x。7.0 用 listpack 取代 ziplist;7.2 起 Set 增加 listpack 编码;8.x 新增 Vector Sets,见 14-Redis与AI。
为什么需要
缓存里放的不只是字符串,还有计数器、用户对象、队列、去重集合、排行榜。它们的访问模式完全不同:计数器只改一个整数,排行榜要按分数范围取,队列只动两端。如果统一用哈希表,小对象的指针和 dictEntry 会占掉大半内存;如果统一用链表,范围查询又退化成 O(N)。
Redis 的做法是把「对外类型」和「内部编码」分开:type 决定能用哪些命令,encoding 由数据量、元素大小、元素类型自动选择。理解编码,才能解释同样存 100 万个元素,为什么有的结构占几百 MB、有的占几 GB。
核心机制
String:SDS 与三种编码
String 底层是简单动态字符串(SDS, Simple Dynamic String)。SDS 用 len 记录长度,取长度 O(1),不依赖 \0 结尾,因此二进制安全。单值上限 512 MB。
| 编码 | 触发条件 | 内存布局 | 代价 |
|---|---|---|---|
int | 值是整数且落在 long 范围 | 指针位直接存数值,不分配 SDS | 最省,INCR 直接改 |
embstr | 长度 ≤ 44 字节 | redisObject + SDS 头 + 数据一次分配 | 一次 malloc/free,缓存友好 |
raw | 长度 > 44 字节 | redisObject 与 SDS 分开分配 | 两次分配,但可独立扩容 |
44 字节来自:64 字节缓存行 − 16 字节 redisObject − 3 字节 sdshdr8 头 − 1 字节结尾。APPEND 会打破 embstr/int 的连续布局,升级为 raw,且不会降回。
内存代价:每个 key 至少还有 dictEntry(约 24 B)、key 的 SDS、redisObject(16 B);一个很小的 key-value 实测常在 80–120 B 量级(经验值,随 jemalloc size class 浮动)。所以「存 1 亿个短 key」首先撞上的是每 key 固定开销,不是 value 本身。
Hash:小对象用 listpack
| 条件 | 编码 |
|---|---|
field 数 ≤ hash-max-listpack-entries(默认 512)且每个 field/value ≤ hash-max-listpack-value(默认 64 B) | listpack |
| 任一条件超出 | hashtable |
listpack 把所有 field-value 对平铺在一块连续内存里,没有指针,适合字段少、字段短的用户对象。field 一多,查找退化为 O(N),于是升级为哈希表。升级不可逆。
内存代价:listpack 每个 entry 只有几字节编码/长度开销;hashtable 每个 field 要付 dictEntry + key SDS。字段数在几百以内时,listpack 往往比「String 存整个 JSON」更省,也能只改单个字段。
List:quicklist = 链表 + listpack
7.0 起 List 统一是 quicklist:外层是双向链表,每个节点内嵌一个 listpack。list-max-listpack-size=-2(7.x 默认)表示每个节点约 8 KB,节点数由元素总大小决定。
两端 LPUSH/RPOP 是 O(1),中间 LINDEX/LSET 要遍历,O(N)。内存代价是每个 quicklist 节点约 40 B 的节点头,加上 listpack 内的元素开销;元素很大时节点数变多,指针开销上升。
Set:整数用 intset,小集合用 listpack
| 条件 | 编码 | 内存特征 |
|---|---|---|
全是整数且元素数 ≤ set-max-intset-entries(默认 512) | intset | 每个元素 2/4/8 B,连续数组,有序二分 |
7.2+:元素数 ≤ set-max-listpack-entries(默认 128)且每个元素 ≤ set-max-listpack-value(默认 64 B) | listpack | 连续内存,逐条比较 |
| 其他 | hashtable | 每元素 dictEntry + SDS,O(1) 但开销大 |
intset 插入/删除要 memmove,且升级(INT16→INT32→INT64)不可逆。Set 适合去重、标签、交并差;不要用它做「需要按成员取值的字典」,那是 Hash。
ZSet:listpack 或「跳表 + 哈希表」
| 条件 | 编码 |
|---|---|
元素数 ≤ zset-max-listpack-entries(默认 128)且 member ≤ zset-max-listpack-value(默认 64 B) | listpack,按 score 升序平铺 |
| 超出 | skiplist + hashtable 双索引 |
双索引的分工:哈希表负责 member → score 的 O(1) 查询与去重;跳表负责按 score 排序、ZRANK、ZRANGE。代价是同一份数据存两遍。小 ZSet 用 listpack 时,ZSCORE 只能顺序找,但 128 个元素以内遍历只要几微秒,比维护双索引更划算。
内存代价(大数据量,经验值):每个元素约「跳表节点 32 B + 平均 1.33 层 × 16 B + member SDS + dictEntry」,通常比同规模 Set 高 1.5–2 倍。
另外四种:什么时候才用
| 类型 | 适用条件 | 内存代价 | 不该用的场景 |
|---|---|---|---|
| Bitmap | 稠密布尔状态:签到、活跃标记、按位与/或 | 约 最大 offset / 8 字节;1 亿位约 12 MB | ID 稀疏(只有几个 1 也按最大 ID 占内存) |
| HyperLogLog | 只需近似 UV,不关心具体成员 | 固定 12 KB,标准误差约 0.81% | 需要精确计数、取成员、删除单个元素 |
| Geo | 附近的人/店,半径查询 | 底层是 ZSet,按 ZSet 计费 | 需要复杂地理围栏或路网计算 |
| Stream | 中小规模消息队列,要消费者组和 ACK | Rax 基数树 + PEL,受内存上限约束 | 百万级/秒吞吐、长期堆积,选 Kafka |
Stream 的持久化仍受内存和 AOF/RDB 约束,不是 Kafka 的替代品;Bitmap 的 offset 不能超过 2³²−1(受 String 512 MB 上限约束)。
最小上手
bash
docker run -d --name redis7 -p 6379:6379 redis:7.4
redis-cli
SET n 100
OBJECT ENCODING n # int
SET s "hello"
OBJECT ENCODING s # embstr
APPEND s " world"
OBJECT ENCODING s # raw
HSET user:1 name alice age 30
OBJECT ENCODING user:1 # listpack
CONFIG SET hash-max-listpack-entries 1
HSET small a 1
HSET small b 2
OBJECT ENCODING small # hashtable(阈值调回后也不会降级)
MEMORY USAGE user:1 # 返回该 key 的字节数MEMORY USAGE 对嵌套结构默认采样 5 个元素估算;要精确值加 SAMPLES 0,但大 Key 上会变慢。
生产动作
- 先看编码再调阈值:
OBJECT ENCODING key确认当前编码,MEMORY USAGE key(7.x)看实际占用,不要凭元素数猜。 - 阈值不是越大越好:把
hash-max-listpack-entries从 512 调到几千,会让单次HGET变成线性扫描,并且在主线程执行。 - 容量估算要算固定开销:key 数 × 约 100 B 的固定成本,再加上 value;1000 万个小 key 仅固定开销就接近 1 GB。
- 大 Key 的根因常在编码选择:一个几百万元素的 Hash/ZSet 无论怎么调阈值都是 hashtable/skiplist,应该拆分,见 10-大Key问题。
- 扫描用
SCAN:KEYS和SMEMBERS/HGETALL在大集合上会阻塞主线程。
边界
- 不是文档数据库:没有二级索引、join、跨 key 约束;Hash 只是「一个 key 下的字段表」,不是关系表。
- 编码切换不可逆:Set 从 intset 升到 hashtable、Hash 从 listpack 升到 hashtable 后,删元素也不会降回;设计时就要按峰值预估。
- HLL 不能删元素,Bitmap 不适合稀疏 ID,Geo 的精度受 geohash 编码限制。
- Stream 不是 Kafka:堆积受内存限制,消费者组的 PEL 也会占内存,长期不 ACK 会持续增长。
- 不要用「五种类型」概括 Redis:Stream、Bitmap、HLL、Geo 都是独立命令族,选错类型比调错参数更难救。
讲清楚
Redis 对外是五种常用类型,对内是一组会随数据量自动切换的编码。String 用 SDS,短字符串走 embstr、长字符串走 raw;Hash、List、ZSet 在小规模时用 listpack 压成连续内存,规模一大就换成哈希表或跳表加哈希表。这样做的目的是在「小对象省内存」和「大对象保 O(1)/O(log N)」之间取平衡。
代价有两个:编码只升不降,阈值调大后单次操作可能退化;每个 key 还有几十到上百字节的固定开销,小 key 数量一多,内存先被元数据吃掉。所以生产上先看
OBJECT ENCODING和MEMORY USAGE,再决定拆 key、换类型,而不是盲目调阈值。