跳到正文

Redis 数据结构 ​

一句话:Redis 用「逻辑类型 + 内部编码」两层设计,小数据量压成连续内存、大数据量切成哈希表或跳表,换来可预测的延迟和内存;代价是编码只升不降,阈值调错会把小对象变成大 Key。

适用版本:Redis 7.x / 8.x。7.0 用 listpack 取代 ziplist;7.2 起 Set 增加 listpack 编码;8.x 新增 Vector Sets,见 14-Redis与AI。

为什么需要 ​

缓存里放的不只是字符串,还有计数器、用户对象、队列、去重集合、排行榜。它们的访问模式完全不同:计数器只改一个整数,排行榜要按分数范围取,队列只动两端。如果统一用哈希表,小对象的指针和 dictEntry 会占掉大半内存;如果统一用链表,范围查询又退化成 O(N)。

Redis 的做法是把「对外类型」和「内部编码」分开:type 决定能用哪些命令,encoding 由数据量、元素大小、元素类型自动选择。理解编码,才能解释同样存 100 万个元素,为什么有的结构占几百 MB、有的占几 GB。

核心机制 ​

String:SDS 与三种编码 ​

String 底层是简单动态字符串(SDS, Simple Dynamic String)。SDS 用 len 记录长度,取长度 O(1),不依赖 \0 结尾,因此二进制安全。单值上限 512 MB。

编码触发条件内存布局代价
int值是整数且落在 long 范围指针位直接存数值,不分配 SDS最省,INCR 直接改
embstr长度 ≤ 44 字节redisObject + SDS 头 + 数据一次分配一次 malloc/free,缓存友好
raw长度 > 44 字节redisObject 与 SDS 分开分配两次分配,但可独立扩容

44 字节来自:64 字节缓存行 − 16 字节 redisObject − 3 字节 sdshdr8 头 − 1 字节结尾。APPEND 会打破 embstr/int 的连续布局,升级为 raw,且不会降回。

内存代价:每个 key 至少还有 dictEntry(约 24 B)、key 的 SDS、redisObject(16 B);一个很小的 key-value 实测常在 80–120 B 量级(经验值,随 jemalloc size class 浮动)。所以「存 1 亿个短 key」首先撞上的是每 key 固定开销,不是 value 本身。

Hash:小对象用 listpack ​

条件编码
field 数 ≤ hash-max-listpack-entries(默认 512)且每个 field/value ≤ hash-max-listpack-value(默认 64 B)listpack
任一条件超出hashtable

listpack 把所有 field-value 对平铺在一块连续内存里,没有指针,适合字段少、字段短的用户对象。field 一多,查找退化为 O(N),于是升级为哈希表。升级不可逆。

内存代价:listpack 每个 entry 只有几字节编码/长度开销;hashtable 每个 field 要付 dictEntry + key SDS。字段数在几百以内时,listpack 往往比「String 存整个 JSON」更省,也能只改单个字段。

List:quicklist = 链表 + listpack ​

7.0 起 List 统一是 quicklist:外层是双向链表,每个节点内嵌一个 listpack。list-max-listpack-size=-2(7.x 默认)表示每个节点约 8 KB,节点数由元素总大小决定。

两端 LPUSH/RPOP 是 O(1),中间 LINDEX/LSET 要遍历,O(N)。内存代价是每个 quicklist 节点约 40 B 的节点头,加上 listpack 内的元素开销;元素很大时节点数变多,指针开销上升。

Set:整数用 intset,小集合用 listpack ​

条件编码内存特征
全是整数且元素数 ≤ set-max-intset-entries(默认 512)intset每个元素 2/4/8 B,连续数组,有序二分
7.2+:元素数 ≤ set-max-listpack-entries(默认 128)且每个元素 ≤ set-max-listpack-value(默认 64 B)listpack连续内存,逐条比较
其他hashtable每元素 dictEntry + SDS,O(1) 但开销大

intset 插入/删除要 memmove,且升级(INT16→INT32→INT64)不可逆。Set 适合去重、标签、交并差;不要用它做「需要按成员取值的字典」,那是 Hash。

ZSet:listpack 或「跳表 + 哈希表」 ​

条件编码
元素数 ≤ zset-max-listpack-entries(默认 128)且 member ≤ zset-max-listpack-value(默认 64 B)listpack,按 score 升序平铺
超出skiplist + hashtable 双索引

双索引的分工:哈希表负责 member → score 的 O(1) 查询与去重;跳表负责按 score 排序、ZRANK、ZRANGE。代价是同一份数据存两遍。小 ZSet 用 listpack 时,ZSCORE 只能顺序找,但 128 个元素以内遍历只要几微秒,比维护双索引更划算。

内存代价(大数据量,经验值):每个元素约「跳表节点 32 B + 平均 1.33 层 × 16 B + member SDS + dictEntry」,通常比同规模 Set 高 1.5–2 倍。

另外四种:什么时候才用 ​

类型适用条件内存代价不该用的场景
Bitmap稠密布尔状态:签到、活跃标记、按位与/或约 最大 offset / 8 字节;1 亿位约 12 MBID 稀疏(只有几个 1 也按最大 ID 占内存)
HyperLogLog只需近似 UV,不关心具体成员固定 12 KB,标准误差约 0.81%需要精确计数、取成员、删除单个元素
Geo附近的人/店,半径查询底层是 ZSet,按 ZSet 计费需要复杂地理围栏或路网计算
Stream中小规模消息队列,要消费者组和 ACKRax 基数树 + PEL,受内存上限约束百万级/秒吞吐、长期堆积,选 Kafka

Stream 的持久化仍受内存和 AOF/RDB 约束,不是 Kafka 的替代品;Bitmap 的 offset 不能超过 2³²−1(受 String 512 MB 上限约束)。

最小上手 ​

bash
docker run -d --name redis7 -p 6379:6379 redis:7.4
redis-cli

SET n 100
OBJECT ENCODING n            # int
SET s "hello"
OBJECT ENCODING s            # embstr
APPEND s " world"
OBJECT ENCODING s            # raw

HSET user:1 name alice age 30
OBJECT ENCODING user:1       # listpack
CONFIG SET hash-max-listpack-entries 1
HSET small a 1
HSET small b 2
OBJECT ENCODING small        # hashtable(阈值调回后也不会降级)
MEMORY USAGE user:1          # 返回该 key 的字节数

MEMORY USAGE 对嵌套结构默认采样 5 个元素估算;要精确值加 SAMPLES 0,但大 Key 上会变慢。

生产动作 ​

  • 先看编码再调阈值:OBJECT ENCODING key 确认当前编码,MEMORY USAGE key(7.x)看实际占用,不要凭元素数猜。
  • 阈值不是越大越好:把 hash-max-listpack-entries 从 512 调到几千,会让单次 HGET 变成线性扫描,并且在主线程执行。
  • 容量估算要算固定开销:key 数 × 约 100 B 的固定成本,再加上 value;1000 万个小 key 仅固定开销就接近 1 GB。
  • 大 Key 的根因常在编码选择:一个几百万元素的 Hash/ZSet 无论怎么调阈值都是 hashtable/skiplist,应该拆分,见 10-大Key问题。
  • 扫描用 SCAN:KEYS 和 SMEMBERS/HGETALL 在大集合上会阻塞主线程。

边界 ​

  • 不是文档数据库:没有二级索引、join、跨 key 约束;Hash 只是「一个 key 下的字段表」,不是关系表。
  • 编码切换不可逆:Set 从 intset 升到 hashtable、Hash 从 listpack 升到 hashtable 后,删元素也不会降回;设计时就要按峰值预估。
  • HLL 不能删元素,Bitmap 不适合稀疏 ID,Geo 的精度受 geohash 编码限制。
  • Stream 不是 Kafka:堆积受内存限制,消费者组的 PEL 也会占内存,长期不 ACK 会持续增长。
  • 不要用「五种类型」概括 Redis:Stream、Bitmap、HLL、Geo 都是独立命令族,选错类型比调错参数更难救。

讲清楚 ​

Redis 对外是五种常用类型,对内是一组会随数据量自动切换的编码。String 用 SDS,短字符串走 embstr、长字符串走 raw;Hash、List、ZSet 在小规模时用 listpack 压成连续内存,规模一大就换成哈希表或跳表加哈希表。这样做的目的是在「小对象省内存」和「大对象保 O(1)/O(log N)」之间取平衡。

代价有两个:编码只升不降,阈值调大后单次操作可能退化;每个 key 还有几十到上百字节的固定开销,小 key 数量一多,内存先被元数据吃掉。所以生产上先看 OBJECT ENCODING 和 MEMORY USAGE,再决定拆 key、换类型,而不是盲目调阈值。

把一门技术讲透到既懂也会用