(一)📊 Redis 表层类型 vs 底层结构映射表
| 表层类型 (Key Type) | 存储场景 (Condition) | 底层结构 (Encoding) | 备注 (Design Rationale) |
|---|---|---|---|
| String | 纯数字 (且可用 long 表示) | INT | 直接存入指针,无额外内存分配,最省。 |
| 字符串长度 ≤\le≤ 44 字节 | EMBSTR | redisObject 与 SDS 连续分配,少一次 malloc。 |
|
| 字符串长度 > 44 字节 | RAW | redisObject 与 SDS 分开分配,适合修改。 |
|
| 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 配置决定的(以下是默认值):
-
Hash 升级阈值:
hash-max-listpack-entries 512(元素个数超过 512)hash-max-listpack-value 64(单个值长度超过 64 字节)- 满足任一条件,ListPack -> Dict。
-
ZSet 升级阈值:
zset-max-listpack-entries 128zset-max-listpack-value 64- 满足任一条件,ListPack -> SkipList + Dict。
-
Set 升级阈值:
set-max-intset-entries 512- 超过 512 个整数,IntSet -> Dict。
🧠 "记忆钩子"
- String: 看长度(44字节线),看类型(是数字就直存)。
- List: 只有一种(QuickList),它是混血儿。
- Hash / ZSet: "小时紧凑,大时离散" 。
- 小时用 ListPack(省内存,不怕 O(N))。
- 大时用 Dict/SkipList(保性能,怕慢)。
- 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)。 - 优化: 直接将数值存入
redisObject的ptr指针字段中,不再分配额外的 SDS 内存。
text
[ redisObject ]
+-----------+
| ... |
| encoding | -> INT
| *ptr | -> 0x000000000000001F (直接存数值 31)
+-----------+
形态 B:EMBSTR (嵌入式字符串)
- 场景: 字符串长度 < 44 字节。
- 优化: 将
redisObject和SDS放在一块连续的内存中分配。 - 收益: 只需要 1 次
malloc,CPU 缓存命中率高。
text
[ 连续内存块 (One Malloc) ]
+-------------+---------------------+
| redisObject | SDS (Header + "Hi") |
+-------------+---------------------+
形态 C:RAW (原始字符串)
- 场景: 字符串长度 > 44 字节。
- 机制:
redisObject和SDS分开分配,通过指针连接。
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 早期版本为了节省内存而设计的一种双向链表替代品。它没有 prev 和 next 指针,而是通过计算偏移量来移动。
整体内存布局:
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 字节:
Entry B发现前一个节点变长了,自己的prev_len需要从 1 字节扩展到 5 字节。Entry B的总长度因此增加了 4 字节。Entry C发现Entry B变长了,Entry C的prev_len也可能需要扩容...- 后果: 导致后续所有节点频繁进行内存重分配(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 A的element_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, 10}。encoding=INTSET_ENC_INT16(每个元素占 2 字节)。- 总内存 = Header + 2 * 2 = Header + 4 字节。
-
插入大数: 插入
655350(超过了 int16 的范围)。- Step 1: 将
encoding升级为INTSET_ENC_INT32(每个元素占 4 字节)。 - Step 2: 重新分配内存(扩容)。
- Step 3: 将原有的
1, 10搬迁并转换为 4 字节存储。 - Step 4: 插入新元素,并保持数组有序。
- Step 1: 将
查询复杂度: 由于数组有序,使用 二分查找 (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 |
+-------+ +-------+
- 触发: 当你对这个 Dict 执行 增删改查 任意操作时,Redis 顺手把
ht[0]中rehashidx指向的那个桶(Bucket)里的链表,搬到ht[1]去。 - 推进: 搬完一个桶,
rehashidx加 1。 - 兜底: 如果系统空闲(没有指令进来),定时任务(Cron)也会抢着搬一点。
- 路由:
- 查/删/改: 先去
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),内存极其紧凑,利用率高。