2. Redis核心数据结构与底层编码:String、List、Hash、Set、ZSet的底层实现

Redis之所以能实现微秒级读写,核心在于其底层数据结构并非简单的键值对,而是针对不同场景设计了多种高效编码。理解这些编码的切换机制与内存权衡,是进行热点削峰与性能调优的基础。

2.1 数据结构与底层编码总览

Redis对外暴露五种核心数据类型,每种类型在内部根据数据规模与特征,动态选择不同的底层编码(encoding)。下表总结了各类型的默认编码与切换条件:

数据类型 底层编码(按切换顺序) 切换触发条件 核心优势
String int → embstr → raw 整数用int;≤44字节用embstr;>44字节用raw 整数运算原子性;短字符串零拷贝
List quicklist(3.2+) 统一使用quicklist(ziplist + 双向链表混合) 内存紧凑;两端操作O(1)
Hash ziplist → hashtable field数<512且每个value≤64字节时用ziplist 小hash节省内存;大hash快速随机访问
Set intset → hashtable 所有元素为整数且数量<512时用intset 整数集合极省内存;成员检查O(1)
ZSet ziplist → skiplist + dict 元素数<128且每个member≤64字节时用ziplist 小zset紧凑;大zset高效范围查询
调优要点: 在热点削峰场景中,应主动控制Hash/ZSet的field数量与value长度,使其尽量保持在ziplist编码范围内,可减少内存碎片并提升读写吞吐。

2.2 String底层编码:SDS与内存优化

String的底层并非C语言原生字符串,而是Redis自建的SDS(Simple Dynamic String)。SDS相比C字符串有三个关键改进:

  • O(1)长度获取:SDS头中存储len属性,无需遍历。
  • 预分配与惰性释放:扩容时额外分配free空间,减少内存重分配次数。
  • 二进制安全:不以'\0'为结束符,可存储图片、序列化对象等二进制数据。

编码切换示例:

# 整数编码
SET key 100
OBJECT ENCODING key  # 输出 "int"

# 短字符串(≤44字节)
SET key "hello"
OBJECT ENCODING key  # 输出 "embstr"

# 长字符串(>44字节)
SET key "a...(45个字符)..."
OBJECT ENCODING key  # 输出 "raw"
性能权衡: embstr编码只分配一次内存(SDS头+数据连续),而raw需要两次分配。在热点削峰中,应尽量将缓存value控制在44字节以内,利用embstr的CPU缓存友好特性。
String底层编码切换流程 SET key value 整数? int编码 ≤44字节? embstr编码 raw编码 embstr:SDS头+数据连续分配,一次内存分配;raw:两次分配,适合大对象

2.3 List底层编码:从ziplist到quicklist

Redis 3.2版本之后,List统一使用quicklist编码。quicklist本质是双向链表 + ziplist的混合结构:每个链表节点内部是一个ziplist(压缩列表),节点之间通过双向指针连接。

关键配置参数:

  • list-max-ziplist-size:控制每个ziplist的最大大小(默认8KB),超过则分裂。
  • list-compress-depth:控制两端不压缩的节点数(默认0不压缩),用于减少内存。
性能陷阱: 在热点削峰场景中,如果List频繁执行LTRIM或LPOP/RPOP操作,应避免ziplist过大导致连锁更新(cascade update)。建议将list-max-ziplist-size控制在4KB~8KB之间。

2.4 Hash与ZSet:ziplist vs 跳表

Hash和ZSet在数据量小时都使用ziplist编码,数据量增大后切换为hashtable或skiplist + dict。ziplist是一种连续内存块,通过固定偏移量存储数据,内存利用率极高,但插入/删除需要移动后续元素。

ZSet的skiplist(跳表)是Redis的核心创新之一:

  • 每个节点包含多个层级(level),上层节点跨越更多元素,实现O(logN)的查找效率。
  • 与平衡树相比,跳表实现简单、范围查询友好、并发插入无需复杂旋转。
  • ZSet同时维护一个dict用于O(1)的成员分数查询。
ZSet底层结构:skiplist + dict dict(哈希表) member1 score1 member2 score2 member3 score3 skiplist(跳表) L3 score1 ptr L2 score2 ptr L1 score3 ptr dict提供O(1)的成员→分数查询;skiplist提供O(logN)的范围查询与排序 两者共享同一份member和score数据,不重复存储 编码切换阈值(可配置) zset-max-ziplist-entries 128 | zset-max-ziplist-value 64字节 超过任一阈值 → 切换为skiplist + dict

2.5 Set底层编码:intset的极致压缩

当Set中所有元素都是整数且数量不超过set-max-intset-entries(默认512)时,Redis使用intset编码。intset是一个有序的整数数组,按升序排列,支持二分查找。

intset的内存优势:

  • 每个整数只占用实际位数(16/32/64位自动升级),无指针开销。
  • 相比hashtable,intset节省约70%~90%的内存。
热点削峰案例: 某电商秒杀场景使用Set存储已抢购用户ID(整数),利用intset编码将内存占用从200MB降至15MB,同时SISMEMBER操作仍保持O(logN)效率。

2.6 内存与性能权衡总结

编码类型 内存效率 读写性能 适用场景
int 极高(8字节/元素) O(1)原子操作 计数器、自增ID
embstr 高(一次分配) O(N)但CPU缓存友好 短缓存value(≤44字节)
raw 中(两次分配) O(N)适合大对象 JSON、序列化数据
ziplist 极高(连续内存) O(N)但小数据量可忽略 小Hash、小ZSet、小List
quicklist 高(ziplist+链表) 两端O(1),中间O(N) 消息队列、时间线
intset 极高(无指针) O(logN)二分查找 整数集合、标签系统
skiplist 中(多级指针) O(logN)范围查询 排行榜、延时队列
hashtable 中(哈希表+链表) O(1)平均 大Hash、大Set
调优红线: 在热点削峰场景中,切勿盲目追求内存压缩而忽略CPU开销。例如,将Hash的field数控制在512以内使用ziplist是好的,但如果频繁执行HGETALL遍历所有field,ziplist的O(N)遍历反而会成为瓶颈。此时应权衡是否提前切换为hashtable。