Redis数据类型与底层结构深度解析:从编码转换到生产排坑
1. 从一个线上事故说起:为什么必须吃透 Redis 底层结构
几年前我负责过一个日活百万级的社区项目,某天凌晨告警群炸了——Redis 内存使用率在半小时内从 40% 飙到 92%,紧接着大量接口超时。排查下来,罪魁祸首是一个看似人畜无害的 List 结构:运营侧为了做“消息流”,往一个 List 里持续 LPUSH,但消费端因为一个 bug 停了两天,结果这个 List 堆了上千万条元素。更坑的是,这个 List 底层已经从 ziplist 转成了 linkedlist,每条元素还带着前后指针的内存开销,实际占用比预估多了将近一倍。
那次事故之后,我把 Redis 五大基础类型加几个扩展类型的底层编码(encoding)全部重新梳理了一遍。我发现很多开发者对 Redis 的认知停留在“String 就是字符串、List 就是列表”这种 API 层面,一旦遇到内存暴涨、命令变慢、大 key 阻塞,就完全不知道从哪下手。Redis 数据类型及底层结构这个主题,表面看是面试八股,实际上是每一个后端、运维、架构同学绕不开的基本功。
这篇文章我会按“是什么—为什么这么设计—怎么用—怎么排坑”的顺序,把 String、List、Hash、Set、ZSet 五大类型的底层结构讲透,同时补充 Bitmap、HyperLogLog、GEO、Stream 这些扩展类型的适用边界。文中会穿插我实际踩过的坑、参数计算过程、以及可以直接抄作业的排查命令。不管你是刚接触 Redis 的新手,还是想系统梳理底层原理的老手,都能从里面找到能直接用的东西。
2. Redis 数据类型整体设计与底层编码思路
2.1 为什么 Redis 要设计多种数据类型
很多人第一次接触 Redis 会疑惑:既然底层都是键值对,为什么不做成一种“万能类型”,非要拆成 String、List、Hash、Set、ZSet?答案藏在内存效率和操作语义这两个词里。
如果只有一种类型,那么所有操作都得靠客户端自己解析和拼装,服务端无法针对特定场景做优化。比如你要实现一个排行榜,用 String 存 JSON 数组也能做,但每次更新都要把整个数组读出来、改完再写回去,O(N) 的复杂度在数据量大时直接崩盘。而 ZSet 天生就是按 score 排序的结构,插入和查询都是 O(log N),这就是“操作语义”带来的价值。
再比如内存效率。Redis 是内存数据库,内存就是最贵的资源。同样是存 100 个用户 ID,用 Set 存可以做去重和交并集,用 List 存则允许重复且有序。底层编码上,Set 在小数据量时用 intset(整数集合),List 在小数据量时用 ziplist(压缩列表),两者都是为“省内存”而生的紧凑结构。如果只有一种类型,这些针对性优化就无从谈起。
所以 Redis 的多类型设计,本质是用类型换效率:让服务端知道数据的语义,从而选择最合适的存储结构和算法。
2.2 底层编码(encoding)与类型的对应关系
Redis 的每个 key 都有一个类型(type)和一个编码(encoding)。类型是给用户看的,比如 string、list;编码是内部实现,比如 int、embstr、raw、ziplist、quicklist、hashtable、intset、skiplist 等。你可以用 OBJECT ENCODING key 命令查看任意 key 的当前编码。
这里有个关键点:同一个类型,在不同数据量和数据特征下,编码会动态转换。比如一个 List,元素少且每个元素短的时候是 ziplist,超过阈值就转成 quicklist;一个 Hash,小的时候是 ziplist,大了就转 hashtable。这种转换是单向的,一旦转过去就不会自动转回来(除非删除重建)。
理解这一点非常重要,因为编码决定了内存占用和命令复杂度。很多“Redis 内存莫名暴涨”的问题,根源就是编码转换后内存开销翻倍。
2.3 编码转换的阈值参数
Redis 通过 redis.conf 里的一组参数控制编码转换的时机,常用的有:
| 参数 | 默认值 | 作用 |
|---|---|---|
hash-max-ziplist-entries |
128 | Hash 元素数超过此值转 hashtable |
hash-max-ziplist-value |
64 | Hash 单个 value 字节数超过此值转 hashtable |
list-max-ziplist-size |
-2 | List 节点大小限制(-2 表示 8KB) |
set-max-intset-entries |
512 | Set 整数元素超过此值转 hashtable |
zset-max-ziplist-entries |
128 | ZSet 元素数超过此值转 skiplist |
zset-max-ziplist-value |
64 | ZSet 单个元素字节数超过此值转 skiplist |
这些参数不是拍脑袋定的,而是 Redis 作者在“内存”和“CPU”之间做的权衡。ziplist 省内存但插入删除可能触发连锁更新(后面会讲),hashtable 查询快但内存开销大。默认值适合大多数场景,但如果你明确知道自己的数据特征,调整这些参数能带来可观的收益。
提示:修改这些参数需要重启或动态
CONFIG SET,但动态修改只对新创建的 key 生效,已存在的 key 不会重新编码。
3. String 类型:最基础也最容易被低估的结构
3.1 String 的三种底层编码
String 是 Redis 最基础的类型,但它的底层并不简单,有三种编码:
- int:当 value 是整数且能用 long 表示时,直接把字符串转成整数存储,省去字符串对象的开销。
- embstr:当 value 是字符串且长度小于等于 44 字节时,用 embstr 编码。它把 RedisObject 和 SDS(简单动态字符串)分配在一块连续内存里,一次内存分配搞定。
- raw:当 value 长度大于 44 字节时,用 raw 编码,RedisObject 和 SDS 分开分配。
为什么是 44 字节这个奇怪的数字?因为 RedisObject 占 16 字节,SDS 头部(sdshdr8)占 3 字节,加上字符串结尾的 \0 占 1 字节,再算上 jemalloc 分配 64 字节内存块的限制,64 - 16 - 3 - 1 = 44。这个数字是算出来的,不是随便定的。
你可以用下面的命令验证:
3.2 SDS 与 C 字符串的区别
Redis 没有直接用 C 语言的字符串,而是自己实现了 SDS(Simple Dynamic String)。原因有三个:
第一,C 字符串不记录长度,获取长度要遍历,复杂度 O(N);SDS 里有个 len 字段,O(1) 就能拿到长度。
第二,C 字符串容易缓冲区溢出。比如 strcat 拼接时如果没检查空间,就会覆盖相邻内存。SDS 在拼接前会检查 free 字段,空间不够就自动扩容。
第三,C 字符串不能存二进制数据,因为 \0 被当作结束符。SDS 用 len 判断结束,所以可以存图片、序列化对象等二进制内容。
SDS 的扩容策略也很有意思:如果新长度小于 1MB,按 2 倍扩容;如果大于 1MB,每次多分配 1MB。这种“惰性释放”策略避免了频繁的内存分配,但也意味着删除数据后内存不一定马上还给操作系统。
3.3 String 的典型使用场景与实操
String 最常用的场景是缓存对象。比如缓存用户信息,可以把 JSON 序列化后存进去:
但这里有个坑:如果每次只改一个字段,也要把整个 JSON 读出来改完再写回去,并发下容易丢更新。更好的做法是用 Hash 存对象字段(后面会讲),或者用 String 存但配合乐观锁。
String 还常用于计数器:
INCR 是原子操作,天然适合做计数。但要注意,如果 value 不是整数,INCR 会报错。我见过有人把计数器初始化成空字符串,结果 INCR 直接失败,排查半天。
另一个高频场景是分布式锁:
这里 NX 表示 key 不存在才设置,PX 30000 表示 30 秒后自动过期。释放锁时要校验 value 是不是自己的 uuid,避免误删别人的锁。这个场景对 String 的要求是原子性,SET 命令加上 NX 和 PX 参数后本身就是原子的,不需要额外加事务。
注意:用 String 做分布式锁时,过期时间一定要设,否则一旦客户端崩溃,锁永远不释放。但过期时间也不能太短,否则业务没执行完锁就没了,会出现两个客户端同时持锁的情况。
4. List 类型:从 ziplist 到 quicklist 的演进
4.1 List 的底层编码变迁
List 的底层编码经历过几次大改。早期版本用 ziplist 和 linkedlist 两种编码,元素少时用 ziplist,多了转 linkedlist。但 linkedlist 每个节点都要存前后指针,内存开销大,而且节点分散在内存各处,CPU 缓存命中率低。
Redis 3.2 引入了 quicklist,它本质是“ziplist 组成的双向链表”。每个 quicklist 节点是一个 ziplist,节点之间用前后指针连接。这样既保留了 ziplist 的紧凑内存,又避免了单个 ziplist 过大导致的连锁更新问题。
到了 Redis 7.0,又引入了 listpack 来替代 ziplist。listpack 解决了 ziplist 的连锁更新问题,每个元素只记录自己的长度,不记录前一个元素的长度,所以修改一个元素不会影响后面的元素。
你可以这样查看 List 的编码:
4.2 ziplist 的连锁更新问题
ziplist 的每个节点(entry)里有一个 prevlen 字段,记录前一个节点的长度。如果前一个节点长度小于 254 字节,prevlen 占 1 字节;否则占 5 字节。
问题来了:假设一个 ziplist 里所有节点长度都在 250 字节左右,prevlen 都是 1 字节。现在在头部插入一个 300 字节的新节点,那么第二个节点的 prevlen 要从 1 字节变成 5 字节,导致第二个节点总长度增加 4 字节。如果第二个节点原本长度刚好在 250 左右,增加 4 字节后超过 254,那么第三个节点的 prevlen 也要从 1 字节变 5 字节……以此类推,可能触发整个 ziplist 的连锁更新,复杂度从 O(1) 退化到 O(N)。
这就是为什么 Redis 要限制 ziplist 的大小。list-max-ziplist-size 默认 -2 表示每个 ziplist 节点最多 8KB,超过就分裂成新的 quicklist 节点,从而把连锁更新的影响控制在局部。
4.3 List 的典型使用场景
List 最经典的场景是消息队列。用 LPUSH 生产,用 BRPOP 消费:
BRPOP 是阻塞版本,队列为空时会挂起等待,避免轮询浪费 CPU。但要注意,BRPOP 是“后进先出”的消费顺序(从右边弹出),如果要多消费者公平消费,应该用 LPUSH + RPOP 或者 RPUSH + LPOP。
另一个场景是“最新 N 条记录”。比如展示最新 100 条评论:
LTRIM 只保留前 100 个元素,后面的自动删除。这样 List 长度永远不超过 100,内存可控。
实操心得:用 List 做队列时,一定要监控长度。我见过一个项目因为消费者挂了,队列堆了 800 万条消息,
LLEN命令直接阻塞了 2 秒。后来加了告警,长度超过 10 万就通知。
5. Hash 类型:对象存储的最优解
5.1 Hash 的底层编码
Hash 有两种编码:ziplist(7.0 后是 listpack)和 hashtable。当元素数不超过 hash-max-ziplist-entries(默认 128)且每个 value 长度不超过 hash-max-ziplist-value(默认 64 字节)时,用 ziplist;否则转 hashtable。
ziplist 编码的 Hash 在内存上非常紧凑,适合存小对象。比如用户信息:
如果字段多了或者某个字段很长,就会转成 hashtable:
5.2 Hash 与 String 存对象的对比
用 Hash 存对象和用 String 存 JSON 各有优劣:
| 对比项 | Hash | String(JSON) |
|---|---|---|
| 单字段更新 | 支持,O(1) | 需读改写,O(N) |
| 内存占用 | 小对象更省 | 相对较大 |
| 过期粒度 | 整个 key 过期 | 整个 key 过期 |
| 可读性 | 字段清晰 | 需反序列化 |
| 嵌套结构 | 不支持 | 支持 |
我的经验是:如果对象字段经常单独更新,或者字段数不多且值较短,用 Hash;如果对象结构复杂、有嵌套,或者需要整体读写,用 String 存 JSON。
5.3 Hash 的注意事项
Hash 有个容易忽略的点:hashtable 编码下的 rehash 是渐进式的。当负载因子超过阈值时,Redis 会同时保留两个哈希表,逐步把旧表迁移到新表。迁移期间查询会同时查两个表,写入只写新表。这个过程是渐进的,不会一次性阻塞,但如果数据量特别大,迁移期间内存会短暂翻倍。
另外,Hash 的 HGETALL 命令在字段多时会阻塞。如果 Hash 有 10 万个字段,HGETALL 会一次性返回所有数据,可能撑爆客户端缓冲区。更好的做法是用 HSCAN 分批遍历:
HSCAN 每次返回一批数据和一个游标,下次用返回的游标继续扫描,直到游标为 0。这样不会阻塞服务端,也不会撑爆客户端。
6. Set 类型:去重与集合运算的利器
6.1 Set 的底层编码
Set 有三种编码:intset、listpack(7.0 前是 ziplist)和 hashtable。
- intset:当所有元素都是整数且元素数不超过
set-max-intset-entries(默认 512)时使用。intset 是一个有序数组,支持二分查找,内存非常紧凑。 - listpack:当元素不是整数但数量少、长度短时使用(7.0 引入,之前用 ziplist)。
- hashtable:当元素数超过阈值或存在非整数元素时使用。
注意,intset 一旦转成 hashtable 就不会转回来,即使删掉非整数元素。
6.2 Set 的集合运算
Set 支持交集、并集、差集:
这些操作在数据量大时可能很慢,因为复杂度是 O(N)。如果要在生产环境做大规模集合运算,建议用 SINTERSTORE 把结果存到新 key,避免阻塞。
Set 的典型场景是标签系统。比如给文章打标签:
另一个场景是抽奖去重。用 SADD 记录已参与用户,SISMEMBER 判断是否已参与,天然去重。
6.3 Set 的随机元素
SRANDMEMBER 可以随机返回元素,适合做抽奖:
SRANDMEMBER 和 SPOP 的区别是前者不删除元素,后者删除。抽奖场景通常用 SPOP,保证一个用户不会中两次。
7. ZSet 类型:排行榜与延迟队列的核心
7.1 ZSet 的底层编码
ZSet 有两种编码:ziplist(7.0 后是 listpack)和 skiplist。元素数不超过 zset-max-ziplist-entries(默认 128)且每个元素长度不超过 zset-max-ziplist-value(默认 64 字节)时用 ziplist,否则转 skiplist。
skiplist(跳表)是 ZSet 的核心。它通过多级索引实现 O(log N) 的查找、插入、删除。相比平衡树,跳表实现更简单,范围查询更方便。
7.2 ZSet 的排行榜实操
排行榜是 ZSet 最经典的场景:
ZREVRANGE 和 ZRANGE 的区别是排序方向,前者从高到低,后者从低到高。排行榜通常用 ZREVRANGE。
7.3 ZSet 做延迟队列
ZSet 的 score 可以存时间戳,从而实现延迟队列:
消费者拿到任务后,用 ZREM 删除,然后执行。这个方案比用 List 做延迟队列更灵活,因为可以按时间范围查询。
注意:ZSet 做延迟队列时,多个消费者可能同时拿到同一个任务。要用
ZREM的返回值判断是否删除成功,只有删除成功的消费者才能执行任务。
8. 扩展类型:Bitmap、HyperLogLog、GEO、Stream
8.1 Bitmap:位运算做签到统计
Bitmap 本质是 String,但按位操作。适合做签到、活跃用户统计:
Bitmap 非常省内存,1 亿用户的签到状态只需要 12.5MB。但要注意,如果用户 ID 不连续,Bitmap 会有大量空洞,反而浪费内存。
8.2 HyperLogLog:基数统计
HyperLogLog 用于统计不重复元素个数,误差率约 0.81%,但内存固定 12KB:
适合做 UV 统计,但不适合需要精确值的场景。
8.3 GEO:地理位置
GEO 底层是 ZSet,把经纬度编码成 score:
8.4 Stream:消息队列的进阶选择
Stream 是 Redis 5.0 引入的,专门为消息队列设计,支持消费者组、消息确认、持久化:
相比 List,Stream 支持多消费者、消息回溯、ACK 机制,更适合生产级消息队列。
9. 常见问题与排查技巧实录
9.1 内存暴涨排查
内存暴涨最常见的原因是编码转换。排查步骤:
INFO memory看整体内存。redis-cli --bigkeys找大 key。OBJECT ENCODING key看编码。MEMORY USAGE key看单个 key 内存。
如果发现某个 List 从 ziplist 转成了 quicklist,内存可能翻倍。这时候要考虑是否调整 list-max-ziplist-size 参数,或者拆分数据。
9.2 命令变慢排查
命令变慢通常是 O(N) 操作导致。常见的有:
| 命令 | 复杂度 | 替代方案 |
|---|---|---|
HGETALL |
O(N) | HSCAN |
SMEMBERS |
O(N) | SSCAN |
KEYS * |
O(N) | SCAN |
LRANGE 0 -1 |
O(N) | 分页 LRANGE |
ZRANGE 0 -1 |
O(N) | 分页 ZRANGE |
用 SLOWLOG GET 10 可以查看慢查询日志,定位具体命令。
9.3 大 key 问题
大 key 是指 value 特别大的 key,比如一个 List 有百万元素,一个 String 有 10MB。大 key 会导致:
- 网络阻塞:一次传输大量数据。
- 内存不均:集群模式下某个节点内存偏高。
- 删除阻塞:
DEL大 key 会阻塞。
解决方案是用 UNLINK 替代 DEL(异步删除),或者用 HSCAN、SSCAN 分批删除。
9.4 编码转换不可逆
前面提过,编码转换是单向的。一个 Hash 从 ziplist 转成 hashtable 后,即使删掉多余字段,也不会转回 ziplist。如果确实需要转回,只能删除 key 重新创建。
实操心得:我一般会在业务低峰期做数据迁移,把需要紧凑编码的 key 重新写入。比如用户信息 Hash,如果发现转成了 hashtable,就写个脚本读出来再写回去,让它重新用 ziplist 编码。
10. 参数调优与生产环境建议
10.1 根据数据特征调整阈值
默认阈值适合大多数场景,但如果你明确知道数据特征,可以调整。比如:
- 如果 Hash 字段都是短字符串且数量在 200 以内,可以把
hash-max-ziplist-entries调到 256,省内存。 - 如果 List 元素都很小,可以把
list-max-ziplist-size调到 -1(4KB),减少 quicklist 节点数。 - 如果 Set 都是整数且数量在 1000 以内,可以把
set-max-intset-entries调到 1024。
调整前一定要在测试环境验证,观察内存和 CPU 变化。
10.2 监控编码分布
生产环境建议监控各编码的 key 数量。可以用 SCAN 遍历 key,统计 OBJECT ENCODING 结果。如果发现大量 key 从紧凑编码转成了非紧凑编码,说明数据量增长超出了预期,需要扩容或优化数据结构。
10.3 集群模式下的注意事项
集群模式下,key 按槽位分布在不同节点。集合运算如 SINTER 如果涉及多个 key,这些 key 必须在同一个槽位,否则会报错。解决方案是用 hash tag,比如 user:{1001}:tags 和 user:{1001}:fans,花括号内的内容相同,保证落在同一槽位。
另外,集群模式下 SCAN 只能扫描当前节点,要遍历所有节点需要分别执行。
11. 我在实际项目中的几点体会
这些年用 Redis,我最大的体会是:不要把它当黑盒。很多人用 Redis 就是 SET、GET,出了问题就重启,从来不关心底层发生了什么。但 Redis 的很多“坑”恰恰藏在底层结构里——编码转换、连锁更新、渐进式 rehash、大 key 阻塞,这些都不是 API 层面能看出来的。
第二个体会是:监控比调优更重要。与其花时间研究怎么把内存压到极致,不如先把 INFO、SLOWLOG、--bigkeys 这些基础监控做起来。我见过太多团队,Redis 内存都 90% 了还没告警,等用户反馈慢的时候已经晚了。
第三个体会是:没有银弹,只有权衡。ziplist 省内存但可能连锁更新,hashtable 查询快但内存开销大,quicklist 折中但节点多了也有开销。选哪种结构,取决于你的数据特征和业务场景。理解这些权衡,比记住几个参数值重要得多。
最后分享一个我常用的小技巧:如果你不确定某个 key 的编码会不会转换,可以先在测试环境用脚本模拟数据增长,观察 OBJECT ENCODING 的变化点。这样上线前就能预判内存走势,避免线上事故。