vault backup: 2026-05-22 00:25:59

This commit is contained in:
hhs
2026-05-22 00:25:59 +08:00
parent 9cb53bad9c
commit dda0d6927c
3 changed files with 888 additions and 372 deletions
+373 -145
View File
@@ -7,51 +7,59 @@ create time: 2026-05-15 18:12
## 概述
> [!SUMMARY] RDB 是什么?
> RDB(Redis Database)是 Redis 最早的持久化机制,通过在特定时间点将内存中的数据集写入磁盘形成**快照文件(.rdb)**,实现数据持久化。
> [!QUESTION] Redis 的数据都存在内存里,那服务器一断电,数据岂不是全没了?
> 没错——这就是**持久化**存在的意义。Redis 提供两种持久化方案:**RDB** 和 **AOF**,本文先讲 RDB。
> [!SUMMARY] 一句话理解 RDB
> RDB(Redis Database)就是在**某个时间点**,把 Redis 内存中的**全部数据**"拍一张照片"存到磁盘上——这个"照片"就是一个 `.rdb` **快照文件**。
>
> 你可以把它想象成**游戏存档**:打 Boss 之前你手动存了一次档,万一翻车了可以读档重来。但代价是——**存档之后到翻车之间**的操作全丢了。
### 核心特点
| 特性 | 说明 |
|------|------|
| ⚡ 恢复极快 | 二进制文件直接加载,避免命令回放 |
| 📦 文件紧凑 | 压缩存储,远小于等量 AOF 日志 |
| 🔧 架构简单 | 无额外复杂逻辑,适合备份和迁移 |
| ⚠️ 数据窗口丢失 | 两次快照之间的写操作不可恢复 |
| 特性 | 说明 | 类比 |
|------|------|------|
| ⚡ 恢复极快 | 二进制文件直接加载到内存,跳过命令回放 | 像从压缩包解压,比照着菜谱重新做菜快得多 |
| 📦 文件紧凑 | 使用 LZF 压缩,远小于等量 AOF 日志 | 一张照片的信息量 > 千言万语的描述 |
| 🔧 架构简单 | 无额外复杂逻辑,一个文件搞定备份/迁移 | "复制-粘贴"级别的简单 |
| ⚠️ 数据窗口丢失 | 两次快照之间的写操作不可恢复 | 存档点之后的操作无法回溯 |
### 何时使用
- **大数据集冷备**:定期全量备份到 S3 / OSS
- **灾难恢复**:配合 CI/CD 快速重建实例
- **大规模数据场景**:恢复速度优先于毫秒级数据一致性
- **大数据集冷备**:定期全量备份到 S3 / OSS,作为最后一道防线
- **灾难恢复**:服务器挂了?把 `.rdb` 文件丢到新机器上,秒级恢复
- **大规模数据场景**:当你的 Redis 存了 50GB 数据,AOF 回放可能要几十分钟,RDB 几秒搞定
- **主从复制**:新从节点首次全量同步时,主节点会自动触发 RDB 快照传给从节点
> [!QUESTION] 为什么 RDB 恢复比 AOF 快?
> RDB 是直接加载二进制快照到内存,AOF 则需要逐条回放命令——数据量越大差距越明显。
> 打个比方:RDB 就像**拍了一张照片**,加载时直接"看照片"还原场景;AOF 则像**记了一本流水账**("先加了张三,再删了李四,又改了王五……"),恢复时需要从头到尾把账本念一遍——数据量越大,"念账本"的时间就越长。
## 触发机制
### 手动触发
```bash
# 当前线程执行,阻塞主线程 ❌
# 当前线程执行,阻塞主线程 ❌ 生产环境禁用!
SAVE
# 主进程 fork 子进程在后台完成(推荐)
# 主进程 fork 子进程在后台完成(✅ 推荐)
BGSAVE
# 查看上次 BGSAVE 状态
# 查看上次 BGSAVE 成功的时间戳
LASTSAVE
```
| 命令 | 行为 | 影响 |
|------|------|------|
| `SAVE` | 同步保存,主线程阻塞 | **生产环境禁用!** |
| `BGSAVE` | fork 子进程异步保存 | 主线程继续服务,但触发 fork 瞬间短暂停顿 |
| `BGREWRITEAOF` | AOF rewrite | 与 BGSAVE 可同时运行(不同子进程) |
| 命令 | 行为 | 适用场景 | 风险 |
|------|------|----------|------|
| `SAVE` | 同步阻塞,直到快照完成 | 紧急调试,或确认实例无流量时 | **主线程完全卡死**——所有客户端请求排队等待 |
| `BGSAVE` | fork 子进程异步完成 | 生产环境日常备份 | fork 瞬间有极短暂的阻塞(通常毫秒级) |
> [!QUESTION] 为什么 SAVE 危险但 Redis 还要提供它?
> 因为 `SAVE` 可以在 **BGSAVE 正在执行时** 强制同步保存(BGSAVE 期间再次 BGSAVE 会被拒绝)。这是一个极端场景的"保底"手段。
### 自动触发——条件持久化
通过 `redis.conf` 配置:
通过 `redis.conf` 配置多条 `save` 规则,Redis 用一个**"脏键计数器"(dirty counter)** 来追踪:
```conf
save 900 1 # 900 秒内至少有 1 个 key 被修改 → 触发快照
@@ -59,227 +67,447 @@ save 300 10 # 300 秒内至少有 10 个 key 被修改
save 60 10000 # 60 秒内至少有 10000 个 key 被修改
```
> [!QUESTION] 为什么要三档条件?
> - **低活跃度**:900s/1key 保证最终一致
> - **中活跃度**:300s/10key 平衡频率和数据量
> - **高活跃度**:60s/10000key 高频大量变更时快速落盘
> [!INFO] 底层是怎么工作的?
> Redis 内部维护两个计数器:
> 1. **`dirty` 计数器**:自上次 BGSAVE 成功以来,总共被修改的 key 数量(每次写操作 +1)
> 2. **`lastsave` 时间戳**:上次成功完成 BGSAVE 的 Unix 时间戳
>
> 每隔 100ms(`serverCron` 定时任务),Redis 会检查:**当前时间 - lastsave ≥ 阈值时间 && dirty ≥ 阈值次数**,两个条件**同时满足**才会触发 BGSAVE。
>
> 所以 `save 900 1` 的意思是:"距离上次快照超过 900 秒**并且**期间有至少 1 个 key 被修改过"。如果 900 秒内没有任何写操作,即使规则匹配了也不会触发——因为没有新数据需要保存。
> [!QUESTION] 为什么要设三档而不是一档?
> 这是一个**渐进式频率**设计:
> - **低活跃度**(900s/1key):写入很少时,15 分钟存一次就够了——即使丢了 15 分钟的数据,也几乎没什么损失
> - **中活跃度**(300s/10key):中等写入压力时,5 分钟存一次,平衡了 I/O 开销和数据安全
> - **高活跃度**(60s/10000key):大量写入时,1 分钟存一次——虽然 fork 更频繁了,但此时丢 1 分钟的数据损失也更大
>
> 本质上是"**数据越重要、变更越频繁,保存就越勤**"的策略。
```bash
# 运行时动态修改
CONFIG SET save "900 1\n300 10\n60 10000"
# 运行时动态修改(覆盖所有规则)
CONFIG SET save "900 1 300 10 60 10000"
# 临时关闭自动快照(谨慎!)
# 临时关闭自动快照(谨慎!相当于禁用 RDB 自动触发)
CONFIG SET save ""
```
> [!WARNING] CONFIG SET 不持久
> `CONFIG SET` 仅在内存中生效,重启后失效。修改 `redis.conf` 并 reload 才是持久化的方式。
> [!WARNING] CONFIG SET 不持久化
> `CONFIG SET` 仅在内存中生效,Redis 重启后会恢复 `redis.conf` 的配置。若要永久生效,必须修改 `redis.conf` 并执行 `CONFIG REWRITE` 或手动重启。
### 其他触发场景
### 其他自动触发场景
| 场景 | 说明 |
|------|------|
| **shutdown 保存** | `SHUTDOWN` / `SHUTDOWN NOSAVE` 会在关闭前执行一次完整 RDB 保存(除非显式传 NOSAVE) |
| **AOF rewrite** | `BGREWRITEAOF` 重写 AOF 时同样会 fork 子进程生成 RDB preamble(开启混合持久化时) |
| **主从同步** | 新从节点首次全量同步时,主节点会自动触发 BGSAVE 并将快照传给从节点 |
除了 `save` 规则外,以下场景也会自动触发 RDB:
> [!TIP] 避免频繁触发
> 多个触发条件同时满足时,Redis 会去重,仅执行一次 BGSAVE。若已有 BGSAVE 在执行,后续的 SAVE 请求将被忽略。
| 场景 | 说明 | 为什么需要? |
|------|------|-------------|
| **正常关闭** | 执行 `SHUTDOWN` 时自动触发一次 BGSAVE,保证关闭前数据落盘 | 优雅关闭 ≠ 数据丢失 |
| **主从全量同步** | 新从节点首次连接主节点时,主节点自动触发 BGSAVE 并将快照传给从节点 | 从节点需要一份完整的数据副本 |
| **AOF 重写** | 开启混合持久化后,`BGREWRITEAOF` 会先写 RDB 快照作为基线 | 后面"混合持久化"章节详解 |
> [!TIP] Redis 如何避免"撞车"?
> Redis 同一时刻**只会有一个子进程在做持久化**。如果 BGSAVE 正在执行,此时再触发 SAVE/BGSAVE:
> - `BGSAVE` → 直接返回错误 `ERR Background save already in progress`
> - `SAVE` → 会等待 BGSAVE 完成后再执行
>
> 这个设计避免了多个子进程争抢 I/O 资源。
## RDB 文件结构
Redis 7.0+ 引入了新格式,更紧凑且支持增量复制:
可以把 `.rdb` 文件想象成一个**快递包裹**:有快递单号(Header)、多个包裹(DB 里的数据)、包裹拆完的标记(EOF)、以及签收校验码(Footer)。
```mermaid
flowchart TD
H1["Header"] --> H2["Magic: REDIS"]
H1 --> H3["RDB Version"]
H1 --> H4["Reserved / CRC64"]
subgraph header["1 HEADER 文件头"]
A["Magic: REDIS07 + Version"]
B["Auxiliary Fields: redis-ver, ctime, used-mem"]
C["DB Selector: FE 00(选择 DB 0)"]
A --> B --> C
end
D1["DB Number<br/>1 byte"] --> D2["Key Type<br/>1 byte"]
D2 --> D3["Encoded Key + Value"]
subgraph data["2 DATA 数据段(每个非空 DB 重复一次)"]
D["Key Type: 1 byte(String=0, List=1, Set=2 ...)"]
E["Expire Info: 可选, TTL 时间戳"]
F["Encoded Key + Value: 直接序列化底层数据结构"]
D --> E --> F
F -.-> G["... 重复 N 个 Key-Value 对 ..."]
end
E1["EOF Marker"] --> E2["CRC64 Checksum<br/>8 bytes"]
subgraph footer["3 FOOTER 文件尾"]
H["EOF Marker: 0xFF(数据结束)"]
I["CRC64 Checksum: 8 bytes(完整性校验)"]
H --> I
end
H1 & H2 & H3 & H4 ==> "Dataset Section"
D1 & D2 & D3 ==> "Dataset Section"
D3 -.-> "... N keys ..."
E1 & E2 ==> "Footer"
header --> data --> footer
```
### 各段含义
### 各段含义详解
| 段落 | 内容 | 说明 |
**1️⃣ Header(文件头)**
- **Magic 字符串**:以 `REDIS` 开头 + 4 字节版本号(如 `REDIS0009`),用于标识"这是一个 RDB 文件"
- **辅助字段(Auxiliary Fields)**:记录 Redis 版本、创建时间、内存占用等元信息——**不是数据本身,只是"快递单信息"**
- **DB Selector**:`FE` + DB 编号,告诉你"接下来的数据属于哪个 DB"(默认只有 DB 0 有数据,所以通常只出现一次)
**2️⃣ Data(数据段)**
每个 key-value 对的存储格式:
| 字段 | 大小 | 说明 |
|------|------|------|
| **Header** | Magic + Version + 校验 | `REDIS` 魔数标识文件类型;版本号用于兼容性判断;CRC64 用于完整性校验 |
| **DB Selector** | 数据库编号 | 默认 16 个 DB(0-15),只序列化非空 DB |
| **Key-Value Pairs** | 类型 + 编码 + 数据 | 每个 key 携带类型标记(String/List/Hash/ZSet…),底层编码直接序列化,因此文件极紧凑 |
| **EOF** | `0xFF` 终止符 | 标识数据段结束 |
| **Footer** | CRC64 校验和 | 8 字节,启动加载时校验文件完整性 |
| **Key Type** | 1 byte | 值的类型标记:String=0, List=1, Set=2, ZSet=3, Hash=4 … |
| **Expire Time** | 0 或 8 bytes | 如果 key 设置了过期时间,会附带一个毫秒级时间戳 |
| **Key** | 变长 | key 名(字符串编码) |
| **Value** | 变长 | **直接序列化底层数据结构**(ziplist、intset、skiplist 等),这是 RDB 文件紧凑的关键 |
**3️⃣ Footer(文件尾)**
- **EOF 标记**:`0xFF`,一个字节,宣告"数据到这里结束了"
- **CRC64 校验和**:8 字节,Redis 加载文件时用它校验完整性——如果校验失败,说明文件损坏,加载直接报错
> [!QUESTION] 为什么 RDB 文件比 AOF 小很多?
> 因为 RDB 直接序列化底层数据结构(如 ziplist、intset),而非记录产生这些数据的命令字符串。一个 Hash 用 ziplist 存储可能只有几十字节,但生成它的 HSET 命令可能有数百字节。
> 核心在于存储方式不同。举个例子:
>
> 假设你用 `HSET user:1 name "Alice" age 25 city "Beijing"` 存了一个 Hash:
> - **AOF 记录的是**:这条命令字符串本身 → `*8\r\n$5\r\nHSET\r\n$6\r\nuser:1\r\n$4\r\nname\r\n...\` 约 **120+ 字节**
> - **RDB 记录的是**:Hash 类型标记 + ziplist 编码的紧凑字节流 → 约 **30-40 字节**
>
> 差距来自:RDB 不记录"怎么做的",只记录"结果是什么"——就像一张照片远比描述照片的文字更紧凑。
## 关键配置参数
```conf
# ---- RDB 核心参数 ----
dbfilename dump.rdb # 快照文件名
dir /var/lib/redis # 工作目录,RDB/AOF 文件存放位置
rdbcompression yes # 是否使用 LZF 压缩字符串(推荐开启,CPU vs 磁盘权衡)
rdbchecksum yes # 是否在文件末尾写入 CRC64 校验(推荐开启)
rdb-save-incremental-fsync yes # 写入磁盘时增量 fsync,避免一次性大量 I/O 阻塞
rdb-del-sync-files no # 无盘复制时,是否删除用于同步的临时 RDB(Redis 7+)
dbfilename dump.rdb # 快照文件名(建议保留默认)
dir /var/lib/redis # 工作目录——RDB/AOF 文件都存放在这里
rdbcompression yes # 是否使用 LZF 压缩字符串
rdbchecksum yes # 是否在文件末尾写入 CRC64 校验
rdb-save-incremental-fsync yes # 写入磁盘时是否增量 fsync
rdb-del-sync-files no # 无盘复制时,是否删除用于同步的临时 RDB(Redis 7+)
```
> [!TIP] rdbcompression 的取舍
> 开启压缩可以显著减少磁盘占用(通常压缩 40%-60%),代价是写入时多消耗一点 CPU。对于绝大多数场景,**保持 yes** 是最优解。只有在 CPU 极度敏感、磁盘充裕的特殊场景下才考虑关闭。
### 参数逐个解释
| 参数 | 推荐值 | 为什么? |
|------|--------|----------|
| `dbfilename` | `dump.rdb` | 保持默认即可。如果一台机器跑多个 Redis 实例,建议用端口号区分,如 `dump-6379.rdb` |
| `dir` | `/var/lib/redis` | 快照文件存放目录。**必须确保该目录有足够磁盘空间**,且 Redis 进程有写权限 |
| `rdbcompression` | `yes` | 开启 LZF 压缩可减少 40%-60% 的磁盘占用,代价是写入时多消耗一点 CPU。**绝大多数场景推荐开启** |
| `rdbchecksum` | `yes` | 开启后在文件末尾写入 CRC64 校验码,加载时会验证完整性。**关闭可略微提升加载速度,但不值得冒险** |
| `rdb-save-incremental-fsync` | `yes` | 开启后,子进程每写入 32MB 就执行一次 `fsync`,避免一次性刷盘造成 I/O 尖峰。对 SSD 尤其友好 |
> [!QUESTION] rdbcompression 关闭能提升多少性能?
> 压缩的 CPU 开销其实很小(LZF 是轻量压缩算法),瓶颈通常在磁盘 I/O 而非 CPU。关闭压缩后文件变大 → 写入时间更长 → 可能反而更慢。所以**除非你有明确的 CPU profiling 数据证明压缩是瓶颈,否则不要关**。
## Fork 过程详解
BGSAVE 的核心操作是 `fork()`——这是 Linux 的系统调用,能以**极低的成本**创建一份进程副本。整个流程如下:
```mermaid
sequenceDiagram
participant Server as Redis Server
participant Parent as 主进程
child ForkGroup as Forked Child
participant Child as 写时复制子进程
participant Disk as 磁盘 (.rdb)
end
participant Client as 客户端
participant Main as 主进程
participant Child as 子进程
participant Disk as 磁盘
Server->>Parent: BGSAVE 指令
Parent->>Server: OK(立即返回)
Parent->>Child: fork() 创建子进程
Note over Child: 子进程独享父进程<br/>内存副本(写时复制 COW)
Child->>Disk: 遍历所有 key → 序列化
Note over Child: 主进程仍可读写<br/>COW 机制按需拷贝脏页
Child->>Parent: 生成临时 .rdb.tmp
Parent->>Disk: RENAME 原子替换
Parent->>Server: BGSAVE 完成通知
Client->>Main: 发送 BGSAVE 命令
Main->>Main: 创建子进程(fork)
Note over Main: fork 返回 0(子进程视角)<br/>fork 返回 pid(主进程视角)
Main->>Client: 返回 OK(立即响应)
par 主进程继续服务
Main->>Main: 正常处理读写请求
and 子进程执行快照
Child->>Child: 遍历所有 key-value
Child->>Disk: 序列化写入 dump.rdb.tmp
Note over Child,Disk: 写入过程中如果主进程修改了数据<br/>内核通过 COW 机制保护子进程视图
Child->>Main: 通知:快照完成
end
Main->>Disk: rename dump.rdb.tmp -> dump.rdb(原子替换)
Main->>Main: 更新 lastsave 时间戳, 重置 dirty 计数器
```
> [!WARNING] COW 内存消耗
> fork 后主进程若持续写入大值,会导致 COW 副本膨胀。例如你有一个 1GB 的值被频繁修改,fork 后每次写都可能多拷贝一页(4KB)。高峰期建议关闭自动 RDB,仅依赖 AOF。
> [!TIP] 为什么 fork 这么快?
> `fork()` 并不会真的复制整个内存。Linux 使用**"写时复制"(Copy-On-Write, COW)**——fork 之后,父子进程**共享同一份物理内存页**,只在某一方**写入时**才真正拷贝那一页。所以 fork 一个 10GB 的 Redis 实例,实际瞬间开销只是创建页表(通常几十 MB),远小于 10GB。
### 深入理解 COW(Copy-On-Write)
fork 后父子进程共享同一份物理内存页(只读),只有当某一方**写入**时,内核才会真正复制该页——这就是"写时复制"。
> [!INFO] 图书馆类比
> 想象一个图书馆(物理内存),里面有很多书(内存页)。
> - **fork 之前**:只有主进程一个人在看书
> - **fork 之后**:子进程也进了图书馆,两人**看同一本书**,不需要额外复印——这就是"共享"
> - **主进程想在书上做笔记(写入)**:图书馆员说"不行,这本书要保护",于是**复印了一份**给主进程,子进程继续看原版——这就是"写时复制"
> - **如果主进程不写**:从头到尾只有那一本书,零额外开销
```mermaid
flowchart TD
F["Fork 完成"] --> S["父子进程共享<br/>所有物理内存页"]
S --> W{"主进程收到<br/>写请求?"}
W -->|"否"| R["子进程继续读取<br/>(零拷贝, 极快)"]
W -->|"否"| R["子进程继续读取原页<br/>(零拷贝,极快)"]
W -->|"是"| C["内核复制该内存页<br/>(Copy-On-Write)"]
C --> N["主进程写入副本<br/>子进程仍读原页"]
C --> N["主进程写入副本页<br/>子进程仍读原页"]
R & N --> D["子进程完成序列化"]
D --> DONE["RDB 文件生成"]
D --> DONE["RDB 文件生成完成"]
```
> [!QUESTION] 为什么 Redis 占用内存会翻倍?
> 这是面试高频题。答案是:**不会翻倍,但最坏情况接近翻倍。** fork 本身不复制内存,只有**主进程实际写入的页面**才会被 COW。如果 BGSAVE 期间主进程几乎不写入,额外内存开销几乎为零。但若全量写入,就会逐步逼近 2 倍。
> [!QUESTION] BGSAVE 期间 Redis 内存会翻倍吗?
> **不会翻倍,但最坏情况接近翻倍。**
>
> **运维经验**:保持系统 `vm.overcommit_memory=1`(Linux 默认),否则 fork 可能因内核认为内存不足而失败。
> fork 本身**不复制任何内存页**。只有当主进程**实际写入**某一页时,那一页才会被 COW 拷贝。两种极端情况:
>
> | 场景 | 额外内存开销 |
> |------|-------------|
> | BGSAVE 期间主进程**几乎不写入** | ≈ 0(只共享,不拷贝) |
> | BGSAVE 期间主进程**全量写入** | ≈ 接近翻倍(每一页都被拷贝) |
> | 实际生产环境 | 通常 **10%-30%**(只有被修改的 key 对应的页才会 COW) |
> [!WARNING] fork 失败的常见原因
> fork 需要内核分配页表空间,如果系统内存不足会失败。关键运维参数:
> - **`vm.overcommit_memory = 1`**:告诉内核"允许过度提交",这是 Redis 官方推荐的 Linux 设置
> - **`vm.overcommit_ratio`**:默认 50,配合 overcommit_memory=2 使用
>
> 如果你看到 `Can't save in background: fork: Cannot allocate memory`,大概率是这个参数没设置对。
### TTL / 过期键处理
> [!QUESTION] RDB 快照会保存已过期的 key 吗?
> **不会。** Redis 在 fork 子进程前会先执行一次主动过期扫描,清理掉所有已超时的 key。
> **不会。** 这是一个容易误解的点——很多人以为快照是"拍照片",会原封不动地保存内存里所有东西。但实际上,Redis 在"拍照"之前会先"打扫房间"。
具体流程:
1. `BGSAVE` 触发时,主线程先将当前所有已过期 key 从内存中删除
2. 子进程 fork 完成后遍历剩余 key → 序列化到 .rdb 文件
3. 因此:**RDB 文件中不会出现已过期键**
1. `BGSAVE` 触发时,主线程**先执行一次主动过期扫描**,把所有已超时的 key 从内存中删除
2. 删除完成后,才 fork 子进程
3. 子进程遍历剩余的 key → 序列化写入 `.rdb` 文件
4. 结论:**`.rdb` 文件中只有有效数据,不包含过期键**
```mermaid
flowchart LR
A["BGSAVE 触发"] --> B["主线程: 主动过期<br/>清理超时 Key"]
A["BGSAVE 触发"] --> B["主线程: 主动过期扫描<br/>清理所有已超时 Key"]
B --> C["Fork 子进程"]
C --> D["子进程: 遍历剩余数据<br/>序列化到 .rdb"]
D --> E[".rdb 无过期键"]
style E fill:"#c4e8b0"
C --> D["子进程: 遍历有效数据<br/>序列化写入 .rdb"]
D --> E["RDB 文件中无过期键"]
style E fill:#c4e8b0
```
> [!NOTE] 过期策略差异
> 虽然 RDB 不保存过期键,但 AOF 可能包含过期命令(如之前的 SET k v EX 100)。AOF 重写时会主动过滤过期键,恢复时也只会加载有效数据。两者优先保证启动时的数据一致性。
> [!NOTE] 一个细节:RDB 加载时也会二次检查
> 即使某个 key 在快照时还没过期,但等到恢复加载时已经过期了——Redis 在加载 RDB 文件时会**再次检查 TTL**,过期的 key 不会被加载到内存。这保证了无论什么时候恢复,数据都是"新鲜"的。
## 恢复流程
Redis 启动时会**自动检测** `dir` 目录下的 `.rdb` 文件并加载——你不需要手动执行任何"恢复命令"。
### 场景一:正常重启(最常见)
```bash
# 1. 停止 Redis
redis-cli SHUTDOWN NOSAVE
# 直接重启即可,Redis 自动加载 dump.rdb
redis-server /path/to/redis.conf
```
# 2. 把 .rdb 放到 redis_dir(由 dir 配置决定)
cp dump.rdb /var/lib/redis/
Redis 启动日志中你会看到类似:
```
* DB loaded from disk: 0.052 seconds # ← 52ms 加载完成
```
# 3. 启动 Redis
redis-server --daemonize yes
### 场景二:从备份恢复(灾难恢复)
当服务器故障需要从备份 `.rdb` 恢复时:
```bash
# 1. 停止当前 Redis(如果还在运行)
redis-cli SHUTDOWN NOSAVE # NOSAVE = 不再生成新快照,直接关闭
# 2. 把备份的 .rdb 文件放到 Redis 工作目录
cp /backup/dump.rdb /var/lib/redis/dump.rdb
# 3. 启动 Redis,它会自动加载这个 .rdb
redis-server /path/to/redis.conf
# 4. 验证数据恢复
redis-cli DBSIZE
redis-cli KEYS "*"
redis-cli DBSIZE # 检查 key 总数
redis-cli INFO keyspace # 查看各 DB 的 key 数量和过期 key 数量
redis-cli GET some_known_key # 抽样验证已知 key 是否存在
```
### 场景三:迁移数据到新实例
```bash
# 在旧实例上手动触发一次快照
redis-cli BGSAVE
# 等待完成
redis-cli LASTSAVE # 观察时间戳变化
# 复制 .rdb 到新服务器
scp /var/lib/redis/dump.rdb user@new-server:/var/lib/redis/
# 在新服务器上启动 Redis(自动加载)
ssh user@new-server "redis-server /path/to/redis.conf"
```
> [!WARNING] 恢复时的常见坑
> 1. **文件权限**:确保 Redis 进程对 `dir` 目录有**读写权限**(启动时需要读取,运行时需要写入新快照)
> 2. **磁盘空间**:`.rdb` 文件恢复时需要临时解压到内存,确保服务器**可用内存 ≥ RDB 文件大小**
> 3. **同时存在 AOF 和 RDB**:如果 `dir` 下同时有 `dump.rdb` 和 `appendonly.aof`,**Redis 优先加载 AOF**(因为 AOF 数据通常更新)。如果你确实想用 RDB 恢复,需要先移走 AOF 文件
> 4. **版本兼容**:低版本 Redis 不能加载高版本生成的 RDB 文件(向后不兼容)
## RDB vs AOF 对比
| 维度 | RDB | AOF |
|------|-----|-----|
| 数据安全性 | 两次快照间数据丢失 | 每秒/每次可配置,接近零丢失 |
| 恢复速度 | ⚡ 极快(直接加载二进制文件) | 🐢 较慢(需要回放日志) |
| 文件大小 | ✅ 小(紧凑压缩) | ❌ 大(文本命令日志) |
| CPU 开销 | 低(偶尔 fork) | 高(持续追加写入) |
| 适用场景 | 备份、灾难恢复、大数据集冷备 | 对可用性要求高的在线服务 |
| 重启耗时 | 秒级 | 取决于日志长度(可 pre-fork) |
| 维度 | RDB | AOF | 为什么? |
|------|-----|-----|----------|
| **数据安全性** | ⚠️ 可能丢失两次快照间的数据 | ✅ 最多丢 1 秒数据(everysec 策略) | RDB 是"定期拍照",AOF 是"实时写日记" |
| **恢复速度** | ⚡ 极快(直接加载二进制文件) | 🐢 较慢(逐条回放命令) | 读一本 100 页的书 vs 回放 100 万条操作日志 |
| **文件大小** | ✅ 小(压缩后的二进制) | ❌ 大(文本命令持续追加) | 照片 vs 逐帧视频——信息密度不同 |
| **CPU 开销** | 低(偶尔 fork) | 高(每条写命令都要追加) | 定期批量 vs 持续实时的天然差异 |
| **对写入性能影响** | 极小(fork 子进程异步完成) | 有影响(每次写都要 append) | BGSAVE 不阻塞主线程,AOF fsync 可能短暂阻塞 |
| **适用场景** | 备份、灾难恢复、主从同步 | 对数据安全要求高的在线服务 | 各取所长 |
## Redis 7 混合持久化
> [!QUESTION] 生产环境怎么选?
> **答案是:不要二选一,两个都开。** Redis 4.0+ 引入的混合持久化(下文详解)完美解决了这个矛盾——RDB 保证恢复速度,AOF 保证数据安全。
结合 RDB + AOF 的优点:
## Redis 混合持久化(推荐)
```conf
aof-use-rdb-preamble yes
```
### 为什么需要混合持久化?
开启后,AOF rewrite 不再只追加命令日志,而是先写入一份 RDB 快照,再追加后续命令:
回顾一下两者的痛点:
| 方案 | 优点 | 痛点 |
|------|------|------|
| RDB | 恢复快、文件小 | 两次快照间的数据可能丢失 |
| AOF | 数据几乎不丢 | 恢复慢、文件大 |
> [!QUESTION] 能不能把两者结合起来——用 RDB 加速恢复 + 用 AOF 保证数据安全?
> 可以!Redis 4.0 引入了**混合持久化(Hybrid Persistence)**,正是为了解决这个矛盾。
### 原理:AOF 文件 = RDB 快照 + 增量日志
开启混合持久化后,AOF **重写**时不再只写命令日志,而是:
```mermaid
flowchart LR
T1["传统 AOF 重建<br/>逐条命令回放"] -->|"O(N) 慢"| R1["重建耗时: 长"]
A["AOF 重写触发"] --> B["先写入一份 RDB 快照<br/>(覆盖重写时刻的全量数据)"]
B --> C["再追加重写期间的增量命令"]
C --> D["最终 AOF 文件"]
H1["RDB 二进制快照<br/>全量快速还原"] -->|"秒级加载"| R2["重建耗时: 短"]
H2["AOF 增量日志<br/>仅保留变更后操作"] -->|"精确到毫秒"| R2
style R1 fill:"#f9d0c4"
style R2 fill:"#c4e8b0"
style D fill:#c4e8b0
```
> [!TIP] 混合持久化几乎是无脑开启的配置——启动快 + 不丢数据。
也就是说,生成的 AOF 文件结构变成了:
```
┌──────────────────────────────────────┐
│ RDB 二进制快照(全量数据基线) │ ← 启动时直接加载,秒级完成
├──────────────────────────────────────┤
│ 增量 AOF 命令(快照之后的写操作) │ ← 逐条回放,保证数据不丢
└──────────────────────────────────────┘
```
> [!INFO] 用"写日记"来类比
> - **纯 RDB** = 定期拍照(恢复快,但照片之间的事不知道)
> - **纯 AOF** = 每天写日记(信息完整,但要从头读到尾才能恢复全部记忆)
> - **混合持久化** = 定期拍照 + 每天写日记。恢复时先看最近的照片(RDB 部分),再读照片之后的日记(AOF 部分)——既快又完整
### 如何开启
```conf
# redis.conf
aof-use-rdb-preamble yes # Redis 4.0+, 默认就是 yes
appendonly yes # 必须开启 AOF
```
### 恢复速度对比
```mermaid
flowchart LR
T1["传统 AOF 恢复<br/>回放数百万条命令"] -->|"O(N) 慢"| R1["耗时: 分钟级别"]
H1["RDB 快照加载<br/>秒级还原全量数据"] -->|"O(1) 快"| R2["耗时: 秒级"]
H2["回放少量增量 AOF<br/>仅重写后的命令"] -->|"极少"| R2
style R1 fill:#f9d0c4
style R2 fill:#c4e8b0
```
> [!TIP] 混合持久化几乎是"无脑开启"的配置
> - 恢复速度 ≈ RDB(因为大部分数据在 RDB 部分,AOF 增量部分很少)
> - 数据安全 ≈ AOF(增量日志保证不丢数据)
> - 开销 = AOF rewrite 的 fork 开销(和纯 AOF 一样)
>
> Redis 5.0+ 已默认开启,建议检查你的配置确认一下。
## 最佳实践
> [!SUMMARY] 核心原则:RDB 是备份手段,不是实时数据保障
> 不要把 RDB 当成"实时安全网"——它的定位是**定期快照 + 灾难恢复兜底**。实时数据安全交给 AOF。
1. **不要只依赖 RDB**:生产环境推荐 `AOF + RDB` 混合持久化,AOF 保证数据安全性,RDB 保证快速恢复
2. **合理设置触发频率**:默认三档(900s/300s/60s)适合大部分场景;写入密集型业务可适当提高频率,但注意 fork 开销
3. **监控 fork 耗时**:`INFO persistence` 中的 `rdb_last_bgsave_time_sec`,超过 1s 就需要关注
4. **备份策略**:定期将 `.rdb` 文件拷贝到异地存储(S3/OSS),建议保留多个历史版本
5. **磁盘选择**:RDB 文件写入对磁盘 I/O 有要求,建议使用 SSD 并配合 `rdb-save-incremental-fsync`
> [!WARNING] 大 Key 是 RDB 的天敌
> 一个 100MB 的 String key,序列化时会导致子进程写入耗时剧增,同时 COW 也可能产生大量页面复制。发现大 Key 后应优先拆分。
## 运维要点
1. **不要只依赖 RDB**:生产环境推荐 `AOF + 混合持久化`,AOF 保证数据安全,RDB 加速恢复
2. **合理设置 save 频率**:默认三档适合大部分场景。写入密集型可适当提高频率,但要注意:
- 频率越高 → fork 越频繁 → CPU 和内存压力越大
- 经验法则:`rdb_last_bgsave_time_sec` 超过 **1 秒**就需要关注
3. **备份策略(自动化)**:
```bash
# 监控 RDB 相关指标
#!/bin/bash
# redis-backup.sh — 每日定时备份脚本示例
REDIS_CLI="redis-cli"
BACKUP_DIR="/backup/redis"
DATE=$(date +%Y%m%d_%H%M%S)
# 1. 触发一次快照
$REDIS_CLI BGSAVE
# 2. 等待完成(轮询 lastsave)
LAST=$($REDIS_CLI LASTSAVE)
while [ "$($REDIS_CLI LASTSAVE)" == "$LAST" ]; do sleep 1; done
# 3. 拷贝并压缩
cp /var/lib/redis/dump.rdb "$BACKUP_DIR/dump-$DATE.rdb"
gzip "$BACKUP_DIR/dump-$DATE.rdb"
# 4. 保留最近 7 天的备份,删除更早的
find "$BACKUP_DIR" -name "dump-*.rdb.gz" -mtime +7 -delete
echo "Backup completed: dump-$DATE.rdb.gz"
```
4. **监控关键指标**:通过 `INFO persistence` 关注以下字段:
| 指标 | 含义 | 告警阈值建议 |
|------|------|-------------|
| `rdb_last_bgsave_status` | 上次 BGSAVE 是否成功 | ≠ `ok` 立即告警 |
| `rdb_last_bgsave_time_sec` | 上次 BGSAVE 耗时 | > 1s 关注,> 5s 告警 |
| `rdb_last_save_time` | 上次成功快照的时间戳 | 距今 > 1h 未触发则排查 |
| `rdb_changes_since_last_save` | 上次快照后的变更 key 数 | 持续增长说明 save 规则可能失效 |
5. **磁盘选择**:RDB 写入对 I/O 有要求,建议使用 SSD 并保持 `rdb-save-incremental-fsync yes`
> [!WARNING] 大 Key 是 RDB 的天敌
> 一个 100MB 的 String key,序列化时子进程写入耗时剧增(单个 key 可能就要写几秒),同时 COW 也会产生大量页面复制。
>
> **排查方法**:`redis-cli --bigkeys` 扫描大 key,发现后优先拆分。详见 [[hhs/Redis/11-运维与性能调优]]。
## 运维速查手册
```bash
# ---- 查看 RDB 状态 ----
INFO persistence
# rdb_last_bgsave_status: OK
# rdb_last_save_time: 1715000000 ← 最后成功时间戳
# 重点关注这些字段:
# rdb_last_bgsave_status: ok ← 上次是否成功
# rdb_last_bgsave_time_sec: 2 ← 上次耗时几秒
# rdb_last_save_time: 1715000000 ← 上次成功时间戳(Unix)
# rdb_changes_since_last_save: 1234 ← 自上次快照后的变更 key 数
# 检查文件大小
# ---- 查看快照文件 ----
ls -lh /var/lib/redis/dump.rdb
# -rw-rw---- 1 redis redis 1.2G May 22 03:00 dump.rdb
# 压缩备份(已内置 LZ4 压缩,通常不需要额外压缩)
tar czf redis-backup.tar.gz dump.rdb
# ---- 手动触发一次快照并等待完成 ----
redis-cli BGSAVE
# 等待:轮询 LASTSAVE 直到时间戳变化
while [ "$(redis-cli LASTSAVE)" == "$OLD" ]; do sleep 0.5; done
# ---- 检查 RDB 文件是否完整(不启动 Redis 的情况下) ----
# Redis 自带工具,用于检查 .rdb 文件的完整性
redis-check-rdb /var/lib/redis/dump.rdb
# 输出 OK 则文件完整,否则会告诉你哪里损坏了
```
## 关联笔记
+171 -78
View File
@@ -7,7 +7,12 @@ create time: 2026-05-15 18:13
## 概述
AOF(Append Only File)以**追加写日志**的方式记录每个写操作。相比 RDB 的快照式恢复,AOF 提供更高的数据安全性——你可以根据策略做到**不丢数据、丢 1 秒、或丢 N 条**。代价是文件更大、恢复更慢。
AOF(Append Only File)以**追加写日志**的方式记录每个写操作。如果你用过 MySQL 的 binlog 或 Kafka 的 commit log,这个思想是一样的:**不存最终状态,而是存每一步变化过程**。
相比 [[hhs/Redis/04-RDB持久化|RDB]] 的快照式恢复,AOF 提供更高的数据安全性——你可以根据策略做到**不丢数据、丢 1 秒、或丢 N 条**。代价是文件更大、恢复更慢。
> [!question] RDB 已经能持久化了,为什么还需要 AOF?
> RDB 是「拍照」——每隔 N 秒拍一张快照,两次拍照之间的数据变化全部丢失。AOF 是「录像」——每个写操作都被记录,最多只丢最后一次 fsync 之后的操作。打个比方:RDB 好比每小时存一次文档,AOF 好比开了自动保存。
## 核心机制
@@ -16,19 +21,32 @@ AOF(Append Only File)以**追加写日志**的方式记录每个写操作。
```mermaid
flowchart LR
Client["客户端写入"] --> Server["Redis 主线程<br/>执行命令 + 返回结果"]
Server --> Buffer["aof_buffer<br/>内核/用户态缓冲区"]
Buffer --> FSyncPolicy{"刷新策略?"}
Server --> Buffer["aof_buf<br/>用户态缓冲区"]
Buffer --> FSyncPolicy{"appendfsync 策略?"}
FSyncPolicy -->|everysec| KernelBuf["操作系统页缓存"]
KernelBuf --> Disk["磁盘 aof 文件"]
KernelBuf --> Disk["磁盘 AOF 文件"]
FSyncPolicy -->|always| Disk
FSyncPolicy -->|no| OSPage["交给 OS 自己 flush"]
FSyncPolicy -->|no| OSPage["交给操作系统自行 flush"]
style Server fill:#f9d,stroke:#96c
style Disk fill:#dfd,stroke:#6c6
```
> [!TIP] 一条命令是怎么落到磁盘的?
> 1. 客户端发送命令 → 2. Redis 在主线程执行并返回结果 → 3. 同时将命令追加到 `aof_buffer` → 4. 根据 `appendfsync` 策略最终刷入磁盘。整个过程对主线程几乎是异步的。
> [!tip] 一条命令是怎么落到磁盘的?
> 1. 客户端发送 `SET mykey hello` → 2. Redis 主线程执行命令、更新内存、返回 OK → 3. **同时**将命令以 RESP 协议格式追加到 `aof_buf`(用户态缓冲区) → 4. 根据 `appendfsync` 策略调用 `fsync()` 最终刷入磁盘
>
> 注意步骤 2 和 3 是同步的,但步骤 4 是异步的——这意味着**命令返回成功 ≠ 数据落盘**。
实际落盘的 AOF 文件长这样(RESP 协议格式):
```
*3\r\n$3\r\nSET\r\n$5\r\nmykey\r\n$5\r\nhello\r\n
*3\r\n$3\r\nSET\r\n$5\r\nmykey\r\n$5\r\nworld\r\n
*2\r\n$3\r\nDEL\r\n$5\r\nmykey\r\n
```
> [!question] 为什么 AOF 用 RESP 协议而不是人类可读的纯文本?
> RESP 是 Redis 自身的通信协议,选择它的原因很简单:**不需要额外的序列化/反序列化开销**,Redis 直接把发给客户端的响应原样写入 AOF,读取时也直接用 RESP 解析器回放。这比发明一套新格式要高效得多。
### 刷新策略配置
@@ -38,18 +56,53 @@ appendfsync everysec # 每秒 fsync <- 推荐(最佳平衡)
# appendfsync no # 完全依赖 OS -> 性能最好,风险最高
```
| 策略 | 丢失风险 | 吞吐量 (ops/s) | 适用场景 |
|------|---------|---------------|---------|
| `always` | 零丢失 | ~900 | 金融级要求(极少用) |
| `everysec` | 最多丢 1 秒 | ~74k | **生产标准配置** |
| `no` | OS 决定,可能丢大量 | ~93k | 可接受全量丢失的场景 |
在理解这三种策略之前,先搞清楚 `fsync` 到底在做什么:
> [!TIP] everysec 的性能真相
> `everysec` 不会阻塞主线程——它把 fsync 放到单独的后台线程。即使某个 fsync 花了 2s(磁盘卡顿),也只是这一秒的写入被推迟到下一周期,不会影响主线程。
> [!info] fsync 的本质:把"快递柜"里的包裹真正送到你手里
> 当 Redis 调用 `write()` 时,数据只是从用户态拷贝到了**内核页缓存**(类似放进了快递柜),操作系统会择机将其刷到物理磁盘。而 `fsync()` 是强制要求操作系统**立即**把页缓存的数据持久化到磁盘——相当于要求快递柜立即派送。
>
> 如果不调用 `fsync()`,数据在页缓存中时如果发生断电,这些数据就丢了。
理解了 fsync,三种策略的区别就一目了然:
| 策略 | fsync 时机 | 丢失风险 | 吞吐量 (ops/s) | 适用场景 |
|------|-----------|---------|---------------|---------|
| `always` | 每条写命令后 | **零丢失** | ~900 | 金融级要求(极少用) |
| `everysec` | 每秒一次(后台线程) | 最多丢 ~1 秒 | ~74k | **生产标准配置** |
| `no` | 从不主动 fsync,交给 OS | OS 决定,可能丢 30 秒+ | ~93k | 可接受数据丢失的场景 |
> [!warning] everysec "最多丢 1 秒" 是有前提条件的
> 这个说法成立的前提是 `fsync()` 能在 1 秒内完成。如果磁盘 IO 阻塞(比如同一块盘上跑着其他 IO 密集任务),一次 `fsync()` 可能卡住几秒甚至十几秒——此时未 fsync 的数据量会持续累积,丢失窗口也随之扩大。不过好在 Redis 的 `everysec` 模式下,`fsync()` 在**后台线程**执行,不会阻塞主线程处理新请求。
> [!tip] 为什么 always 这么慢?
> `always` 是在**主线程**中同步调用 `fsync()`。一次 `fsync()` 耗时约 0.2~2ms(取决于磁盘),这意味着每条写命令都要等磁盘确认,吞吐量直接从万级跌到百级——这就是"数据安全 vs 性能"的经典权衡。
## AOF 重写(Rewrite)
AOF 文件会不断增长,需要定期重写来去除无效命令(如 key 被 DEL 后又 SET)。
AOF 文件只会**追加**、永远不会自动清理。时间一长,大量"垃圾命令"会让文件体积失控:
```mermaid
flowchart LR
subgraph AOF_LOG["appendonly.aof(实际内容)"]
CMD1["SET counter 0"]
CMD2["SET counter 1"]
CMD3["SET counter 2"]
CMD4["INCR counter"]
CMD5["DEL temp_key"]
CMD6["SET temp_key abc"]
CMD7["DEL temp_key"]
end
subgraph REALITY["内存中的最终状态"]
MEM["counter = 3<br/>temp_key 不存在"]
end
AOF_LOG -.->|"7 条命令 → 真实状态只有 1 条"| REALITY
style AOF_LOG fill:#fdd,stroke:#c66
style REALITY fill:#dfd,stroke:#090
```
> [!question] 为什么不直接"删除"过期命令?
> AOF 是顺序追加的日志文件,就像流水账本——你不能从账本中间撕掉一页而不影响后面所有页码。**只能"重写":把当前内存的真实状态重新写一份新的、精简的 AOF 文件来替换旧文件。**
### 重写触发条件
@@ -71,85 +124,106 @@ auto-aof-rewrite-min-size 64mb # AOF 最小绝对大小(避免刚启动就
### 重写过程(类似 RDB fork)
重写并不是简单地"删减旧 AOF",而是**从内存中的真实数据出发,生成全新的精简 AOF 文件**。整个过程分三步:
```mermaid
sequenceDiagram
participant S as Redis Server
participant P as 主进程
child CG as Rewriter Child
participant C as 重写子进程
participant Log as appendonly.aof
participant NewLog as appendonly.aof_rewrite_tmp
end
participant P as "主进程(继续服务)"
participant C as "重写子进程"
participant Old as "appendonly.aof(旧)"
participant Tmp as "aof_rewrite_tmp(新)"
participant Buf as "aof_rewrite_buf"
S->>P: BGREWRITEAOF
P->>S: OK
P->>C: fork() 子进程
C->>NewLog: fork 时刻起遍历内存 -> 重写为精简命令
Note over P,C: Main process continues serving requests
loop 每个新写命令
P->>Log: 追加原始命令
P->>C: 同时写入 aof_rewrite_buf<br/>(重写期间的新操作)
end
C->>NewLog: 追加 aof_rewrite_buf 中的内容
C->>P: 完成
P->>Log: rename(原 -> 旧.bak, 新 -> 当前)
P->>P: "收到 BGREWRITEAOF 命令"
P->>C: "fork() 创建子进程"
Note over C: "第一步:遍历内存所有 key<br/>生成精简命令写入新文件"
C->>Tmp: "SET key1 val1<br/>SET key2 val2<br/>HSET hash f1 v1 ..."
Note over P: "主进程继续正常处理请求"
P->>Old: "新命令照常追加到旧 AOF"
P->>Buf: "同时复制一份到重写缓冲区"
Note over C: "第二步:内存遍历完成"
C->>Buf: "请求主进程发送缓冲区内容"
Note over C: "第三步:把重写期间的新命令追加到新文件末尾"
Buf->>Tmp: "追加增量命令"
C->>P: "重写完成通知主进程"
P->>P: "原子 rename:旧文件 → .bak,新文件 → appendonly.aof"
```
> [!WARNING] 重写的内存开销
> fork 后重写子进程也使用 COW 机制。如果你的 AOF 正在持续增长且写入量大,可能需要评估可用内存是否足够支撑 fork 的 COW 副本。在 100GB+ 的 Redis 实例上,一次 fork 可能导致额外的几 GB 内存占用。
> [!tip] 关键理解:为什么需要"重写缓冲区"?
> 子进程在遍历内存生成新 AOF 的过程中(可能持续几十秒甚至几分钟),主进程还在不断接收新写命令。这些"重写期间的增量命令"被暂存在 `aof_rewrite_buf` 中,等子进程遍历完内存后,再将这些增量追加到新文件末尾——这样才能保证新 AOF 不丢任何数据。
> [!warning] 重写的内存开销
> fork 后重写子进程使用 COW(Copy-On-Write)机制,和 RDB 的 BGSAVE 一样。如果你的实例内存很大(100GB+)且写入频繁,fork 可能导致额外几 GB 的内存占用。在内存紧张的环境中,建议控制 AOF 文件大小、及时触发重写,避免单次重写数据量过大。
## AOF 开启方式
```conf
appendonly yes # 开启 AOF
appendfilename "appendonly.aof" # AOF 文件名
appendonly yes # 开启 AOF(默认 no)
appendfilename "appendonly.aof" # AOF 文件名(Redis 7 会在此基础上创建子目录)
dir /var/lib/redis # AOF/RDB 文件存放目录
```
> [!tip] 在线开启/关闭 AOF
> 可以通过 `CONFIG SET appendonly yes` 在运行时开启 AOF,但需要注意:这会触发一次阻塞式的 AOF 初始化(类似 BGSAVE),**生产环境建议在低峰期操作**。关闭 AOF 同理:`CONFIG SET appendonly no`。
Redis 7.x 的多文件目录结构:
```bash
# Redis 7.x 目录结构(新版本使用多文件目录而非单文件)
ls /var/lib/redis/appendonlydir/
total 256M
drwxr-x--- 2 redis redis 4.0K May 15 18:00 .
drwxr-x--- 2 redis redis 4.0K May 15 18:00 ..
-rw-r----- 1 redis redis 16K May 15 18:00 manifest.aof
-rw-r----- 1 redis redis 128M May 15 18:00 base.rdb
-rw-r----- 1 redis redis 128M May 15 18:01 incr-0000000000000003.aof
# total 256M
# manifest.aof <- 文件清单(记录哪些文件组成完整 AOF)
# base.rdb <- 最近一次重写生成的 RDB 全量快照
# incr-0000000000000003.aof <- 增量 AOF(记录重写之后的新写操作)
```
### 思考:为什么 AOF 会持续增长?
想象以下操作序列:`SET x 1 -> SET x 2 -> SET x 3 -> DEL x`。如果不做处理,AOF 中会记录这 4 条命令,但 key `x` 已经不存在了——这些写入了磁盘却没有任何价值。这就是为什么需要**重写**。
> [!question] 为什么 Redis 7 要把单文件拆成目录结构?
> 旧版单个 AOF 文件有一个痛点:重写时需要先生成临时文件,再 `rename` 替换。如果 AOF 有 50GB,rename 操作虽然原子但磁盘 IO 开销巨大。拆成多文件后,重写只需替换 `base.rdb` 和新增 `incr-*.aof`,IO 开销大幅降低,也更方便做增量备份。
## 混合持久化(Hybrid Persistence)
混合持久化是 Redis 4.0 引入、Redis 7 默认开启的特性——在 AOF 重写时,先用 RDB 快照全量备份当前内存状态,再用 AOF 追加增量变化。
混合持久化是 Redis 4.0 引入、Redis 7 默认开启的特性,它解决了 AOF 重写的一个尴尬问题:**重写时到底该生成 RDB 还是 AOF?**
> [!question] 纯 AOF 重写的痛点
> 纯 AOF 重写需要把每个 key 的当前状态都转成 SET/HSET 等命令写入文件。当实例有几十万个 key 时,这些命令序列化+解析的过程会显著拖慢恢复速度。而 RDB 是二进制格式,加载速度比逐条执行 AOF 命令快 10~100 倍。
>
> 混合持久化的答案很简单:**两种都用**。重写时先写 RDB 全量快照作为"底子",再追加一小段 AOF 增量命令作为"补丁"。
```conf
# redis.conf
aof-use-rdb-preamble yes # 开启混合持久化(默认值)
```
```mermaid
flowchart LR
BG["BGREWRITEAOF"] --> Fork["fork 子进程"]
Fork --> RDBPart["RDB 全量部分<br/>二进制快照,写入极快"]
Fork --> AOFPart["AOF 增量部分<br/>仅含 fork 后的写操作"]
RDBPart --> Merge["合并为临时文件"]
AOFPart --> Merge
Merge --> Rename["原子 rename 替换"]
重写后生成的文件结构如下:
style RDBPart fill:#bbf,stroke:#66c
style AOFPart fill:#dfd,stroke:#090
```mermaid
flowchart TD
subgraph NEW_AOF["重写后的新 AOF 文件"]
direction LR
RDB["RDB 二进制块<br/>(全量数据快照)"] --> AOF["AOF 增量块<br/>(仅重写期间的新写操作)"]
end
Fork["fork 子进程"] --> RDB
Fork --> AOF
RDB --> Merge["合并写入临时文件"]
AOF --> Merge
Merge --> Rename["原子 rename 替换旧文件"]
style RDB fill:#bbf,stroke:#66c
style AOF fill:#dfd,stroke:#090
style Merge fill:#ffd,stroke:#cc0
```
**好处有两个:**
1. **恢复速度快**:启动时先加载 RDB 全量快照(二进制格式比解析 AOF 命令快 10~100 倍),再回放少量 AOF 增量命令
2. **文件体积小**:相比纯 AOF 记录每条操作指令,混合模式下文件通常缩小 70%~90%
1. **恢复速度快**:启动时先加载 RDB 二进制快照(顺序读、无需解析命令语法),再回放少量 AOF 增量命令。即使 AOF 增量段有几十 MB,相比从头解析几十 GB 的纯 AOF 命令也快得多
2. **文件体积小**:RDB 本身就是压缩的二进制格式,比等量数据的纯 AOF 文本命令小 70%~90%
> [!WARNING] 恢复流程的变化
> 混合模式下,Redis 启动时先读取 `base.rdb` 还原全部数据,再逐条执行增量 `.aof` 段中的命令。如果只保留 AOF 段而删除 `base.rdb`,恢复将退化为纯 AOF 模式,速度大幅下降。
> [!warning] Redis 7 的多文件目录结构
> Redis 7 不再使用单个 AOF 文件,而是采用多文件目录结构:
> - `base.rdb`:AOF 重写时生成的 RDB 全量快照
> - `incr-*.aof`:增量 AOF 文件(记录重写之后的新写操作)
> - `manifest.aof`:文件清单,记录哪些文件属于同一个 AOF 实例
>
> 恢复时 Redis 会按 manifest 加载:先加载 `base.rdb`,再按顺序回放所有 `incr-*.aof`。如果删除了 `base.rdb`,恢复退化为纯 AOF 模式,速度会大幅下降。
## AOF vs RDB 对比
@@ -167,29 +241,48 @@ flowchart LR
## AOF 故障恢复
断电或异常退出时,AOF 文件可能出现**尾部截断**(最后一条命令不完整)。Redis 启动时会自动检测并尝试修复。
### 常见故障场景与修复
```bash
# 如果 AOF 损坏(断电等原因导致截断)
redis-server --repair appendonly.aof
# 或 Redis 7 下修复整个目录
redis-server --fix appendonlydir/aof-manifest
# 场景一:AOF 文件末尾被截断(最常见——断电导致最后一条命令写了一半)
# Redis 启动时会自动丢弃末尾不完整的命令,无需手动干预
# 场景二:AOF 文件损坏严重(如磁盘坏块导致文件中间损坏)
# 需要手动修复:找到最后一个合法命令,截断之后的内容
redis-check-aof --fix appendonly.aof
# 场景三:Redis 7 多文件目录结构
redis-check-aof --fix appendonlydir/appendonly.aof.1.incr.aof
```
> [!tip] 实际修复流程
> 1. 先备份损坏的 AOF 文件:`cp appendonly.aof appendonly.aof.bak`
> 2. 运行 `redis-check-aof --fix`(交互式,会告诉你损坏位置并确认是否截断)
> 3. 重新启动 Redis,检查数据完整性
> 4. 如果数据缺失严重,从 RDB 冷备份恢复 + 回放最近的增量 AOF
### fsync 失败时的行为
```
fsync -> EIO(磁盘错误)
↓
ERROR "I/O error writing to APPEND ONLY FILE..."
↓
立即 SHUTDOWN NOSAVE(停止服务防止数据进一步损坏)
```mermaid
flowchart TD
FS["fsync() 调用"] --> OK{"返回成功?"}
OK -->|"是"| CONT["继续正常服务"]
OK -->|"否:EIO 磁盘错误"| LOG["记录日志<br/>I/O error writing to APPEND ONLY FILE"]
LOG --> SD["执行 SHUTDOWN NOSAVE<br/>立即停止服务"]
style SD fill:#fdd,stroke:#c00
style CONT fill:#dfd,stroke:#090
```
> [!NOTE] Redis 为什么会主动 shutdown?
> 这是一种「fail-fast」设计哲学。AOF 的核心价值就是数据安全,当底层磁盘出现问题时,继续运行只会导致更多数据静默损坏。宁可停机也不能让用户在「不确定」的数据上继续业务。
> [!note] Redis 为什么会主动 shutdown?
> 这是一种「fail-fast」设计哲学。AOF 的核心价值就是数据安全,当底层磁盘出现问题时,继续运行只会导致更多数据静默损坏。**宁可停机也不能让用户在"不确定"的数据上继续业务**——这和数据库遇到 WAL 写入失败时的处理逻辑是一样的。
> [!WARNING] AOF 不是万能的
> - AOF 修复工具只能恢复大部分,极端情况下会有部分命令丢失
> - 定期做冷备份(RDB 迁移到 OSS/S3)仍然是必须的兜底手段
> [!warning] AOF 不是万能的
> - AOF 修复工具只能处理末尾截断,中间损坏只能截断丢失后面的数据
> - 定期做冷备份(RDB 快照传到 OSS/S3)仍然是必须的兜底手段
> - 生产环境建议部署**主从架构**:即使主节点 AOF 出问题,从节点仍有完整数据
## AOF vs RDB 选型决策树
+344 -149
View File
@@ -7,13 +7,29 @@ create time: 2026-05-15 18:13
## 概述
在生产环境中,单节点 Redis 存在两大问题:**写入能力有上限**、**宕机即停服**。本节介绍两个构建基础高可用的核心机制——
### 从单机困境说起
假设你的 Redis 跑在一台服务器上,所有读写都走这一个实例。随着业务增长,两个现实问题会逐渐暴露:
1. **读性能瓶颈**:热点 key 被反复访问,单实例 QPS 顶到天花板(通常 10w 左右),请求开始排队
2. **单点故障风险**:服务器宕机 = Redis 全线不可用,数据甚至可能丢失
> [!QUESTION] 想一想:如果让你来设计解决方案,你会怎么做?
> - 瓶颈在"读"→ 能不能让**多个节点分摊读请求**?
> - 风险在"单点"→ 能不能让数据**自动备份到其他机器**?
> - 故障了靠人恢复太慢 → 能不能**自动检测并切换**?
这就是 **主从复制** 和 **Sentinel** 要解决的问题。
### 两大核心机制
| 机制 | 解决什么问题 | 一句话说明 |
|------|------------|----------|
| **主从复制(Replication)** | 数据冗余 + 读扩展 | Master 处理写操作,Replica 自动同步数据并承担读请求 |
| **Sentinel(哨兵)** | 人工恢复慢 | 自动检测 Master 故障,提升最佳 Replica 为新 Master |
通俗地说:主从复制是"抄作业"机制——Master 做完(写入),Replica 抄一份;Sentinel 是"监考老师"——发现 Master"交不了卷"(宕机),立刻指定一个 Replica"接替答题"。
> [!SUMMARY] 知识定位
> - ✅ 本方案适合 **读多写少** 的场景(8:2 ~ 9:1)
> - ⚠️ 写入仍然集中在单点,若需水平写入 → [[hhs/Redis/07-集群方案]]
@@ -40,79 +56,120 @@ flowchart TD
### 异步 vs 半同步
> [!QUESTION] 为什么 Redis 选择异步复制?
> 如果等 Replica 确认后 Master 才返回客户端,那网络延迟 = 写入延迟。对于追求低延迟的场景(微秒级),这是不可接受的。
复制的核心矛盾是:**性能** 和 **数据安全** 不可兼得。我们先看两种策略的区别:
Redis 默认**异步复制**——Master 写完即返回客户端,不等待 Replica 确认。这保证了极致低延迟,代价是极端情况下可能丢失数据:
```text
场景: 写 A=1 → Master 返回 OK → Master 宕机(还没传到 Replica)→ 重启后 A 不存在
```mermaid
flowchart LR
subgraph async["异步复制(Redis 默认)"]
A1["Master 写入"] -->|"立即返回 OK"| A2["客户端"]
A1 -.->|"后台异步发送"| A3["Replica"]
end
subgraph semisync["半同步(手动启用)"]
B1["Master 写入"] -->|"等待 Replica 确认"| B2["Replica"]
B2 -->|"确认收到"| B3["客户端收到 OK"]
end
```
若业务需要 **至少一份副本落盘** 的强一致性保证,可配合 Lua + `wait` 命令实现弱半同步:
> [!QUESTION] 为什么 Redis 默认选择异步复制?
> 想象一下:你去超市买东西,收银员每收一笔钱都要先打电话给总部确认到账了才给你小票——你愿意等吗?对追求低延迟的 Redis 来说,**等 Replica 确认 = 强制每次写入多等一次网络往返**(通常 0.1~1ms),这对微秒级响应的场景是不可接受的。
**异步复制** 的工作方式:Master 写完就返回客户端,Replica 的事"后台慢慢同步"。代价是极端情况下可能丢数据:
```text
场景: 写 A=1 → Master 返回 OK → Master 突然宕机(数据还没来得及传到 Replica)
→ Replica 上没有 A=1 → 用 Replica 接管后,这个写入就丢了
```
> [!NOTE] 这个丢数据的概率高吗?
> 正常情况下极低——因为 Master 到 Replica 的同步几乎是实时的(毫秒级),只有在 Master 写入后**立刻宕机**的极短窗口内才会丢。这就像你刚存完钱、银行还没来得及记账就停电了——概率很小,但在金融场景下不能接受。
#### 如果业务要求"至少一份副本确认"呢?
Redis 提供了 `WAIT` 命令,可以让客户端**阻塞等待**指定数量的副本确认同步完成。这不算严格的半同步(因为 Master 已经先写入并返回了),但能大幅降低丢数据的概率:
```go
// Go: 确保至少 N 个副本成功写入
// Go: 写入后,确保至少 1 个副本已经同步了这条命令
client.Set(ctx, "order:12345", orderData, 0)
numReplicas := 1 // 至少 1 个 replica 收到
timeout := 100 // 超时 100ms
result := client.Do(ctx, "WAIT", numReplicas, timeout).Int()
// result == 1 → 至少有 1 个副本已同步
timeout := 100 // 最多等 100ms,超时就放弃
replicaCount := client.Do(ctx, "WAIT", numReplicas, timeout).Int()
if replicaCount >= 1 {
// 放心:就算 Master 立刻宕机,至少有 1 个 Replica 有这份数据
} else {
// 超时了,数据可能只在 Master 上 → 业务层面考虑重试或告警
}
```
> [!TIP] WAIT 的代价
> 阻塞当前线程直到满足条件或超时。高频写场景慎用,建议仅在关键事务中使用。
> `WAIT` 会阻塞当前连接直到满足条件或超时。高频写场景慎用,建议仅在关键业务路径上使用(如订单创建、扣款等),普通缓存写入不需要。
### 全量同步 vs 增量同步
主从同步分为两种模式,理解它们的前提是知道一个关键概念:**Replication Offset(复制偏移量)**——你可以把它想象成一条数据"流水线"上的**刻度尺**。Master 每写入一条命令,刻度就往后移一点;Replica 同步到哪里了,也有自己的刻度。只要两个刻度对得上,就能"增量同步"。
> [!NOTE] PSync:一次连接,多次复用
> Redis 2.8+ 引入 PSYNC 协议替代旧版 SYNC,核心优势:**断线重连后可增量同步**。
> Redis 2.8+ 引入 PSYNC 协议替代旧版 SYNC,核心优势:**断线重连后可增量同步**。旧版 SYNC 每次断线都得全量传输(相当于每次请假都得抄一整本笔记),PSYNC 改为"只抄你缺的那几页"。
```mermaid
sequenceDiagram
participant R as Replica
participant M as Master
Note over R,M: ── 阶段 1: 全量同步(首次或断线过长)──
R->>M: PSYNC ? -1 (首次连接)
M->>M: BGSAVE → dump.rdb.tmp
M-->>R: +CONTENTS dump.rdb
R->>R: 重写本地数据集
Note over R,M: "阶段 1: 全量同步(首次连接或断线过久)"
R->>M: PSYNC ? -1 (首次连接, 没有 offset 记录)
M->>M: 触发 BGSAVE, 生成 RDB 快照文件
M-->>R: +FULLRESYNC <runId> <offset>, 然后发送 RDB 文件
R->>R: 清空旧数据, 用 RDB 全量覆盖
loop 同步期间的新命令
Note over M: "RDB 生成期间, 新的写入命令不能丢"
loop "RDB 传输期间的新写入"
M->>M: 追加到 repl-backlog-buffer
end
M-->>R: +CONTENTS buf (缓冲命令流)
R->>R: 重放缓冲命令
M-->>R: 发送 backlog 中缓存的命令流
R->>R: 重放缓冲命令, 追上 Master 的进度
Note over R,M: ── 阶段 2: 增量同步(后续心跳)──
loop 通常 1 秒一次
R->>M: PSync <masterRunId> <offset>
M->>R: 发送 offset 之后的命令
Note over R,M: "阶段 2: 增量同步(后续心跳, 每秒一次)"
loop "正常运行期间"
R->>M: PSYNC <masterRunId> <myOffset>
M->>R: 只发送 offset 之后的增量命令
end
```
> [!QUESTION] 全量同步的代价是什么?
> 全量同步需要 Master 执行 `BGSAVE`(fork 子进程生成 RDB 文件),然后通过网络传给 Replica。如果数据量大(比如 10GB),这个过程会**消耗大量 CPU、内存和网络带宽**。所以我们的目标是:**尽量避免全量同步,让增量同步成为常态**。
#### 什么情况下触发全量同步?
| 触发条件 | 说明 |
|---------|------|
| 首次连接 | Replica 为空,必须下载完整 RDB |
| Master 重启 | Run ID 改变,无法增量恢复 |
| Replica 手动 SLAVEOF | 主动请求全量同步 |
| Backlog 过小 | 断线时间超出 repl-backlog-size 缓存容量 |
| config-resetstat 执行 | 元数据重置导致无法增量 |
| 触发条件 | 为什么? | 能避免吗? |
|---------|------|------|
| **首次连接** | Replica 是空的,没有数据也没有 offset,只能全量下载 | ❌ 必然发生 |
| **Master 重启** | Master 重启后 Run ID 变了,Replica 认不出"老朋友" | ⚠️ 持久化可缓解(重启后 Run ID 不变) |
| **Replica 手动执行 SLAVEOF** | 相当于主动"认新主人",必须重新来过 | ❌ 手动触发,通常可控 |
| **Backlog 溢出** | 断线太久,Master 的"缓冲笔记本"已经翻页覆盖了断线前的内容 | ✅ **增大 `repl-backlog-size`** |
| **config-resetstat 执行** | 元数据被重置,offset 信息丢失 | ⚠️ 少用此命令 |
### 复制背调(Replication Backlog)
> [!WARNING] 生产环境最常见的"不必要全量同步"
> 绝大多数情况是 **Backlog 过小**导致的。网络抖动几秒,Backlog 不够用,Replica 被迫全量同步——这在网络不稳定的环境中会反复发生,严重拖垮 Master 性能。**加大 Backlog 是性价比最高的优化手段**。
每个 Master 内部维护一个固定大小的环形缓冲区(默认 1MB):
### 复制背压(Replication Backlog)
每个 Master 内部维护一个固定大小的 **环形缓冲区**(默认仅 1MB),这是实现增量同步的关键:
```mermaid
flowchart LR
WriteHead["写入指针"] -->|"新命令流入"| Buffer["repl-backlog-buffer<br/>环形缓冲区(默认1MB)"]
ReadHead["读取指针"] -->|"命令回放给 Replica"--> Buffer
Buffer -->|"溢出"`覆盖最旧数据`"
WriteHead["写入指针(新命令)"] -->|"追加写入"| Buffer["repl-backlog-buffer<br/>环形缓冲区"]
Buffer -->|"读取并发送"| ReadHead["读取指针(发给 Replica)"]
Buffer -->|"空间不足时"| Overwrite["覆盖最旧数据"]
```
> [!NOTE] 什么是"环形缓冲区"?
> 想象一个**圆形的笔记本**,只有固定的 100 页。你从第 1 页开始记笔记,记满了就回到第 1 页覆盖重写。Replica 落后得越多,它需要"回看"的内容就越远。如果它的位置已经被覆盖了,就只能全量重新抄一本——这就是全量同步。
>
> 所以 Backlog 的大小直接决定了:**Replica 最多能断线多久还能增量恢复**。
```conf
# 相关配置
repl-backlog-size 256mb # 缓冲区大小,增大允许更长断线恢复
@@ -123,68 +180,90 @@ repl-backlog-ttl 3600 # 无人订阅时多久自动释放(秒)
- backlog = 1MB → 约支持 **2 秒** 断线恢复
- backlog = 256MB → 约支持 **8 分钟** 断线恢复
### 大键分裂问题(Big Key Split)
### 大 Key 问题
当某个 key 体积很大时(如几 MB 的 Hash/Set),一次 `SET` 会阻塞整个 Redis 进程数毫秒甚至更久。对 Replica 来说,这个命令在网络上传输也会成为瓶颈。
> [!QUESTION] 一个 key 有 5MB 大小,会有什么问题?
> - **写入阻塞**:Redis 是单线程的,写入一个 5MB 的 Hash/Set 会阻塞其他所有请求几毫秒甚至更久
> - **同步瓶颈**:这条命令不仅要在 Master 上执行,还要通过网络传给每个 Replica,相当于同一条 5MB 的数据在内网里传 N 次
> - **内存尖峰**:`BGSAVE` 时 fork 子进程,大 key 会导致 COW(Copy-On-Write)产生大量内存拷贝
> [!SUMMARY] 如何发现大 Key?
> ```bash
> redis-cli --bigkeys # 在线扫描(谨慎!高 QPS 环境有性能影响)
> scan 0 MATCH * COUNT 100000 # 逐页扫描,用 type/hashlist/set 分别评估
> scan 0 MATCH * COUNT 100000 # 逐页扫描,用 type/hashlist/set 分别评估
> ```
解决方案:
- 写入侧:**分拆大 key** 为多个小 key(如按用户 ID 哈希分散)
- 同步侧:增大 `repl-diskless-sync-delay` 让多个 Replica 同时接收,避免重复传输
**解决方案**:
- **写入侧**:拆分大 key 为多个小 key(例如一个存储用户全部订单的 Hash → 按月份拆分为 `user:orders:2026-05`、`user:orders:2026-06` 等多个小 Hash)
- **同步侧**:启用无盘复制(`repl-diskless-sync yes`),Master 直接通过 socket 把 RDB 发给所有 Replica,避免写磁盘再读磁盘的双重开销
## 二、级联复制(拓扑优化)
当 Replica 数量增多时,每个都连 Master 会造成带宽压力:
### 为什么要级联?
假设 Master 需要服务 10 个 Replica,每个 Replica 每秒同步 ~500KB 的命令流,那 Master 每秒要输出 **5MB 的复制流量**。而且每个 Replica 断线重连都可能触发全量同步——Master 的网络带宽和 CPU(BGSAVE 的 fork)会成为瓶颈。
> [!QUESTION] 怎么降低 Master 的压力?
> 答案:不让所有 Replica 都直接连 Master,而是让部分 Replica "转发"数据给其他 Replica,形成**树形拓扑**。
```mermaid
flowchart LR
Master["Master"]
R1["Replica-1<br/>(也接受从连接)"]
Master["Master<br/>只服务 2 个 Replica"]
R1["Replica-1<br/>(同时作为下游的 Master)"]
R2["Replica-2"]
R3["Replica-3"]
R4["Replica-4"]
R3["Replica-3<br/>(从 R1 同步)"]
R4["Replica-4<br/>(从 R1 同步)"]
Master --> R1
Master --> R2
R1 --> R3
R1 --> R4
Master -->|"直接同步"| R1
Master -->|"直接同步"| R2
R1 -->|"级联同步"| R3
R1 -->|"级联同步"| R4
```
> [!TIP] 最佳实践
> 只有 1~2 个 Replica 直接连 Master,其余通过级联获取数据。降低 Master 的网络和 CPU 负担。
> - 只有 1~2 个 Replica 直连 Master,其余通过级联获取数据
> - 级联的 Replica 也能配置为接受下游连接(Redis 原生支持,无需额外设置)
> - **缺点**:级联层级越深,末端 Replica 的数据延迟越大。通常 **两层** 是合理的上限
## 三、只读副本注意事项
```conf
replica-read-only yes # 默认值
```
Replica 默认是 **只读** 的(`replica-read-only yes`),这不是 Redis 故意限制你,而是有充分的技术原因:
> [!WARNING] 禁止在 Replica 上写操作
> 即使关闭 `read-only`,Replica 上的写入在主从重新同步时会被清掉。而且 `DEL` 一个不存在的 key 也会触发额外命令发送给 Master。
> [!WARNING] 为什么不应该在 Replica 上写数据?
> 想象一下:你在 Replica 上写入了 `SET foo bar`,但 Master 上没有这个写入。下一次同步发生时,Master 会把自己的数据集"覆盖"到 Replica 上——你写的数据**悄无声息地消失了**。更糟糕的是,如果你在 Replica 上 `DEL` 一个不存在的 key,这个命令可能会被传播到 Master,导致 Master 上原本存在的数据被删掉。
>
> **结论**:在 Replica 上写数据,本质上是在制造**数据不一致的定时炸弹**。
### Replica 的其他关键配置
### Replica 的关键配置详解
```conf
# --- 副本如何发现 Master ---
replica-announce-ip 192.168.1.20 # 对外宣告的 IP(Docker/NAT 环境下必设)
# 在 Docker/NAT 环境下,容器内部 IP 和宿主机 IP 不同。
# 如果不设置 announce-ip,Sentinel 和其他节点可能会记录容器内部 IP,
# 导致跨网络访问失败。
replica-announce-ip 192.168.1.20 # 对外宣告的 IP
replica-announce-port 6379 # 对外宣告的端口
# --- Replica 是否可被查询 ---
replica-lazy-flush no # FULLRESYNC 后清空旧数据策略:no=立即 flush
replica-serve-stale-data yes # Master 不可达时是否继续服务请求(默认是)
replica-read-only yes # 只读保护
# 这组配置决定:当 Replica 和 Master 断开连接时,还能不能对外服务?
replica-serve-stale-data yes # yes=断线时仍返回旧数据(可用性优先)
# no=断线时直接报错(一致性优先)
replica-read-only yes # 只读保护,防止误写入导致数据不一致
replica-lazy-flush no # FULLRESYNC 时清空旧数据的策略:
# no=立即 flush(默认),yes=惰性清理
# --- 副本同步相关 ---
repl-diskless-sync no # 无盘复制开关
repl-diskless-sync-delay 5 # 等待其他副本同时连接的延迟(秒)
repl-timeout 60 # 同步超时阈值
repl-diskless-sync no # 无盘复制:Master 不写 RDB 文件,
# 直接通过 socket 发给 Replica(适合 SSD 环境)
repl-diskless-sync-delay 5 # 开启无盘复制后,等几秒让多个 Replica 同时接收
repl-timeout 60 # 同步超时阈值(秒),超时则断开重连
```
> [!QUESTION] `replica-serve-stale-data` 应该设为 yes 还是 no?
> 这取决于你的业务对**可用性 vs 一致性**的取舍:
> - **电商首页**、**推荐列表**等场景:设为 `yes`,返回稍微过期的数据总比报错好
> - **库存扣减**、**余额查询**等场景:设为 `no`,宁可不可用也不能返回错误数据
## 四、核心参数调优参考
> [!NOTE] 根据业务特点选择合适的参数组合
@@ -222,8 +301,23 @@ redis-cli INFO replication
## 五、Sentinel 哨兵机制
### 为什么需要 Sentinel?
上文介绍了主从复制,但有一个关键问题没解决:**Master 宕机了怎么办?**
> [!QUESTION] 没有 Sentinel 时,Master 故障的处理流程是什么?
> 1. 运维人员收到告警(可能是半夜 3 点)
> 2. 登录服务器,确认 Master 确实挂了
> 3. 手动挑一个数据最新的 Replica,执行 `SLAVEOF NO ONE` 提升为新 Master
> 4. 让其他 Replica 指向新 Master
> 5. 通知客户端切换连接地址
>
> 整个过程可能需要 **几分钟甚至更久**,而且容易出错。Sentinel 要做的就是把这整套流程**自动化**。
### 架构组成
Sentinel 本身也是一个 Redis 进程(只是不处理普通数据读写),它的工作就是**盯着 Master 和 Replica,必要时自动故障转移**。
```mermaid
flowchart TD
S1["Sentinel-1"]
@@ -233,36 +327,52 @@ flowchart TD
MasterNode["Master"]
RepNode["Replica"]
S1 <-->|心跳 PING/PONG| S2
S1 <-->|心跳 PING/PONG| S3
S2 <-->|心跳 PING/PONG| S3
S1 <-->|"互相探测心跳"| S2
S1 <-->|"互相探测心跳"| S3
S2 <-->|"互相探测心跳"| S3
S1 <--> MasterNode
S2 <--> MasterNode
S3 <--> MasterNode
S1 <-->|"监控 Master/Replica 状态"| MasterNode
S2 <-->|"监控 Master/Replica 状态"| MasterNode
S3 <-->|"监控 Master/Replica 状态"| MasterNode
MasterNode <--> RepNode
MasterNode <-->|"主从同步"| RepNode
```
> [!QUESTION] 为什么需要奇数个 Sentinel?
> Sentinel 选举采用多数派原则(Quorum)。3 个节点中 2 个达成共识即可;5 个中需要 3 个。偶数增加 1 个 Sentinel 不会带来额外投票优势,反而浪费资源。
> 故障转移需要**多数派投票**才能决策(和选举类似)。3 个 Sentinel 中有 2 个同意就能执行;但如果只有 2 个,一旦 1 个挂了,剩下的 1 个永远达不到多数,故障转移就无法执行。偶数个(比如 4 个)也浪费——4 个节点允许 1 个故障,和 3 个一样,却多消耗了一份资源。
>
> **记住这个公式:N 个 Sentinel 允许 (N-1)/2 个故障**。3 个允许 1 个故障,5 个允许 2 个故障。
### 核心功能
#### 1. 监控(Monitoring)
Sentinel 有三大核心职责:**监控**、**通知**和**故障转移**。我们逐一拆解。
```bash
# Sentinel 定期向 Master/Replica 发 PING
# Master 必须响应:PING → PONG 或 INFO
# Replica 同理
#### 1. 监控(Monitoring)——"心跳检测"
# 三种下线判断
1. SDOWN (Subjectively Down) — 某个 Sentinel 认为不可达
2. ODOWN (Objectively Down) — Quorum 数量的 Sentinel 都认为不可达
3. Failover in progress — 进入故障转移流程
每个 Sentinel 每隔 1 秒向 Master 和 Replica 发送 `PING` 命令,期待收到 `PONG` 回复。如果在 `down-after-milliseconds` 时间内没有收到回复,Sentinel 就认为这个节点"有问题了"。
但一个 Sentinel 的判断可能是误判(比如只是 Sentinel 和 Master 之间的网络抖动),所以需要多个 Sentinel 协同确认:
```mermaid
flowchart TD
A["Sentinel-1 向 Master 发 PING"] --> B{"收到 PONG?"}
B -->|"是"| C["一切正常, 继续监控"]
B -->|"否"| D["标记 Master 为 SDOWN<br/>(主观下线: 我觉得它挂了)"]
D --> E{"询问其他 Sentinel:<br/>你们觉得 Master 还活着吗?"}
E -->|"Quorum 个 Sentinel<br/>都认为挂了"| F["标记 Master 为 ODOWN<br/>(客观下线: 大家都确认它挂了)"]
E -->|"没达到 Quorum"| G["可能是网络局部问题<br/>保持 SDOWN, 继续观察"]
F --> H["进入故障转移流程"]
```
#### 2. 选主(Leader Election)
> [!NOTE] SDOWN vs ODOWN,一句话总结
> - **SDOWN**(Subjectively Down)= "我觉得它挂了"——单个 Sentinel 的主观判断
> - **ODOWN**(Objectively Down)= "大家都确认它挂了"——达到 Quorum 个 Sentinel 的客观共识
>
> 只有 ODOWN 才会触发真正的故障转移,SDOWN 只是一个"预警"状态。
#### 2. 选主(Leader Election)——"谁来操刀故障转移"
当 Master 被判定 ODOWN 后,**不能所有 Sentinel 都去执行故障转移**(否则会乱套)。需要先选举一个 **Sentinel Leader** 来统一操刀:
```mermaid
sequenceDiagram
@@ -270,98 +380,170 @@ sequenceDiagram
participant S2 as Sentinel-2
participant S3 as Sentinel-3
S1->>S2: 提议自己为 leader (SEND ME YOUR VOTE)
S2->>S1: OK (投票给 S1)
S3->>S1: OK (投票给 S1)
S1->>S2: 我当选 leader
Note over S1,S3: ⚡ 任意 Sentinel 都可以主动发起选举
Note over S1,S3: "Master 被判定 ODOWN, 需要选出一个 Leader"
S1->>S2: "我要当 Leader, 请投我一票"
S1->>S3: "我要当 Leader, 请投我一票"
S2->>S1: "同意, 投给你了"
S3->>S1: "同意, 投给你了"
Note over S1: "获得多数票, 我是 Leader 了"
S1->>S1: "开始执行故障转移..."
```
### 故障转移步骤
> [!TIP] 投票规则细节
> - **先到先得**:每个 Sentinel 在一轮投票中只能投一票,投给第一个来拉票的
> - **一轮只选一个**:如果多个 Sentinel 同时发起选举,票数不够就都不当选,等随机超时后重新发起(类似以太网的 CSMA/CD 避免冲突)
> - **任期制**:每次故障转移都有一个 `configEpoch`(类似 Raft 的 term),防止旧 Leader 误操作
当 Master 被判定 ODOWN 后,Sentinel Leader 执行以下流程:
### 故障转移步骤详解
Sentinel Leader 当选后,按以下顺序执行故障转移。整个过程通常在 **几秒到几十秒** 内完成:
```mermaid
flowchart TD
A["1. 选出最佳 Replica"] --> B["评估标准:<br/>复制偏移量 > 优先级 > RunID"]
B --> C["SLAVEOF NO ONE<br/>提升为新 Master"]
C --> D["其他 Replica → SLAVEOF new-master"]
D --> E["更新集群元数据"]
E --> F["通知客户端新地址"]
A["Step 1: 选出最佳 Replica"] --> B["Step 2: 提升为新 Master"]
B --> C["Step 3: 让其他 Replica 转向新 Master"]
C --> D["Step 4: 通知客户端"]
D --> E["Step 5: 旧 Master 回来后自动降级"]
```
**最佳 Replica 选择标准**(按优先级排序):
1. **复制偏移量最大** — 离最新数据的 Replica 优先
2. **`replica-priority` 最小** — 值越小越优先当选(0 = 不可当选)
3. **Run ID 字典序最小** — 作为最终 tie-breaker
**Step 1:选出最佳 Replica** — "谁来接班?"
从所有健康的 Replica 中选出数据最新的一个,选择标准按优先级排序:
| 优先级 | 评估维度 | 含义 | 举例 |
|--------|---------|------|------|
| 1(最高) | **复制偏移量最大** | 同步进度最接近 Master | Replica-A offset=10000, Replica-B offset=9998 → 选 A |
| 2 | **`replica-priority` 最小** | 配置中人为指定的优先级 | priority=10 优先于 priority=100;priority=0 表示"永不选我" |
| 3(最低) | **Run ID 字典序最小** | 纯粹的 tie-breaker | 两个 Replica 前两项完全相同,选 Run ID 字母序靠前的 |
**Step 2:提升为新 Master**
Sentinel 向选中的 Replica 发送 `SLAVEOF NO ONE`,使其断开与旧 Master 的复制关系,变成独立的 Master。
**Step 3:让其他 Replica 转向新 Master**
Sentinel 向其余 Replica 发送 `SLAVEOF <new-master-ip> <port>`,它们开始从新 Master 同步数据。
> [!NOTE] `parallel-syncs` 的作用
> Sentinel 配置中的 `parallel-syncs` 控制**同时转向新 Master 的 Replica 数量**。设为 1 表示一个接一个地切换,避免所有 Replica 同时中断服务去同步(同步期间可能短暂不可用)。
**Step 4:通知客户端**
Sentinel 通过 Pub/Sub 机制在 `+switch-master` 频道广播新 Master 的地址。支持 Sentinel 协议的客户端库(如 go-redis)会自动订阅并切换。
**Step 5:旧 Master 回来后怎么办?**
旧 Master 恢复上线后,Sentinel 会自动把它配置为新 Master 的 Replica——它从"领导"变成了"下属",不会再抢回 Master 身份。
### Sentinel 配置文件
```conf
# ── 核心配置:监控哪个 Master ──
sentinel monitor mymaster 192.168.1.10 6379 2
# ↑ name ↑ host port quorum(达成SDOWN共识的个数)
# ↑ Master地址 ↑ 端口 ↑ Quorum(至少几个 Sentinel 同意才判定 ODOWN)
sentinel auth-pass mymaster your-password
# ── 认证 ──
sentinel auth-pass mymaster your-password # Master 设置了密码时必填
# 多久无响应即标记 SDOWN(毫秒)
sentinel down-after-milliseconds mymaster 30000
# ── 三个最重要的时间参数 ──
# 故障转移超时(毫秒),超过则失败重试
sentinel failover-timeout mymaster 180000
# 1. 主观下线阈值:Master 多久没回 PING 就标记 SDOWN
# 不要太低!网络抖动 1~2 秒在生产环境很常见
sentinel down-after-milliseconds mymaster 30000 # 30 秒(推荐 15~30 秒)
# 最多多少 Replica 同时与新 Master 同步
# 2. 故障转移超时:一次 Failover 最多花多长时间
# 超时则认为本次转移失败,等下次重试
sentinel failover-timeout mymaster 180000 # 3 分钟
# 3. 并行同步数:同时转向新 Master 的 Replica 个数
# 设为 1 = 逐个切换,保证至少有部分 Replica 始终可用
sentinel parallel-syncs mymaster 1
```
> [!WARNING] Sentinel 配置陷阱
> - `down-after-milliseconds` 不要设太低(如 1s),网络抖动会导致误判;建议 **15~30 秒**
> - `failover-timeout` 不宜太短,防止频繁重试加重系统负载
> - `parallel-syncs` 设为 1 可避免转移期间所有 Replica 同时不可用
> - **生产环境建议手动维护 Sentinel 配置**,避免自动感知的不确定性
> - Redis 6+ 启用 ACL 后,需配置 `sentinel user/myuser password/mypass` 而非 `auth-pass`
> - **`down-after-milliseconds` 不要太低**(如 1s)——网络抖动会触发误判,导致不必要的故障转移。建议 15~30 秒
> - **`failover-timeout` 不宜太短**——如果 Master 数据量大,Replica 同步需要时间,太短会导致 Failover 反复超时失败
> - **`parallel-syncs` 设为 1**——虽然切换慢一点,但保证服务不停;设为 0 或很大则所有 Replica 同时中断服务去同步
> - **生产环境建议手动维护 Sentinel 配置文件**——虽然 Sentinel 有自动感知功能,但配置文件中的信息更可控
> - **Redis 6+ ACL 兼容**——启用 ACL 后,用 `sentinel user myuser >password` 替代 `auth-pass`
### Sentinel 下的客户端连接
Go 和 Python 生态都提供了原生支持——客户端内部自动发现 Master/Replica,故障时重新连接。
> [!QUESTION] 故障转移后,客户端怎么知道新 Master 的地址?
> 传统方式是客户端硬编码 Redis 地址——Master 换了 IP,所有客户端都得改配置重启。Sentinel 方案下,客户端**直接连接 Sentinel**(而不是 Redis),由 Sentinel 告诉客户端"谁是当前的 Master"。
>
> 主流语言的 Redis 客户端库都内置了 Sentinel 支持,原理是:
> 1. 启动时向 Sentinel 查询当前 Master 地址
> 2. 连接 Master 进行读写
> 3. 如果连接失败,重新向 Sentinel 查询新地址
**Go(go-redis)示例**——客户端自动发现和切换:
```go
// go-redis: 原生 Sentinel 模式
rdb, err := redis.NewFailoverClient(&redis.FailoverOptions{
MasterName: "mymaster",
SentinelAddrs: []string{"sento1:26379", "sento2:26379", "sento3:26379"},
MasterName: "mymaster", // Sentinel 中配置的 Master 名称
SentinelAddrs: []string{"sentinel1:26379", "sentinel2:26379", "sentinel3:26379"},
Password: "your-password",
MaxRetries: 3,
MaxRetries: 3, // 连接失败重试次数
RetryDelay: 500 * time.Millisecond,
})
// 读请求走 Replica,写请求走 Master
_ = rdb.Get(ctx, "key")
// 客户端自动感知 Master:读请求走 Replica(如果配置了读写分离),写请求走 Master
_ = rdb.Set(ctx, "order:12345", data, 0) // 写 → Master
val, _ := rdb.Get(ctx, "order:12345").Result() // 读 → 可能走 Replica
```
**Python(redis-py)示例**:
```python
# redis-py Sentinel
from redis.sentinel import Sentinel
sentinel = Sentinel([('sento1', 26379), ('sento2', 26379), ('sento3', 26379)])
master = sentinel.master_for('mymaster', password='your-password') # 写
slave = sentinel.slave_for('mymaster', password='your-password') # 读
# 连接 Sentinel 节点
sentinel = Sentinel([
('sentinel1', 26379),
('sentinel2', 26379),
('sentinel3', 26379),
])
# 获取 Master 和 Slave 连接(自动发现 + 故障切换)
master = sentinel.master_for('mymaster', password='your-password') # 用于写
slave = sentinel.slave_for('mymaster', password='your-password') # 用于读
master.set('key', 'value') # 写入 Master
value = slave.get('key') # 从 Replica 读取
```
## 五、生产实践 checklist
## 六、生产实践 checklist
### 部署前
### 部署前 Checklist
- [ ] **Sentinel 节点数 ≥ 3**,奇数个,部署在不同可用区或机器上
- [ ] **Master + Replica 数量建议 1:2 ~ 1:3**,过多会影响同步延迟
- [ ] **`replica-priority` 设值**:不可作为 Master 的设为 `0`
- [ ] **持久化策略一致**:Master 和 Replica 的 RDB/AOF 策略应相同
- [ ] **Sentinel 节点数 ≥ 3,且部署在不同机器/可用区** — 避免单机故障导致 Sentinel 丧失多数派
- [ ] **Master + Replica 数量建议 1:2 ~ 1:3** — 太多 Replica 会加重 Master 同步负担;太少则冗余不足
- [ ] **配置 `replica-priority`** — 不应被选为 Master 的 Replica 设为 `0`(如配置较低的从节点)
- [ ] **持久化策略保持一致** — Master 和 Replica 都应开启 RDB 或 AOF,否则故障转移后新 Master 无持久化可能导致数据丢失
### 监控项
日常巡检重点关注以下指标(建议每秒采样,配合 Prometheus/Grafana 看趋势):
```bash
# 关键指标(每秒采样)
INFO replication # connected_slaves, master_repl_offset
INFO memory # used_memory_human (同步期间会突增)
INFO stats # instantaneous_ops_per_sec
# 复制状态 —— 最核心,异常时第一时间查这个
redis-cli INFO replication
# 关键字段:
# connected_slaves:3 ← 在线 Replica 数量,突然减少说明有 Replica 掉线
# master_repl_offset:12345678 ← Master 当前写入偏移量,持续增长说明正常
# repl_backlog_active:1 ← Backlog 是否活跃
# slave0:offset=12345600,lag=0 ← 每个 Replica 的同步偏移量和延迟
# 内存 —— BGSAVE 期间内存会临时翻倍(fork 子进程)
redis-cli INFO memory
# 关键字段:
# used_memory_human:2.50GB ← 同步期间如果骤增,可能触发 OOM
# 每秒操作数 —— 发现流量突增或突降
redis-cli INFO stats
# 关键字段:
# instantaneous_ops_per_sec:15000
```
### 故障排查流程
@@ -379,29 +561,42 @@ flowchart TD
### 常见坑点汇总
| 坑 | 现象 | 解决 |
|---|------|------|
| Docker 环境下 IP 错乱 | Sentinel 记录了容器的内部 IP | 设置 `replica-announce-ip` |
| 脑裂(Split Brain) | 网络分区后 Master 仍处理写请求 | 用 `min-replicas-to-write 1` 保护 |
| OOM during BGSAVE | fork 子进程导致内存翻倍 | 关闭 THP,增大 swap,限制 maxmemory |
| 坑 | 现象 | 根因 | 解决方案 |
|---|------|------|------|
| **Docker 环境 IP 错乱** | Sentinel 把容器内部 IP(如 172.17.0.x)当成 Master 地址告诉客户端,客户端连不上 | Docker 网络与宿主机网络不同 | 设置 `replica-announce-ip` 为宿主机 IP |
| **脑裂(Split Brain)** | 网络分区后出现两个 Master 同时接受写入,分区恢复后一个 Master 的数据被覆盖丢失 | Master 与 Sentinel 网络断开,但与部分客户端仍连通 | 配置 `min-replicas-to-write` 保护 |
| **BGSAVE 时 OOM** | 同步期间 Redis 内存突然翻倍,被 OOM Killer 杀掉 | fork 子进程使用 COW 机制,内存紧张时几乎复制整个数据集 | 关闭 THP、增大 swap、预留 50% 内存空间 |
> [!NOTE] 防脑裂配置
> [!NOTE] 脑裂是什么?怎么防?
> **脑裂**指的是:Master 实际还活着(只是和 Sentinel 之间网络断了),但 Sentinel 以为它挂了,选出了一个新 Master。这时候**两个 Master 同时接受写入**。网络恢复后,旧 Master 变成新 Master 的 Replica,它的数据会被覆盖——写入旧 Master 的数据就丢了。
>
> **防御措施**:在 Master 端配置"至少要有 N 个 Replica 在线才接受写入":
> ```conf
> # Master 端配置:至少 N 个 Replica 在线才接受写入
> # 至少 1 个 Replica 在线且延迟不超过 10 秒,Master 才接受写入
> min-replicas-to-write 1
> # 对应的最大允许延迟(秒),超时视为掉线
> min-replicas-max-lag 10
> ```
> 这样当 Master 被隔离时(没有 Replica 在线),它会**拒绝写入**,而不是默默接受数据最终丢失的写操作。
## 六、局限性与替代方案
## 七、局限性与替代方案
| 问题 | Sentinel 无法解决 |
|------|----------------|
| 单 Master 写入瓶颈 | ❌ 只有一个 Master |
| 数据量超限单机容量 | ❌ |
| 跨机房容灾 | ❌(Sentinel 跨网段延迟太高) |
Sentinel 方案虽然解决了"高可用"问题,但它有明确的边界。理解这些边界才能选对方案:
> [!TIP] 需要水平扩展?看 Cluster 方案 → [[hhs/Redis/07-集群方案]]
| 场景 | Sentinel 能解决吗? | 为什么? | 应该用什么? |
|------|----------------|---------|-----------|
| Master 宕机自动恢复 | ✅ | 这正是 Sentinel 的核心功能 | — |
| 读请求太多,单节点扛不住 | ✅ | 增加 Replica 分摊读压力 | 主从复制 |
| **写请求太多,单 Master 瓶颈** | ❌ | 只有一个 Master 处理所有写入 | [[hhs/Redis/07-集群方案]] Cluster |
| **数据量超过单机内存** | ❌ | 每个节点都存全量数据 | Cluster(数据分片) |
| **跨机房容灾** | ❌ | 跨网段延迟太高,Sentinel 心跳会误判 | 异地多活架构 |
> [!QUESTION] 什么时候该从 Sentinel 升级到 Cluster?
> 当你遇到以下任一情况时,就该考虑 Cluster 了:
> - 单 Master 写 QPS 持续超过 5w,垂直扩展(换更强的机器)已到极限
> - 数据量超过单机内存(如 100GB+),且无法通过压缩优化
> - 需要水平扩展写入能力(多个 Master 分片处理不同 key 的写入)
>
> → 详见 [[hhs/Redis/07-集群方案]]
## 关联笔记