Redis 数据类型与底层结构
Redis 常被当作"大号 HashMap"使用,但它的内存开销、命令复杂度和性能拐点都由底层编码决定。本文以 Redis 7.x 为基准,从 redisObject 出发,逐一拆解 String、List、Hash、Set、ZSet 的编码方式与转换阈值,并给出可以直接落地的选型建议。适合已经会用的读者补齐原理。
一、对象模型:类型与编码是两回事
每条 Redis 命令最终都落到一个 redisObject 上:
typedef struct redisObject {
unsigned type:4; // 对外类型:string/list/hash/set/zset/stream
unsigned encoding:4; // 内部编码:int/embstr/raw/listpack/...
unsigned lru:24; // LRU 时钟,或 LFU 的 ldt + counter
int refcount; // 引用计数,用于共享整数对象
void *ptr; // 指向真正的数据结构
} robj;
type 决定命令能不能执行,encoding 决定内存布局和单条命令的复杂度。同一类型在数据量变化时会切换编码,阈值由 redis.conf 中的 *-max-listpack-* 系列参数控制。用 OBJECT ENCODING key 可以直接查看当前编码。
| 类型 | 小数据编码 | 大数据编码 | 主要阈值 |
| --- | --- | --- | --- |
| String | int / embstr | raw | 44 字节 |
| List | quicklist + listpack | quicklist + listpack | 单节点 8 KB |
| Hash | listpack | hashtable | 128 个 / 64 字节 |
| Set | intset / listpack | hashtable | 512 / 128 个 |
| ZSet | listpack | skiplist + dict | 128 个 / 64 字节 |
二、String:int、embstr 与 raw
String 是唯一可以直接存二进制、数字和 JSON 的类型。三种编码的判定顺序是:
- 值是整数且能用
long表示,编码为int,ptr直接存数值,不再额外分配。 - 长度小于等于 44 字节,编码为
embstr,redisObject与 SDS 在一次malloc中连续分配。 - 其余情况编码为
raw,对象头与 SDS 分开分配。
44 这个数字来自内存对齐:redisObject 占 16 字节,sdshdr8 头占 3 字节,再加 1 字节结束符,64 字节的 jemalloc 小对象块刚好容纳 44 字节内容。
SDS 相比 C 字符串多了 len 和 alloc 字段,因此 STRLEN 是 O(1);追加时按需预分配,小于 1 MB 时翻倍,大于 1 MB 时每次加 1 MB;同时二进制安全,可以存 \0。APPEND 或 SETRANGE 会让 embstr 退化为 raw,因为 embstr 被设计为只读。
典型用法:计数器(INCR)、分布式锁(SET NX PX)、Session、Bitmap。
三、List:quicklist 套 listpack
Redis 3.2 起 List 统一为 quicklist:一个双向链表,每个节点挂一个压缩块。Redis 7.0 把节点内的 ziplist 换成了 listpack,解决了 ziplist 在中间插入时级联更新 prevlen 导致的 O(n²) 问题。
listpack 的结构是:4 字节总长度、2 字节元素个数、若干条目(编码 + 数据 + 变长 backlen)、1 字节 0xFF 结束符。backlen 记录当前条目自身的长度,而不是前一条目的偏移,因此修改一个条目不会影响其他条目。
每个 quicklist 节点的大小由 list-max-listpack-size 控制,默认 -2 表示单节点不超过 8 KB;正数表示按元素个数限制。list-compress-depth 可以对首尾之外的节点做 LZF 压缩,默认 0 不压缩。
提示:
LRANGE key 0 -1在大 List 上是 O(n),且会一次性分配大块内存,线上应避免;分页请用固定窗口。
四、Hash:listpack 与 hashtable
字段少时 Hash 用 listpack 顺序存储,查找是遍历,但数据量小,常数极低。满足以下任一条件即转为 hashtable:
- 字段数超过
hash-max-listpack-entries,默认 128; - 任一字段名或值的长度超过
hash-max-listpack-value,默认 64 字节。
hashtable 就是标准的链地址法字典,负载因子为 1 时扩容、为 0.1 时缩容,采用渐进式 rehash:同时保留 ht[0] 和 ht[1],用 rehashidx 记录进度,每次增删改查顺带迁移一个桶,定时任务再补 1 ms。因此 rehash 期间内存会短暂翻倍。
编码转换是单向的:一旦变成 hashtable,即使删到只剩一个字段也不会退回 listpack。这也是"删完大 Hash 内存不降"的常见原因。
五、Set:intset、listpack 与 hashtable
Set 有三段式编码:
- 所有元素都是整数且数量不超过
set-max-intset-entries(默认 512),使用intset; - Redis 7.2 起,非整数但元素数不超过
set-max-listpack-entries(默认 128)且单个元素不超过set-max-listpack-value(默认 64 字节),使用listpack; - 其余使用
hashtable。
intset 是一段连续内存:encoding(2/4/8 字节)、length、有序数组。查找用二分,复杂度 O(log n)。插入超出当前位宽的值会触发整体升级,例如插入 2^40 会把所有元素从 2 字节扩到 8 字节,且不可逆。
Set 适合去重、SINTER 求共同好友、SRANDMEMBER 抽奖。元素较多时 SMEMBERS 会阻塞主线程,应改用 SSCAN。
六、ZSet:listpack 与 skiplist
ZSet 在元素数不超过 zset-max-listpack-entries(128)且成员长度不超过 zset-max-listpack-value(64 字节)时用 listpack,按 score 有序排列,插入需要 O(n) 搬移。
超过阈值后转为 skiplist + dict 的组合:
dict保存 member → score,ZSCORE为 O(1);skiplist按 score 排序,节点带span字段,ZRANK可以在 O(log n) 内算出排名。
跳表最大层数 32,每层晋升概率 0.25,期望查询复杂度 O(log n)。Redis 选跳表而不是红黑树,原因有三:范围查询只需在底层链表顺序走、实现和调试简单、并发场景下局部修改更容易加锁。
七、扩展类型
Bitmap 本质是 String,最大偏移 2^32 - 1,即 512 MB。适合签到、活跃用户统计,BITCOUNT 按字节查表,BITOP 可做与或非。
HyperLogLog 也存成 String,固定 12 KB:16384 个 6 位寄存器,标准误差 0.81%。只支持 PFADD、PFCOUNT、PFMERGE,不能取回元素,适合 UV 去重计数。
GEO 底层是 ZSet,把经纬度编码成 52 位 geohash 存进 score,支持 GEOADD、GEOSEARCH。
Stream 是唯一为消息队列设计的类型:rax 索引加 listpack 存储,支持消费组、XACK 与 PEL(Pending Entries List),但堆积时内存增长快,重吞吐场景仍建议用 Kafka 或 RocketMQ。
八、怎么查、怎么调
redis-cli -h 127.0.0.1 -p 6379
127.0.0.1:6379> HSET user:1 name tom age 18
(integer) 2
127.0.0.1:6379> OBJECT ENCODING user:1
"listpack"
127.0.0.1:6379> MEMORY USAGE user:1
(integer) 104
MEMORY USAGE 返回 key 及其值占用的字节数,定位大 key 时比 STRLEN 更准确。调整阈值只能在写入前生效,已存在的 key 不会因为改配置而回退编码。
常见坑 / 最佳实践
- 不要为了"省内存"盲目调大 listpack 阈值。 128 个元素、64 字节是压测后的平衡点,调到 1024 会让单次
HGET的遍历成本上升,主线程更容易被拖慢。 - Hash 字段值超过 64 字节会直接退化。 缓存 JSON 对象时如果某个字段是长文本,整个 Hash 都会变成
hashtable,内存可能翻 2~3 倍,应把长文本拆到独立 String。 - 禁止在生产环境执行
KEYS *、大 Hash 的HGETALL、大 Set 的SMEMBERS。 用SCAN、HSCAN、SSCAN的游标方式分批取。 - ZSet 用时间戳做 score 时注意精度。 score 是 double,毫秒时间戳在 2^53 内仍可精确表示,但微秒级会丢精度,建议用「秒 + 序号」拼接或改用 Stream。
- List 当队列时用
LPUSH+BRPOP,并关注list-max-listpack-size。 队列很长时节点数增多,内存碎片和遍历成本都会上升。
小结
- Redis 的
type是对外语义,encoding才是内存与复杂度的决定因素,用OBJECT ENCODING验证。 - String 三态由 44 字节决定;List 是 quicklist 套 listpack;Hash、Set、ZSet 在数据量小时都用 listpack,超过阈值转为 hashtable 或 skiplist。
- 编码转换基本单向,删数据不会退回紧凑编码,容量规划要按峰值估算。
- 选型先看访问模式:整取用 String,部分更新用 Hash,排序用 ZSet,去重用 Set,计数用 HLL 或 Bitmap。
- 所有阈值改动都要在写入前完成,并通过压测确认主线程延迟没有恶化。
- 点赞
- 收藏
- 关注作者
评论(0)