Redis 深度解析
#组件 · #Redis · #缓存 · #数据结构 · #分布式 · #哨兵 · #集群 · #RDB · #AOF · #jemalloc
从进程模型到数据结构底层实现,覆盖 Redis 核心设计思想与工程实践。
一、进程/线程模型
1.1 单线程事件循环
Redis 6.0 之前是纯粹的单线程模型,所有命令执行都在一个线程中串行完成:
mermaid
flowchart LR
A["客户端连接"] --> B["epoll_wait<br/>I/O多路复用就绪"]
B --> C["命令解析<br/>RESP 协议解码"]
C --> D["命令执行<br/>操作内存数据结构"]
D --> E["返回结果给客户端"]
E --> B核心机制:aeEventLoop 事件驱动框架,底层基于 epoll (Linux) / kqueue (FreeBSD) / select。单线程循环:等待事件 → 读取命令 → 执行命令 → 写回响应,周而复始。
| 原因 | 说明 |
|---|---|
| 纯内存操作 | 绝大部份命令 O(1),无磁盘 I/O 阻塞 |
| 非阻塞 I/O 多路复用 | epoll_wait 同时监听数千连接,只处理就绪 fd |
| 避免上下文切换 | 无线程切换开销,无锁竞争 |
| 精心设计的数据结构 | 针对内存和缓存优化(SDS/ziplist/skiplist 等) |
1.2 6.0+ 多线程 I/O
Redis 6.0 引入 I/O 线程,但命令执行仍是单线程:
mermaid
flowchart TB
subgraph IOThreads["I/O 线程池 (读/写 socket)"]
T0["IO Thread 0"]
T1["IO Thread 1"]
T2["IO Thread 2"]
end
IOThreads -->|"解析后的命令"| Main["主线程<br/>串行执行所有命令<br/>操作内存数据结构"]
Main -->|"响应数据"| IOThreads关键:I/O 线程只负责读 socket 字节流和写回响应,不执行命令。所有对内存数据结构的操作仍在主线程中串行进行。
配置:io-threads 4 io-threads-do-reads yes
1.3 后台线程
Redis 一直有后台线程处理阻塞操作:
| 后台线程 | 作用 |
|---|---|
bio_close_file | 异步关闭大文件 |
bio_aof_fsync | 异步 AOF fsync 刷盘 |
bio_lazy_free | 异步惰性删除大 key(UNLINK、FLUSHDB ASYNC) |
1.4 通信协议 — RESP
Redis 使用 RESP(REdis Serialization Protocol)作为客户端-服务端之间的通信协议:
- 基于 TCP 的文本协议,人类可读,易于实现和调试
- 5 种数据类型标识:
| 类型 | 前缀 | 示例 |
|---|---|---|
| Simple String | + | +OK\r\n |
| Error | - | -ERR unknown command\r\n |
| Integer | : | :1000\r\n |
| Bulk String | $ | $5\r\nhello\r\n (长度 + 数据) |
| Array | * | *2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n |
协议示例:
text
// 客户端发送: SET name redis
*3\r\n$3\r\nSET\r\n$4\r\nname\r\n$5\r\nredis\r\n
// 服务端回复: +OK\r\nRESP2 vs RESP3(Redis 6.0+ 开始支持 RESP3):
| 特性 | RESP2 | RESP3 |
|---|---|---|
| 复杂度 | 简单,所有值扁平化 | 引入 Map/Set/Boolean 等新类型 |
| 推送消息 | 不支持 | 原生支持 Pub/Sub 推送 |
| 浮点数 | Bulk String 封装 | 原生 Double 类型 |
| 兼容性 | 所有客户端支持 | 需客户端适配 |
为什么用文本协议而非二进制协议(如 Protobuf)?Redis 定位是高性能但简单的 KV 存储,文本协议易于理解、调试和实现多语言客户端,解析开销相比纯内存操作可忽略。
连接方式:
Client ──TCP── Redis Server (:6379)
│
├── 明文传输(默认,内网环境)
└── TLS 加密(6.0+, 公网/跨机房)Pipeline:客户端可连续发送多条命令不等待回复,服务端批量返回,大幅减少 RTT:
text
// 无 Pipeline: Ping-Pong 模式
C: SET a 1 → S: +OK → C: SET b 2 → S: +OK → C: GET a → S: $1\n1
// Pipeline: 批量模式
C: SET a 1\nSET b 2\nGET a\nGET b → S: +OK\n+OK\n$1\n1\n$1\n2Pipeline 不是原子操作,中间可能插入其他客户端的命令。需要原子性请用
MULTI/EXEC事务或 Lua 脚本。
二、核心数据结构与底层实现
2.1 SDS(Simple Dynamic String)
Redis 的 String 类型本质是 Key-String 的映射。每个 String Value 对应的内存结构是
redisObject+SDS。
SDS 结构(Redis 3.2+ 根据长度细分,此处以通用结构示意):
c
// Redis 3.2+ 实际有 sdshdr5/8/16/32/64 五种,按长度自动选择
struct sdshdr {
int len; // 已使用长度
int free; // 剩余空间 (buf 总长 - len - 1)
char buf[]; // 柔性数组,末尾自动补 '\0' 兼容 C 函数
};内存布局示意(embstr 编码):
┌──────────────┬──────┬──────┬──────────────────────────┐
│ redisObject │ len │ free │ buf[...] '\0' │
│ (16B) │ (4B) │ (4B) │ (len + 1 字节) │
└──────────────┴──────┴──────┴──────────────────────────┘
←────────────── 一次 malloc ──────────────────────────────→2.1.1 SDS vs 主流语言 String 实现深度对比
| 维度 | C char* | C++ std::string | Go string | Redis SDS |
|---|---|---|---|---|
| 获取长度 | O(n) strlen 遍历 | O(1) .size() | O(1) len() | O(1) 读 len 字段 |
| 二进制安全 | ❌ \0 截断 | ✅ | ✅ | ✅ |
| 可变性 | 可变 | 可变 | 不可变 | 可变 |
| 内存布局 | 仅数据本体 | SSO: ≤15B 栈上 + heap 长串 | 16B 头(ptr+len) + 堆数据 | 头(len/free) + 堆(buf) |
| 扩容策略 | 无 (手动 realloc) | 2× (MSVC 1.5×) | 不可扩容(需 strings.Builder) | <1MB 预分配等长; ≥1MB 预分配 1MB |
| 缓冲区溢出 | 高风险 | 安全 | 不可变无此问题 | 安全 |
| C 函数兼容 | 原生 | .c_str() | 需 []byte 转换 | 原生(buf 末尾 \0) |
| 内存碎片 | N/A | 频繁 append 可能 | 每次"修改" = 新分配 | 预分配减少碎片 |
| 典型适用 | 系统/内核 | 通用应用 | 只读/并发安全 | 高性能 KV 存储 |
逐语言分析:
C char*
┌───┬───┬───┬───┬───┬────┐
│ h │ e │ l │ l │ o │ \0 │ ← 仅数据,无元信息
└───┴───┴───┴───┴───┴────┘- 不以长度描述,以
\0终结 →strlen是 O(n) strcat无越界检查,缓冲区溢出是经典安全漏洞- 优点:零开销,适合内核和极简嵌入式场景
C++ std::string
// SSO: ≤15 字符时栈上存储,无需堆分配
struct basic_string { // libstdc++ (GCC) 实现
char* ptr; // 指向数据
size_t size; // 长度
union {
char local_buf[16]; // SSO 栈缓冲区
size_t capacity; // 堆分配时的容量
};
};
// 总大小: 32 字节 (x64)- SSO(Short String Optimization):短串直接存在对象内部栈空间,零堆分配,极快
- 扩容因子通常 2×(MSVC 用 1.5× 减少内存碎片)
- 缺点:每次扩容需复制全量数据,大串频繁 append 性能下降
Go string
go
type stringStruct struct {
str unsafe.Pointer // 指向底层字节数组
len int // 字节长度
}
// 总大小: 16 字节 (x64)- 不可变(Immutable):任何"修改"都会创建新 string,旧 string 可能被 GC
- 优点:并发安全(只读),子串切片 O(1)(共享底层数组)
- 缺点:频繁拼接用
+→ O(n²) 拷贝,必须用strings.Builder优化 - 与 SDS 的根本差异:Go 面向并发只读场景,SDS 面向高频可变场景
Redis SDS — 集各家之长
Redis 3.2+ 按长度使用 5 种 SDS 头,极致节省内存:
c
struct __attribute__ ((__packed__)) sdshdr5 { uint8_t flags; char buf[]; }; // <32B
struct __attribute__ ((__packed__)) sdshdr8 { uint8_t len, alloc, flags; char buf[]; }; // <256B
struct __attribute__ ((__packed__)) sdshdr16 { uint16_t len, alloc; uint8_t flags; char buf[]; }; // <64KB
struct __attribute__ ((__packed__)) sdshdr32 { uint32_t len, alloc; uint8_t flags; char buf[]; }; // <4GB
struct __attribute__ ((__packed__)) sdshdr64 { uint64_t len, alloc; uint8_t flags; char buf[]; }; // ≥4GB设计精要:
| 设计点 | 说明 |
|---|---|
__packed__ | 取消结构体对齐填充,节省内存 |
| 5 种头 | 短串用 uint8_t,而非一律 4 字节 len,减少元数据开销 |
buf 末端 \0 | 可直接传给 printf、strcmp 等 C 函数 |
| 惰性释放 | sdstrim() 后不立即 realloc,free 空间留给下次使用 |
| 预分配 | APPEND 高频场景下减少 realloc 次数,实际是空间换时间 |
编码与 redisObject 的关系:
redisObject 是 Redis 所有 key/value 的容器(16 字节):
redisObject {
type: 4 bit (string/list/hash/set/zset)
encoding: 4 bit (int/embstr/raw/ziplist/...)
lru: 24 bit (LRU 时钟 / LFU 访问计数)
refcount: 32 bit (引用计数)
ptr: 64 bit (指向实际数据)
}三种 String 编码:
int: redisObject.ptr 直接存整数值 (无额外分配)
┌─────────────┐
│ redisObject │ ptr = 42
└─────────────┘
embstr: redisObject + SDS 连续分配 (一次 malloc, ≤44B)
┌──────────────┬──────────────┐
│ redisObject │ SDS(含buf) │
└──────────────┴──────────────┘
raw: redisObject + SDS 分开分配 (两次 malloc, >44B)
┌──────────────┐ ┌──────────────┐
│ redisObject │───→│ SDS(含buf) │
└──────────────┘ └──────────────┘embstr 阈值为什么是 44?
64 字节是 jemalloc 的一个 bin 大小,redisObject 16B + sdshdr8 3B + buf 44B + \0 1B = 64B,刚好一个 cache line。超过则变为两次分配。
完整的内存布局推导:
jemalloc 的内存分配策略:
jemalloc 将内存按 size class 分配:
8, 16, 32, 48, 64, 80, 96, 112, 128, ...
申请 ≤64B 的内存 → 分配 64B 的 bin(无浪费)
申请 65B → 分配 80B 的 bin(浪费 15B)
Redis 的 embstr 编码目标: 让 redisObject + SDS 一次 malloc 放进 64B
内存布局:
┌──────────────────────────────────────────────────────────────────┐
│ 64 字节 (1 个 cache line) │
├────────────────┬───────────────────────────────────────────────────┤
│ redisObject │ SDS (sdshdr8) │
│ 16 字节 │ │
├────────────────┼──────┬──────┬──────────────────────────────┬─────┤
│ type(4b) │ len │alloc │ buf (字符串内容) │ \0 │
│ encoding(4b) │ (1B) │(1B) │ 最多 44 字节 │(1B) │
│ lru(24b) │ │ │ │ │
│ refcount(4B) │ │ │ │ │
│ *ptr(8B) │ │ │ │ │
├────────────────┼──────┼──────┼──────────────────────────────┼─────┤
│ 16B │ 1B │ 1B │ 44B │ 1B │
└────────────────┴──────┴──────┴──────────────────────────────┴─────┘
↑ sdshdr8 的 flags 字段 1B 在哪?
实际: flags(1B) + len(1B) + alloc(1B) = 3B
16B + 3B + 44B + 1B = 64B ✅
为什么这个设计好?
1. 一次 malloc: embstr 只需一次内存分配(raw 需要两次: redisObject + SDS 分开分配)
→ 减少内存碎片 + 减少 malloc 系统调用
2. 一个 cache line: 64B 恰好是 x86 CPU 的一个 cache line
→ 读取整个字符串只需一次内存访问(L1 cache hit 后全部可用)
→ raw 编码需要两次内存访问(先读 redisObject,再通过 ptr 跳转读 SDS)
3. 只读优化: embstr 是只读的(修改时会转为 raw)
→ 因为修改可能导致长度变化 → 需要 realloc → 但 redisObject 和 SDS 是连续的
→ realloc 后 redisObject 的地址可能变了 → 所有引用都要更新 → 太复杂
→ 所以 embstr 一旦修改就转为 raw(两次分配,各自独立 realloc)2.2 Dict(字典/Hash 底层)
Redis 的 Hash、Set(部分情况)、全局键空间都基于字典。
版本标注:以下代码基于 Redis ≤7.2 的实现。Redis 7.4(2024年7月发布)将
dict内部实现重构为新的hashtable模块,核心变化:拉链法 → 开放寻址。
旧实现(≤7.2):拉链法
bucket 数组:
┌─────┬─────┬─────┬─────┬─────┐
│ 0 │ 1 │ 2 │ 3 │ ... │
└──┬──┴──┬──┴─────┴─────┴─────┘
│ │
▼ ▼
dictEntry → dictEntry → NULL dictEntry → NULL
{key,val} {key,val} {key,val}冲突时用链表串联,弊端:
- 每个
dictEntry是独立malloc的,内存不连续 → 大量 cache miss - 每个 entry 多一个
next指针(8 字节开销/key) - 查找时必须跟随链表指针跳转
新实现(7.4+):开放寻址
开放寻址的思路:不用链表,所有 entry 直接内联存储在 bucket 数组中。冲突时,按探测序列在数组内找下一个空闲 slot。
Redis 7.4 使用的具体探测策略是 Robin Hood 哈希(一种带位移平衡的线性探测变体):
线性探测(朴素版):
hash("foo") → slot 2 被占 → 试 slot 3 → slot 4 → slot 5 → 找到空位
问题:形成"聚集"(cluster),长链查找越来越慢
Robin Hood 哈希:
每个 entry 额外存储一个 "PSL"(Probe Sequence Length,探测距离)
= 当前所在位置 - hash 原始位置
插入时:如果新 key 的 PSL > 当前位置的 PSL
→ "劫富济贫":踢走当前位置的 entry(它 PSL 小,离"家"近)
→ 被踢走的 entry 继续向前找位置
→ 最终效果:所有 entry 的 PSL 方差极小 → 查找距离稳定
Robin Hood 哈希的核心性质:
最大 PSL ≈ O(log n),远优于朴素线性探测的 O(√n)
这意味着最坏查找也是对数级别的,而非根号级别bucket 数组(连续内存):
┌─────────────┬─────────────┬─────────────┬─────────────┬─────────────┐
│ slot[0] │ slot[1] │ slot[2] │ slot[3] │ slot[4] ... │
│ key,val │ key,val │ (empty) │ key,val │ (empty) │
└─────────────┴─────────────┴─────────────┴─────────────┴─────────────┘
查找 key="foo" → hash("foo") = 2:
检查 slot[2] → 不是 → 按探测序列找 slot[3] → 是开放寻址 vs 拉链法对比:
| 维度 | 拉链法(≤7.2) | 开放寻址(7.4+) |
|---|---|---|
| 内存布局 | entry 散落各处(每次 malloc) | 单块连续数组(一次 malloc) |
| Cache 友好度 | 差,链表跳转 ≈ cache miss | 好,顺序访问 slots |
| 空间开销 | 每 key 多 8B(next 指针) | 无额外指针开销 |
| 查找速度 | 跟随链表,不可预测 | 线性探测,CPU 预取友好 |
| 删除 | 简单,从链表摘除 | 需用 tombstone 标记(逻辑删除) |
tombstone 删除的完整机制:
开放寻址中不能直接物理删除 entry——因为删除一个 slot 会打断探测链,导致后面的 entry 找不到。
示例:
hash("A")=0, hash("B")=1, hash("C")=1
slot[0]=A, slot[1]=B, slot[2]=C (B被hash冲突推到slot[1], C推到slot[2])
物理删除 slot[1]=B:
slot[0]=A, slot[1]=(空), slot[2]=C
查找 C: hash("C")=1 → slot[1] 为空 → 停止 → 找不到 C!❌
Tombstone 删除:
slot[0]=A, slot[1]=(TOMBSTONE), slot[2]=C
查找 C: hash("C")=1 → slot[1] 是 tombstone → 继续探测 → slot[2]=C ✅
插入 D: hash("D")=1 → slot[1] 是 tombstone → 直接复用这个 slot
Tombstone 的清理:
1. 新插入可以覆盖 tombstone(释放这个 slot)
2. 达到一定比例(如 25%)后触发 rehash → 重建数组 → 彻底清除
3. 渐进式 rehash 迁移时自然跳过 tombstone为什么 Redis 现在才改? 不是 Redis 不想用开放寻址,而是渐进式 rehash 对开放寻址的设计要求更高——两张表同时存在时,开放寻址的"从 table[0] 迁移到 table[1]"需要处理更复杂的边界条件。7.4 的 hashtable 模块专门为渐进式 rehash 设计了迁移协议。
c
// Redis ≤7.2 的 dict 实现
typedef struct dict {
dictType *type; // 类型特定函数
dictEntry **table[2]; // 两张哈希表 (用于渐进式 rehash)
long size; // 哈希表大小
long used; // 已用 entry 数
int rehashidx; // rehash 进度,-1 表示未进行
} dict;
typedef struct dictEntry {
void *key;
union { void *val; uint64_t u64; int64_t s64; } v;
struct dictEntry *next; // 拉链法解决冲突(7.4 改为开放寻址)
} dictEntry;渐进式 Rehash:避免大字典 rehash 时 STW:
rehashidx = -1 → 正常状态
rehashidx = 0 → 开始 rehash, 数据在 table[0] 和 table[1]
rehashidx = N → 第 N 个 slot 已迁移
rehashidx = -1 → 迁移完成,table[1] → table[0]每次增删改查操作顺带迁移 1 个 slot,分摊开销。
渐进式 Rehash 的分摊分析——为什么不会影响响应时间?
问题: 如果一次性 rehash 100 万个 key → 需要遍历所有 slot → 阻塞 ~100ms
Redis 单线程 → 这 100ms 内所有请求都被阻塞 → 不可接受
渐进式方案:
1. 触发 rehash 时,分配新 table[1](大小 = table[0] × 2)
2. 设置 rehashidx = 0
3. 每次 CRUD 操作时,额外迁移 table[0][rehashidx] 这个 slot 的所有 entry
4. rehashidx++
5. 直到 rehashidx == table[0].size → 迁移完成 → 释放 table[0]
分摊代价分析:
假设 table[0] 有 N 个 slot,总共 M 个 key
每个 slot 平均有 M/N 个 entry(负载因子 = M/N ≈ 1)
每次 CRUD 操作额外迁移 1 个 slot:
迁移代价 = 遍历该 slot 的链表 ≈ O(M/N) ≈ O(1)(负载因子为 1 时)
总迁移代价 = N 个 slot × O(1) = O(N)
分摊到 N 次操作 → 每次操作额外 O(1) 代价 ✅
最坏情况: 某个 slot 有很多 entry(哈希冲突严重)
→ 迁移该 slot 时代价 > O(1)
→ 但 Redis 限制了单次迁移的最大步数(dictRehash 的 n 参数)
→ 超过限制就停下,下次继续
rehash 期间的查找:
先查 table[0] → 没找到 → 再查 table[1]
→ 最多两次查找 → 代价 O(1) + O(1) = O(1)
rehash 期间的插入:
新 key 只插入 table[1](不插入 table[0])
→ 保证 table[0] 只减不增 → 最终一定能迁移完
定时器辅助:
如果 Redis 空闲(没有客户端请求),serverCron 定时器也会主动迁移
→ 避免"如果没有请求,rehash 永远完不成"的问题
→ 每次定时器迁移 100 个 slot(dictRehashMilliseconds)2.2.1 Hash 跨语言对比
不同语言/框架的 HashMap/Dict 实现有根本性的设计差异:
| 维度 | Redis Dict | Java HashMap | Go map | Python dict (3.6+) |
|---|---|---|---|---|
| 冲突解决 | 拉链法(单链表) | 拉链法 → 树化 (≥8) | 开放寻址 | 开放寻址 |
| 扩容策略 | 渐进式 (分摊) | 一次性全量 | 渐进式 (evacuation) | 一次性全量 |
| 负载因子 | 触发 rehash=1, 收缩=0.1 | 0.75 | 6.5 (扩容) | 2/3 (~0.67) |
| 有序性 | ❌ | ❌ | ❌ (迭代随机) | ✅ 插入序 (3.7 保证) |
| 线程安全 | ❌ | ❌ | ❌ (并发写 panic) | ❌ (GIL 保护) |
| 缩减机制 | 手动/惰性 | ❌ 不缩减 | 可能缩减 | 可能缩减 |
| 内存结构 | 两个 table 数组 | 单数组 + 链表/树 | 桶数组(bucket) | 索引数组 + entries 数组 |
逐语言分析:
Java HashMap (JDK 8+)
java
// 核心思想: 链长≥8 && 总容量≥64 → 链表转红黑树
class Node<K,V> {
int hash;
K key;
V value;
Node<K,V> next; // 链表
}
// treeify: Node → TreeNode (红黑树)- 拉链转红黑树:极端哈希冲突下,查找从 O(n) 退化为 O(log n)
- 一次性全量扩容:resize() 时可能引起 GC 停顿
- Redis 为何不学?内存库数据量远小于 JVM 堆,O(n) 链遍历通常 OK;且渐进式 rehash 已解决停顿问题
Go map
go
// runtime/map.go — 开放寻址,非拉链法
type hmap struct {
count int // 元素数量
B uint8 // 2^B = 桶数量
buckets unsafe.Pointer // 桶数组
oldbuckets unsafe.Pointer // 扩容时的旧桶
}
// 每个桶存 8 个 kv 对 + tophash- 开放寻址而非拉链:Go 认为缓存局部性更重要(遍历数组比遍历链表快)
- 扩容分批进行(evacuation):类似 Redis 渐进式,但触发时机不同(赋值/删除时迁移 1-2 个桶)
- tophash:用 key hash 的高 8 位做快速比较,避免每次都比较完整 key
- 与 Redis 根本差异:Go 面向通用编程做了缓存友好优化,Redis 面向内存紧凑和渐进式做了设计
Python dict (3.6+ Compact Dict)
python
# 3.6+: 紧凑字典 (Compact Dict) — 两个数组分离
indices = [idx1, idx2, ...] # 哈希索引表 (稀疏)
entries = [(hash,key,val), ...] # 紧密 entry 数组 (按插入序)- 分离索引和条目,entries 按插入顺序紧密排列 → 自动保序
- 比旧版节省内存且迭代更快
- Redis 对比:应用场景不同,Redis 不需要迭代序保证,节省内存优先级更高
总结:Redis Dict 的独特设计
| 设计 | 原因 |
|---|---|
| 双 table | 支持渐进式 rehash,避免单次大迁移 |
| 拉链而非开放寻址 | 实现简单,rehash 时节点拷贝成本低 |
| 不树化 | 内存库数据规模可控,O(n) 链遍历可接受 |
| 不自动缩减 | 惰性删除:避免频繁 alloc/free;通过 HT_RESIZE 手动触发 |
| union val | 值可以是 ptr/u64/s64,减少小整数的额外内存分配 |
2.3 Ziplist(压缩列表)
Hash 和 ZSet 在小数据量时的底层编码,连续内存块:
┌────────┬────────┬───────┬───────┬─────┬───────┬────────┐
│zlbytes │zltail │zllen │entry1 │ ... │entryN │zlend │
│(总字节) │(尾偏移) │(节点数)│ │ │ │(0xFF) │
└────────┴────────┴───────┴───────┴─────┴───────┴────────┘每个 entry 结构:
┌─────────────┬──────────────┬──────────┐
│ prevlen │ encoding │ data │
│ (前节点长度) │ (类型+长度) │ (实际值) │
└─────────────┴──────────────┴──────────┘| 优点 | 缺点 |
|---|---|
| 内存紧凑,无指针开销 | 插入/删除可能连锁更新(prevlen 回溯) |
| CPU cache 友好 | 不能太大(连锁更新代价 O(n²)) |
连锁更新的数学分析:
连锁更新的根因在于 prevlen 字段的动态长度:
prevlen 编码规则:
前一个 entry 长度 < 254 字节 → prevlen 占 1 字节
前一个 entry 长度 ≥ 254 字节 → prevlen 占 5 字节 (0xFE + 4字节长度)
触发条件:
1. entry_i 原来是 <254 字节,它的 prevlen 占 1 字节
2. entry_i 之前插入了一个 ≥254 字节的新 entry
3. entry_i 的 prevlen 需要从 1 字节膨胀到 5 字节 → entry_i 自身变长 4 字节
4. 如果 entry_i 变长后恰好也从 <254 变成了 ≥254
→ entry_{i+1} 的 prevlen 也要膨胀
→ 级联下去!
最坏情况分析:
假设有一串连续的 entry,每个都是 253 字节 (prevlen=1B + encoding=1B + data=251B)
在头部修改一个 entry 使其变为 257 字节:
entry₁ 膨胀 4 字节 → 253+4=257 ≥ 254
→ entry₂ 的 prevlen 需要 5 字节 → entry₂ 也膨胀 → 257 ≥ 254
→ entry₃ 的 prevlen 需要 5 字节 → entry₃ 也膨胀 → ...
→ 整个链表 N 个 entry 都需要重新分配内存!
每次 realloc 都是 O(N_entry) 的内存拷贝(因为 ziplist 是连续内存)
→ 总代价 O(N × N_entry) = O(n²)
这就是为什么 ziplist 只能用于小数据(≤6.2 时默认 `zset-max-ziplist-entries 128`,7.0+ 改用 listpack 后参数名为 `zset-max-listpack-entries`):
链表越长,连锁更新的风险越大,代价也越大。2.4 Listpack(紧凑列表,替代 Ziplist)
Redis 7.0 起全面替代 ziplist,解决连锁更新问题:
┌─────────────┬───────┬───────┬─────┬───────┬────────┐
│ total_bytes │ num │entry1 │ ... │entryN │ 0xFF │
└─────────────┴───────┴───────┴─────┴───────┴────────┘每个 entry 的 encoding 里包含自身总长度,不再依赖 prevlen,彻底消除连锁更新。
Listpack 如何消除连锁更新:
Ziplist entry:
┌─────────────┬──────────────┬──────────┐
│ prevlen │ encoding │ data │ ← prevlen 存的是前一个 entry 的长度!
│ (1 或 5 字节) │ │ │ 前一个变 → 我也得变 → 级联
└─────────────┴──────────────┴──────────┘
Listpack entry:
┌──────────────┬──────────────┬──────────┬─────────────┐
│ encoding │ data │ backlen │ │
│ (含自身总长度) │ │(自身长度) │ │
└──────────────┴──────────────┴──────────┴─────────────┘
关键改变: 每个 entry 存的是**自己的**总长度(backlen),不是前一个 entry 的长度。
向前遍历: 读取当前 entry 末尾的 backlen → 知道当前 entry 有多长 → 指针前移 backlen 字节 → 到达前一个 entry
向后遍历: 读取当前 entry 的 encoding → 知道总长度 → 指针后移 total_len 字节 → 到达下一个 entry
为什么不会连锁更新?
entry₁ 被修改变长 → entry₁ 的 encoding 和 backlen 更新(仅影响 entry₁ 自身)
entry₂ 的 prevlen? → Listpack 根本没有 prevlen!
→ entry₂ 完全不受影响,后面的 entry 也都无关
→ 修改成本 O(1),与链表长度无关 ✅Ziplist 和 Listpack 的对比:
| 维度 | Ziplist | Listpack |
|---|---|---|
| 向前遍历 | 读 prevlen | 读 backlen |
| 向后遍历 | 读 encoding | 读 encoding |
| 修改后的影响范围 | 可能连锁 O(n²) | 仅自身 O(1) |
| 内存开销 | prevlen 1-5B | backlen 1-5B(基本相同) |
| 首次引入 | Redis 2.x | Redis 5.0 (Stream), 7.0 (全面) |
2.5 Skiplist(跳表,ZSet 底层)
2.5.1 为什么 ZSet 用跳表 + 字典双结构?
有序集合(ZSet)在数据量大时使用跳表 + 字典组合,各司其职:
ZSet:
┌─────────────┐ ┌─────────────────┐
│ dict │ │ zskiplist │
│ member→score│ │ score→member │
│ (O(1) 查找) │ │ (O(log n) 范围) │
└─────────────┘ └─────────────────┘
↕ (member 和 score 在跳表节点中共享, 不重复存储)c
typedef struct zskiplistNode {
sds ele; // member 值
double score; // 分值
struct zskiplistNode *backward; // 后退指针
struct zskiplistLevel {
struct zskiplistNode *forward; // 前进指针
unsigned long span; // 跨度 (本层跨过节点数)
} level[]; // 层级数组
} zskiplistNode;
typedef struct zskiplist {
struct zskiplistNode *header, *tail;
unsigned long length; // 节点数
int level; // 最大层数(不含表头)
} zskiplist;2.5.2 层级生成与概率分析
层级生成:随机投掷硬币法,每次有 P = 1/4 概率加一层,最大 64 层:
c
int zslRandomLevel(void) {
int level = 1;
while ((random() & 0xFFFF) < (ZSKIPLIST_P * 0xFFFF)) // P = 0.25
level += 1;
return (level < ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL; // max 64
}为什么 P = 0.25 而非 0.5?
| P 值 | 每节点期望层数 | 搜索复杂度 | 内存开销 |
|---|---|---|---|
| 0.5 | 2.0 | O(log₂ n) | 每节点 ~2 层指针 |
| 0.25 | ~1.33 | O(log₄ n) ≈ O(0.5×log₂ n) | 每节点 ~1.33 层指针 |
- P=0.5:每节点平均 2 层,查找更快,但内存开销更大(每个 forward 指针 8B + span 8B = 16B)
- P=0.25:每节点平均 1.33 层,内存节省 ~33%,查找只慢常数倍(仍为 O(log n))
- Redis 作为内存库,选择内存优先,P=0.25 已足够
span 字段——Redis 跳表的独门武器:
Level 3: head ──────────── span=2 ──────────→ 50 ──→ NULL
Level 2: head ── span=1 ──→ 10 ── span=2 ──→ 50 ──→ NULL
Level 1: head → 10 → 20 → 30 → 50 → NULL
span: 1 1 1 1
ZRANK "30" 计算: 从 head 开始, 累计沿途 span = 1+1+1 = 3 (第3个元素, 从0开始)span 让 ZRANK(查排名)和 ZRANGE(按排名查)都能在 O(log n) 完成,这是普通跳表做不到的。
2.5.3 跳表 vs 其他有序结构
| 结构 | 插入 | 查找 | 范围查询 | 排名查询 | 实现复杂度 | 内存 |
|---|---|---|---|---|---|---|
| 跳表 (Redis) | O(log n) | O(log n) | O(log n + M) | O(log n) | ⭐⭐ 简单 | 中 |
| 红黑树 | O(log n) | O(log n) | O(log n + M) | O(n)* | ⭐⭐⭐⭐ 复杂 | 低 |
| B+ 树 | O(log n) | O(log n) | O(log n + M) | O(log n) | ⭐⭐⭐⭐⭐ | 低(磁盘友好) |
| 有序数组 | O(n) | O(log n) | O(log n + M) | O(1) | ⭐ 极简 | 极低 |
| 二叉堆 | O(log n) | O(n) | ❌ 不支持 | ❌ | ⭐⭐ | 低 |
*红黑树排名查询需维护子树大小,标准实现不提供
ZSet 为什么用跳表而非红黑树/AVL?
- 实现简单:跳表插入只需改前后指针,红黑树需要旋转和变色,边界情况多
- 范围查询自然:跳表 Level 1 是完整有序链表,定位起点后直接遍历;红黑树需要中序遍历
- 支持排名:span 字段天然支持 rank,红黑树需要额外维护 size 字段
- 调试友好:跳表可视化简单,红黑树旋转难以直观理解
完整的量化对比——空间、时间、并发三个维度:
一、时间复杂度对比(N 个元素)
操作 跳表 红黑树 差异原因
查找 O(log N) O(log N) 理论相同
插入 O(log N) O(log N) 跳表常数更小(无旋转)
删除 O(log N) O(log N) 跳表常数更小(无旋转)
范围查询 O(log N + M) O(log N + M) 跳表 cache 更友好 ↓
排名查询 O(log N) O(log N)* 红黑树需额外维护 size
*红黑树排名需要每个节点存子树大小,标准库不提供
范围查询的 cache 行为差异:
跳表: 定位起点后,Level 1 是连续链表 → 顺序遍历 → prefetch 有效
红黑树: 中序遍历需要"左-根-右"递归 → 指针跳转不连续 → cache miss 多
实测: 范围查询 1000 个元素
跳表: ~15μs (链表顺序遍历)
红黑树: ~25μs (中序遍历,频繁跳转)
差距: 跳表快 40-60%二、空间开销对比(N=100万, 64位系统)
跳表节点:
struct zskiplistNode {
sds ele; // 8B (指针)
double score; // 8B
struct backward; // 8B (指针)
struct level[] { // 每层: forward(8B) + span(8B) = 16B
forward, span
};
};
平均层数 = 1/(1-p) = 1/(1-0.25) = 1.33 层
平均每节点 = 8+8+8 + 1.33×16 ≈ 45.3 字节
红黑树节点:
struct rbtree_node {
void *key; // 8B
void *value; // 8B
struct *left; // 8B
struct *right; // 8B
struct *parent; // 8B
int color; // 4B (通常对齐到 8B)
};
每节点 = 8+8+8+8+8+8 = 48 字节
对比: 跳表 45.3B vs 红黑树 48B → 空间基本持平!
(p=0.25 时跳表甚至更省内存)
如果 p=0.5 (Java ConcurrentSkipListMap):
平均层数 = 1/(1-0.5) = 2 层
平均每节点 = 8+8+8 + 2×16 = 56 字节 → 比红黑树多 17%三、并发友好性对比
跳表的并发优势:
插入/删除只需修改前后指针 → 可以用 CAS 原子操作实现无锁
不同层的指针修改相互独立 → 可以分层加锁或无锁
Java ConcurrentSkipListMap 就是无锁跳表的典型实现:
- 标记删除 (mark) + CAS 更新 next 指针
- 无需全局锁,高并发下性能远超 synchronized TreeMap
红黑树的并发困难:
旋转操作涉及 3-4 个节点的指针同时修改 → 必须原子完成
旋转可能传播到根节点 → 影响范围不可预测
→ 实际只能用粗粒度锁(如 Java TreeMap 不支持并发)
Redis 是单线程,不需要并发 → 这个优势对 Redis 不重要
但这解释了为什么 Java/LevelDB 等多线程场景也选择跳表2.5.4 跳表跨语言对比
| 实现 | 语言 | 并发 | P 值 | 最大层 | 特点 |
|---|---|---|---|---|---|
| Redis zskiplist | C | ❌ 单线程 | 0.25 | 64 | span 支持 rank、backward 双向遍历 |
Java ConcurrentSkipListMap | Java | ✅ 无锁 CAS | 0.5 | 31 | 线程安全、NavigableMap 接口 |
| LevelDB SkipList | C++ | ❌ 单线程* | 0.25 | 12 | Arena 分配器、极简实现 |
| Go (无标准库) | Go | — | — | — | 需第三方如 golang-skiplist |
*LevelDB 的跳表虽然单线程,但 memtable 写是单线程的
Java ConcurrentSkipListMap 与 Redis 跳表的关键差异:
java
// Java: 并发安全的跳表, 天然支持 NavigableMap (lowerKey, ceilingKey 等)
ConcurrentSkipListMap<String, Integer> map = new ConcurrentSkipListMap<>();
map.put("a", 1);
map.ceilingKey("b"); // 找 ≥ "b" 的最小 key
// 无锁实现: CAS 原子更新指针
// P=0.5: 牺牲内存换查找速度
// 无 span: 不支持直接查 rankLevelDB 跳表(RocksDB 同源):
- Arena 内存分配器:批量分配节点,减少 malloc 开销
- 12 层上限:因为内存数据量远小于磁盘,无需太高
- 无后退指针:不需要反向遍历(memtable 只用于写入和点查)
- 无 span:不需要排名查询
总结:Redis 跳表的唯一性
Redis zskiplist = 跳表基础结构 + span(排名) + backward(反向遍历) + dict 辅助(member→score O(1))。这种设计在满足 ZSet 全部需求(范围、排名、反向、点查)的同时,实现了代码简洁和内存高效的最优平衡。
2.6 Quicklist(List 底层,3.2+ / 7.0+)
2.6.1 演进历史
Redis List 的底层实现经历了三次重大变革:
Redis 2.x Redis 3.0.x Redis 3.2+ / 7.0+
┌──────────┐ ┌──────────┐ ┌──────────────────┐
│linkedlist│ 或 │ ziplist │ → │ quicklist │
│ │ │ │ │ (ziplist 节点) │
│ O(1) │ │ 连续内存 │ │ ↓ │
│push/pop │ │ cache友好 │ │ (listpack 节点) │
│ │ │ │ │ (7.0+ 全面替代) │
│ 指针开销大│ │ 连锁更新 │ └──────────────────┘
└──────────┘ └──────────┘各方案的权衡:
| 方案 | 内存 | 头部 push/pop | 中间插入 | 范围查询 | Cache 友好 |
|---|---|---|---|---|---|
| 纯双向链表 | ❌ 差 (每节点 2 指针) | ✅ O(1) | ✅ O(1) | ❌ 离散遍历 | ❌ 差 |
| 纯 Ziplist | ✅ 好 (无指针) | ❌ O(n) 迁移 | ❌ 连锁更新 | ✅ 连续遍历 | ✅ 好 |
| Quicklist | ⚠️ 折中 | ✅ O(1) | ✅ O(log n)* | ✅ 节点内连续 | ⚠️ 折中 |
*中间插入:先定位节点 O(n/ziplist_size),再节点内插入 O(ziplist_size),近似 O(n/k)
2.6.2 Quicklist 结构详解
quicklist:
┌──────────┐ ┌──────────┐ ┌──────────┐
│ quicklist │ │ quicklist │ │ quicklist │
│ node │◄──►│ node │◄──►│ node │
│ ┌──────┐ │ │ ┌──────┐ │ │ ┌──────┐ │
│ │ list │ │ │ │ list │ │ │ │ list │ │
│ │ pack │ │ │ │ pack │ │ │ │ pack │ │
│ │(8KB) │ │ │ │(8KB) │ │ │ │(8KB) │ │
│ └──────┘ │ │ └──────┘ │ │ └──────┘ │
└──────────┘ └──────────┘ └──────────┘list-max-listpack-size(7.0+,旧名list-max-ziplist-size):控制每个节点最大字节数/元素数(默认 -2 = 8KB)list-compress-depth:LZF 压缩中间节点(两端不压,因为 LPUSH/RPOP 高频)
c
typedef struct quicklist {
quicklistNode *head, *tail;
unsigned long count; // 总元素数
unsigned long len; // 节点数
int fill : 16; // 节点填充因子
unsigned int compress : 16; // 压缩深度
} quicklist;节点分裂与合并:
- 插入时若节点满(超过 fill),在中间分裂
- 删除后节点太空(< fill/2),尝试与相邻节点合并
- 保持节点在合理大小范围,平衡链表灵活性与连续内存效率
2.6.3 List 跨语言对比
| 维度 | Redis Quicklist | C++ std::vector | Go slice | C++ std::list | Java LinkedList |
|---|---|---|---|---|---|
| 底层 | 链表 + 连续块 | 连续数组 | 连续数组 | 双向链表 | 双向链表 |
| 随机访问 | O(n/k) | O(1) | O(1) | O(n) | O(n) |
| 头部插入 | O(1) | O(n) | O(n) | O(1) | O(1) |
| 尾部插入 | O(1) | O(1)* | O(1)* | O(1) | O(1) |
| 中间插入 | O(n/k) | O(n) | O(n) | O(1) | O(1) |
| 内存开销/元素 | ~1 字节 | 0 (紧邻) | 0 (紧邻) | 16B (2 指针) | ~24B (Node对象) |
| Cache 友好 | 节点内好 | 极好 | 极好 | 差 | 差 |
| 批量操作 | 节点内连续 | 连续 | 连续 | 离散 | 离散 |
为什么 Redis 不直接用连续数组(像 vector/slice)?
LPUSH (头部插入):
vector/slice: [ ][ ][ ] → 所有元素右移一位 → O(n)
Redis: 直接在第一个节点头部写入 → O(1)为什么 Redis 不直接用双向链表?
- 64位系统每个链表节点至少需要 prev/next 两个指针(16 字节)
- 存一个小整数如
42,数据 8 字节,指针开销 16 字节 → 200% 额外开销 - Quicklist 每个节点可存数百元素,指针开销被均摊
总结:Quicklist 的设计哲学
在「纯连续」的 vector/slice 和「纯离散」的 linked list 之间取折中:节点间用链表(灵活插入),节点内用连续块(内存紧凑 + cache 友好)。这是 Redis 对 "既能头部 O(1) push/pop,又要内存高效" 需求的精确回应。
2.7 Intset(整数集合,Set 小量时)
Set 在元素全为整数且数量少时的底层编码:
c
typedef struct intset {
uint32_t encoding; // INTSET_ENC_INT16 / INT32 / INT64
uint32_t length; // 元素个数
int8_t contents[]; // 有序整数数组
} intset;有序存储,二分查找 O(log n)。插入时若类型不匹配则自动升级(如 int16 → int32)。
升级而非降级:如 int16→int32 后,即使删除导致所有值都能回到 int16 范围,也不会降级回 int16。原因是降级判断需要遍历全部元素,代价高且场景罕见。
为什么只升级不降级?——空间vs时间的权衡分析:
intset 的三种编码:
INTSET_ENC_INT16: 每个元素 2 字节,存储范围 [-32768, 32767]
INTSET_ENC_INT32: 每个元素 4 字节,存储范围 [-2³¹, 2³¹-1]
INTSET_ENC_INT64: 每个元素 8 字节,存储范围 [-2⁶³, 2⁶³-1]
升级场景:
Set 中全是 int16 → encoding=INT16
插入 40000 (超出 int16 范围) → 升级: int16→int32
所有已有元素 ×2 的内存拷贝 (2B → 4B)
← 这个升级过程是 O(n) 的,但只发生一次
降级场景:
Set 经历过升级 int16→int32
删除 40000 → Set 中只剩下 int16 范围的值
如果降级: 遍历所有元素检查是否都在 int16 范围内 → O(n)
然后重新分配内存 → O(n)
← 降级也需要 O(n) 代价!
权衡:
升级: 用户插入一个大值 → 升级是必须的(否则存不下)→ O(n) 可以接受
降级: 用户删除了大值 → 降级是"optional"的 → 多占用一倍内存 (4B vs 2B)
假设 500 个元素 × 额外 2B = 1KB → 这点空间不值得 O(n) 的遍历代价
只有当 intset 中的元素数量非常大(数千以上),降级才有空间收益
但此时 intset 可能已被编码切换为 hashtable(set-max-intset-entries 默认 512)
→ 降级场景实际很少出现 → 主动降级的 CPU 代价 > 额外内存代价
结论: 不降级策略在绝大多数场景下是最优的(省 CPU,多占的那点内存可忽略)2.7.1 Set 跨语言对比
Redis Set 的两层编码(intset / hashtable)与其他语言的集合实现有显著差异:
| 维度 | Redis Set | C++ std::set | C++ std::unordered_set | Java HashSet | Go (无原生Set) |
|---|---|---|---|---|---|
| 底层 | intset(有序数组) / hashtable | 红黑树 | 哈希表 | HashMap 封装 | map[T]struct{} |
| 有序性 | intset 有序; hashtable 无序 | ✅ 有序 | ❌ | ❌ | ❌ |
| 查/插/删 | O(log n) / O(1) | O(log n) | O(1) 平均 | O(1) | O(1) |
| 内存效率 | intset 极好; hashtable 一般 | 每个节点额外指针+颜色 | 一般 | Node 对象开销大 | struct{} 值零开销 |
| 集合运算 | 原生 SINTER/SUNION/SDIFF | 需 set_intersection 等算法 | 需手动实现 | 需手动实现 | 需手动实现 |
| 编码切换 | intset→hashtable 自动 | N/A | N/A | N/A | N/A |
关键差异分析:
C++ std::set — 红黑树,追求有序
cpp
std::set<int> s = {3, 1, 2};
// 内部: 平衡二叉搜索树, 迭代有序: 1, 2, 3
// 内存: 每个节点 ≈ 3ptr (left/right/parent) + color = ~40B 额外开销- 优点:始终有序,范围查询高效
- 缺点:内存开销大,适用于需要排序的场景
- Redis 选择:有序需求用 ZSet(跳表),Set 专注去重和集合运算,无需排序开销
Java HashSet — HashMap 的壳
java
// HashSet 内部就是一个 HashMap, value 是哑对象
public class HashSet<E> {
private transient HashMap<E, Object> map;
private static final Object PRESENT = new Object();
}- 相比 Redis Set,多一层 HashMap.Node 包装,每个元素有
hash/key/value/next四字段
Go — 没有原生 Set,用 map 模拟
go
// Go 风格: 利用 map 的 key 唯一性
set := make(map[string]struct{})
set["a"] = struct{}{} // struct{} 占 0 字节
// 缺点: 无原生集合运算, 需手写循环
intersection := make(map[string]struct{})
for k := range set1 {
if _, ok := set2[k]; ok {
intersection[k] = struct{}{}
}
}Redis Set 的独特优势
| 特性 | 说明 |
|---|---|
| 原生集合运算 | SINTER(交)、SUNION(并)、SDIFF(差) 在服务端高效执行 |
| intset 编码 | 纯整数场景,连续内存 + 二分查找,极致紧凑 |
| 自动编码切换 | 小数据用 intset 省内存,大数据转 hashtable 保性能 |
| SINTERSTORE 等 | 结果可直接存为新 key,避免数据传输开销 |
三、五种数据类型及其编码选择
| 类型 | 小数据编码 | 大数据编码 | 切换阈值配置 |
|---|---|---|---|
| String | int / embstr | raw | 长度 > 44 |
| Hash | listpack | hashtable | hash-max-listpack-entries 512(7.0+) |
| List | quicklist | (quicklist 统一) | list-max-listpack-size -2(7.0+) |
| Set | intset | hashtable | set-max-intset-entries 512 |
| Sorted Set | listpack | skiplist + dict | zset-max-listpack-entries 128(7.0+) |
版本注意:Redis 7.0 将 ziplist 全面替换为 listpack,配置参数名也随之变更。旧参数名
hash-max-ziplist-entries、zset-max-ziplist-entries、list-max-ziplist-size在 7.0+ 中已失效(以别名形式兼容,但推荐用新名)。
各类型核心命令复杂度速查
| 命令 | 复杂度 | 说明 |
|---|---|---|
GET/SET | O(1) | |
HGET/HSET | O(1) | |
LPUSH/RPOP | O(1) | |
LRANGE 0 N | O(N) | 越大的 N 越慢 |
SADD/SREM | O(1) | |
ZADD/ZREM | O(log N) | 跳表插入/删除 |
ZRANGE/ZRANK | O(log N + M) | M 为返回元素数 |
SORT | O(N + M*logM) | 避免对大集合使用 |
KEYS pattern | O(N) | 生产禁用,用 SCAN |
四、持久化
4.1 RDB(快照)
SAVE (主线程阻塞) → 生产禁用
BGSAVE (fork 子进程) → 使用 COW,子进程写盘触发条件:save 900 1 (900秒内1次修改) / save 300 10 / save 60 10000
RDB 优缺点:
- ✅ 文件紧凑,适合备份/灾备;恢复快
- ❌ 非实时,最多丢配置间隔内的数据
4.2 AOF(追加日志)
┌──────────┐ write ┌──────────┐ fsync ┌──────┐
│ 命令执行 │ ────────────→ │ AOF 缓冲区 │ ──────────→ │ 磁盘 │
└──────────┘ └──────────┘ └──────┘appendfsync 策略:
| 值 | 行为 | 性能 | 安全性 |
|---|---|---|---|
always | 每条命令 fsync | 最低 | 最高 |
everysec | 每秒 fsync 一次 | 较好 | 丢 1 秒 |
no | 交给 OS 决定 | 最高 | 最不安全 |
AOF 重写(BGREWRITEAOF):fork 子进程,根据当前数据库状态生成最小命令集,压缩 AOF 体积。
4.3 混合持久化(4.0+)
aof-use-rdb-preamble yes:AOF 文件前半段是 RDB 格式的快照,后半段是增量命令。兼顾恢复速度和数据完整性。
4.4 Multi-part AOF(7.0+)
Redis 7.0 重构了 AOF 存储机制,解决了单文件 AOF 的三大痛点。
旧机制(≤6.2)的问题:
| 问题 | 说明 |
|---|---|
| 重写需要临时文件 | BGREWRITEAOF fork 子进程 → 写临时文件 → 完成后 rename 替换。大量写入时临时文件可能和原文件一样大,磁盘空间翻倍 |
| 重写期间写入堆积 | 子进程重写期间,主进程的增量命令存在 AOF 缓冲区中。重写完成后一次性写入——如果增量太多,导致短暂的 I/O 尖峰 |
| 文件不可预测 | 只有一个 appendonly.aof 文件,损坏时需要全量修复 |
新机制(7.0+):
text
data-dir/
├── appendonly.aof.1.base.rdb ← Base 文件(RDB 格式快照)
├── appendonly.aof.1.incr.aof ← 增量文件(写命令)
└── appendonly.aof.manifest ← Manifest 文件(元数据)- Base 文件:RDB 格式的快照,体积小、恢复快
- Incr 文件:自上次快照以来的所有写命令(AOF 格式)
- Manifest 文件:记录当前生效的 base + incr 文件列表
Multi-part AOF 的优势:
| 优势 | 说明 |
|---|---|
| 重写无需临时文件 | 直接生成新的 base 文件 + 新的 incr 文件,旧文件继续服务读 |
| 增量文件可轮转 | incr 文件到达阈值(aof-file-rewrite-percentage)后自动创建新 incr 文件,避免单文件膨胀 |
| 恢复更快 | base 是 RDB 格式,直接加载;incr 只有少量增量命令 |
| 可预测磁盘使用 | 重写完成后旧文件才删除,不会出现临时文件导致磁盘空间翻倍 |
配置:
conf
# 7.0+ Multi-part AOF(默认开启)
aof-use-rdb-preamble yes
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb五、过期与淘汰
5.1 过期删除策略(惰性 + 定期)
| 策略 | 时机 | 说明 |
|---|---|---|
| 惰性删除 | 访问 key 时 | expireIfNeeded() 检查是否过期,过期则删 |
| 定期删除 | 每 100ms | activeExpireCycle() 随机抽 20 个 key,过期比例 >25% 则继续 |
activeExpireCycle 的完整算法
每 100ms 触发一次(server.hz 默认 10,即每秒 10 次):
1. 遍历所有 DB(默认 16 个)
2. 对每个 DB:
a. 从 expires 字典中随机取 20 个 key
b. 删除其中已过期的 key
c. 如果过期比例 > 25%(即 20 个中有 >5 个过期)
→ 重复步骤 a(说明过期 key 很多,需要继续清理)
d. 如果过期比例 ≤ 25% → 跳到下一个 DB
3. 每轮有时间限制(默认 25ms),超时则退出,下次继续
为什么是 25%?
- 太低(如 10%):清理太频繁,浪费 CPU
- 太高(如 50%):大量过期 key 堆积,浪费内存
- 25% 是经验值:保证过期 key 占比不会太高,同时不过度消耗 CPU
为什么用随机抽样而不是遍历?
- expires 字典可能有数百万个 key
- 全量遍历一次可能需要数秒,会阻塞主线程
- 随机抽样 + 概率保证:只要过期 key 占比 >25%,就会持续清理
- 数学上可以证明:这种策略能保证过期 key 占比稳定在 25% 以下5.2 内存淘汰策略(maxmemory)
| 策略 | 行为 |
|---|---|
noeviction | 不淘汰,写返回错误 |
allkeys-lru | 所有 key 中淘汰最近最少使用的 |
volatile-lru | 设置了过期时间的 key 中 LRU |
allkeys-lfu | 所有 key 中淘汰最不频繁使用的 (4.0+) |
volatile-lfu | 设置了过期时间的 key 中 LFU |
allkeys-random | 随机淘汰 |
volatile-random | 在设置了过期时间的 key 中随机淘汰 |
volatile-ttl | 淘汰 TTL 最短的 key |
LRU 实现(近似):每个 redisObject 存 24bit 时钟,抽样 maxmemory-samples 个 key 淘汰。
为什么 Redis 不用真正的 LRU 链表?
真正的 LRU 需要一个双向链表 + 哈希表(如 Linux 页缓存的实现)。但 Redis 有数百万甚至上亿个 key,维护一个全局 LRU 链表的开销太大:
- 每次访问都要移动链表节点 → O(1) 但常数大(指针操作 + cache miss)
- 额外内存:每个 key 多两个指针(prev/next)= 16 字节/key
- 1 亿 key × 16B = 1.6GB 额外内存,不可接受
Redis 的近似 LRU:
每个 redisObject 有一个 24bit 的 lru 字段,记录最后访问时间(秒级精度)。
淘汰时:
1. 随机抽样 maxmemory-samples 个 key(默认 5)
2. 比较它们的 lru 时间戳
3. 淘汰最旧的那个
4. 重复直到内存降到 maxmemory 以下
为什么 5 个样本就够了?
- Redis 官方测试:5 个样本的近似 LRU 命中率已接近真实 LRU
- 10 个样本几乎等同于真实 LRU
- 样本数越多越精确,但每次淘汰的 CPU 开销也越大LFU 实现(4.0+):24bit 拆分为高 16bit ldt(上次访问时间) + 低 8bit logc(对数计数器)。
Morris Counter(对数计数器)是怎么工作的?
LFU 需要统计每个 key 的访问频率,但用普通计数器有两个问题:
- 8bit 最多只能计到 255,远远不够
- 热点 key 的计数器很快溢出
Morris Counter 的核心思想:不精确计数,而是用概率递增。
counter 的含义不是"访问了 N 次",而是"访问了大约 2^N 次"。
递增规则(每次访问时):
1. 生成随机数 r ∈ [0, 1)
2. 计算递增概率 p = 1 / (counter × lfu_log_factor + 1)
(lfu_log_factor 默认 = 10)
3. 如果 r < p → counter++
4. 否则不变
效果:
counter=0 时: p = 1/(0×10+1) = 1.0 → 100% 递增(第一次访问必增)
counter=1 时: p = 1/(1×10+1) = 0.09 → 约 11 次访问才递增一次
counter=5 时: p = 1/(5×10+1) = 0.02 → 约 50 次访问才递增一次
counter=10时: p = 1/(10×10+1) = 0.01 → 约 100 次访问才递增一次
counter=255时: p ≈ 0.0004 → 约 2550 次访问才递增一次
这样 8bit 的 counter 就能表示从 1 到数百万次的访问频率范围!LFU 的衰减机制
如果只增不减,曾经的热点 key 永远不会被淘汰。Redis 用 ldt(上次衰减时间)实现衰减:
每次访问时检查:
elapsed_minutes = (当前时间 - ldt) / lfu_decay_time
counter = max(0, counter - elapsed_minutes)
lfu_decay_time 默认 = 1(每分钟衰减 1)
效果:一个 key 如果 10 分钟没被访问,counter 减少 10。
曾经的热点 key 会逐渐"冷却",最终可被淘汰。六、主从复制与集群
6.1 主从复制流程
1. 从: SLAVEOF host port (或 REPLICAOF)
2. 从: 建立 TCP 连接
3. 从: 发送 PSYNC replid offset
4. 主: 判断是部分同步 还是 完全同步
├─ 部分同步: 发送 replication backlog 中增量数据
└─ 完全同步: BGSAVE → 发送 RDB → 发送缓冲区命令PSYNC 机制(部分重同步)依赖:
replication backlog:固定大小环形缓冲区(默认 1MB),存最近命令replid:主节点 ID(重启后变化,无法部分同步)
6.2 Sentinel(哨兵)
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Sentinel│ │ Sentinel│ │ Sentinel│ (≥3 个, 奇数)
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
└────────────┼────────────┘
▼
┌─────────────┐
│ Redis 集群 │
│ M ─ S ─ S │
└─────────────┘核心功能:监控 → 通知 → 自动故障转移 → 配置提供者(客户端发现)。
主观下线 (SDOWN) 和 客观下线 (ODOWN):
- SDOWN:单个 Sentinel 认为节点不可达
- ODOWN:≥ quorum 个 Sentinel 都认为不可达,触发故障转移
6.3 Redis Cluster
数据分片使用 16384 个哈希槽(hash slot):
CRC16(key) & 16383 → 槽编号为什么是 16384 而不是 65536 或其他数字?
这个数字是 Redis 作者 antirez 在设计时权衡的结果:
1. 心跳包大小限制:
Cluster 节点间通过 Gossip 协议交换信息,每个心跳包包含一个 bitmap
标记"我负责哪些槽"。
16384 个槽 → bitmap = 16384 / 8 = 2KB
65536 个槽 → bitmap = 65536 / 8 = 8KB
每秒每个节点发送多个心跳包,2KB vs 8KB 的差距在大集群中很显著。
2. 集群规模上限:
Redis 官方建议集群最多 ~1000 个主节点。
16384 / 1000 ≈ 16 个槽/节点(最少情况)。
如果只有 1000 个槽,每个节点只分到 1 个槽,迁移粒度太粗。
16384 在"心跳包不太大"和"迁移粒度够细"之间取得平衡。
3. CRC16 的值域:
CRC16 输出 16bit = 65536 种值。
16384 = 2^14,用 & 16383 取低 14 位,计算简单高效。┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Master A │ │ Master B │ │ Master C │
│ slots 0-5460│ │ slots 5461- │ │ slots 10923- │
│ │ │ 10922 │ │ 16383 │
└──────────────┘ └──────────────┘ └──────────────┘MOVED vs ASK:
MOVED:槽已迁移到永久 owner,客户端更新 slot 映射表ASK:迁移中,只本次重定向到目标节点
七、缓存问题与应对
7.1 缓存穿透
请求不存在的数据,绕过缓存直达 DB。
| 方案 | 实现 |
|---|---|
| 布隆过滤器 | BF.ADD / BF.EXISTS,拦截不存在 key |
| 缓存空值 | 设短过期时间(30-60s) |
| 参数校验 | 过滤不合理 ID(如负数、超长) |
7.2 缓存击穿
热点 key 过期瞬间大量请求打到 DB。
| 方案 | 实现 |
|---|---|
| 逻辑过期 | value 存过期时间,异步更新 |
| 互斥锁 | SETNX lock 抢锁,抢到的查 DB 回写 |
| 永不过期 | 热点 key 不设 TTL,手动更新 |
7.3 缓存雪崩
大量 key 同时过期或 Redis 宕机。
| 方案 | 实现 |
|---|---|
| TTL 加随机 | 过期时间加 random(1, 300) 秒 |
| 多级缓存 | 本地 Caffeine + Redis |
| 主从 + 哨兵/集群 | 高可用,降级熔断 |
7.4 大 Key / 热 Key
| 问题 | 危害 | 排查 | 处理 |
|---|---|---|---|
| 大 Key | 阻塞、带宽 | redis-cli --bigkeys | 拆分、压缩、删 |
| 热 Key | CPU 不均 | redis-cli --hotkeys | 本地缓存、读写分离 |
7.5 Client-side Caching(6.0+)— 服务端辅助的客户端缓存
Redis 6.0 引入的 tracking 模式,让客户端缓存与服务器数据保持强一致,无需应用层维护过期和失效逻辑。
传统客户端缓存的问题:
App 本地缓存 key="user:123"
↓
其他服务修改了 user:123 → App 本地缓存是脏数据
↓
需要设置 TTL,但 TTL 短了缓存命中低,长了数据不一致Redis Client-side Caching 的两种模式:
| 模式 | 原理 | 适用场景 |
|---|---|---|
| 默认模式(追踪) | 客户端执行 CLIENT TRACKING on → Redis 记住该连接读取了哪些 key → 当 key 被修改时推送失效消息(invalidate) | 需要强一致性 |
| 广播模式 | Redis 不追踪每个连接读了什么,而是向所有订阅的客户端广播被修改的 key 前缀 | 大量客户端共享相同缓存 |
bash
# === 默认模式(服务端追踪每个客户端读过的 key) ===
# 客户端 A 开启追踪(RESP3 协议,自动推送失效消息)
CLIENT TRACKING on REDIRECT <redirect-client-id>
GET user:123 # 服务端记住:Client A 读过 user:123
# 客户端 B 修改
SET user:123 "newvalue" # Redis 自动推送 → Client A: invalidate user:123
# 客户端 A 收到失效通知后,下次请求会重新从 Redis 读取
GET user:123 # 从服务器获取最新值
# === 广播模式(前缀匹配,简化服务端追踪) ===
CLIENT TRACKING on BCAST PREFIX user: PREFIX order:
# 任何使用 user: 或 order: 前缀的 key 被修改时,所有订阅客户端都收到失效消息RESP2 兼容写法(需要单独的失效通知连接):
bash
# 连接1: 主连接,开启追踪并指定通知到连接2
CLIENT TRACKING on REDIRECT <conn2-id>
# 连接2: 独立的失效通知连接,专门接收 invalidate 消息
# 此连接除了接收失效消息外不做任何操作为什么重要:Client-side Caching 是 Redis 6.0+ 最重要的企业级特性之一。它让 Redis 从"集中式缓存"进化到"服务端 + 本地"两级缓存架构,且数据一致性由 Redis 保证。在高 QPS 场景下(如社交 feeds、排行榜),本地缓存命中可将 Redis 访问量降低 50-90%。
八、Pipeline 与事务
Pipeline
客户端批量发送命令不等待回复,服务端批量返回:
Client: SET a 1 \n SET b 2 \n GET a \n GET b
Server: +OK \n +OK \n $1 \n 1 \n $1 \n 2- 不是原子操作(中间可能插入其他客户端命令)
- 节省 RTT,比逐条发送快 10-100 倍
事务(MULTI/EXEC)
MULTI → 开启事务
SET a 1 → 入队
INCR b → 入队
EXEC → 原子执行- 不支持回滚(命令错误会跳过继续执行)
WATCH key实现乐观锁:被 watch 的 key 在 EXEC 前被修改,EXEC 返回 nil
Lua 脚本
EVAL "return redis.call('GET', KEYS[1])" 1 mykey- 脚本执行期间阻塞其他命令(原子性保证)
- 用
SCRIPT KILL杀死长时间运行的只读脚本 - 最佳实践:脚本短小、逻辑简单
Lua 脚本的工程痛点:
| 痛点 | 说明 |
|---|---|
| 脚本无版本管理 | EVAL 传源码,多次调用传重复代码,浪费带宽 |
| 无持久化 | 重启后脚本丢失,需重新 SCRIPT LOAD |
| 无命名空间 | 所有脚本共享全局函数空间,命名冲突风险 |
| 调试困难 | 出错只知道行号不知道是哪个脚本 |
Redis Functions(7.0+)— 替代 EVAL
Redis 7.0 引入 Functions API,彻底解决 Lua 脚本的工程化问题。Functions 以库的形式持久化管理脚本:
lua
-- mylib.lua
#!lua name=mylib
-- 注册一个函数(持久化到 AOF/RDB,重启后仍可用)
redis.register_function(
'get_or_set', -- 函数名
function(key, default_val)
local val = redis.call('GET', key)
if val then return val end
redis.call('SET', key, default_val)
return default_val
end
)
-- 注册一个只读函数
redis.register_function(
'stats',
function()
return redis.call('INFO', 'memory')
end,
{ flags = { 'no-writes' } } -- 标记为只读
)bash
# 加载函数库(持久化!重启后仍在)
cat mylib.lua | redis-cli -x FUNCTION LOAD REPLACE
# 调用
redis-cli FCALL get_or_set 1 mykey "default_value"
redis-cli FCALL_RO stats 0 # 只读,可被 SCRIPT KILL 中断
# 管理
redis-cli FUNCTION LIST
redis-cli FUNCTION DELETE mylib| EVAL | REDIS FUNCTIONS |
|---|---|
| 脚本存在客户端,每次发送源码 | 函数存在服务端,持久化到 RDB/AOF |
| 无版本管理 | FUNCTION LOAD REPLACE 覆盖更新 |
| 重启丢失 | 重启后自动恢复 |
| 无法被监控 | 每个函数有独立的调用统计 |
| 共享全局命名空间 | 每个库独立命名空间(mylib.get_or_set) |
九、分布式锁
单节点方式
SET lock_key unique_value NX PX 30000解锁用 Lua 脚本保证原子性:
lua
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
endRedlock(多节点)
在 N 个独立 Redis 实例(N 为奇数,推荐 5)上分别获取锁,过半成功 + 总耗时 < 锁有效期 才算获取成功。
争议:Martin Kleppmann 指出时钟跳跃等边缘场景下 Redlock 不安全。
十、性能优化清单
| 方面 | 建议 |
|---|---|
| 避免 KEYS | 用 SCAN 游标迭代 |
| 避免大 key | string < 10KB,集合元素 < 5000 |
| 连接池 | 复用连接,减少 TCP 握手 |
| Pipeline | 批量命令减少 RTT |
| 慢查询日志 | slowlog-log-slower-than 10000 (10ms) |
| 关闭 THP | echo never > /sys/kernel/mm/transparent_hugepage/enabled |
| CPU 绑定 | 绑定到固定核心 + 关闭 NUMA 自动平衡 |
| 内存碎片 | activedefrag yes (4.0+) 自动整理 |
十一、客户端工具与诊断
11.1 redis-cli 常用操作
bash
# === 连接 ===
redis-cli -h 127.0.0.1 -p 6379 -a password -n 0
redis-cli -h 127.0.0.1 -p 6379 --tls --cacert ca.crt # TLS 连接
redis-cli -u redis://user:pass@host:6379/0 # URI 方式
# === 批量操作 ===
redis-cli --pipe < commands.txt # 管道批量导入(极快, 比逐条快 100×)
cat data.txt | redis-cli --pipe # 从文件批量灌入
# === 数据迁移 ===
redis-cli --cluster reshard <host>:<port> # 集群 rebalance
redis-cli --cluster check <host>:<port> # 集群健康检查
# === 监控模式 ===
redis-cli --stat # 实时 QPS/内存统计 (每秒刷新)
redis-cli --latency # 实时延迟采样
redis-cli --latency-history # 延迟历史趋势 (15s 间隔, 持续采样)
redis-cli --latency-dist # 延迟分布直方图 (CDF, 需服务端 7.0+)
redis-cli --intrinsic-latency <sec> # 测试系统内核延迟 (非 Redis)
# === 扫描与诊断 ===
redis-cli --bigkeys # 扫描大 key (按类型统计最大 key)
redis-cli --memkeys # 按内存占用排序 (需采样)
redis-cli --hotkeys # 扫描热 key (基于 LFU, 需 maxmemory-policy 为 LFU)
# === 集群批量操作 ===
redis-cli --cluster call <host>:<port> <command> # 在所有节点执行命令
redis-cli -c # 集群模式 (自动 FOLLOW MOVED/ASK)11.2 诊断命令
INFO — 系统全景
bash
redis-cli INFO [section]
# 关键 section:
INFO server # 版本、运行时间、模式
INFO clients # 连接数、阻塞客户端数
INFO memory # used_memory、碎片率、maxmemory
INFO stats # QPS、命中率、key 数量、网络流量
INFO replication # 主从状态、offset 差
INFO cpu # CPU 使用情况
INFO commandstats # 每个命令的调用次数/耗时
INFO keyspace # 每个 DB 的 key 数量/过期数关键指标速查:
| 指标 | 含义 | 告警阈值 |
|---|---|---|
instantaneous_ops_per_sec | 实时 QPS | 参考日常基线 |
used_memory_rss / used_memory | 内存碎片率 | > 1.5 严重碎片 |
keyspace_hits / (hits + misses) | 缓存命中率 | < 90% 缓存效用低 |
rejected_connections | 被拒连接 | > 0 需关注 |
blocked_clients | 阻塞中的客户端 | > 0 可能有慢命令 |
rdb_last_bgsave_status | 上次 RDB 状态 | err 需立即排查 |
master_repl_offset - slave_repl_offset | 主从延迟 | > 1MB 需关注 |
MONITOR — 实时命令审计
bash
redis-cli MONITOR
# 输出所有客户端执行的每条命令 (生产慎用, 影响性能 30-50%)
# 输出: 时间戳 [db 客户端] "命令" "参数"...
# 1765432100.123456 [0 10.0.0.1:52341] "GET" "user:1001"
MONITOR是调试利器,但生产环境建议用--bigkeys/--hotkeys/SLOWLOG替代。
SLOWLOG — 慢命令追踪
bash
redis-cli SLOWLOG GET 10 # 最近 10 条慢命令
redis-cli SLOWLOG LEN # 慢日志条数
redis-cli SLOWLOG RESET # 清空
# 输出字段:
# 1) 自增 ID
# 2) Unix 时间戳
# 3) 执行耗时 (微秒)
# 4) 命令与参数列表bash
# 配置 (redis.conf)
slowlog-log-slower-than 10000 # 超过 10ms 记录 (微秒)
slowlog-max-len 128 # 最多保存 128 条LATENCY — 延迟诊断
bash
redis-cli LATENCY LATEST # 最新延迟事件
redis-cli LATENCY HISTORY cmd # 某类延迟事件的历史
redis-cli LATENCY DOCTOR # 自动诊断建议
redis-cli LATENCY RESET # 重置统计
# LATENCY DOCTOR 典型输出:
# "I have detected a major background save in this instance"
# "I have detected a major AOF rewrite in this instance"11.3 常见错误排查
| 症状 | 排查命令 | 常见原因 |
|---|---|---|
| 响应变慢 | SLOWLOG GET 10 + LATENCY DOCTOR | KEYS/slow command/大 key/BGSAVE |
| OOM | INFO memory | maxmemory 不足/碎片 |
| 连接拒绝 | INFO stats → rejected_connections | maxclients 达上限 |
| 主从延迟大 | INFO replication → offset 差 | 网络带宽/从库负载 |
| 缓存命中率低 | INFO stats → keyspace_hits/misses | key 过期快/淘汰策略 |
| CPU 100% | INFO commandstats + SLOWLOG | 热 key/复杂命令(如 SORT) |
| 内存碎片高 | INFO memory → mem_fragmentation_ratio | 大量短命 key/jemalloc 问题 |
快速诊断三板斧:
bash
# 1. 看全局
redis-cli INFO stats memory replication
# 2. 看慢命令
redis-cli SLOWLOG GET 20
# 3. 看大 key / 热 key
redis-cli --bigkeys
redis-cli --hotkeys十二、命令全览
12.1 通用命令
| 命令 | 复杂度 | 说明 |
|---|---|---|
KEYS pattern | O(N) | 匹配所有 key,生产禁用 |
SCAN cursor [MATCH] [COUNT] | O(1)/次 | 游标迭代,替代 KEYS |
EXISTS key [key...] | O(N) | 判断 key 是否存在 |
DEL key [key...] | O(N) | 删除 key |
UNLINK key [key...] | O(1) | 异步删除(大 key 推荐) |
TYPE key | O(1) | key 的类型 |
TTL / PTTL key | O(1) | 剩余过期时间 (秒/毫秒) |
EXPIRE key seconds | O(1) | 设置过期时间 |
EXPIREAT key timestamp | O(1) | 设置在指定时间戳过期 |
PERSIST key | O(1) | 移除过期时间 |
RENAME key newkey | O(1) | 重命名(覆盖 newkey) |
RENAMENX key newkey | O(1) | 重命名(newkey 存在则失败) |
OBJECT ENCODING key | O(1) | 查看底层编码 |
OBJECT REFCOUNT key | O(1) | 引用计数 |
OBJECT IDLETIME key | O(1) | 空闲时间 (秒) |
SORT key [BY] [LIMIT] [GET] [ASC|DESC] | O(N+M*logM) | 排序,大集合慎用 |
12.2 String 命令
| 命令 | 复杂度 | 说明 |
|---|---|---|
GET key | O(1) | 获取值 |
SET key value [EX|PX] [NX|XX] | O(1) | 设置值(支持过期+条件) |
GETSET key value | O(1) | 设新值返旧值(6.2 起建议用 SET GET) |
SETNX / SETEX / PSETEX | O(1) | 条件设置/含过期设置 |
MGET key [key...] | O(N) | 批量获取,减少 RTT |
MSET key value [key value...] | O(N) | 批量设置,原子操作 |
GETRANGE key start end | O(N) | 子串(支持负索引) |
SETRANGE key offset value | O(N) | 覆盖子串 |
STRLEN key | O(1) | 值长度 |
APPEND key value | O(1) | 追加 |
INCR / DECR / INCRBY / DECRBY | O(1) | 整数加减(原子) |
INCRBYFLOAT key increment | O(1) | 浮点数加减 |
GETDEL key | O(1) | 获取并删除 (6.2+) |
GETEX key [EX|PX|PERSIST] | O(1) | 获取并设置/清除过期 (6.2+) |
典型用法:
bash
SET lock_key unique_value NX EX 30 # 分布式锁
SET cache_key data EX 3600 # 带过期缓存
MSET user:1:name "Alice" user:1:age 25 # 批量原子写入
INCR page:home:views # 原子计数器12.3 Hash 命令
| 命令 | 复杂度 | 说明 |
|---|---|---|
HSET key field value [field value...] | O(N) | 设置字段 |
HGET key field | O(1) | 获取字段值 |
HMSET / HMGET | O(N) | 批量设/取 |
HGETALL key | O(N) | 获取全部字段,大 key 慎用 |
HDEL key field [field...] | O(N) | 删除字段 |
HEXISTS key field | O(1) | 判断字段存在 |
HLEN key | O(1) | 字段数量 |
HKEYS / HVALS key | O(N) | 所有字段名/值,大 key 慎用 |
HINCRBY / HINCRBYFLOAT | O(1) | 字段数值运算 |
HSCAN key cursor [MATCH] [COUNT] | O(1)/次 | 迭代字段 |
HSTRLEN key field | O(1) | 字段值长度 (3.2+) |
12.4 List 命令
| 命令 | 复杂度 | 说明 |
|---|---|---|
LPUSH / RPUSH key elem [elem...] | O(N) | 左/右推入 |
LPOP / RPOP key [count] | O(N) | 左/右弹出 (6.2+ 支持 count) |
LLEN key | O(1) | 长度 |
LRANGE key start stop | O(S+N) | 范围获取,大 offset 慎用 |
LINDEX key index | O(N) | 按下标获取 |
LSET key index value | O(N) | 按下标设置 |
LREM key count value | O(N+M) | 删除指定值 |
LTRIM key start stop | O(N) | 裁剪范围 |
LINSERT key BEFORE|AFTER pivot elem | O(N) | 在 pivot 前/后插入 |
LPOS key elem [RANK] [COUNT] [MAXLEN] | O(N) | 查找元素位置 (6.0.6+) |
BLPOP / BRPOP key [key...] timeout | O(N) | 阻塞弹出(超时秒) |
LMOVE / BLMOVE | O(1) | 原子移动元素 (6.2+) |
List 经典模式:
bash
# 消息队列 (FIFO): LPUSH + RPOP
# 栈 (LIFO): LPUSH + LPOP
# 阻塞队列: LPUSH + BRPOP 5
# 有限队列: LPUSH + LTRIM 0 99 (保留最新 100 条)12.5 Set 命令
| 命令 | 复杂度 | 说明 |
|---|---|---|
SADD key member [member...] | O(N) | 添加成员 |
SREM key member [member...] | O(N) | 移除成员 |
SCARD key | O(1) | 成员数 |
SISMEMBER key member | O(1) | 判断是否存在 |
SMEMBERS key | O(N) | 获取所有成员,大 key 禁用 |
SSCAN key cursor [MATCH] [COUNT] | O(1)/次 | 迭代成员 |
SRANDMEMBER key [count] | O(N) | 随机获取(count 不重复,负值可重复) |
SPOP key [count] | O(N) | 随机弹出并删除 |
SMOVE source dest member | O(1) | 原子移动 |
| 集合运算 | ||
SINTER key [key...] | O(N*M) | 交集 |
SUNION key [key...] | O(N) | 并集 |
SDIFF key [key...] | O(N) | 差集 |
SINTERSTORE / SUNIONSTORE / SDIFFSTORE | O(N*M) | 运算结果存为新 key |
12.6 Sorted Set 命令
| 命令 | 复杂度 | 说明 |
|---|---|---|
ZADD key [NX|XX] [GT|LT] [CH] [INCR] score member | O(log N) | 添加/更新 |
ZREM key member [member...] | O(M*log N) | 移除 |
ZCARD key | O(1) | 成员数 |
ZSCORE key member | O(1) | 获取分数 |
ZRANK / ZREVRANK key member | O(log N) | 排名(升序/降序) |
ZRANGE key min max [BYSCORE|BYLEX] [REV] [LIMIT] [WITHSCORES] | O(log N+M) | 范围查询 |
ZRANGEBYSCORE / ZRANGEBYLEX | O(log N+M) | 按分数/字典序范围 |
ZREM key member [member...] | O(log N) | 移除成员 |
ZREMRANGEBYRANK key start stop | O(log N+M) | 按排名删除 |
ZREMRANGEBYSCORE key min max | O(log N+M) | 按分数删除 |
ZCOUNT key min max | O(log N) | 分数范围内计数 |
ZINCRBY key increment member | O(log N) | 分数增减 |
ZPOPMIN / ZPOPMAX key [count] | O(log N*M) | 弹出最小/最大 |
BZPOPMIN / BZPOPMAX key [key...] timeout | O(log N) | 阻塞弹出 |
| 集合运算 | ||
ZINTER / ZUNION numkeys key [key...] [WEIGHTS] [AGGREGATE] | O(NK)+O(Mlog M) | 交集/并集 |
ZINTERSTORE / ZUNIONSTORE | O(NK)+O(Mlog M) | 运算结果存为新 key |
典型用法:
bash
ZADD leaderboard 1000 alice 950 bob 880 charlie # 排行榜
ZRANGE leaderboard 0 9 WITHSCORES # Top 10
ZREVRANK leaderboard alice # alice 排名
ZINCRBY leaderboard 10 alice # 加分
ZREMRANGEBYRANK leaderboard 0 -11 # 只保留 Top 1012.7 Stream 命令(5.0+)
| 命令 | 说明 |
|---|---|
XADD key [MAXLEN] * field value ... | 追加消息(* = 自动 ID) |
XREAD [COUNT] [BLOCK ms] STREAMS key ID | 读取(阻塞读用 $) |
XREADGROUP GROUP group consumer STREAMS key ID | 消费者组读取 |
XGROUP CREATE/DESTROY/SETID/DELCONSUMER | 管理消费者组 |
XACK key group ID [ID...] | 确认消息 |
XCLAIM / XPENDING / XAUTOCLAIM | 消息认领与 pending 管理 |
XRANGE / XREVRANGE key start end [COUNT] | 范围查询 |
XLEN key | 消息数量 |
XDEL key ID [ID...] | 删除消息 |
XTRIM key MAXLEN [~] count | 裁剪长度(~ 近似裁剪) |
12.8 其他类型命令
Bitmap(本质是 String 的位操作):
| 命令 | 说明 |
|---|---|
SETBIT key offset value | 设置位 |
GETBIT key offset | 获取位 |
BITCOUNT key [start end] | 统计 1 的个数 |
BITOP AND/OR/XOR/NOT dest key [key...] | 位运算 |
BITPOS key bit [start] [end] | 查找第一个 0/1 |
HyperLogLog(基数估算,标准误差 0.81%):
| 命令 | 说明 |
|---|---|
PFADD key element [element...] | 添加元素 |
PFCOUNT key [key...] | 估算基数 |
PFMERGE dest key [key...] | 合并多个 HLL |
Geospatial(地理位置):
| 命令 | 说明 |
|---|---|
GEOADD key longitude latitude member | 添加坐标 |
GEODIST key m1 m2 [m|km|ft|mi] | 距离 |
GEORADIUS / GEORADIUSBYMEMBER | 半径搜索 |
GEOSEARCH / GEOSEARCHSTORE | 区域搜索 (6.2+) |
GEOHASH key member | 获取 GeoHash |
12.5 内存碎片与 jemalloc 深度分析
12.5.1 为什么 Redis 选择 jemalloc?
Redis 默认使用 jemalloc 作为内存分配器(编译时可选 libc/tcmalloc/jemalloc):
| 分配器 | Redis 适配度 | 原因 |
|---|---|---|
| jemalloc | ⭐⭐⭐⭐⭐ | size class 精细、碎片低、arena 隔离、统计完善 |
| tcmalloc | ⭐⭐⭐ | 快速路径极快,但碎片控制不如 jemalloc |
| glibc ptmalloc | ⭐⭐ | 碎片严重,多 arena 锁竞争 |
jemalloc 对 Redis 的关键优势:
- 精细的 size class:Redis 对象大小分布广(SDS 从几字节到几 MB),jemalloc 200+ 个 size class 减少内部碎片
- arena 隔离:后台线程(bio)和主线程使用不同 arena,减少锁竞争
- decay 机制:空闲内存不立即归还 OS,短期内可复用,减少 mmap/munmap 开销
- 统计接口:
jemalloc.stats.*提供精确的内存使用分析
12.5.2 mem_fragmentation_ratio 的真正含义
bash
redis-cli INFO memory
# used_memory:1073741824 ← Redis 认为自己用了多少(逻辑)
# used_memory_rss:1610612736 ← OS 看到的 RSS(物理)
# mem_fragmentation_ratio:1.50 ← RSS / used_memorymermaid
flowchart LR
subgraph "used_memory (Redis 视角)"
A["实际存储的数据"]
B["内部数据结构开销"]
C["Lua 脚本内存"]
end
subgraph "used_memory_rss (OS 视角)"
D["used_memory"]
E["jemalloc 内部碎片<br/>(size class 对齐浪费)"]
F["jemalloc 外部碎片<br/>(空闲但未归还的页)"]
G["jemalloc 元数据"]
H["页表、内核开销"]
end碎片率判断标准:
| 碎片率 | 含义 | 行动 |
|---|---|---|
| < 1.0 | RSS < used_memory,通常有 swap | ⚠️ 检查是否在用 swap |
| 1.0 - 1.1 | 几乎无碎片 | ✅ 理想状态 |
| 1.1 - 1.5 | 轻度碎片 | ✅ 正常范围 |
| 1.5 - 2.0 | 中度碎片 | ⚠️ 考虑开启 activedefrag |
| > 2.0 | 严重碎片 | 🚨 必须处理 |
12.5.3 碎片产生的根因
mermaid
flowchart TD
A["碎片产生"] --> B["内部碎片"]
A --> C["外部碎片"]
B --> B1["请求 40B → jemalloc 分配 48B<br/>浪费 8B (size class 对齐)"]
B --> B2["SDS 预分配空间<br/>实际用 100B,alloc 200B"]
C --> C1["大量 key 过期/删除<br/>留下不连续的空洞"]
C --> C2["value 大小频繁变化<br/>旧空间释放,新空间另分配"]
C --> C3["不同 size class 的对象交错分配<br/>页内混合导致无法整页归还"]最容易产生碎片的使用模式:
| 模式 | 碎片原因 | 典型场景 |
|---|---|---|
| 大量短命 key | 频繁分配/释放,空洞累积 | 会话缓存、限流计数器 |
| value 大小频繁变化 | APPEND/SET 导致 SDS 重新分配 | 计数器字符串、动态列表 |
| 大量不同大小的对象混合 | 不同 size class 交错,页无法整体回收 | 混合业务 |
| 大 key 删除后 | 大块空间被释放但无法被小对象复用 | 大 Hash/Set 过期 |
12.5.4 activedefrag — Redis 4.0+ 在线碎片整理
bash
# 开启主动碎片整理
CONFIG SET activedefrag yes
# 相关配置
active-defrag-enabled yes
active-defrag-ignore-bytes 100mb # 碎片绝对量 < 100MB 不整理
active-defrag-threshold-lower 10 # 碎片率 < 10% 不整理
active-defrag-threshold-upper 100 # 碎片率 > 100% 全力整理
active-defrag-cycle-min 1 # 最小 CPU 占比 (%)
active-defrag-cycle-max 25 # 最大 CPU 占比 (%)
active-defrag-max-scan-fields 1000 # 每次扫描的最大字段数activedefrag 工作原理:
mermaid
flowchart LR
A["扫描所有 key"] --> B["检查对象是否在碎片页上"]
B --> C{"对象所在页的<br/>空闲率高?"}
C -->|是| D["分配新空间<br/>拷贝数据<br/>释放旧空间"]
C -->|否| E["跳过"]
D --> F["旧页空闲率进一步提高<br/>最终可整页归还 OS"]本质:通过"搬家"(重新分配 + 拷贝 + 释放旧地址)让对象聚集到少数页上,使空闲页可以整体归还。
注意事项:
- activedefrag 在主线程中执行(和命令交替),会占用 CPU
- 大 key 的整理可能导致延迟抖动
- 建议在低峰期开启,或设置合理的 cycle-max
12.5.5 jemalloc 的 dirty page decay 对 Redis RSS 的影响
jemalloc 释放的内存不会立即归还 OS,而是保留为 "dirty pages":
text
jemalloc 内存状态:
allocated → 正在使用
active → 已分配给 arena 但可能部分空闲
dirty → 已释放但未归还 OS(保留一段时间供复用)
muzzy → 已 madvise(MADV_FREE) 但 OS 还没回收
retained → 已 munmap 归还 OSdecay 机制:
text
dirty_decay_ms = 10000 (默认 10 秒)
→ dirty page 在 10 秒后开始归还 OS
→ 10 秒内如果有新分配,直接复用 dirty page(无系统调用)
muzzy_decay_ms = 10000 (默认 10 秒)
→ muzzy page 在 10 秒后彻底释放对 Redis 的影响:
| 现象 | 原因 | 是否需要处理 |
|---|---|---|
| 删除大量 key 后 RSS 不立即降 | dirty pages 保留中 | 等 10-20 秒后观察 |
| RSS 持续高于 used_memory | decay 保留 + 碎片 | 看碎片率是否在合理范围 |
| 突发流量后 RSS 居高不下 | 峰值分配的 arena 空间被保留 | 可调低 decay_ms |
调优 jemalloc decay(Redis 6.0+ 支持):
bash
# 查看 jemalloc 统计
redis-cli MEMORY MALLOC-STATS
# 手动触发内存归还(Redis 7.0+)
redis-cli MEMORY PURGE
# 编译时调整 jemalloc 配置(需重新编译 Redis)
# 或通过环境变量:
MALLOC_CONF="dirty_decay_ms:5000,muzzy_decay_ms:5000" redis-server12.5.6 BGSAVE/AOF rewrite 时的 COW 与碎片
mermaid
flowchart TD
A["BGSAVE 触发 fork()"] --> B["父子进程共享物理页 (COW)"]
B --> C["父进程继续写入"]
C --> D["写入触发 COW: 复制整个 4KB 页"]
D --> E["即使只改 1 字节<br/>也要复制整页"]
E --> F["内存使用量临时翻倍<br/>(最坏情况)"]COW 放大碎片的机制:
text
正常情况:
一个 4KB 页上有 100 个小对象
修改其中 1 个 → COW 复制整页 → 额外 4KB
碎片严重时:
一个 4KB 页上只有 10 个对象(其余是碎片空洞)
修改其中 1 个 → COW 复制整页 → 额外 4KB
但这 4KB 中有效数据只有 10%!
结论: 碎片越严重,COW 的"有效载荷比"越低,内存放大越严重实战建议:
| 场景 | 建议 |
|---|---|
| BGSAVE 期间内存翻倍 | 预留 2× 内存,或错峰执行 |
| 碎片率高 + 频繁 BGSAVE | 先整理碎片,再做持久化 |
| 容器 memory limit 紧张 | 考虑关闭 RDB,只用 AOF |
12.5.7 碎片排障流程
mermaid
flowchart TD
A["INFO memory 看碎片率"] --> B{"ratio > 1.5?"}
B -->|否| C["正常,无需处理"]
B -->|是| D["MEMORY MALLOC-STATS<br/>看 jemalloc 详细统计"]
D --> E{"dirty pages 占比高?"}
E -->|是| F["等待 decay 或 MEMORY PURGE"]
E -->|否| G{"外部碎片为主?"}
G -->|是| H["开启 activedefrag"]
G -->|否| I["内部碎片为主<br/>检查 value 大小分布"]
H --> J["监控 CPU 和延迟"]
I --> K["优化数据模型<br/>减少 size class 跨度"]十三、工程实践:Redis 真正难的是延迟抖动和热点传播
13.0 一眼看懂:Redis 为什么会从热点抖成系统抖
mermaid
flowchart LR
A["热 key / 大 key / 慢命令"] --> B["主线程停留变长"]
B --> C["请求排队"]
C --> D["应用超时与重试"]
D --> E["DB 回源与系统放大"]| 现象 | 优先看什么 | 常见误判 |
|---|---|---|
| RT 抖动 | SLOWLOG、LATENCY DOCTOR、fork/fsync | 只怪网络不好 |
| CPU 高 | 热 key、命令模型、慢脚本 | 只怪单线程架构 |
| 命中率下降 | 淘汰策略、访问模式变化 | 只怪容量不够 |
| 内存突然涨 | COW、碎片、大 key、惰性删除 | 只怪泄漏 |
13.1 Redis 的核心瓶颈不是“单线程太弱”,而是主线程承载了太多关键路径
很多人一提 Redis,第一反应就是“单线程会不会成为瓶颈”。更准确的说法其实是:
- 命令执行主线程是串行的
- 主线程一旦被某个慢操作占住,其他请求就要排队
- 所以 Redis 更怕的是尾延迟放大,而不是平均吞吐不够
也就是说,Redis 线上最危险的时刻通常不是“整体都很慢”,而是:
- 某个命令偶发很慢
- 主线程被占住几十毫秒到几百毫秒
- 所有后续请求一起排队
- 应用体感突然变差
13.2 真正拖慢 Redis 的,往往不是普通 GET/SET,而是少量“破坏事件循环节奏”的操作
典型风险包括:
- 大 key 删除
- 大集合遍历
KEYS/ 大范围SMEMBERS/HGETALL- 长 Lua 脚本
- 复杂聚合命令
- 大量过期集中触发
- RDB/AOF 背景任务带来的 COW 放大
这些问题的共同点是:它们不一定很多,但只要一出现,就会拉长主线程停留时间。
13.3 持久化带来的抖动,很多时候比命令本身更隐蔽
Redis 是内存数据库,但线上延迟经常不是纯 CPU 问题,而是持久化副作用:
BGSAVE/BGREWRITEAOF触发forkfork期间页表复制带来卡顿- 后续写流量高时,COW 导致内存额外膨胀
- AOF fsync 或磁盘抖动又会进一步放大延迟
mermaid
flowchart LR
A["BGSAVE / AOF rewrite"] --> B["fork 成本上升"]
B --> C["写流量期间 COW 增大"]
C --> D["内存压力 / 延迟抖动"]
D --> E["应用侧超时和重试增多"]所以 Redis 的持久化配置,本质上不是“开不开”,而是:你愿意拿多少延迟稳定性去换数据安全和恢复能力。
13.4 热 key 和大 key,危害完全不同
- 热 key:请求过于集中,单线程局部热点过高,CPU、网络、队列被一个 key 放大
- 大 key:一次操作就可能搬大量数据,导致主线程执行时间拉长、网络包过大、复制和持久化成本升高
两者都能把 Redis 拖慢,但处理方式不同:
- 热 key 更多是分散访问、加本地缓存、拆热点
- 大 key 更多是重构数据模型、拆分、异步删除、避免全量返回
13.5 淘汰策略选错,问题会从缓存层一路传到数据库
mermaid
flowchart LR
A["淘汰策略和流量模式不匹配"] --> B["命中率下降"]
B --> C["应用回源增多"]
C --> D["MySQL/下游负载升高"]
D --> E["整体RT抖动 / 超时"]所以 Redis 的内存淘汰不是“缓存内部问题”,而是整个系统容量设计的一部分。
13.6 Pipeline、Lua、事务都不是免费的
Pipeline减少 RTT,但单次打包太大也会放大主线程连续处理时间- Lua 脚本提供原子性,但执行期间 Redis 不能切换去处理其他命令
MULTI/EXEC可以保证一批命令连续执行,但并不会让复杂逻辑更便宜
它们都适合减少往返和保证一致性,但不适合承载过重业务逻辑。
13.7 一个典型故障链
mermaid
flowchart LR
A["热key/大key/慢命令出现"] --> B["Redis 主线程停留时间上升"]
B --> C["请求排队"]
C --> D["应用侧超时和重试"]
D --> E["Redis QPS 更抖,DB 回源增加"]
E --> F["故障从缓存层扩散到整个系统"]13.8 常见误判
| 现象 | 容易误判为 | 实际可能是 |
|---|---|---|
| Redis RT 抖动 | 网络不好 | fork / fsync / 大key / 慢命令 |
| CPU 高 | 单线程架构不行 | 热key 或命令模型不合理 |
| 命中率下降 | Redis 容量不够 | 淘汰策略失配 / 访问模式变化 |
| 从库延迟大 | 复制机制差 | 主库大 key、网络、磁盘或从库负载 |
| 内存突然上升 | 泄漏 | COW、碎片、惰性删除堆积 |
13.9 排障顺序
| 现象 | 优先看什么 |
|---|---|
| 延迟抖动 | LATENCY DOCTOR, SLOWLOG, INFO commandstats |
| 内存上涨 | INFO memory, 碎片率、COW、bigkeys |
| CPU 高 | 热 key、慢命令、Lua、集合操作 |
| 命中率下降 | INFO stats, 淘汰策略、TTL 分布 |
| 主从延迟 | INFO replication, 网络、从库回放压力 |
13.10 一个实战原则
text
把 Redis 当作“单线程事件循环 + 内存数据结构 + 持久化副作用”的组合体,
不要只把它当作一个很快的 KV。这样你更容易判断:问题到底是命令、数据模型、持久化,还是整个缓存策略。
登录后即可发表评论 👇