Redis数据类型底层结构全解析:编码转换、性能调优与实战避坑
1. 从一个线上事故说起:为什么要啃 Redis 底层结构
前阵子帮一个朋友排查线上问题,他们的服务在晚高峰突然大面积超时,日志里刷屏的是 redis command timed out,但看监控,Redis 实例的 CPU 和内存都没打满,QPS 也不算离谱。最后定位下来,是一个存了上百万成员的 Hash 被频繁执行 HGETALL,单次命令阻塞了主线程几百毫秒,把后面的请求全堵住了。这个问题表面上是"命令用错了",根子上其实是没搞清楚 Redis 每种数据类型底层到底是怎么存的、什么规模下会切换编码方式。
这也是我写这篇东西的初衷。Redis 大家都会用,SET、GET、LPUSH、ZADD 敲得飞起,但真正到了性能调优、内存优化、面试深挖的时候,能不能说清楚"一个 List 在什么条件下从 ziplist 变成 quicklist""String 超过多少字节会用 raw 编码""Hash 的 field 数量超过多少会触发转换",这才是分水岭。这篇文章就围绕 Redis 常见数据类型及底层结构 这条主线,把 String、List、Hash、Set、ZSet 这五大基础类型,加上 Bitmap、HyperLogLog、GEO、Stream 这些扩展类型,从使用场景、底层编码、转换阈值、实操验证四个维度掰开揉碎讲一遍。
适合谁看?如果你已经会基本的 Redis 命令,但一遇到"为什么我的内存占用比预期高好几倍""为什么某个命令突然变慢"就抓瞎,那这篇就是给你准备的。如果你正在准备面试,想把这部分讲得比背八股更透,也能用得上。我会尽量用"生活类比 + 实际案例 + 可复现命令"的方式来讲,保证你看完能自己动手验证,而不是停留在概念层面。
先给个整体认知:Redis 对外暴露的是逻辑数据类型,对内实际用的是底层数据结构,两者之间靠编码转换机制连接。同一个 List,元素少的时候可能是一个紧凑的连续内存块,元素多了就变成双向链表加内存块分片的组合。理解这层"表里不一",是理解 Redis 性能特性的钥匙。
2. 五大基础类型的底层结构全拆解
2.1 String:不只是字符串,还是计数器、锁和二进制容器
String 是 Redis 最基础也最万能的类型。很多人以为它就是个字符串,其实它能存文本、整数、浮点数,甚至二进制数据(比如一张小图片的字节流)。底层上,Redis 自己实现了一个叫 SDS(Simple Dynamic String,简单动态字符串) 的结构,而不是直接用 C 语言的 char*。
为什么不用 C 字符串?因为 C 字符串有几个硬伤:获取长度要遍历(O(n))、不能存二进制(遇到 \0 就截断)、拼接容易缓冲区溢出。SDS 通过维护 len(已用长度)和 alloc(分配长度)两个字段解决了这些问题——取长度 O(1),二进制安全,扩容时先检查空间。SDS 还做了空间预分配和惰性释放:追加内容时如果剩余空间够就直接用,不够就多分配一些(小于 1MB 时翻倍,大于 1MB 时多给 1MB),避免频繁内存分配;缩短字符串时不立即释放内存,留着下次用。
String 的编码有三种:int、embstr、raw。存的是整数且能用 long 表示,就用 int 编码,直接把数值存在指针位置,省一次内存分配。存的是字符串且长度小于等于 44 字节,用 embstr,SDS 和 redisObject 一次性分配在连续内存里。超过 44 字节就用 raw,需要两次内存分配。这个 44 字节不是随便定的,和 redisObject 结构大小、内存分配器粒度有关,记住结论就行。
实操验证一下:
注意:
APPEND命令会让embstr强制变成raw,因为 embstr 设计上是只读的,任何修改操作都会触发转换。如果你有大量短字符串拼接场景,提前用raw反而更省事。
String 的典型用法除了缓存,还有计数器(INCR/DECR,原子操作)、分布式锁(SET key value NX EX 10)、限流(配合过期时间做滑动窗口)。这里有个坑:用 SETNX + EXPIRE 两条命令做锁不是原子的,中间宕机就死锁了,一定要用 SET key value NX EX seconds 这种一条命令搞定的写法。
2.2 List:从 ziplist 到 quicklist 的演进逻辑
List 是个有序的字符串列表,支持两端插入删除,底层实现经历过好几次迭代。早期版本元素少的时候用 ziplist(压缩列表),元素多了用 linkedlist(双向链表)。但这两个极端都有问题:ziplist 是一整块连续内存,插入删除要移动数据,元素多了性能急剧下降;linkedlist 每个节点都有前后指针,内存开销大,而且节点分散在堆上,CPU 缓存命中率低。
Redis 3.2 之后引入了 quicklist,本质是"linkedlist of ziplists"——把多个 ziplist 用双向链表串起来。每个 ziplist 节点控制在一定大小内,既保留了链表的插入删除灵活性,又利用 ziplist 的连续内存提升缓存友好度。这个设计思路很像操作系统里的"分页",用分片平衡局部性和全局性。
quicklist 有两个关键参数:list-max-ziplist-size 控制单个 ziplist 的大小(默认 -2,表示每个 ziplist 不超过 8KB),list-compress-depth 控制是否压缩中间节点(默认 0 不压缩)。如果你存的是大 List 且访问集中在两端,可以设置压缩深度,把中间不常访问的节点用 LZF 算法压缩,省内存。
提示:Redis 7.0 用 listpack 替代了 ziplist 作为小数据量的编码,listpack 解决了 ziplist 的"连锁更新"问题——ziplist 每个节点存了前一个节点的长度,修改一个节点可能导致后续节点长度字段连锁变化,最坏情况 O(n²)。listpack 改成只记录自身长度,从后往前遍历,规避了这个问题。
List 的经典场景是消息队列(LPUSH + BRPOP)、最新列表(LPUSH + LTRIM 保留前 N 条)、栈(同端进出)。但要注意,用 List 做队列时,如果消费者处理失败,消息就丢了,没有 ACK 机制。对可靠性要求高的场景,还是得上 Stream 或者专业 MQ。
2.3 Hash:小对象省内存,大对象要当心
Hash 是键值对集合,特别适合存对象,比如用户信息 HSET user:1001 name "张三" age 28 city "北京"。底层编码有两种:元素少且值小的时候用 listpack(老版本是 ziplist),否则用 hashtable(哈希表)。
转换阈值由两个配置控制:hash-max-listpack-entries(默认 128,field 数量超过就转)和 hash-max-listpack-value(默认 64,单个 value 字节数超过就转)。这两个条件满足任意一个就触发转换,而且转换是单向的,一旦变成 hashtable 就不会再变回 listpack。
为什么这么设计?listpack 是连续内存,查找要遍历,但省内存、缓存友好,适合小对象;hashtable 查找 O(1),但每个键值对都要额外的指针和桶开销,内存占用大。Redis 用阈值来平衡"小对象省内存"和"大对象保性能"。
这里有个实战经验:如果你明确知道一个 Hash 会存很多 field,别用 Hash,拆成多个小 Hash 或者直接用 String 序列化。我见过一个项目把所有用户属性塞进一个大 Hash,几十万 field,每次 HGETALL 都卡主线程。正确做法是按业务维度拆分,比如 user:1001:base、user:1001:ext,或者用 HSCAN 分批取。
hashtable 的扩容也值得说一句。Redis 用的是渐进式 rehash:扩容时同时保留新旧两个哈希表,每次操作顺带迁移一部分数据,避免一次性 rehash 阻塞主线程。这也是为什么 Redis 单线程还能保持高性能的原因之一——把大操作拆成小步骤。
2.4 Set:去重集合,整数集合有惊喜
Set 是无序不重复集合,底层编码有三种:intset(整数集合)、listpack(7.0+,老版本是 ziplist)、hashtable。如果所有元素都是整数且数量不超过 set-max-intset-entries(默认 512),用 intset,这是一块有序的连续内存,查找用二分,非常省内存。如果元素不是整数但数量少、值小,用 listpack。否则用 hashtable。
intset 有个细节:它内部按元素大小排序存储,支持 int16、int32、int64 三种编码。如果一开始存的是小整数,后来加入一个大整数,会触发升级操作,把整个集合的元素都转成更大的类型。升级是不可逆的,但能保证灵活性。
Set 的典型场景是标签系统、共同好友(SINTER)、抽奖去重(SADD + SRANDMEMBER)、UV 统计(小规模用 Set,大规模用 HyperLogLog)。做共同好友时,SINTER 的时间复杂度是 O(N*M),N 是最小集合大小,M 是集合数量,集合特别大时要小心。
2.5 ZSet:跳表 + 哈希表的黄金组合
ZSet(有序集合)是 Redis 里最有技术含量的类型,每个元素带一个 score,按 score 排序。底层用 skiplist(跳表) + hashtable 组合:hashtable 存 member 到 score 的映射,保证 O(1) 查 score;skiplist 按 score 排序,支持范围查询和排名。
为什么用跳表不用红黑树?Redis 作者 antirez 给过解释:跳表实现简单、范围查询方便(红黑树范围查询要中序遍历)、并发友好(虽然 Redis 单线程,但思路通用)、内存可控。跳表通过多级索引把查找复杂度降到 O(log N),平均来说和平衡树一个量级,但代码好维护得多。
元素少且值小的时候,ZSet 也用 listpack 编码,阈值是 zset-max-listpack-entries(默认 128)和 zset-max-listpack-value(默认 64)。
ZSet 的经典场景是排行榜(ZADD + ZREVRANGE)、延时队列(score 存时间戳,ZRANGEBYSCORE 取到期任务)、滑动窗口限流(score 存时间戳,清理过期元素)。做延时队列时要注意,ZRANGEBYSCORE 取任务和 ZREM 删除不是原子的,多消费者场景要用 ZPOPMIN 或者 Lua 脚本保证原子性。
3. 扩展类型:Bitmap、HyperLogLog、GEO、Stream 的底层逻辑
3.1 Bitmap:用位运算把内存压到极致
Bitmap 本质还是 String,只是把 String 当成 bit 数组来用。SETBIT key offset 1 把第 offset 位设为 1,GETBIT 读取,BITCOUNT 统计 1 的个数。一个 bit 存一个状态,1 亿用户的签到状态只需要 12.5MB,这是任何其他结构都做不到的。
底层就是 SDS,按位操作。offset 最大是 2^32-1,也就是 512MB。BITCOUNT 统计时如果数据量大,可以用 BITCOUNT key start end 指定字节范围分批统计,避免阻塞。
注意:
SETBIT的 offset 如果很大,中间会补零,可能瞬间分配大量内存。比如SETBIT key 100000000 1会分配 12.5MB。用之前一定确认 offset 范围可控。
3.2 HyperLogLog:用概率换空间的基数统计
HyperLogLog 用来做基数统计(不重复元素个数),标准误差 0.81%,但内存固定 12KB,不管统计多少元素。底层基于概率算法:把元素哈希后看二进制串的前导零个数,用调和平均数估算基数。这个算法很巧妙,但只能估算不能精确统计,也不能判断某个元素是否存在。
适合 UV 统计、独立访客去重这类"要个数不要明细"的场景。如果业务要求精确,还是老老实实用 Set。
3.3 GEO:把地理位置编码成一维分数
GEO 底层是 ZSet,把经纬度通过 GeoHash 算法编码成一个 52 位的整数作为 score,然后用 ZSet 的范围查询找附近的人。GEOADD 添加位置,GEORADIUS 或 GEOSEARCH 查附近。
GeoHash 的原理是把二维的经纬度交替二分,编码成一维字符串,相邻位置的前缀相同,所以能用 ZSet 的范围查询做附近搜索。精度取决于编码位数,52 位大概能精确到厘米级。
3.4 Stream:Redis 版的消息队列
Stream 是 Redis 5.0 引入的专门做消息队列的类型,底层用 rax(基数树) 存储消息,每个消息有唯一 ID(时间戳-序号)。支持消费者组、ACK 确认、消息回溯,比 List 做队列可靠得多。
Stream 的 ID 设计很讲究:毫秒时间戳-序号,保证单调递增,也方便按时间范围查询。消费者组用 PEL(Pending Entries List)记录未确认消息,宕机重启后能重新消费。
4. 编码转换阈值与内存优化实战
4.1 一张表看懂所有转换阈值
| 类型 | 小数据量编码 | 大数据量编码 | 转换阈值配置 | 默认值 |
|---|---|---|---|---|
| String | int / embstr | raw | 44 字节 | 固定 |
| List | listpack | quicklist | list-max-listpack-size | -2 (8KB) |
| Hash | listpack | hashtable | hash-max-listpack-entries / value | 128 / 64 |
| Set | intset / listpack | hashtable | set-max-intset-entries | 512 |
| ZSet | listpack | skiplist | zset-max-listpack-entries / value | 128 / 64 |
这些阈值都可以在 redis.conf 里调整,但不建议盲目调大。调大意味着更多数据用紧凑编码,省内存但单次操作变慢(因为要遍历)。调小则相反。要根据实际数据规模和访问模式权衡。
4.2 用 OBJECT ENCODING 和 MEMORY USAGE 验证
Redis 提供了两个神器:OBJECT ENCODING key 看编码,MEMORY USAGE key 看内存占用。调优时先用这两个命令摸清现状。
我一般会写个脚本,模拟真实数据量灌进去,然后对比不同编码下的内存占用。比如同样存 1000 个用户信息,用一个大 Hash 和 1000 个 String,内存能差好几倍。
4.3 内存优化的几个实操技巧
第一,控制 key 的长度。key 本身也占内存,user:profile:1001:name 比 u:p:1001:n 多占不少。当然可读性也要考虑,折中方案是用短前缀加文档说明。
第二,小对象优先用 Hash。存一个对象的多个字段,用 Hash 比多个 String 省内存,因为 Hash 的 listpack 编码把字段和值紧凑存在一起,而多个 String 每个都有独立的 redisObject 开销。
第三,大 key 拆分。单个 key 超过 10KB 就要警惕,超过 1MB 基本要拆。拆分维度按业务来,比如按时间、按用户 ID 哈希取模。
第四,合理设置过期时间。临时数据一定要设 TTL,避免内存无限增长。但注意大量 key 同时过期会造成瞬时压力,可以给 TTL 加个随机偏移。
5. 常见问题与排查技巧实录
5.1 命令突然变慢,怎么定位
先看慢查询日志:SLOWLOG GET 10 看最近 10 条慢命令。如果发现是 HGETALL、SMEMBERS、KEYS 这类 O(N) 命令,基本就是大 key 问题。用 redis-cli --bigkeys 扫描大 key,或者用 MEMORY USAGE 逐个查。
我遇到过一次 DEL 一个大 Hash 卡住主线程,因为删除大 key 是同步的。解决办法是用 UNLINK 异步删除,或者用 HSCAN + HDEL 分批删。
5.2 内存占用比预期高很多
先确认编码是不是意外变成了 hashtable/skiplist。比如你存了 200 个 field 的 Hash,超过 128 阈值,就从 listpack 变成 hashtable,内存可能翻倍。这时候要么调大阈值,要么拆 key。
还有一种情况是内存碎片。Redis 频繁增删后,分配器留下的碎片会导致 RSS 远大于 used_memory。可以用 INFO memory 看 mem_fragmentation_ratio,大于 1.5 就考虑重启或者开 activedefrag。
5.3 主从同步和持久化对底层结构的影响
RDB 持久化时,不同编码的序列化方式不同,但恢复后编码可能变化。比如一个 listpack 编码的 List,RDB 恢复后如果元素没超阈值,还是 listpack。但 AOF 重写时,Redis 会按当前数据重新生成命令,可能改变编码。
主从同步时,从库的编码和主库一致,因为同步的是命令流。但如果从库配置的阈值和主库不同,可能出现主从编码不一致的情况,排查问题时要注意。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查命令 | 解决方向 |
|---|---|---|---|
| 命令超时 | 大 key O(N) 操作 | SLOWLOG GET / --bigkeys | 拆分 key / 用 SCAN 类命令 |
| 内存暴涨 | 编码转换 / 碎片 | OBJECT ENCODING / INFO memory | 调阈值 / 重启 / activedefrag |
| 删除卡顿 | 同步删除大 key | LATENCY MONITOR | 用 UNLINK 异步删除 |
| 主从延迟 | 大 key 同步 | INFO replication | 拆分 / 错峰 |
| CPU 高 | 频繁 rehash / 复杂命令 | INFO commandstats | 优化命令 / 预分配 |
实操心得:生产环境一定要开慢查询日志,阈值设 10ms 左右。很多问题都是慢命令积累出来的,早发现早处理。另外
MONITOR命令只在调试时短时间用,它会严重拖慢性能。
6. 我踩过的坑和几条实在建议
说几个我自己踩过的坑。第一个是用 List 做队列没设上限,生产者速度远大于消费者,List 涨到几百万,内存爆了不说,LRANGE 还特别慢。后来改成 LPUSH + LTRIM 保留最近 N 条,或者直接用 Stream 加 MAXLEN。
第二个是Hash 的 field 数量没控制,一个配置项 Hash 存了几万个 field,每次 HGETALL 都卡。后来改成按模块拆成多个小 Hash,用 HMGET 批量取需要的字段。
第三个是ZSet 的 score 用了浮点数做时间戳,精度丢失导致排序错乱。时间戳一定要用整数,毫秒级用 long 完全够。
最后给几条实在建议:新项目设计阶段就把数据规模和访问模式想清楚,别等上线了再优化;压测时用真实数据量,别拿几百条数据测;监控一定要覆盖慢查询、内存碎片、大 key 这几个指标;遇到性能问题先看编码,再看命令复杂度,最后才怀疑网络和硬件。
Redis 的底层结构设计处处体现着"权衡"二字——内存和性能的权衡、实现复杂度和维护成本的权衡、通用性和专用性的权衡。理解这些权衡,比记住几个阈值数字重要得多。