Redis 之所以快,第一直觉是「它在内存里」。但纯在内存并不能解释它为什么比一个塞进 HashMap 的进程还省内存、还稳定——真正的功夫在于:Redis 为每一种对外数据类型都准备了多套底层编码,小数据用紧凑编码省内存、大数据用通用编码换性能,并在阈值处自动切换。理解了这套「类型 vs 编码」的二层结构,你就能解释一大半线上现象:为什么同样是 Hash,有的 key 只占几十字节、有的却成了 bigkey;为什么内存删了 key 却降不下来;为什么 ZSet 能既做排行榜又做延时队列。
Redis 深挖三篇:本篇是第一篇「数据结构与内存模型」,另外两篇是 持久化与高可用 与 线程模型与性能。三篇都是 缓存系列开篇 的下钻,建议先读开篇建立全景。
TL;DR
- 两层结构:对外 5 种类型(String/List/Hash/Set/ZSet)↔ 底层 8 种编码;
type是「对外是什么」,encoding才是「内存里怎么存」,用OBJECT ENCODING <key>实测。 - redisObject:每个 value 都有约 16 字节的对象头(type / encoding / lru-lfu / refcount / *ptr),这是「小 key 也不便宜」的根源。
- String:
int(长整数,共享 0–9999 小整数池)→embstr(≤44 字节,robj 与 SDS 一次分配、连续内存)→raw(更长,两次分配)。底层是 SDS:记长度、二进制安全、预分配 + 惰性释放。 - List:
quicklist= 以listpack为节点的双向链表,兼顾内存紧凑与两端 O(1)。 - Hash / Set / ZSet:小而少时用
listpack/intset(顺序紧凑存),超阈值升级为hashtable/skiplist。 - listpack 治好了 ziplist 的连锁更新(cascade update):新版默认用 listpack。
- 编码「只升不降」:一旦升级为通用编码,就算元素再删回来也不会自动降回紧凑编码——这是一些 bigkey 内存降不下来的原因,需重建 key 才能回收。
- 内存开销:jemalloc 按 size class 分配(有内部碎片)、
mem_fragmentation_ratio看碎片、activedefrag在线整理。 - bigkey 的编码级成因:升级为 hashtable/skiplist 后指针与元数据开销陡增;大集合的迁移、
DEL阻塞、网络传输都被放大,删除应用UNLINK。 - 探测工具:
redis-cli --bigkeys、MEMORY USAGE key、OBJECT ENCODING、DEBUG OBJECT、SCAN采样。 - 选型直觉:对象用 Hash 存字段(比多个 String 省对象头)、排行榜/延时队列用 ZSet、判重用 Set/HLL、状态位用 Bitmap,能落进紧凑编码就尽量落进去。
Table of contents
Open Table of contents
- 1. redisObject:一个 value 在内存里长什么样
- 2. 类型 → 编码:用 OBJECT ENCODING 看穿底层
- 3. String:int / embstr / raw 与 SDS
- 4. List:quicklist
- 5. Hash:listpack ↔ hashtable
- 6. Set:intset ↔ listpack ↔ hashtable
- 7. ZSet:listpack ↔ skiplist
- 8. listpack 如何治好 ziplist 的连锁更新
- 9. 编码转换:阈值与「只升不降」
- 10. 内存开销与碎片
- 11. 从编码层重新理解 bigkey
- 12. 探测工具
- 13. 数据结构选型直觉
- 14. AdTech 落地:竞价场景的结构选型
- 参考
1. redisObject:一个 value 在内存里长什么样
Redis 里每个 value 都被包成一个 redisObject(简称 robj)。它是一个约 16 字节的「对象头」,真正的数据挂在它的指针后面:
robj 里 type 是对外类型、encoding 才决定底层怎么存;小而少用紧凑编码省内存,超阈值升级为通用编码换性能,且「只升不降」。
关键字段:
type(4 bit):对外类型,TYPE <key>看到的就是它(string/list/hash/set/zset/stream 等)。encoding(4 bit):底层编码,OBJECT ENCODING <key>看到的是它。lru(24 bit):淘汰用的时钟或 LFU 频次(取决于maxmemory-policy,详见 线程模型与性能)。refcount(32 bit):引用计数,共享对象(如小整数池)靠它。*ptr(64 bit):指向真正承载数据的结构(SDS / quicklist / dict / zskiplist …)。
一个推论:即使存一个很短的字符串,也要付出对象头 + SDS 头 + jemalloc 对齐的固定成本。这就是「几百万个小 String key 也能吃掉大量内存」的原因,也是「能用一个 Hash 存一个对象的多个字段,往往比拆成多个 String key 省内存」的底层依据(少了很多对象头)。
2. 类型 → 编码:用 OBJECT ENCODING 看穿底层
同一种对外类型,底层可能是完全不同的结构。直接实测最直观:
127.0.0.1:6379> set n 12345
127.0.0.1:6379> object encoding n
"int"
127.0.0.1:6379> set s "hello"
127.0.0.1:6379> object encoding s
"embstr"
127.0.0.1:6379> set big "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
127.0.0.1:6379> object encoding big
"raw"
127.0.0.1:6379> hset h f1 v1
127.0.0.1:6379> object encoding h
"listpack"
127.0.0.1:6379> zadd z 1 a 2 b
127.0.0.1:6379> object encoding z
"listpack"
下面逐一拆开每种类型的编码与切换阈值。(本文以 Redis 7.x 为准:listpack 已在多处取代旧的 ziplist 成为默认紧凑编码。)
3. String:int / embstr / raw 与 SDS
String 有三种编码:
int:值是可用long表示的整数时,直接把整数存进*ptr(不额外分配)。Redis 还内置了 0–9999 的共享整数池,这些小整数对象被复用(refcount计数),进一步省内存。embstr:短字符串(≤ 44 字节)。robj 和 SDS 一次性连续分配在同一块内存里——一次malloc、一次free、CPU 缓存友好。44 这个边界正是「robj + SDS 头 + 内容」凑进一个 64 字节 jemalloc 块的结果。raw:更长的字符串。robj 和 SDS 分两次分配,各自独立。
底层的字符串结构是 SDS(Simple Dynamic String),而非 C 原生字符串:
- O(1) 取长度:结构里存了
len,STRLEN不用遍历。 - 二进制安全:以
len为准,可存任意字节(含\0),所以能存图片、序列化对象。 - 预分配 + 惰性释放:
APPEND/扩容时多分配一些空间,缩短时不立刻还,减少频繁realloc——代价是可能有暂时用不上的余量。
一个常见坑:对一个
int编码的 key 做APPEND或SETRANGE,会把它转成raw,且不会再变回 int(编码只升不降,见 §9)。计数器请老老实实用INCR。
4. List:quicklist
List 现在统一用 quicklist:一个双向链表,但每个链表节点本身是一段 listpack(早期是 ziplist)。
- 这样既有链表两端插入/弹出的 O(1),又不至于「每个元素一个独立节点」那样浪费指针内存。
- 每个节点里放多少元素由
list-max-listpack-size控制(也可按字节限制);节点还能开压缩(list-compress-depth)把中间冷节点 LZF 压缩,两端热节点保持不压。
适合做队列、栈、时间线;但别把它当随机访问数组用——LINDEX/LRANGE 到中间是 O(N)。
5. Hash:listpack ↔ hashtable
Hash 小的时候用 listpack:把 field/value 一对一对顺序排在一段连续内存里,省掉哈希表的桶和指针开销;查找是线性扫描,但元素少时反而更快、更省。
当任一阈值被突破就升级为 hashtable(dict):
hash-max-listpack-entries(默认 128):字段数超了;hash-max-listpack-value(默认 64):任一 field 或 value 的字节数超了。
hashtable 用两个哈希表做渐进式 rehash(扩容时新老表并存,操作时逐步搬迁,避免一次性 rehash 卡住主线程)。O(1) 读写,但每个 entry 都要付出 dictEntry + 指针开销,内存明显更大。
「用一个大 Hash 存对象」很香(省对象头、字段聚合),但要盯住阈值:一旦某个 value 超过 64 字节,整个 Hash 就从 listpack 升成 hashtable,内存陡增。
6. Set:intset ↔ listpack ↔ hashtable
Set 有三态:
intset:元素全是整数且数量不多时,用一个有序整数数组,二分查找、极省内存;listpack:含非整数但元素少而短时(7.2+);hashtable:元素多或过长时升级为 dict,O(1) 判重。
触发条件(任一满足即升级):元素出现非整数(intset → listpack/hashtable)、set-max-intset-entries(默认 512)、set-max-listpack-entries(默认 128)、set-max-listpack-value(默认 64)。
7. ZSet:listpack ↔ skiplist
ZSet 小的时候也是 listpack(member+score 成对顺序存);超过 zset-max-listpack-entries(默认 128)或 zset-max-listpack-value(默认 64)就升级为 skiplist + dict 的组合:
跳表是多层有序链表:底层串全部节点、上层是稀疏快速通道,查找/插入/范围查询均 O(logN);并存的 dict(member→节点)让 ZSCORE 等按成员访问是 O(1)。
- skiplist:按 score 有序的多层链表,范围查询(
ZRANGEBYSCORE)、排名(ZRANK)都是 O(logN);层高随机、实现比红黑树简单得多。 - dict:
member → 节点的映射,让ZSCORE、ZADD更新已有成员是 O(1)。
这套组合是 ZSet 「既能按分数排序、又能按成员定位」的关键,也是它能兼任排行榜(score=分数)与延时队列(score=到期时间戳,ZRANGEBYSCORE 0 now 捞到期任务)的底层原因。
8. listpack 如何治好 ziplist 的连锁更新
老编码 ziplist 的每个 entry 里记着「前一个 entry 的长度」(prevlen),用于反向遍历。问题是 prevlen 是变长的:当某个 entry 长度跨过 254 字节边界时,后一个 entry 的 prevlen 要从 1 字节涨到 5 字节,这可能又把它自己的总长度顶过边界,进而触发下一个 entry 也扩张……最坏情况一次插入引发连锁更新(cascade update),退化到 O(N²)。
listpack(7.x 默认)重新设计了 entry 格式:每个 entry 把长度信息存在自己内部、可从两端解析,不再依赖前一个 entry 的长度,从根上消除了连锁更新,同时保持紧凑与顺序存储的优点。所以现在 Hash/ZSet/Set/List 的紧凑编码都以 listpack 为基础。
9. 编码转换:阈值与「只升不降」
任一阈值被突破就升级为通用编码;String 则按长度在 int → embstr(≤44B) → raw 间选定。关键是「只升不降」:元素之后减少也不会自动降回紧凑编码。
汇总阈值(默认值,可在 redis.conf 调):
| 类型 | 紧凑编码 | 升级为 | 触发阈值 |
|---|---|---|---|
| Hash | listpack | hashtable | entries > 128 或 value > 64B |
| ZSet | listpack | skiplist+dict | entries > 128 或 value > 64B |
| Set | intset / listpack | hashtable | 非整数 / intset > 512 / entries > 128 / value > 64B |
| List | listpack 节点 | quicklist | 由 list-max-listpack-size 控制每节点 |
| String | int / embstr | raw | 非整数 / 长度 > 44B / 被 APPEND 等修改 |
「只升不降」是最容易踩的特性:一个 Hash 曾经膨胀到 200 个字段升成了 hashtable,之后就算删到只剩 3 个字段,它仍是 hashtable,内存不会自动降回 listpack。想回收,只能重建 key(如 HGETALL 后删掉重写、或 DUMP+RESTORE)。这正是「删了很多元素但内存降不下来」的常见原因之一。
10. 内存开销与碎片
Redis 用 jemalloc 分配内存,它按 size class 分档(16、32、48、64…字节)。申请 50 字节实际给你 64 字节的块,多出的 14 字节就是内部碎片。所以:
- 大量「刚好卡在档位边界之上」的对象会浪费明显;
INFO memory里used_memory(Redis 视角)与used_memory_rss(OS 视角)之比就是mem_fragmentation_ratio:> 1:有碎片(略大于 1 正常);< 1:内存不够、开始用 swap(危险,延迟会炸)。
- 在线碎片整理:开
activedefrag yes,Redis 会在后台边服务边搬迁对象、合并碎片(有 CPU 成本,按active-defrag-*阈值触发)。
11. 从编码层重新理解 bigkey
缓存开篇 讲过 bigkey 的危害,这里从编码层看清为什么大:
- 元数据开销放大:升级为 hashtable/skiplist 后,每个元素都带 dictEntry / 跳表节点 / 指针,一个「元素很多」的集合,光结构开销就很可观。
- 删除阻塞:
DEL一个大集合在主线程同步释放所有元素,会卡住整个单线程(Redis 命令单线程执行,详见 线程模型与性能)。应改用UNLINK(bio 后台线程异步释放)。 - 迁移/持久化放大:Cluster reshard 逐 key 搬迁时,大 key 迁移耗时长、期间触发 ASK 重定向(见 持久化与高可用);RDB/AOF 也要完整序列化它。
- 网络传输放大:
HGETALL/SMEMBERS一次性拉回整个大集合,撑大缓冲、拉长延迟。
治理:拆分(大 Hash 按 field 哈希分桶成多个小 Hash:key:{crc(field)%N})、控制阈值让它留在紧凑编码、删除用 UNLINK、需要遍历用 HSCAN/SSCAN 而非一次性全取。
12. 探测工具
OBJECT ENCODING <key>:看单个 key 的底层编码。MEMORY USAGE <key>:估算单个 key 占用字节(含对象头与结构开销)。redis-cli --bigkeys:抽样扫描,报告每种类型最大的 key(基于SCAN,不阻塞)。redis-cli --memkeys:按内存维度找大 key。DEBUG OBJECT <key>:底层细节(serializedlength、ql_nodes等),排查用。SCAN/HSCAN/SSCAN:游标式遍历,替代会阻塞主线程的KEYS/HGETALL。
13. 数据结构选型直觉
- 存一个对象:优先 Hash(一个 key 存多个字段,省对象头),并留意别让某字段超 64B 把整 Hash 顶成 hashtable。字段极少也可用多个 String,但对象头成本更高。
- 排行榜 / 延时队列 / 带权去重:ZSet(score 排序 + 成员 O(1) 定位)。
- 判重 / 标签集合:Set;纯整数集合能命中 intset 极省内存。
- 海量去重计数(可接受误差):HyperLogLog(
PFADD/PFCOUNT,固定 ~12KB 估基数)。 - 状态位 / 签到 / 布尔特征:Bitmap(
SETBIT/BITCOUNT),按位存极省。 - 地理位置:GEO(底层是 ZSet + geohash 编码)。
- 原则:能落进紧凑编码就尽量落进去(控制元素数与单值长度),紧凑编码省下的往往是数量级的内存。
14. AdTech 落地:竞价场景的结构选型
广告竞价对 Redis 的用法非常「结构敏感」,因为单机数万 QPS、每次要在 tmax(80–150ms)内读多份数据(详见 缓存开篇 §4):
- 预算 / 频控计数:
String+INCR/DECR(int编码、原子、极省);频控 key 带 TTL,如fc:{user}:{campaign}。 - campaign / 创意元数据:
Hash存一个 campaign 的多字段(出价、定向摘要、状态),比拆成一堆 String 省对象头;注意别把大素材塞进字段。 - 人群包 / 定向标签:整数 uid 集合用
Set(命中 intset);超大人群改Bitmap(uid 作偏移)或 Roaring 思路,判存在快又省。 - 曝光去重 / UV:
HyperLogLog估算,省内存且够用。
实践提示:按 campaign 拆 key(避免超大 Hash)、竞价只 HMGET 需要的字段而非 HGETALL、删除统一用 UNLINK。
参考
- Redis 官方文档 · Data types:https://redis.io/docs/latest/develop/data-types/
- Redis 官方文档 · Memory optimization:https://redis.io/docs/latest/operate/oss_and_stack/management/optimization/memory-optimization/
- Redis 源码 listpack 设计说明(
listpack.md):https://github.com/redis/redis/blob/unstable/src/listpack.md - 黄健宏《Redis 设计与实现》:https://redisbook.com/
- Redis 官方文档 ·
OBJECT ENCODING:https://redis.io/docs/latest/commands/object-encoding/