Redis 五种基本数据类型的底层实现
Redis 对外暴露的是 String、Hash、List、Set、Sorted Set 五种类型,但内部每种类型往往有 两种(甚至多种)底层编码,根据数据量大小自动切换。核心设计哲学是:小数据用紧凑、连续内存的结构省内存;数据量大了再切换成哈希表/跳表这类效率更高但更耗内存的结构。
1. String —— SDS(Simple Dynamic String)
Redis 没有直接用 C 语言原生字符串,而是自己实现了 SDS。
结构(以 sdshdr8 为例):
len : 已使用长度
alloc : 总分配空间
flags : 类型标记
buf[] : 实际字节数组(以 \0 结尾,但不依赖它判断长度)
根据字符串长度不同,还细分为 sdshdr5/8/16/32/64,用最小的头部类型存放,进一步省内存。
为什么这样设计:
- C 字符串要遍历到 \0 才能拿到长度,SDS 直接读 len 字段,O(1)。
- C 字符串不是二进制安全的(遇到 \0 就截断),SDS 靠 len 判断边界,可以存图片、序列化数据等任意二进制内容。
- 拼接前会检查剩余空间,杜绝了缓冲区溢出。
- 空间预分配:字符串变长时,若新长度 < 1MB,分配双倍空间;若 ≥ 1MB,每次只多分配 1MB,避免频繁 realloc。
- 惰性空间释放:字符串变短时不立即释放内存,留着以后用。
优缺点: 换来了安全性、O(1) 长度获取、减少内存重分配次数,代价是比原生 C 字符串多占一点元数据空间。
2. Hash —— listpack / hashtable
- 小对象:用
listpack(Redis 7.0 之前叫ziplist),本质是一段连续内存,紧凑存放key1 value1 key2 value2...,没有指针开销。 - 大对象:转为
hashtable(Redis 自己实现的dict),本质是数组 + 链地址法解决哈希冲突,支持渐进式 rehash。
触发转换的阈值(可配置):
- hash-max-listpack-entries(默认 128)
- hash-max-listpack-value(默认 64 字节)
任一超限,整个 hash 从 listpack 升级为 hashtable(且不可逆)。
为什么这样设计: - 小规模数据下,指针、哈希桶这些元数据的开销占比反而比数据本身还大,用连续内存的 listpack 更省空间,也更利于 CPU cache 命中。 - 数据量一大,listpack 的查找是 O(n)(要顺序扫描),性能会急剧下降,所以切到 O(1) 查找的 hashtable。
优缺点对比:
| 优点 | 缺点 | |
|---|---|---|
| listpack | 内存紧凑,省空间 | 查找/插入 O(n) |
| hashtable | O(1) 查找/插入/删除 | 内存开销大(指针+桶数组),rehash 有额外开销 |
3. List —— quicklist(listpack 节点)
List 的实现经历了几次演进:
- 早期:小数据用
ziplist,大数据用普通双向链表linkedlist。 - Redis 3.2 之后至今:统一用 quicklist——一个双向链表,每个节点本身是一个 ziplist(7.0 后为 listpack)。
为什么这样设计:
- 纯链表:每个元素都要额外存 prev/next 指针,元素多时内存开销很大,且内存不连续,cache 不友好。
- 纯 ziplist:所有数据挤在一块连续内存里,一旦数据量大,中间插入/删除会导致大量数据搬移,且单个 entry 过大会引发"连锁更新"问题。
- quicklist 折中:把数据切成多个小 ziplist/listpack 节点,用链表串起来——链表层面保证两端 push/pop 是 O(1),节点内部又保持紧凑存储。
- 额外优化:不常访问的中间节点可以用 LZF 算法压缩,进一步省内存。
优缺点: 兼顾了内存效率和操作效率,但随机访问中间某个索引的元素仍然要沿链表遍历,时间复杂度是 O(n);同时结构本身比单一的数组或链表更复杂。
4. Set —— intset / listpack / hashtable
三种编码,按数据特征选择:
- intset:当集合里所有元素都是整数,且数量不多(默认
set-max-intset-entries512)时使用。本质是有序的整数数组,根据实际存的数值大小自动升级为 int16/int32/int64,查找用二分查找 O(log n)。 - listpack(Redis 7.2 引入):元素不全是整数,但数量少、长度短时使用,进一步补充了 intset 覆盖不到的场景。
- hashtable:元素多,或类型复杂时使用,本质是 value 为空的
dict,O(1) 查找。
为什么这样设计: 业务中大量集合场景其实是纯数字 ID 集合(比如粉丝 ID、标签 ID),intset 用一个紧凑数组就能表示,比 hashtable 省下大量指针和桶开销。
优缺点:
| 优点 | 缺点 | |
|---|---|---|
| intset | 极省内存 | 只能存整数,查找 O(log n) |
| listpack | 省内存,支持非整数 | 操作 O(n) |
| hashtable | 任意类型、O(1) | 内存开销大 |
5. Sorted Set(Zset)—— listpack / skiplist + dict
- 小对象:
listpack(阈值同样可配zset-max-listpack-entries默认 128、zset-max-listpack-value默认 64)。 - 大对象:skiplist(跳表)+ dict 双结构组合。
dict负责 member → score 的映射,支持 O(1) 的ZSCORE。skiplist按 score 排序存储,支持 O(log n) 的范围查询(ZRANGE、ZRANGEBYSCORE)、排名查询。- 两个结构共享同一份 member 和 score 数据(通过指针复用,不会重复存两份完整数据)。
为什么用跳表而不是红黑树等平衡树: - 跳表实现简单,代码量小,出错概率低,维护成本低于红黑树的旋转平衡逻辑。 - 跳表本质是有序链表 + 多级索引,天然适合范围查询——找到起点后沿链表顺序遍历即可,红黑树做范围查询还要中序遍历,逻辑更复杂。 - 平均时间复杂度两者都是 O(log n),但跳表的常数因子更小,插入/删除不需要像红黑树那样做旋转操作。 - 内存开销上跳表和平衡树相当,谈不上明显劣势。
优缺点:
| 优点 | 缺点 | |
|---|---|---|
| listpack | 省内存 | O(n) 操作 |
| skiplist+dict | 同时支持 O(1) 按成员查分数 和 O(logN) 范围/排名查询 | 两份结构都要维护,内存有一定冗余 |
总结:一以贯之的设计思想
- 小规模数据 → 内存连续的紧凑结构(listpack/intset):省内存、cache 友好,牺牲的是操作复杂度(O(n)),但反正数据量小,O(n) 也很快。
- 大规模数据 → 哈希表/跳表类结构:牺牲内存开销,换取 O(1) 或 O(log n) 的操作效率。
- 阈值可配置、编码可自动升级(不可降级):这是 Redis "面向真实业务场景做工程权衡"的体现——多数业务中容器类型的元素数并不多,紧凑编码能大幅节省整体内存占用,只有真正的大 key 才需要付出哈希表/跳表的内存代价。
- dict 的渐进式 rehash:无论是独立的 Hash 类型还是 Zset 内部用到的 dict,扩容时都不会一次性 rehash 阻塞主线程,而是维护
ht[0]、ht[1]两张表,每次操作顺带迁移一小部分,分摊到多次请求中完成。