Redis 常用数据类型的底层实现数据结构(详细版)
面向:Redis 5/6/7 使用者(Redis 7 仍是主流)。
你关心的点:同一种逻辑类型在不同规模下会切换不同 encoding(底层结构),这会直接影响内存、性能、AOF/RDB、复制成本。
说明:Redis 内部实现会随版本演进(阈值/编码名可能变),下面以 Redis 6/7 的通用规律为主,关键地方会注明“不同版本可能略有差异”。
目录
- 1. 两个概念:逻辑类型 vs encoding
- 2. Redis 里最常见的底层结构总览
- 3. String 的底层实现
- 4. List 的底层实现(quicklist)
- 5. Hash 的底层实现(listpack/hashtable)
- 6. Set 的底层实现(intset/hashtable)
- 7. ZSet 的底层实现(listpack/skiplist+dict)
- 8. Bitmap 的底层实现(本质是 String)
- 9. HyperLogLog 的底层实现(稀疏/稠密 + String)
- 10. GEO 的底层实现(ZSet)
- 11. Stream 的底层实现(radix tree + listpack)
- 12. 编码切换阈值:你应该怎么理解
- 13. 如何在线上确认 encoding 与内存占用
- 14. 典型性能/内存坑:从底层结构角度解释
1. 两个概念:逻辑类型 vs encoding
- 逻辑类型(data type):你在命令层面看到的:String/Hash/List/Set/ZSet/Stream…
- encoding(内部编码):Redis 在内存里真实使用的结构。
例如 Hash 可能是listpack或hashtable,ZSet 可能是listpack或skiplist。
你可以用:
TYPE key
OBJECT ENCODING key
来分别看到逻辑类型与内部编码。
2. Redis 里最常见的底层结构总览
下面这些是你理解 Redis 内部的“乐高积木”:
2.1 SDS(Simple Dynamic String)
- Redis 自己的字符串结构(不是 C 的
char*)。 - 特点:
- O(1) 获取长度(
len字段) - 支持二进制安全(可存
\0) - 有预分配策略减少频繁 realloc
- O(1) 获取长度(
2.2 dict(哈希表)
- Redis 的核心结构:很多类型最终都会落到 dict。
- 用途:Key 空间本身(数据库字典)、Hash、Set、ZSet 的 member->score 映射等。
- 特点:
- 渐进式 rehash(避免一次性扩容卡顿)
- 平均 O(1) 查找/插入/删除
2.3 listpack(紧凑列表)
- Redis 取代 ziplist 的“新一代紧凑编码”(ziplist 在新版本逐步淘汰/移除)。
- 用途:小 Hash、小 ZSet、Stream 的 entries 等。
- 特点:
- 连续内存,极省空间(适合小规模)
- 插入/删除可能涉及内存移动:大了就不划算,所以需要阈值切换到更适合的结构
2.4 quicklist(快速列表)
- List 的主要实现方式(把“链表 + 压缩块”结合)。
- 本质:双向链表,每个节点挂一个 listpack(或历史上的 ziplist)。
- 目的:既能两端 O(1) push/pop,又能比纯链表省内存。
2.5 intset(整数集合)
- Set 在“全是整数且数量小”时的省内存编码。
- 特点:
- 内部是有序整数数组(按 int16/int32/int64 自适应升级)
- 查找用二分(O(log n)),插入可能移动(O(n))
- 一旦出现非整数元素就会升级成 hashtable
2.6 skiplist(跳表)
- ZSet 在大规模时用于按 score 排序。
- 特点:
- 范围查询、按 rank 查询效率高(O(log n + m))
- 与 dict 配合:dict 负责 member->score O(1),skiplist 负责有序视图
2.7 radix tree(基数树)
- Stream 的核心索引结构(按消息 ID 有序组织)。
- 特点:
- 适合存储大量相似前缀的 key(Stream 的 ID 很符合这种模式)
- 支持范围迭代
3. String 的底层实现
String 在 Redis 内部有几种常见编码:
3.1 int(整数编码)
- 当 value 是可解析的整数并且范围合适时,Redis 会用整数直接存。
- 你会看到
OBJECT ENCODING key返回int。 - 好处:省内存 + INCR/DECR 快(避免字符串到整数转换的额外成本)
3.2 embstr(短字符串优化)
- 小字符串会用 embstr:
redisObject头 + SDS 一次性分配在同一块内存里(减少一次 malloc)。 - 好处:小对象非常常见,embstr 能明显降低碎片和分配开销。
3.3 raw(普通 SDS)
- 字符串较大时,用 raw(常规 SDS + 对象头分配)。
- 特点:更通用,但分配次数更多。
小结:String 的编码选择本质是“减少分配 + 减少额外数据结构”。
4. List 的底层实现(quicklist)
4.1 结构长啥样
- List 使用 quicklist:
- 外层:双向链表节点
- 每个节点:一个 listpack(紧凑存若干元素)
- 节点的 listpack 可以做 LZF 压缩(取决于配置与数据分布)
4.2 为什么不用纯链表 / 纯数组?
- 纯链表:每个元素一个节点,指针开销巨大,CPU cache 也差。
- 纯数组(连续内存):两端 push/pop 不方便(需要搬移)。
- quicklist:折中方案:
- 两端操作仍然快
- 通过“块”减少指针数量
- 通过压缩减少内存
4.3 性能直觉
LPUSH/RPOP:通常 O(1)(落在头尾块)LRANGE:需要遍历块并定位,返回越多成本越高- 坑:单个 List 超大 -> quicklist 节点很多 -> 遍历、复制、RDB/AOF 都变重
5. Hash 的底层实现(listpack/hashtable)
Hash 常见两种编码:
5.1 listpack(小 Hash)
- 当 field 数量小、field/value 长度都比较小,Redis 会用 listpack。
- 结构:一个紧凑块按顺序存
field1 value1 field2 value2 ... - 优点:极省内存
- 缺点:
- 查找需要线性扫描(O(n)),但 n 很小的时候反而比 hashtable 更快(cache 友好)
- 插入/删除可能触发内存移动
5.2 hashtable(大 Hash)
- 当 Hash 变大或出现“大 field/value”,会升级为 hashtable(dict)。
- 优点:查找/更新平均 O(1)
- 缺点:指针/哈希桶开销更高,内存更大
经验:Hash 适合“对象字段”模型,但如果单个 Hash 的 field 上万级,
HGETALL就会非常危险(网络+复制+慢查询)。
6. Set 的底层实现(intset/hashtable)
6.1 intset(小且全整数)
- 适用:成员全部是整数,并且数量较小。
- 结构:有序整数数组(紧凑)
- 查找:二分 O(log n)
- 插入:O(n)(可能移动)
- 升级:
- intset 会在需要更大整数范围时升级底层存储宽度(16->32->64)
- 一旦加入非整数成员,整体升级为 hashtable(不可逆)
6.2 hashtable(通用 Set)
- 结构:dict,key=member,value=NULL
- 平均 O(1) 增删查
- 内存:比 intset 大,但更通用、适合大集合
经验:Set 用来“去重 + membership”,很强。但
SMEMBERS对大 Set 会直接把你拖死(一次性返回太多)。
7. ZSet 的底层实现(listpack/skiplist+dict)
ZSet 同时需要:
- member -> score(快速查)
- 按 score 排序(范围查、排行)
所以它的内部通常是“两套结构并存”。
7.1 listpack(小 ZSet)
- 小规模时用 listpack 存:
member1 score1 member2 score2 ...
- 优点:省内存,cache 友好
- 缺点:查找/更新需要扫描(O(n)),但 n 小时没问题
7.2 skiplist(大 ZSet)
当 ZSet 变大后会升级成组合结构:
- dict:
member -> score(O(1)) - skiplist:按
(score, member)排序(O(log n) 范围查)
范围查询复杂度通常:O(log n + m)(m 为返回个数)。
经验:排行榜、延时队列大多靠 ZSet。真正的坑不是 ZSet 慢,而是你一次
ZRANGE拿 10 万条 + 带 score + 网络/序列化/复制一起炸。
8. Bitmap 的底层实现(本质是 String)
Bitmap 不是独立结构,本质就是 String:
SETBIT key offset 1会把 String 当 bit 数组用- 内部还是 SDS(raw/embstr 等),只是读写时按 bit 位操作
坑点非常经典:
- offset 很大(比如 1e9)会导致 String 直接扩容到巨大长度(稀疏空间不友好)
-> 一瞬间内存爆炸。
9. HyperLogLog 的底层实现(稀疏/稠密 + String)
HyperLogLog(HLL)在 Redis 内部也存成 String(字节数组),但有两种表示:
- Sparse(稀疏):元素较少时更省空间
- Dense(稠密):元素较多时固定大小更高效
你用 PFADD/PFCOUNT 时,Redis 会在稀疏与稠密之间自动转换。
关键点:
- HLL 能把海量去重计数压到很小的内存(但有误差)
- 只适合“要数量,不要明细成员”
10. GEO 的底层实现(ZSet)
GEO 在 Redis 内部是 ZSet:
- member 是地点标识(比如店铺 ID)
- score 存储经过编码的地理坐标(geohash 相关的编码映射)
所以你看到:
TYPE geoKey是zsetOBJECT ENCODING geoKey也会是 zset 的编码(listpack/skiplist)
11. Stream 的底层实现(radix tree + listpack)
Stream 是 Redis 最“像 MQ”的结构,它的内部相对复杂:
11.1 顶层索引:radix tree
- 以消息 ID(如
1690000000000-0)为 key,做有序组织与范围扫描
11.2 消息体:listpack
- 每条消息的 fields/value 通常用紧凑结构存(listpack)
- 还会做一些“共享字段名”的优化(很多消息字段相同,能省不少)
11.3 消费者组(Consumer Group)相关结构
- PEL(Pending Entries List)等也有专门结构维护(内部仍会大量用 dict/listpack/radix tree 组合)
经验与坑:
- Stream 会不断增长,不裁剪就会越来越大
XTRIM ... MAXLEN ~ N是必备动作
- pending 不处理会堆积(消费者挂了、没 ack)
12. 编码切换阈值:你应该怎么理解
Redis 选择 encoding 的核心目标只有两个:
- 小规模:省内存 + cache 友好(listpack/intset/embstr)
- 大规模:保证操作复杂度(hashtable/skiplist)
切换触发因素一般是:
- 元素数量超过某个阈值(比如 Hash/ZSet 的最大小编码元素数)
- 单个元素长度超过阈值(field/value/member 太长)
- 数据类型限制被打破(Set 加入非整数,intset -> hashtable)
阈值通常来自配置项(不同版本命名略有区别),典型的你会见到:
- Hash/ZSet 的“max listpack entries / max listpack value”
- List 的 quicklist 块大小、压缩策略等
你不需要死背阈值数字:记住“小而密用紧凑结构,变大或变长就升级”就够了。
真要确认阈值:看你线上 Redis 的配置与版本,再用OBJECT ENCODING实测。
13. 如何在线上确认 encoding 与内存占用
13.1 看类型与编码
TYPE mykey
OBJECT ENCODING mykey
13.2 看这个 key 大概吃了多少内存
MEMORY USAGE mykey
13.3 看总体内存与碎片
INFO memory
MEMORY STATS
MEMORY DOCTOR
13.4 找慢命令(经常是“大结构被全量读/写”)
SLOWLOG GET 20
INFO commandstats
14. 典型性能/内存坑:从底层结构角度解释
14.1 HGETALL / SMEMBERS / 大范围 ZRANGE
- 问题本质:你把一个“大结构”全量拉出来
- 代价:
- Redis 单线程要遍历
- 返回数据巨大占用网络
- 主从复制也会更重
- 建议:
- Hash:按字段读(HMGET),或拆分 key
- Set:避免 SMEMBERS;用 SCAN/SSCAN 分批
- ZSet:范围 + LIMIT 分页
14.2 大 Key
- List 超长:quicklist 节点多,遍历与持久化成本高
- Hash 超大:hashtable 很大,rehash/复制/备份成本高
- Bitmap offset 极大:String 直接膨胀
- Stream 不裁剪:radix tree + entries 越来越大
解决思路:
- 拆分(分片 key)
- 限制(写入前做 guard)
- 分批访问(SCAN/SSCAN/HSCAN/ZSCAN)
- TTL + 随机抖动(避免同时过期)
14.3 热点 key(Hot Key)
- 即使编码很省内存,也会被访问频率打爆单核
- 常见:热点计数器、热点排行榜、热点配置
- 解决:
- 本地缓存/多级缓存
- 拆分计数(分桶再汇总)
- 业务层限流/降级
最后给你一个“选型+底层直觉”的一句话
- 小对象/小集合:Redis 会用 紧凑编码(embstr/listpack/intset) 把内存压到极致
- 规模上来:Redis 会切换到 dict/skiplist/quicklist/radix tree 保证复杂度与可用性
- 真正线上事故往往不是“某结构慢”,而是你对“大结构做了全量操作”。
9769

被折叠的 条评论
为什么被折叠?



