场景:同样是 Hash,内存差了几倍
线上常遇到这样的现象:两个 Redis 实例都存了一百万个 Hash,业务逻辑几乎一样,内存占用却差了三四倍。OBJECT ENCODING 一查,省内存的那个编码是 listpack(或老版本的 ziplist),费内存的是 hashtable。同一个 HSET 命令,背后可能是两种截然不同的内存布局。
Redis 的对外类型(String、List、Hash、Set、ZSet)只是接口,每种类型底层会根据数据规模在多种编码间切换。理解这套"小数据用紧凑结构、大数据用高效结构"的自适应机制,是做容量规划和性能调优的基础。
紧凑编码:ziplist / listpack 为何省内存
普通哈希表为了 O(1) 查找,要维护桶数组、链表指针、每个 entry 的对象头,这些指针和元数据在小数据量下占比极高——存一个 8 字节的值可能要搭上几十字节的管理开销。
ziplist(以及替代它的 listpack)走的是另一条路:把所有元素紧凑地连续存放在一块内存里,像一个手工编码的字节数组。它的逻辑结构是:
1 | <zlbytes><zltail><zllen><entry1><entry2>...<entryN><zlend> |
每个 entry 内部记录"前一个元素的长度 + 本元素的编码与内容":
1 | <prevlen><encoding><content> |
prevlen:前驱长度,使得可以从尾部反向遍历。encoding:用紧凑位编码描述 content 是整数还是字符串、占几字节。小整数甚至直接编进 encoding 字段,不额外占空间。
这样存储没有指针、没有对象头,内存几乎全花在真实数据上,所以小数据量下极省内存。代价是:它是线性结构,查找和插入都是 O(N);中间插入还可能触发后续元素的内存搬移。
listpack 是 ziplist 的改进版。ziplist 有个著名缺陷叫连锁更新(cascade update):因为每个 entry 存了前驱长度,当某个元素长度跨过 254 字节边界,prevlen 字段要从 1 字节扩到 5 字节,这会改变本 entry 长度,进而可能触发它后面那个 entry 的 prevlen 也扩容……最坏情况下一次更新引发整条链的连锁搬移,O(N²) 级别的抖动。listpack 重新设计了 entry 格式,让长度信息可以从元素自身两端读取,从根上消除了连锁更新。新版 Redis 已用 listpack 全面替代 ziplist。
何时切换到 hashtable
当数据规模超过阈值,Redis 会把紧凑编码转成真正的哈希表。控制阈值的配置:
1 | hash-max-listpack-entries 128 # 元素个数超过则转 hashtable |
逻辑是:小数据量下 O(N) 线性扫描的常数很小,且省内存,划算;数据一大,O(N) 变成性能杀手,这时宁可花指针的内存换 O(1) 查找。这个转换是单向不可逆的——即使后续删到只剩几个元素,编码也不会退回 listpack。原因是反向转换收益有限却要额外判断逻辑,Redis 选择简化。
调参时要权衡:调大阈值能省内存,但会让大集合的单次操作退化为 O(N),阻塞主线程。Redis 是单线程处理命令的,一次慢操作会拖累所有客户端。所以阈值不宜盲目调大。
重头戏:ZSet 与 skiplist
有序集合(ZSet)要同时支持两类操作:按成员查分数(ZSCORE,要求按成员快速定位)和按分数范围取成员(ZRANGEBYSCORE,要求按分数有序遍历)。单一结构很难两者兼顾,所以大数据量下 ZSet 用两个结构组合:
- 一个 哈希表:
member -> score,让按成员查分数是 O(1)。 - 一个 跳表(skiplist):按 score 排序,支持 O(log N) 的范围查询和排名。
为什么是跳表而不是平衡树(如红黑树)?核心原因有三:
- 实现简单。跳表靠随机层高维持平衡,不需要红黑树那套旋转、变色的复杂逻辑,代码量小、易维护、不易出 bug。
- 范围查询天然友好。跳表底层是一条有序链表,找到区间起点后顺着指针走就行;平衡树做范围遍历要中序遍历,实现更绕。
- 并发/缓存特性(对 Redis 单线程下意义有限,但设计上更灵活)。
跳表的本质是给有序链表加多级索引。最底层是完整链表,上面每层是下层的稀疏索引,查找时从顶层大跨步逼近目标再下沉:
1 | L3: HEAD --------------------------> 30 -----------> NIL |
每个节点的层高在插入时随机决定:以概率 p(Redis 用 p=0.25)决定是否再升一层。期望层高是 1/(1-p),查找、插入、删除的期望复杂度都是 O(log N)。随机化避免了维护严格平衡的开销,代价是复杂度是概率意义上的——理论上存在极端坏运气,但概率随 N 指数衰减,工程上可忽略。
Redis 的 skiplist 节点还存了一个 span(跨度)字段,记录本指针跨越了多少个底层节点。靠累加 span,ZRANK(求排名)也能做到 O(log N),这是标准跳表没有、Redis 为支持排名查询而加的。
小 ZSet 同样用 listpack 紧凑编码,阈值由 zset-max-listpack-entries / zset-max-listpack-value 控制,超限才转成"哈希表 + 跳表"组合。
工程权衡与踩坑
内存与速度的取舍贯穿始终。 listpack 省内存但操作 O(N),hashtable/skiplist 操作快但有指针开销。调阈值就是在这条线上选点。
踩坑一:大 key 阻塞。 一个有几十万元素、却仍处于(或被强行配置成)线性编码的集合,一次 HGETALL/ZRANGE 全量操作会长时间占用单线程,造成所有请求延迟飙升。排查时用 redis-cli --bigkeys 找大 key,用 OBJECT ENCODING 看编码。
踩坑二:误以为编码会自动优化回去。 数据缩小后编码不回退,内存不会自动降下来。需要省内存时只能重建 key。
踩坑三:阈值调得太大。 为省内存把 hash-max-listpack-entries 调到几千,结果每次 HSET 都在大 listpack 里做 O(N) 插入并可能搬移内存,写入吞吐崩盘。
小结
Redis 的精髓在于用编码的自适应切换,在内存和性能之间动态选优:小数据用 listpack 把开销压到极致,大数据用 hashtable 保证查找、用 skiplist 同时满足按成员定位和按分数范围查询。跳表用随机层高换来了平衡树级别的复杂度却远更简单的实现,加上 span 字段支持排名。掌握这套机制,你就能从 OBJECT ENCODING 一眼看出内存与延迟的来龙去脉。