Redis五种基本数据类型的底层结构深度解析
Redis 是一个高性能的键值存储数据库,以其高效的数据结构和灵活的使用场景著称。Redis 提供了五种基本数据类型:String(字符串)、List(列表)、Set(集合)、Hash(哈希)、Zset(有序集合) 。每种数据类型的底层实现都经过精心设计,结合多种数据结构以优化性能。本文将深入探讨这五种数据类型的底层结构,分析其实现原理、切换条件、收益,以及 ziplist 和 listpack 的差异,并通过模拟面试官的深入拷问,确保对结构的透彻理解。部分 C 代码片段将用于讲解实现细节。
1. String(字符串)
底层结构:SDS(Simple Dynamic String)
Redis 的字符串使用 SDS(简单动态字符串) 而非 C 的字符数组。SDS 的结构如下(简化版):
struct sdshdr {
int len; // 字符串长度
int free; // 未使用空间大小
char buf[]; // 实际存储字符串的数组
};
特性与优势
- O(1) 获取长度:通过
len字段直接获取长度,避免 C 字符串遍历。 - 二进制安全:不依赖
\0结尾,可存储任意二进制数据。 - 动态扩展:通过
free字段预留空间,减少内存分配。 - 内存优化:根据字符串长度使用不同头部(
sdshdr8、sdshdr16等)。
使用场景与切换条件
String 有三种编码:
- int:存储整数值(如
SET key 123)。 - embstr:短字符串(<=44 字节),SDS 和 Redis 对象头存储在连续内存。
- raw:长字符串(>44 字节),SDS 和对象头分开分配。
切换逻辑:
- 当值是整数时,使用
int编码,存储数值。 - 当字符串 <= 44 字节时,使用
embstr,减少一次内存分配。 - 当字符串 > 44 字节或需要扩展时,切换为
raw。
收益:
int:节省内存,直接存储数值,无需序列化。embstr:连续内存分配减少碎片,提升分配效率。raw:支持动态扩展,适合长字符串。
实现示例
创建 SDS 的代码(简化版):
sds sdsnew(const char *init) {
size_t initlen = (init == NULL) ? 0 : strlen(init);
struct sdshdr *sh = zmalloc(sizeof(struct sdshdr) + initlen + 1);
sh->len = initlen;
sh->free = 0;
if (init) memcpy(sh->buf, init, initlen);
sh->buf[initlen] = '\0';
return (char*)sh->buf;
}
面试官拷问
-
为什么 SDS 需要二进制安全?
- 答:Redis 的字符串可能存储非文本数据(如图片、序列化对象)。C 字符串以
\0结尾,无法处理包含\0的数据。SDS 通过len记录长度,确保任意数据都能正确存储和读取。
- 答:Redis 的字符串可能存储非文本数据(如图片、序列化对象)。C 字符串以
-
embstr 和 raw 的切换阈值 44 字节是如何确定的?
- 答:Redis 对象头(
robj)和 SDS 头部占用一定字节,embstr要求整体分配。Redis 选择 44 字节是因为它接近内存分配器的常见分桶大小(64 字节),既能减少碎片,又能保持性能。超过 44 字节后,动态扩展成本增加,raw更适合。
- 答:Redis 对象头(
-
SDS 的预分配策略如何优化性能?
- 答:SDS 在扩展时会预分配空间(
free字段),规则是:若新长度 < 1MB,分配双倍空间;若 >= 1MB,分配额外 1MB。这种策略减少了频繁的内存分配,提升了追加操作(如APPEND)的效率。
- 答:SDS 在扩展时会预分配空间(
2. List(列表)
底层结构:Listpack 与 Quicklist
Redis 的 List 使用 quicklist,结合 listpack(Redis 7.0 后取代 ziplist)和双向链表的优点。
Listpack vs. Ziplist
-
Ziplist:连续内存,存储节点(前节点长度、当前节点长度、数据)。插入/删除可能引发连锁更新,性能下降。
-
Listpack:改进版 ziplist,去除前节点长度字段,节点更紧凑,插入/删除更高效。
-
差异:
- Listpack 节点只记录自身长度,减少内存开销。
- Listpack 支持更大节点(无 2^32 字节限制)。
- Listpack 插入/删除无需更新后续节点的前长度字段,性能更优。
Quicklist
- 结构:双向链表,每个节点是一个 listpack。
- C 实现(简化):
typedef struct quicklistNode {
struct quicklistNode *prev, *next;
unsigned char *lp; // 指向 listpack
unsigned int sz; // listpack 大小
} quicklistNode;
特性与优势
- Listpack:内存紧凑,适合小规模数据,顺序访问效率高。
- Quicklist:分片存储(每个节点一个 listpack),降低连锁更新成本,支持高效插入/删除。
使用场景与切换条件
-
Listpack:元素数量少(默认 < 512,
list-max-listpack-size)且元素大小小(默认 < 64 字节,list-max-listpack-entries)。 -
Quicklist:当元素数量或大小超过阈值,List 整体转为 quicklist(实际 List 总是 quicklist,单节点 listpack 是特例)。
-
收益:
- Listpack:内存效率高,适合小列表。
- Quicklist:通过分片降低连锁更新成本,适合大列表,支持高效
LPUSH/RPOP。
面试官拷问
-
Listpack 相较 Ziplist 的具体改进是什么?
- 答:Ziplist 每个节点记录前节点长度,插入/删除可能引发后续节点更新,复杂度 O(n)。Listpack 移除前长度字段,节点只记录自身长度,插入/删除只需更新当前节点,复杂度接近 O(1)。此外,Listpack 支持更大节点,扩展性更强。
-
Quicklist 为什么不直接使用双向链表?
- 答:纯双向链表每个节点需额外指针,内存开销大。Quicklist 用 listpack 存储小块数据,减少指针开销,同时保留链表的灵活性,平衡内存和性能。
-
如何配置 Quicklist 的 listpack 大小?
- 答:通过
list-max-listpack-size(元素数量)和list-compress-depth(压缩深度)控制。增大 listpack 大小可减少 quicklist 节点数,但增加连锁更新风险;压缩深度决定是否对中间节点压缩,优化内存。
- 答:通过
3. Set(集合)
底层结构:整数集合(intset)与哈希表(hashtable)
Set 存储无序、唯一的元素,底层使用 intset 或 hashtable。
整数集合(intset)
- 结构:有序、无重复的整数数组,支持 int16、int32、int64。
- C 实现(简化):
typedef struct intset {
uint32_t encoding; // 编码类型(int16/int32/int64)
uint32_t length; // 元素数量
int8_t contents[]; // 整数数组
} intset;
哈希表(hashtable)
- 结构:基于字典,键为元素,值为空。
- 优势:O(1) 查询,适合大规模数据。
使用场景与切换条件
-
intset:所有元素是整数且数量少(默认 < 512,
set-max-intset-entries)。 -
hashtable:添加非整数元素或数量超过阈值时转换。
-
收益:
- intset:内存紧凑,二分查找效率高。
- hashtable:支持任意类型,查询效率 O(1)。
面试官拷问
-
intset 如何处理整数类型升级?
- 答:当添加更大范围的整数(如 int16 升级到 int32),intset 会重新分配内存,将所有元素转换为新编码。升级是单向的(不可降级),确保操作一致性。
-
为什么 intset 使用有序数组而非无序?
- 答:有序数组支持二分查找(O(log n)),便于
SISMEMBER等操作。无序数组需线性扫描(O(n)),效率低。有序性还简化了去重逻辑。
- 答:有序数组支持二分查找(O(log n)),便于
-
hashtable 相较 intset 的内存开销如何?
- 答:hashtable 需存储键值对和指针(冲突链表),内存开销远高于 intset 的紧凑数组。intset 适合小规模整数,hashtable 适合大规模或非整数场景。
4. Hash(哈希)
底层结构:Listpack 与哈希表(hashtable)
Hash 存储键值对,底层使用 listpack 或 hashtable(基于字典)。
Listpack
- 结构:键值对按顺序存储,紧凑内存布局。
- 优势:内存效率高,适合小规模数据。
哈希表(hashtable)
- 结构:Redis 的 hashtable 是一个字典(dict),包含两个哈希表支持渐进式 rehash。
- C 实现(简化):
typedef struct dict {
dictEntry **table; // 哈希表数组
unsigned long size; // 哈希表大小
unsigned long used; // 已使用节点数
} dict;
typedef struct dictEntry {
void *key; // 键
void *val; // 值
struct dictEntry *next; // 冲突链表
} dictEntry;
澄清:Hash 数据类型与哈希表的区别
- Hash 数据类型:用户操作的键值对集合(如
HSET、HGET)。 - 哈希表:Hash 数据类型的底层实现之一,基于字典结构。
- Hash 数据类型可能使用 listpack 或 hashtable,hashtable 是字典的具体实现。
使用场景与切换条件
-
listpack:键值对数量少(默认 < 512,
hash-max-listpack-entries)且键值大小小(默认 < 64 字节,hash-max-listpack-value)。 -
hashtable:键值对数量或大小超过阈值。
-
收益:
- listpack:内存紧凑,适合小规模数据。
- hashtable:O(1) 查询效率,适合大规模数据。
面试官拷问
-
hashtable 的渐进式 rehash 如何实现?
- 答:Redis 使用两个哈希表(ht[0] 和 ht[1])。扩容时,ht[1] 初始化为新大小,键值对逐步从 ht[0] 迁移到 ht[1]。每次操作(如
HSET)迁移部分键,rehashidx记录进度,避免一次性 rehash 的性能瓶颈。
- 答:Redis 使用两个哈希表(ht[0] 和 ht[1])。扩容时,ht[1] 初始化为新大小,键值对逐步从 ht[0] 迁移到 ht[1]。每次操作(如
-
listpack 在 Hash 中的局限性是什么?
- 答:listpack 是顺序存储,查询复杂度 O(n),不适合大规模键值对。插入/删除可能引发内存重新分配,性能下降。hashtable 提供 O(1) 查询,适合大数据。
-
如何优化 Hash 的内存占用?
- 答:增大
hash-max-listpack-entries和hash-max-listpack-value,优先使用 listpack。启用hash-compress-depth压缩 listpack 节点,减少内存开销。但需权衡查询性能。
- 答:增大
5. Zset(有序集合)
底层结构:Listpack 与字典+跳表
Zset 存储元素及其分值,元素唯一,按分值排序。
Listpack
- 结构:元素和分值成对存储,按分值排序。
- 优势:内存紧凑,适合小规模数据。
字典+跳表
- 字典:映射元素到分值,O(1) 查询。
- 跳表:维护有序性,支持范围查询。
- C 实现(简化):
typedef struct zskiplistNode {
sds ele; // 元素
double score; // 分值
struct zskiplistNode *backward; // 后向指针
struct zskiplistLevel {
struct zskiplistNode *forward; // 前向指针
unsigned int span; // 跨度
} level[]; // 多层指针
} zskiplistNode;
使用场景与切换条件
-
listpack:元素数量少(默认 < 128,
zset-max-listpack-entries)且元素大小小(默认 < 64 字节,zset-max-listpack-value)。 -
dict+skiplist:元素数量或大小超过阈值。
-
收益:
- listpack:内存效率高,适合小数据。
- dict+skiplist:O(log n) 范围查询,适合大规模数据。
面试官拷问
-
跳表为何比平衡树更适合 Zset?
- 答:跳表实现简单,插入/删除逻辑清晰,平均复杂度 O(log n)。平衡树(如 AVL)需复杂旋转,维护成本高。跳表通过随机层数控制内存,适合 Redis 的内存敏感场景。
-
字典和跳表如何协同工作?
- 答:字典提供 O(1) 的元素到分值映射(如
ZSCORE)。跳表维护分值排序,支持范围查询(如ZRANGE)。两者结合实现高效查询和排序。
- 答:字典提供 O(1) 的元素到分值映射(如
-
如何优化跳表的性能?
- 答:调整
zset-max-skiplist-level(默认 32),控制最大层数。层数越高,查询越快,但内存开销增加。需根据数据规模权衡。
- 答:调整
总结
Redis 的五种基本数据类型通过动态切换底层结构,优化内存和性能:
- String:SDS(int、embstr、raw)支持二进制安全和动态扩展。
- List:Quicklist(listpack)平衡内存和插入/删除效率。
- Set:intset 和 hashtable 针对整数和大规模数据优化。
- Hash:listpack 和 hashtable 适应不同规模键值对。
- Zset:listpack 和 dict+skiplist 支持高效排序和查询。
Listpack 相较 ziplist 的改进提高了插入/删除效率,Redis 的结构切换机制(如从 listpack 到 hashtable)确保性能与内存的平衡。开发者可通过配置(如 hash-max-listpack-entries)优化特定场景。