Skip to content
Charles Shao
Go back

Redis 深挖(一)· 数据结构与内存模型

views

Redis 之所以快,第一直觉是「它在内存里」。但纯在内存并不能解释它为什么比一个塞进 HashMap 的进程还省内存、还稳定——真正的功夫在于:Redis 为每一种对外数据类型都准备了多套底层编码,小数据用紧凑编码省内存、大数据用通用编码换性能,并在阈值处自动切换。理解了这套「类型 vs 编码」的二层结构,你就能解释一大半线上现象:为什么同样是 Hash,有的 key 只占几十字节、有的却成了 bigkey;为什么内存删了 key 却降不下来;为什么 ZSet 能既做排行榜又做延时队列。

Redis 深挖三篇:本篇是第一篇「数据结构与内存模型」,另外两篇是 持久化与高可用线程模型与性能。三篇都是 缓存系列开篇 的下钻,建议先读开篇建立全景。

TL;DR

Table of contents

Open Table of contents

1. redisObject:一个 value 在内存里长什么样

Redis 里每个 value 都被包成一个 redisObject(简称 robj)。它是一个约 16 字节的「对象头」,真正的数据挂在它的指针后面:

redisObject 对象头结构与五种对外类型到底层编码的映射:robj 含 type(4bit 对外类型)、encoding(4bit 底层编码)、lru/lfu(24bit 淘汰时钟/频次)、refcount(32bit 引用计数)、*ptr(64bit 指向真正数据);下半展示 String→int/embstr/raw、List→listpack/quicklist、Hash→listpack/hashtable、Set→intset/listpack/hashtable、ZSet→listpack/skiplist 的升级路径

robj 里 type 是对外类型、encoding 才决定底层怎么存;小而少用紧凑编码省内存,超阈值升级为通用编码换性能,且「只升不降」。

关键字段:

一个推论:即使存一个很短的字符串,也要付出对象头 + SDS 头 + jemalloc 对齐的固定成本。这就是「几百万个小 String key 也能吃掉大量内存」的原因,也是「能用一个 Hash 存一个对象的多个字段,往往比拆成多个 String key 省内存」的底层依据(少了很多对象头)。

2. 类型 → 编码:用 OBJECT ENCODING 看穿底层

同一种对外类型,底层可能是完全不同的结构。直接实测最直观:

127.0.0.1:6379> set n 12345
127.0.0.1:6379> object encoding n
"int"
127.0.0.1:6379> set s "hello"
127.0.0.1:6379> object encoding s
"embstr"
127.0.0.1:6379> set big "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
127.0.0.1:6379> object encoding big
"raw"
127.0.0.1:6379> hset h f1 v1
127.0.0.1:6379> object encoding h
"listpack"
127.0.0.1:6379> zadd z 1 a 2 b
127.0.0.1:6379> object encoding z
"listpack"

下面逐一拆开每种类型的编码与切换阈值。(本文以 Redis 7.x 为准:listpack 已在多处取代旧的 ziplist 成为默认紧凑编码。)

3. String:int / embstr / raw 与 SDS

String 有三种编码:

底层的字符串结构是 SDS(Simple Dynamic String),而非 C 原生字符串:

一个常见坑:对一个 int 编码的 key 做 APPENDSETRANGE,会把它转成 raw,且不会再变回 int(编码只升不降,见 §9)。计数器请老老实实用 INCR

4. List:quicklist

List 现在统一用 quicklist:一个双向链表,但每个链表节点本身是一段 listpack(早期是 ziplist)。

适合做队列、栈、时间线;但别把它当随机访问数组用——LINDEX/LRANGE 到中间是 O(N)。

5. Hash:listpack ↔ hashtable

Hash 小的时候用 listpack:把 field/value 一对一对顺序排在一段连续内存里,省掉哈希表的桶和指针开销;查找是线性扫描,但元素少时反而更快、更省。

任一阈值被突破就升级为 hashtable(dict):

hashtable 用两个哈希表做渐进式 rehash(扩容时新老表并存,操作时逐步搬迁,避免一次性 rehash 卡住主线程)。O(1) 读写,但每个 entry 都要付出 dictEntry + 指针开销,内存明显更大。

「用一个大 Hash 存对象」很香(省对象头、字段聚合),但要盯住阈值:一旦某个 value 超过 64 字节,整个 Hash 就从 listpack 升成 hashtable,内存陡增。

6. Set:intset ↔ listpack ↔ hashtable

Set 有三态:

触发条件(任一满足即升级):元素出现非整数(intset → listpack/hashtable)、set-max-intset-entries(默认 512)、set-max-listpack-entries(默认 128)、set-max-listpack-value(默认 64)。

7. ZSet:listpack ↔ skiplist

ZSet 小的时候也是 listpack(member+score 成对顺序存);超过 zset-max-listpack-entries(默认 128)或 zset-max-listpack-value(默认 64)就升级为 skiplist + dict 的组合:

ZSet 的 skiplist 跳表结构:多层有序链表,底层 L0 串起全部节点,上层 L1-L3 是稀疏的快速通道;查找 score=17 时从最高层向右探、探过头就下沉一层,跳过大片节点,平均 O(logN)

跳表是多层有序链表:底层串全部节点、上层是稀疏快速通道,查找/插入/范围查询均 O(logN);并存的 dict(member→节点)让 ZSCORE 等按成员访问是 O(1)。

这套组合是 ZSet 「既能按分数排序、又能按成员定位」的关键,也是它能兼任排行榜(score=分数)与延时队列(score=到期时间戳,ZRANGEBYSCORE 0 now 捞到期任务)的底层原因。

8. listpack 如何治好 ziplist 的连锁更新

老编码 ziplist 的每个 entry 里记着「前一个 entry 的长度」(prevlen),用于反向遍历。问题是 prevlen 是变长的:当某个 entry 长度跨过 254 字节边界时,后一个 entry 的 prevlen 要从 1 字节涨到 5 字节,这可能又把它自己的总长度顶过边界,进而触发下一个 entry 也扩张……最坏情况一次插入引发连锁更新(cascade update),退化到 O(N²)。

listpack(7.x 默认)重新设计了 entry 格式:每个 entry 把长度信息存在自己内部、可从两端解析,不再依赖前一个 entry 的长度,从根上消除了连锁更新,同时保持紧凑与顺序存储的优点。所以现在 Hash/ZSet/Set/List 的紧凑编码都以 listpack 为基础。

9. 编码转换:阈值与「只升不降」

Redis 编码转换阈值:Hash(listpack → hashtable,entries>128 或 value>64B)、ZSet(listpack → skiplist+dict,entries>128 或 value>64B)、Set(intset/listpack → hashtable,非整数或超 512/128);三者都是紧凑编码触发阈值后升级为通用编码,且只升不降

任一阈值被突破就升级为通用编码;String 则按长度在 int → embstr(≤44B) → raw 间选定。关键是「只升不降」:元素之后减少也不会自动降回紧凑编码。

汇总阈值(默认值,可在 redis.conf 调):

类型紧凑编码升级为触发阈值
Hashlistpackhashtableentries > 128 或 value > 64B
ZSetlistpackskiplist+dictentries > 128 或 value > 64B
Setintset / listpackhashtable非整数 / intset > 512 / entries > 128 / value > 64B
Listlistpack 节点quicklistlist-max-listpack-size 控制每节点
Stringint / embstrraw非整数 / 长度 > 44B / 被 APPEND 等修改

「只升不降」是最容易踩的特性:一个 Hash 曾经膨胀到 200 个字段升成了 hashtable,之后就算删到只剩 3 个字段,它仍是 hashtable,内存不会自动降回 listpack。想回收,只能重建 key(如 HGETALL 后删掉重写、或 DUMP+RESTORE)。这正是「删了很多元素但内存降不下来」的常见原因之一。

10. 内存开销与碎片

Redis 用 jemalloc 分配内存,它按 size class 分档(16、32、48、64…字节)。申请 50 字节实际给你 64 字节的块,多出的 14 字节就是内部碎片。所以:

11. 从编码层重新理解 bigkey

缓存开篇 讲过 bigkey 的危害,这里从编码层看清为什么大:

治理:拆分(大 Hash 按 field 哈希分桶成多个小 Hash:key:{crc(field)%N})、控制阈值让它留在紧凑编码、删除用 UNLINK、需要遍历用 HSCAN/SSCAN 而非一次性全取。

12. 探测工具

13. 数据结构选型直觉

14. AdTech 落地:竞价场景的结构选型

广告竞价对 Redis 的用法非常「结构敏感」,因为单机数万 QPS、每次要在 tmax(80–150ms)内读多份数据(详见 缓存开篇 §4):

实践提示:按 campaign 拆 key(避免超大 Hash)、竞价只 HMGET 需要的字段而非 HGETALL、删除统一用 UNLINK

参考


views
Share this post on:

Previous Post
Redis 深挖(二)· 持久化与高可用
Next Post
本地缓存深挖:Caffeine、W-TinyLFU 与堆外缓存