Redis底层数据结构与编码转换实战:从线上事故到性能调优
1. 从一个线上事故说起:为什么底层结构比命令本身更重要
几年前我负责过一个日活百万级的社交产品,某天凌晨收到告警:一个存储用户会话的 Redis 实例内存使用率在半小时内从 40% 飙到 92%,同时慢查询数量暴涨。排查后发现,问题出在一个看似无害的 HGETALL 命令上——有个业务方把用户的所有好友关系塞进了一个 Hash,单个 Key 的字段数超过了 80 万。HGETALL 一次性拉取全部字段,阻塞了主线程,后续请求排队堆积,内存和延迟同时失控。
这个事故让我彻底意识到一件事:只会用 SET、GET、LPUSH 这些命令,根本不算会用 Redis。真正决定一个 Redis 实例稳不稳、快不快、省不省内存的,是命令背后那套底层数据结构。你写的每一行命令,最终都会落到某种具体的编码(encoding)上,而编码的选择直接决定了时间复杂度、内存占用和操作行为。
这篇内容就是把我这些年踩过的坑、做过的优化、面试里被问到的底层原理,系统地梳理一遍。核心围绕 Redis 的五大基础数据类型——String、List、Hash、Set、ZSet,讲清楚它们各自对应哪些底层结构,这些结构在什么条件下会转换,以及在实际业务里怎么根据这些知识做选型和调优。不管你是刚接触 Redis 的新手,还是已经用了几年但没深究过底层的开发者,都能从中拿到能直接落地的东西。
需要先明确一个前提:Redis 的底层结构不是一成不变的,它会根据数据量、元素大小、元素数量动态切换编码。这个"动态切换"机制是理解一切性能问题的钥匙。下面我会逐个类型拆开讲,每个类型都会落到"什么场景用什么编码、怎么验证、怎么避免踩坑"这个层面。
2. Redis 对象模型:所有数据类型的地基
2.1 redisObject 到底存了什么
在讲具体类型之前,必须先搞清楚 Redis 是怎么表示一个"值"的。Redis 里所有的值都被封装成一个叫 redisObject 的结构体,你可以把它理解成"值的身份证"。它主要包含几个字段:type(类型,比如 string、list)、encoding(编码,也就是底层用的什么结构)、ptr(指向真实数据的指针)、refcount(引用计数,用于内存回收)、lru(最近访问时间,用于淘汰策略)。
这里最关键的是 type 和 encoding 的分离。type 是你用命令时看到的逻辑类型,比如你 LPUSH 一个列表,type 就是 list;而 encoding 是 Redis 内部真正用来存这个列表的结构,可能是 ziplist,也可能是 quicklist,甚至是 listpack。同一个 type 可以对应多种 encoding,这就是为什么同样一个 LPUSH,在不同数据量下性能表现完全不同。
你可以用 OBJECT ENCODING key 这个命令直接查看某个 Key 当前的编码。这是排查性能问题最常用的手段之一,我几乎每次做 Redis 优化都会先跑一遍这个命令,看看实际编码和预期是否一致。很多人以为自己存的是"小列表",结果一看编码是 quicklist,说明早就超过阈值转换了,性能特征完全变了。
2.2 编码转换的触发条件
编码转换不是随机的,它由一组配置参数控制。这些参数在 redis.conf 里,也可以在运行时用 CONFIG SET 动态调整。核心的几个参数包括:list-max-ziplist-size(列表压缩阈值)、hash-max-ziplist-entries 和 hash-max-ziplist-value(Hash 转换阈值)、set-max-intset-entries(整数集合阈值)、zset-max-ziplist-entries 和 zset-max-ziplist-value(有序集合阈值)。
这些参数的含义是:当元素数量或单个元素大小超过阈值时,Redis 会把紧凑型编码转换成通用型编码。紧凑型编码(如 ziplist、intset、listpack)内存占用小、缓存友好,但插入删除可能涉及内存搬移;通用型编码(如 hashtable、skiplist、quicklist)操作更稳定,但内存开销更大。理解这个权衡,是做好 Redis 选型的基础。
注意:编码转换是单向的,一旦从紧凑型转成通用型,即使后续元素减少,也不会自动转回去。这意味着如果你先写入大量数据触发转换,再删除大部分数据,内存并不会降回紧凑型的水平。这个特性在"先批量导入再清理"的场景里特别容易造成内存浪费。
3. String 类型:不只是字符串那么简单
3.1 三种编码与适用边界
String 是 Redis 最基础也最容易被低估的类型。它有三种编码:int、embstr、raw。当你 SET 一个纯数字且不超过 long 范围的值时,Redis 会用 int 编码,直接把数字存在 ptr 指针位置,连额外内存分配都省了。当值是一个短字符串(Redis 3.2 之后是 44 字节以内)时,用 embstr,它把 redisObject 和字符串数据分配在一块连续内存里,减少一次内存分配和缓存 miss。超过 44 字节,就用 raw,redisObject 和 SDS 分开分配。
为什么是 44 字节这个数字?因为 redisObject 本身占 16 字节,加上 SDS 头部(sdshdr8 是 3 字节)和结尾的 \0(1 字节),再考虑内存分配器 jemalloc 按 64 字节对齐,16 + 3 + 1 + 44 = 64,刚好卡在一个内存块里。这个细节说明 Redis 在内存布局上是精打细算的,理解它能帮你在设计 Key 的时候有意识地控制值的大小。
SDS(Simple Dynamic String)是 String 底层真正用的字符串结构,它和 C 语言原生字符串最大的区别是:SDS 记录了长度,所以获取长度是 O(1);SDS 有预分配和惰性释放机制,追加操作不用每次都重新分配;SDS 是二进制安全的,可以存图片、序列化对象等任意字节。这也是为什么 Redis 能存二进制数据而 C 字符串不行。
3.2 计数器与分布式场景的实操
String 最典型的用法是计数器。INCR、DECR、INCRBY 这些命令在 int 编码下是原子操作,性能极高。我做过一个接口限流的场景,用 INCR 配合 EXPIRE 实现固定窗口计数,QPS 上万的情况下单实例毫无压力。这里有个细节:INCR 和 EXPIRE 是两条命令,不是原子的,如果中间宕机可能导致 Key 永不过期。正确做法是用 SET key value EX 60 NX 初始化,或者用 Lua 脚本把两步合成原子操作。
另一个高频场景是分布式锁。SET key value NX PX 30000 是标准的加锁写法,NX 保证只有 Key 不存在时才设置,PX 设置毫秒级过期防止死锁。释放锁时必须校验 value 再删除,否则可能误删别人的锁,这个校验加删除也得用 Lua 脚本保证原子性。我见过太多人直接 DEL,在高并发下锁被提前释放导致数据错乱。
实操心得:用 String 存序列化对象时,尽量控制大小。我见过有人把整个用户画像(几十 KB 的 JSON)塞进一个 String,结果单次
GET耗时从 0.1ms 涨到 2ms,QPS 直接掉一半。大对象要么拆分,要么改用 Hash 按字段存取。
3.3 位图与 HyperLogLog 的延伸
String 还支撑了两个高级用法:位图(Bitmap)和 HyperLogLog。位图本质是把 String 当 bit 数组用,SETBIT、GETBIT、BITCOUNT 这些命令可以做用户签到、活跃统计。一个用户一年的签到只需要 365 个 bit,约 46 字节,比用 Set 存日期省几十倍内存。HyperLogLog 用来做基数统计,比如统计 UV,标准误差 0.81%,每个 Key 固定 12KB,不管统计多少元素。这两个都是基于 String 底层实现的,理解了 String 的编码,用起来心里更有底。
4. List 类型:从 ziplist 到 quicklist 的演进
4.1 早期 ziplist 与 linkedlist 的双编码
在 Redis 3.2 之前,List 有两种编码:ziplist 和 linkedlist。元素少且小时用 ziplist,它是一块连续内存,每个元素紧凑排列,遍历快、内存省;元素多或大时转成 linkedlist,双向链表,插入删除 O(1),但每个节点都有前后指针开销,内存碎片也多。这个设计的痛点是:ziplist 在中间插入删除时要搬移后续所有元素,数据量大时性能急剧下降;而 linkedlist 内存开销大,缓存不友好。
4.2 quicklist:折中方案的设计哲学
Redis 3.2 引入了 quicklist,它本质是"linkedlist of ziplist"——一个双向链表,每个节点是一个 ziplist。这样既保留了链表的插入删除灵活性,又通过 ziplist 降低了每个元素的内存开销。list-max-ziplist-size 参数控制每个 ziplist 节点的大小,可以按元素个数或字节数限制。默认是 -2,表示每个 ziplist 节点不超过 8KB。
为什么是 8KB?这是个经验值。太小了链表节点多,指针开销大;太大了单个 ziplist 操作慢,且内存碎片严重。8KB 大约能放几百个普通元素,在内存和性能之间取得平衡。你可以根据业务调整:如果列表元素都很小且操作以遍历为主,可以调大;如果频繁在中间插入,可以调小让每个节点更轻。
Redis 7.0 之后,ziplist 逐渐被 listpack 取代。listpack 解决了 ziplist 的"连锁更新"问题——ziplist 每个节点记录前一个节点的长度,修改一个节点可能导致后续节点长度字段连锁变化,最坏情况 O(n²)。listpack 只记录自己的长度,避免了这个问题。这是底层结构持续演进的一个典型例子。
4.3 消息队列与时间线的实操要点
List 最经典的用法是轻量级消息队列。LPUSH + BRPOP 实现生产者消费者,BRPOP 的阻塞特性避免了轮询空转。但这里有个坑:BRPOP 是阻塞命令,如果多个消费者同时阻塞,新消息来了只会唤醒一个,且不保证公平。另外,List 不支持消费确认,消息弹出就没了,如果消费者处理失败,消息就丢了。所以它只适合对可靠性要求不高的场景,比如日志收集、异步通知。
另一个场景是时间线。用 LPUSH 存最新动态,LRANGE 分页读取。但要注意 LRANGE 的复杂度是 O(S+N),S 是偏移量,N 是返回数量。如果偏移量很大(比如翻到第 1000 页),性能会明显下降。我的做法是用 LTRIM 限制列表长度,只保留最近 N 条,既控制内存又保证分页性能。
常见问题:为什么我的 List 内存占用比预期大很多?先查
OBJECT ENCODING,如果已经是 quicklist,再看list-max-ziplist-size配置。另外,List 元素如果都是小整数,Redis 会尝试用整数编码存储,但如果混入了字符串,整个节点就会退化成字符串存储,内存翻倍。
5. Hash 类型:小对象存储的最优解
5.1 ziplist 与 hashtable 的转换逻辑
Hash 有两种编码:ziplist(Redis 7.0 后是 listpack)和 hashtable。当 Hash 的字段数不超过 hash-max-ziplist-entries(默认 512)且每个字段名和值的长度都不超过 hash-max-ziplist-value(默认 64 字节)时,用 ziplist 存储。ziplist 里字段和值交替排列,紧凑高效。超过任一阈值,转成 hashtable,也就是标准的哈希表,支持 O(1) 查找但内存开销大。
这个转换逻辑有个容易忽略的点:只要有一个字段的值超过 64 字节,整个 Hash 就转成 hashtable。我见过一个用户信息 Hash,大部分字段都很短,但有个签名字段偶尔超过 64 字节,导致整个 Hash 频繁在两种编码间切换(实际上是单向转换,转过去就回不来),内存直接翻几倍。解决办法是把大字段单独拆成 String 存,Hash 里只放小字段。
5.2 为什么 Hash 比多个 String 更省内存
很多人问:存用户信息,是用一个 Hash 还是多个 String?答案通常是 Hash。原因在于:多个 String 意味着多个 redisObject、多个 Key 的元数据、多次内存分配;而一个 Hash 在 ziplist 编码下,所有字段共享一个 redisObject,字段名和值紧凑排列,内存占用可能只有前者的几分之一。我实测过一个 20 字段的用户对象,用 Hash 存约 300 字节,拆成 20 个 String 要 1.5KB 以上。
但 Hash 也不是万能的。如果字段数很少(比如就 2-3 个),且需要单独过期或单独更新,那 String 更灵活。另外,Hash 不支持对单个字段设过期时间,整个 Hash 只能统一过期。所以选型要看具体需求:字段多、整体读写、内存敏感,选 Hash;字段少、需要独立控制,选 String。
5.3 购物车与配置中心的落地案例
Hash 最典型的落地是购物车。用 HSET cart:userId productId count 存商品数量,HINCRBY 增减,HGETALL 拉取全车。这里要注意 HGETALL 的性能:如果购物车商品很多(比如上千),HGETALL 会一次性返回所有字段,阻塞主线程。更好的做法是用 HSCAN 分批遍历,或者业务上限制购物车商品数量。
另一个场景是配置中心。把一组配置项放在一个 Hash 里,应用启动时 HGETALL 一次性加载。这种场景字段数可控(通常几十个),值也不大,ziplist 编码下性能极好。我做过一个灰度配置系统,用 Hash 存不同版本的配置,配合 HGET 按版本读取,简单可靠。
实操心得:Hash 的
HGETALL在大 Key 上是危险操作。我一般会先用HLEN看字段数,超过 1000 就改用HSCAN。另外,HSCAN的 COUNT 参数只是提示,实际返回可能多也可能少,遍历时要循环直到游标为 0。
6. Set 类型:去重与集合运算的利器
6.1 intset 与 hashtable 的取舍
Set 有两种编码:intset 和 hashtable。当集合里全是整数且元素数量不超过 set-max-intset-entries(默认 512)时,用 intset。intset 是一个有序的整数数组,查找用二分,内存极省。一旦插入非整数元素,或者数量超限,就转成 hashtable。hashtable 的 value 是 null,相当于只用 key 做去重。
intset 的有序性是个隐藏福利:它天然支持范围查询,虽然 Redis 没直接暴露这个能力,但底层是有序的。另外,intset 在插入时如果新元素导致数组扩容,会重新分配内存并搬移,所以大量插入时性能不如 hashtable 稳定。这也是为什么阈值默认是 512,超过就转换。
6.2 标签系统与共同好友的实现
Set 最常用的场景是标签和关系。比如给用户打标签,SADD user:1:tags tag1 tag2,查两个用户的共同标签用 SINTER,推荐场景用 SDIFF 找差异。集合运算的复杂度是 O(N*M),N 是最小集合大小,M 是集合数量。如果集合很大,SINTER 可能阻塞。我的做法是先用 SCARD 估算大小,大集合的运算放到从库或离线做。
共同好友是社交产品的经典需求。用 SINTER user:1:friends user:2:friends 直接算交集。但要注意,如果好友列表用 Set 存,单个用户好友上千,交集运算就是上千次比较。更好的方案是用 Bitmap 存好友关系(用户 ID 作为 bit 偏移),交集用 BITOP AND,性能高一个数量级。这就是理解底层结构带来的优化空间。
6.3 随机抽奖与 SPOP 的注意事项
SPOP 和 SRANDMEMBER 可以做随机抽奖。区别是 SPOP 会移除元素,SRANDMEMBER 不会。抽奖场景通常用 SPOP,保证不重复中奖。但 SPOP 在 intset 编码下是 O(1)(随机选一个索引),在 hashtable 下是 O(1) 平均(随机选一个桶)。如果一次抽多个,SPOP key count 的复杂度是 O(count)。
注意:
SPOP在大 Set 上抽少量元素没问题,但如果 Set 本身很大且要抽很多,性能会下降。另外,SPOP移除元素后,如果 Set 变小,编码不会从 hashtable 转回 intset,内存不会自动释放。
7. ZSet 类型:排行榜背后的跳表与压缩列表
7.1 ziplist 与 skiplist 的转换
ZSet 有两种编码:ziplist(7.0 后 listpack)和 skiplist。元素数量不超过 zset-max-ziplist-entries(默认 128)且每个元素长度不超过 zset-max-ziplist-value(默认 64 字节)时用 ziplist,元素按 score 有序排列,member 和 score 交替存储。超过阈值转成 skiplist,同时配一个 dict 做 member 到 score 的映射。
为什么 ZSet 需要两个结构?因为 ZSet 要支持两种查询:按 member 查 score(O(1)),按 score 范围查 member(O(logN))。dict 负责前者,skiplist 负责后者。skiplist 是一种多层链表,查找、插入、删除都是 O(logN),实现比平衡树简单,且范围查询天然友好。这是 Redis 作者在权衡后的选择。
7.2 排行榜的分页与 score 设计
排行榜是 ZSet 最经典的场景。ZADD rank score member 添加,ZREVRANGE rank 0 9 WITHSCORES 取前十。但分页有个坑:ZREVRANGE 的偏移量大了之后性能下降,因为 skiplist 要从头遍历。如果排行榜数据量大且要深度分页,更好的做法是用 ZREVRANGEBYSCORE 配合上一页的 score 做游标,避免大偏移。
score 的设计也有讲究。如果 score 相同,Redis 会按 member 字典序排列。有时候业务需要"分数相同按时间先后排",可以把时间戳编码进 score,比如 score = 分数 * 10^10 + (最大值 - 时间戳),这样分数优先、时间次之。这个技巧在秒杀排行榜、游戏积分榜里很常用。
7.3 延时队列与滑动窗口
ZSet 还能做延时队列。把任务的执行时间戳作为 score,ZADD delay_queue timestamp task,消费者用 ZRANGEBYSCORE delay_queue 0 now 拉取到期任务。这个方案比 List 灵活,因为可以按时间范围取,也支持取消(ZREM)。但要注意,多个消费者同时拉取可能重复消费,需要配合分布式锁或 Lua 脚本保证原子性。
滑动窗口限流也能用 ZSet 实现。每次请求把时间戳作为 score 和 member 插入,然后 ZREMRANGEBYSCORE 删除窗口外的,ZCARD 统计窗口内数量。这个方案精确但内存开销大,每个请求都要存一条。高 QPS 下更推荐用令牌桶或固定窗口。
常见问题:ZSet 的
ZRANGE和ZREVRANGE在大偏移量下慢怎么办?用ZRANGEBYSCORE或ZRANGEBYLEX做游标分页,或者业务上限制最大页数。我一般会限制排行榜只展示前 1000 名,后面的用其他方案。
8. 编码转换的验证与调优实操
8.1 用命令验证实际编码
理论讲再多,不如动手验证。下面这套命令是我排查 Redis 问题的标准流程。先 OBJECT ENCODING key 看编码,再 DEBUG OBJECT key 看更详细的信息(包括序列化长度、引用计数等),然后 MEMORY USAGE key 看实际内存占用。这三个命令组合起来,基本能定位大部分内存和性能问题。
DEBUG OBJECT 在线上环境要慎用,它有一定开销,且部分云厂商禁用了这个命令。MEMORY USAGE 相对安全,可以采样使用。我一般会在压测环境先跑一遍,确认编码符合预期再上线。
8.2 阈值参数的调整策略
默认阈值是通用场景的折中,不一定适合你的业务。调整的原则是:如果数据普遍很小且以读为主,可以调大阈值让更多数据用紧凑编码,省内存;如果数据大小不一或写操作频繁,调小阈值让更早转成通用编码,避免 ziplist 的搬移开销。
举个例子:如果你的 Hash 字段数稳定在 100 左右,值都很短,那把 hash-max-ziplist-entries 调到 256 甚至 512 都没问题,能省不少内存。但如果字段数波动大,偶尔上千,那保持默认或调小更稳妥,避免大 ziplist 的操作卡顿。调整前一定要在压测环境验证,观察 INFO memory 和 INFO commandstats 的变化。
8.3 大 Key 的识别与治理
大 Key 是 Redis 性能的头号杀手。识别方法有几种:redis-cli --bigkeys 扫描(会阻塞,慎用)、SCAN 配合 MEMORY USAGE 采样、或者用监控工具(如 Redis 自带的 LATENCY 监控)。治理手段包括:拆分(大 Hash 拆成多个小 Hash)、压缩(用更紧凑的序列化)、迁移(冷数据移到其他存储)。
我处理过一个案例:一个 ZSet 存了 500 万成员,ZADD 耗时从 0.1ms 涨到 5ms。方案是按时间分片,每天一个 ZSet,查询时合并结果。这样单个 ZSet 控制在几十万,性能恢复。分片键的设计要考虑查询模式,避免跨片查询过多。
9. 常见问题速查与避坑清单
9.1 编码相关高频问题
| 问题现象 | 可能原因 | 排查与解决 |
|---|---|---|
| 内存比预期大很多 | 编码已转成通用型 | OBJECT ENCODING 确认,检查阈值配置 |
| 写入变慢 | ziplist 搬移或大 Key | SLOWLOG 查看慢命令,拆分大 Key |
| 编码频繁变化 | 数据大小不稳定 | 统一数据格式,或调小阈值提前转换 |
HGETALL 卡顿 |
Hash 字段过多 | 改用 HSCAN,或拆分 Hash |
LRANGE 大偏移慢 |
List 遍历开销 | 用 LTRIM 限制长度,或游标分页 |
9.2 选型决策的实操建议
选型没有标准答案,但有决策框架。先问三个问题:数据量多大?读写比例如何?是否需要范围查询?数据量小、读多、需要范围查询,优先紧凑编码(ziplist/intset);数据量大、写多、需要稳定 O(1),优先通用编码(hashtable/skiplist)。另外,能用 Hash 就别用多个 String,能用 intset 就别用 hashtable,这是省内存的基本盘。
避坑清单:不要在单个 Key 里存超过 10 万元素;不要对未知大小的 Key 用
HGETALL、LRANGE 0 -1、SMEMBERS;不要忽略编码转换的单向性;不要在生产环境随意调大阈值而不压测;不要用KEYS *查 Key,用SCAN。
9.3 监控与容量规划
最后说监控。INFO memory 看内存碎片率和 used_memory,INFO stats 看命中率和过期 Key 数,INFO commandstats 看各命令调用次数和耗时。容量规划上,我一般按"峰值数据量 × 1.5"预留内存,因为编码转换、内存碎片、复制缓冲都会额外占用。如果用了持久化,还要留出 fork 时的 COW 内存。
我个人在实际操作中的体会是:Redis 的底层结构知识,平时看着用不上,但一旦遇到性能问题,它就是排查的指南针。知道 OBJECT ENCODING 怎么用、知道阈值在哪配、知道大 Key 怎么拆,这三件事能解决 80% 的 Redis 性能问题。剩下的 20%,靠的是对业务数据特征的理解——你的数据长什么样,决定了它该用什么结构存。