架构师视角:深度解构 Redis 底层数据结构的设计哲学

(一)📊 Redis 表层类型 vs 底层结构映射表

表层类型 (Key Type) 存储场景 (Condition) 底层结构 (Encoding) 备注 (Design Rationale)
String 纯数字 (且可用 long 表示) INT 直接存入指针,无额外内存分配,最省
字符串长度 ≤\le≤ 44 字节 EMBSTR redisObjectSDS 连续分配,少一次 malloc
字符串长度 > 44 字节 RAW redisObjectSDS 分开分配,适合修改
List (通用标准) QuickList 双向链表 + ListPack。兼顾头尾操作效率与内存压缩。
Hash 元素少 且 值短 ListPack (旧版: ZipList) 紧凑排列,节省内存。遍历查找 O(N) 但 N 小时很快。
元素多 或 值长 Dict (HashTable) 只有 Dict。O(1) 查找,空间换时间。
Set 纯整数 且 元素少 IntSet 有序数组,二分查找 O(logN)。
含非整数 或 元素多 Dict Value 指向 NULL 的哈希表。
ZSet 元素少 且 值短 ListPack (旧版: ZipList) 元素和分值紧凑排列。
元素多 或 值长 SkipList + Dict Dict 查分 O(1),SkipList 排序/范围查 O(logN)。

⚙️ 核心阈值配置

Redis 什么时候从"小数据结构"切换到"大数据结构"?是由 redis.conf 配置决定的(以下是默认值):

  1. Hash 升级阈值:

    • hash-max-listpack-entries 512 (元素个数超过 512)
    • hash-max-listpack-value 64 (单个值长度超过 64 字节)
    • 满足任一条件,ListPack -> Dict。
  2. ZSet 升级阈值:

    • zset-max-listpack-entries 128
    • zset-max-listpack-value 64
    • 满足任一条件,ListPack -> SkipList + Dict。
  3. Set 升级阈值:

    • set-max-intset-entries 512
    • 超过 512 个整数,IntSet -> Dict。

🧠 "记忆钩子"

  1. String: 看长度(44字节线),看类型(是数字就直存)。
  2. List: 只有一种(QuickList),它是混血儿。
  3. Hash / ZSet: "小时紧凑,大时离散"
    • 小时用 ListPack(省内存,不怕 O(N))。
    • 大时用 Dict/SkipList(保性能,怕慢)。
  4. Set: 只要不是纯整数,立马变成 Dict。

(二)Redis 表层类型(5种)

1. String (字符串) ------ 万物基石

最基础的类型,二进制安全,最大 512MB

  • 常用场景: 缓存对象(JSON)、分布式锁、计数器、Session共享。

  • 实操命令:

    bash 复制代码
    # 1. 增/改
    SET user:1001:name "DaBao"
    # 2. 查
    GET user:1001:name
    # 3. 批量操作 (原子性,减少网络RTT)
    MSET user:1001:age 31 user:1001:role "Architect"
    MGET user:1001:name user:1001:age
    # 4. 计数器 (原子递增)
    INCR article:views
    INCRBY article:views 10
    # 5. 分布式锁 (原子加锁 + 过期时间)
    SET lock:order:1001 "locked" EX 10 NX
2. List (列表) ------ 双向队列

有序列表,允许重复。底层是双向链表逻辑。

  • 常用场景: 消息队列、最新消息排行榜(Timeline)。

  • 实操命令:

    bash 复制代码
    # 1. 左进 (LPUSH)
    LPUSH tasks "task1" "task2"  # 此时 task2 在最左边
    # 2. 右出 (RPOP)
    RPOP tasks                  # 弹出 "task1" (实现了队列 FIFO)
    # 3. 范围查询
    LRANGE tasks 0 -1           # 查看所有元素
    # 4. 阻塞弹出 (做消息队列用)
    BRPOP tasks 5               # 等待 5 秒,有数据就弹,没有就超时
3. Hash (哈希) ------ 结构化对象

键值对集合,适合存储对象。

  • 常用场景: 购物车、用户个人信息。

  • 实操命令:

    bash 复制代码
    # 1. 存对象
    HSET cart:1001 item:100 2 item:200 1
    # 2. 取单个字段
    HGET cart:1001 item:100
    # 3. 取所有字段和值
    HGETALL cart:1001
    # 4. 增加数量
    HINCRBY cart:1001 item:100 1
4. Set (集合) ------ 无序去重

无序,自动去重。支持集合运算(交、并、差)。

  • 常用场景: 标签(Tag)、抽奖、共同好友。

  • 实操命令:

    bash 复制代码
    # 1. 添加 (去重)
    SADD tags:user:1 "java" "ai" "java"  # 实际只存了2个
    # 2. 判断是否存在 (O(1) 高效)
    SISMEMBER tags:user:1 "php"
    # 3. 随机抽奖 (SRANDMEMBER)
    SRANDMEMBER lottery_pool 1
    # 4. 交集 (共同好友)
    SINTER tags:user:1 tags:user:2
5. ZSet (有序集合) ------ 排行榜神器

有序,去重,每个元素带一个分数(Score)。

  • 常用场景: 排行榜、延迟队列。

  • 实操命令:

    bash 复制代码
    # 1. 添加 (Score Member)
    ZADD rank:coding 100 "DaBao" 90 "ZhangSan"
    # 2. 查排名 (从大到小)
    ZREVRANGE rank:coding 0 -1 withscores
    # 3. 查某人排名
    ZREVRANK rank:coding "DaBao"
    # 4. 范围查询 (查 80-100 分的人)
    ZRANGEBYSCORE rank:coding 80 100

(三)Redis 底层存储方式

第一部分:万物之源与字符串的极致优化

(redisObject & SDS)

在 Redis 的设计哲学中,**"内存效率""CPU 效率"**的平衡贯穿始终。一切始于一个通用的对象头。

1. 通用对象头:redisObject

Redis 内部并不直接存储原始数据,而是将所有 Key 和 Value 封装为 redisObject。它实现了类似面向对象语言中的多态(Polymorphism)

架构示意图:

text 复制代码
+---------------------+
|     redisObject     |  <-- 16 Bytes (固定开销)
+---------------------+
| type (4 bits)       |  -> 标识逻辑类型 (String/List/Hash...)
| encoding (4 bits)   |  -> 标识物理编码 (RAW/INT/ZIPLIST...)
| lru (24 bits)       |  -> LRU 时间戳 (用于内存淘汰)
| refcount (32 bits)  |  -> 引用计数 (用于内存共享)
| *ptr (64 bits)      |  -> 核心指针 (指向实际数据)
+----------+----------+
           |
           v
    [ 实际底层数据结构 ] (SDS / Dict / SkipList ...)
  • 设计意图: 通过 encoding 字段,Redis 可以在运行时根据数据量的大小,动态切换底层实现(例如从压缩列表切换到双向链表),对上层业务透明。

2. 字符串的进化:SDS (Simple Dynamic String)

C 语言原生的字符串(以 \0 结尾)存在获取长度慢 O(N)缓冲区溢出非二进制安全等缺陷。Redis 重新设计了 SDS。

内存布局示意图(以存储 "Redis" 为例):

text 复制代码
      +-------+-------+-------+---+---+---+---+---+---+
SDS = |  len  | alloc | flags | R | e | d | i | s | \0 |  ... (预分配空间) ...
      +-------+-------+-------+---+---+---+---+---+---+
      |Header |               |      Buf (字符数组)     |
  • len: 已使用长度。获取字符串长度仅需 O(1)
  • alloc: 分配的总空间。alloc - len = 剩余空间。
  • flags: 标识头部类型(sdshdr5/8/16...)。
  • Buf: 即使存二进制数据,也是通过 len 判断结束,而不是 \0,因此是二进制安全的。

3. String 的三种内存编码形态

为了节省哪怕 1 个字节的内存,Redis 对 String 做了三种物理布局优化。

形态 A:INT (整数编码)

  • 场景: 存储的值是可用 long 表示的整数(如 SET age 31)。
  • 优化: 直接将数值存入 redisObjectptr 指针字段中,不再分配额外的 SDS 内存
text 复制代码
[ redisObject ]
+-----------+
| ...       |
| encoding  | -> INT
| *ptr      | -> 0x000000000000001F (直接存数值 31)
+-----------+

形态 B:EMBSTR (嵌入式字符串)

  • 场景: 字符串长度 < 44 字节。
  • 优化:redisObjectSDS 放在一块连续的内存中分配。
  • 收益: 只需要 1 次 malloc,CPU 缓存命中率高。
text 复制代码
[ 连续内存块 (One Malloc) ]
+-------------+---------------------+
| redisObject | SDS (Header + "Hi") |
+-------------+---------------------+

形态 C:RAW (原始字符串)

  • 场景: 字符串长度 > 44 字节。
  • 机制: redisObjectSDS 分开分配,通过指针连接。
text 复制代码
[ 内存块 1 ]              [ 内存块 2 ]
+-------------+           +--------------------------+
| redisObject |---------> | SDS (Header + LongText)  |
+-------------+           +--------------------------+

好的,大宝。接上一篇,我们继续深入 Redis 的**"内存魔法"**。

在 Redis 中,当 List、Hash、ZSet 存储的数据量较少(元素个数少且单个元素小)时,Redis 为了节省内存,不会使用标准的 Linked List 或 Hash Table,而是采用一种**"时间换空间"**的紧凑型数据结构。

这是理解 Redis 极致性能的关键章节。


第二部分:小数据时代的极致压缩与演进

(ZipList, ListPack & IntSet)

这部分的核心设计哲学是:内存连续分配,消除指针开销。

在现代 CPU 架构中,连续内存能极大地利用 CPU Cache(L1/L2 缓存),其遍历速度往往优于非连续内存的指针跳转。

1. 时代的眼泪:ZipList (压缩列表)

ZipList 是 Redis 早期版本为了节省内存而设计的一种双向链表替代品。它没有 prevnext 指针,而是通过计算偏移量来移动。

整体内存布局:

text 复制代码
+---------+--------+-------+---------+---------+-----+-------+
| zlbytes | zltail | zllen | Entry 1 | Entry 2 | ... | zlend |
+---------+--------+-------+---------+---------+-----+-------+
| 4 bytes | 4 bytes| 2 b   |    ?    |    ?    |     | 0xFF  |
  • zltail: 记录最后一个 Entry 的偏移量,实现快速定位尾部(用于 RPOP)。
  • zlend: 固定为 0xFF (255),标识列表结束。

Entry(节点)内部结构 ------ 问题的根源:

text 复制代码
+-------------------+----------+---------+
| prev_entry_len    | encoding | content |
+-------------------+----------+---------+
  • prev_entry_len: 记录前一个节点 的长度。
    • 如果前一个节点长度 < 254 字节,这里占 1 字节
    • 如果前一个节点长度 >= 254 字节,这里占 5 字节

架构缺陷:连锁更新 (Cascade Update)

这是 ZipList 被弃用的根本原因。

text 复制代码
[Entry A (253字节)] --> [Entry B (prev_len=1字节)] --> [Entry C]

假如我们将 Entry A 更新为 255 字节:

  1. Entry B 发现前一个节点变长了,自己的 prev_len 需要从 1 字节扩展到 5 字节。
  2. Entry B 的总长度因此增加了 4 字节。
  3. Entry C 发现 Entry B 变长了,Entry Cprev_len 也可能需要扩容...
  4. 后果: 导致后续所有节点频繁进行内存重分配(Realloc),性能急剧下降。

2. 完美的继任者:ListPack (紧凑列表)

从 Redis 5.0 引入,Redis 7.0 全面替代 ZipList。它保留了紧凑的特性,但彻底解决了耦合问题。

Entry(节点)内部结构 ------ 解耦设计:

text 复制代码
+----------+---------+-------------------+
| encoding | content | element_total_len |
+----------+---------+-------------------+
                       ^
                       |
               只记录自己的长度!
  • element_total_len: 记录当前节点的总长度。
  • 设计精髓: 这个长度字段采用了特殊的编码方式,允许从后向前解码
  • 反向遍历原理:
    • 当指针指向 Entry B 的开头时,想要找 Entry A
    • 指针向左移动(p - 1),读取 Entry Aelement_total_len
    • 计算出 Entry A 的长度 L
    • 指针向前跳 L 个字节,精准落地 Entry A 的开头。

结论: 无论 Entry A 怎么变,Entry B 都不需要修改任何数据。连锁更新被根除。


3. 整数的特权:IntSet (整数集合)

当 Set 集合中只包含整数 且数量不多时,Redis 底层使用 IntSet。它本质上是一个有序的、无重复的数组

内存布局:

text 复制代码
+----------+----------+---------------------------------+
| encoding |  length  | contents (柔性数组)              |
+----------+----------+---------------------------------+
| uint32_t | uint32_t | int8_t contents[]               |
|          |          | [ 1, 10, 50, 100 ... ]          |
+----------+----------+---------------------------------+

核心机制:自动升级 (Upgrade)

IntSet 会根据元素的大小,动态选择最省内存的编码方式。

场景演示:

  1. 初始状态: 集合里只有 {1, 10}

    • encoding = INTSET_ENC_INT16 (每个元素占 2 字节)。
    • 总内存 = Header + 2 * 2 = Header + 4 字节。
  2. 插入大数: 插入 655350 (超过了 int16 的范围)。

    • Step 1:encoding 升级为 INTSET_ENC_INT32 (每个元素占 4 字节)。
    • Step 2: 重新分配内存(扩容)。
    • Step 3: 将原有的 1, 10 搬迁并转换为 4 字节存储。
    • Step 4: 插入新元素,并保持数组有序。

查询复杂度: 由于数组有序,使用 二分查找 (Binary Search) ,复杂度为 O(logN)


第三部分:大数据时代的性能高速公路

(Dict, SkipList & QuickList)

1. 字典:Dict (Hash Table)

这是 Redis 中使用频率最高的数据结构,不仅用于 Hash 类型,整个 Redis 的 Key-Space (键空间) 本身就是一个巨大的 Dict。

核心结构:

Redis 的 Dict 设计与 Java HashMap 类似(数组+链表),但为了适应单线程模型,它做了一个天才般的设计:双哈希表架构

c 复制代码
typedef struct dict {
    dictht ht[2];       // 重点:这里有两个哈希表!
    long rehashidx;     // 游标:记录当前 Rehash 搬迁到了哪个桶(-1表示没在搬)
} dict;

核心机制:渐进式 Rehash (Incremental Rehash)

  • 痛点: Java HashMap 扩容是 "Stop the World" 的。由于 Redis 是单线程,如果一次性把 1000 万数据从 ht[0] 搬到 ht[1],服务器会卡死几秒钟,这在生产环境是灾难。
  • 解法: 分期付款,化整为零。

搬迁过程示意图:

text 复制代码
状态:正在 Rehash 中 (rehashidx = 10)

   ht[0] (旧表, 只读/删)             ht[1] (新表, 只写)
+-------+                       +-------+
| Bucket|                       | Bucket|
|   0   | --(已搬空)-->         |   0   | -> [Key-A] -> [Key-F]
|   ... |                       |  ...  |
|   10  | -> [Key-B] -> [Key-C] |   10  | (正在搬运中...)
|   11  | -> [Key-D]            |   11  |
+-------+                       +-------+
  1. 触发: 当你对这个 Dict 执行 增删改查 任意操作时,Redis 顺手把 ht[0]rehashidx 指向的那个桶(Bucket)里的链表,搬到 ht[1] 去。
  2. 推进: 搬完一个桶,rehashidx 加 1。
  3. 兜底: 如果系统空闲(没有指令进来),定时任务(Cron)也会抢着搬一点。
  4. 路由:
    • 查/删/改: 先去 ht[0] 找,找不到再去 ht[1] 找。
    • 增: 直接插入 ht[1](保证 ht[0] 只减不增,终会搬完)

2. 排序之王:SkipList (跳表)

这是 ZSet (Sorted Set) 在数据量大时的底层实现

结构直观图:

想象一个有序链表,我们在上面盖了几层"高架桥"。

text 复制代码
Level 3:  1 -----------------------------------------> 10 -> NULL
Level 2:  1 -----------> 5 --------------------------> 10 -> NULL
Level 1:  1 -> 2 -> 3 -> 5 -> 6 -> 7 -> 8 -> 9 -> 9.5 -> 10 -> NULL
  • 查找逻辑(坐电梯):
    • 查找 7
    • 从 L3 出发,发现 1 < 7,下一站是 10 > 7。下沉到 L2。
    • 在 L2 走到 5,发现 5 < 7,下一站是 10 > 7。下沉到 L1。
    • 在 L1 从 5 往后走,经过 6,找到 7
  • 复杂度: 平均 O(logN),媲美二叉搜索树。

关键设计:随机层数 (Probabilistic Balancing)

  • 红黑树: 靠复杂的旋转(左旋/右旋)来维持平衡,代码几千行,难以调试。
  • 跳表:抛硬币
    • 插入新节点时,随机生成一个层数(50% 概率加一层,25% 再加一层...)。
    • 从概率论上讲,当数据量够大时,它的结构自然是平衡的。
    • 优势: 实现简单,范围查找(Range Query) 极快(找到起点后直接遍历 L1 链表即可,红黑树还需要中序遍历)。

ZSet 的双重本体:

ZSet 实际上是一个复合结构:

c 复制代码
typedef struct zset {
    dict *dict;    // 字典:Key -> Score
    zskiplist *zsl; // 跳表:按 Score 排序
} zset;
  • Dict 保证 ZSCORE 查分数是 O(1)
  • SkipList 保证 ZRANGE 查排名是 O(logN)
  • 数据共享: 它们存储的是同一个指针,数据只有一份,不浪费内存。

3. 列表的终极形态:QuickList (快速列表)

这是 List (列表) 的标准底层实现(Redis 3.2+)。它是"链表"和"压缩列表"的混血儿。

痛点分析:

  • 纯双向链表: 指针开销太大(prev/next 占 16 字节,数据可能才 4 字节),内存碎片化严重。
  • 纯 ZipList: 插入删除引发连锁更新,性能不稳定。

QuickList 架构:

它是一个双向链表,但链表的每个节点(Node)不再存单一数据,而是存一个 ListPack(或 ZipList)。

text 复制代码
[Node] <---> [Node] <---> [Node]
  |            |            |
  v            v            v
[ListPack]   [ListPack]   [ListPack]
(存了50个)    (存了80个)    (存了30个)
  • 宏观上: 它是链表,头尾插入极快。
  • 微观上: 它是数组(ListPack),内存极其紧凑,利用率高。
相关推荐
橘子132 小时前
MySQL表的基本查询(六)
数据库·mysql
running up that hill2 小时前
日常刷题记录
java·数据结构·算法
王五周八2 小时前
从测试到执行计划:拆解 SQL 性能坑的底层逻辑
数据库·sql
Eugene Jou2 小时前
Dinky+Flink SQL达梦数据库实时同步到Doris简单实现
数据库·sql·flink
玄同7652 小时前
SQLAlchemy 会话管理终极指南:close、commit、refresh、rollback 的正确打开方式
数据库·人工智能·python·sql·postgresql·自然语言处理·知识图谱
【赫兹威客】浩哥2 小时前
【赫兹威客】完全分布式HBase测试教程
数据库·分布式·hbase
一晌小贪欢2 小时前
Python ORM 深度解析:告别繁琐 SQL,让数据操作如丝般顺滑
开发语言·数据库·python·sql·python基础·python小白
九号铅笔芯2 小时前
社区评论系统设计
java·数据库·sql
DLGXY2 小时前
数据结构——快慢指针(十五)
数据结构