Redis五种基本数据类型的底层结构深度解析

360 阅读10分钟

Redis五种基本数据类型的底层结构深度解析

Redis 是一个高性能的键值存储数据库,以其高效的数据结构和灵活的使用场景著称。Redis 提供了五种基本数据类型:String(字符串)、List(列表)、Set(集合)、Hash(哈希)、Zset(有序集合) 。每种数据类型的底层实现都经过精心设计,结合多种数据结构以优化性能。本文将深入探讨这五种数据类型的底层结构,分析其实现原理、切换条件、收益,以及 ziplistlistpack 的差异,并通过模拟面试官的深入拷问,确保对结构的透彻理解。部分 C 代码片段将用于讲解实现细节。


1. String(字符串)

底层结构:SDS(Simple Dynamic String)

Redis 的字符串使用 SDS(简单动态字符串) 而非 C 的字符数组。SDS 的结构如下(简化版):

struct sdshdr {
    int len;       // 字符串长度
    int free;      // 未使用空间大小
    char buf[];    // 实际存储字符串的数组
};
特性与优势
  1. O(1) 获取长度:通过 len 字段直接获取长度,避免 C 字符串遍历。
  2. 二进制安全:不依赖 \0 结尾,可存储任意二进制数据。
  3. 动态扩展:通过 free 字段预留空间,减少内存分配。
  4. 内存优化:根据字符串长度使用不同头部(sdshdr8sdshdr16 等)。
使用场景与切换条件

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;
}
面试官拷问
  1. 为什么 SDS 需要二进制安全?

    • :Redis 的字符串可能存储非文本数据(如图片、序列化对象)。C 字符串以 \0 结尾,无法处理包含 \0 的数据。SDS 通过 len 记录长度,确保任意数据都能正确存储和读取。
  2. embstr 和 raw 的切换阈值 44 字节是如何确定的?

    • :Redis 对象头(robj)和 SDS 头部占用一定字节,embstr 要求整体分配。Redis 选择 44 字节是因为它接近内存分配器的常见分桶大小(64 字节),既能减少碎片,又能保持性能。超过 44 字节后,动态扩展成本增加,raw 更适合。
  3. SDS 的预分配策略如何优化性能?

    • :SDS 在扩展时会预分配空间(free 字段),规则是:若新长度 < 1MB,分配双倍空间;若 >= 1MB,分配额外 1MB。这种策略减少了频繁的内存分配,提升了追加操作(如 APPEND)的效率。

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
面试官拷问
  1. Listpack 相较 Ziplist 的具体改进是什么?

    • :Ziplist 每个节点记录前节点长度,插入/删除可能引发后续节点更新,复杂度 O(n)。Listpack 移除前长度字段,节点只记录自身长度,插入/删除只需更新当前节点,复杂度接近 O(1)。此外,Listpack 支持更大节点,扩展性更强。
  2. Quicklist 为什么不直接使用双向链表?

    • :纯双向链表每个节点需额外指针,内存开销大。Quicklist 用 listpack 存储小块数据,减少指针开销,同时保留链表的灵活性,平衡内存和性能。
  3. 如何配置 Quicklist 的 listpack 大小?

    • :通过 list-max-listpack-size(元素数量)和 list-compress-depth(压缩深度)控制。增大 listpack 大小可减少 quicklist 节点数,但增加连锁更新风险;压缩深度决定是否对中间节点压缩,优化内存。

3. Set(集合)

底层结构:整数集合(intset)与哈希表(hashtable)

Set 存储无序、唯一的元素,底层使用 intsethashtable

整数集合(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)。
面试官拷问
  1. intset 如何处理整数类型升级?

    • :当添加更大范围的整数(如 int16 升级到 int32),intset 会重新分配内存,将所有元素转换为新编码。升级是单向的(不可降级),确保操作一致性。
  2. 为什么 intset 使用有序数组而非无序?

    • :有序数组支持二分查找(O(log n)),便于 SISMEMBER 等操作。无序数组需线性扫描(O(n)),效率低。有序性还简化了去重逻辑。
  3. hashtable 相较 intset 的内存开销如何?

    • :hashtable 需存储键值对和指针(冲突链表),内存开销远高于 intset 的紧凑数组。intset 适合小规模整数,hashtable 适合大规模或非整数场景。

4. Hash(哈希)

底层结构:Listpack 与哈希表(hashtable)

Hash 存储键值对,底层使用 listpackhashtable(基于字典)。

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 数据类型:用户操作的键值对集合(如 HSETHGET)。
  • 哈希表:Hash 数据类型的底层实现之一,基于字典结构。
  • Hash 数据类型可能使用 listpack 或 hashtable,hashtable 是字典的具体实现。
使用场景与切换条件
  • listpack:键值对数量少(默认 < 512,hash-max-listpack-entries)且键值大小小(默认 < 64 字节,hash-max-listpack-value)。

  • hashtable:键值对数量或大小超过阈值。

  • 收益

    • listpack:内存紧凑,适合小规模数据。
    • hashtable:O(1) 查询效率,适合大规模数据。
面试官拷问
  1. hashtable 的渐进式 rehash 如何实现?

    • :Redis 使用两个哈希表(ht[0] 和 ht[1])。扩容时,ht[1] 初始化为新大小,键值对逐步从 ht[0] 迁移到 ht[1]。每次操作(如 HSET)迁移部分键,rehashidx 记录进度,避免一次性 rehash 的性能瓶颈。
  2. listpack 在 Hash 中的局限性是什么?

    • :listpack 是顺序存储,查询复杂度 O(n),不适合大规模键值对。插入/删除可能引发内存重新分配,性能下降。hashtable 提供 O(1) 查询,适合大数据。
  3. 如何优化 Hash 的内存占用?

    • :增大 hash-max-listpack-entrieshash-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) 范围查询,适合大规模数据。
面试官拷问
  1. 跳表为何比平衡树更适合 Zset?

    • :跳表实现简单,插入/删除逻辑清晰,平均复杂度 O(log n)。平衡树(如 AVL)需复杂旋转,维护成本高。跳表通过随机层数控制内存,适合 Redis 的内存敏感场景。
  2. 字典和跳表如何协同工作?

    • :字典提供 O(1) 的元素到分值映射(如 ZSCORE)。跳表维护分值排序,支持范围查询(如 ZRANGE)。两者结合实现高效查询和排序。
  3. 如何优化跳表的性能?

    • :调整 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)优化特定场景。