Files
cs-note/hhs/Redis/05-AOF持久化.md
T
2026-05-24 11:42:38 +08:00

312 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [Redis, 缓存, 持久化, AOF]
create time: 2026-05-15 18:13
---
# AOF 持久化
## 概述
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 好比开了自动保存。
## 核心机制
### 命令追加过程
```mermaid
flowchart LR
Client["客户端写入"] --> Server["Redis 主线程<br/>执行命令 + 返回结果"]
Server --> Buffer["aof_buf<br/>用户态缓冲区"]
Buffer --> FSyncPolicy{"appendfsync 策略?"}
FSyncPolicy -->|everysec| KernelBuf["操作系统页缓存"]
KernelBuf --> Disk["磁盘 AOF 文件"]
FSyncPolicy -->|always| Disk
FSyncPolicy -->|no| OSPage["交给操作系统自行 flush"]
style Server fill:#f9d,stroke:#96c
style Disk fill:#dfd,stroke:#6c6
```
> [!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 解析器回放。这比发明一套新格式要高效得多。
### 刷新策略配置
```conf
# appendfsync always # 每次写入都 fsync -> 最安全,性能差
appendfsync everysec # 每秒 fsync <- 推荐(最佳平衡)
# appendfsync no # 完全依赖 OS -> 性能最好,风险最高
```
在理解这三种策略之前,先搞清楚 `fsync` 到底在做什么:
> [!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 文件只会**追加**、永远不会自动清理。时间一长,大量"垃圾命令"会让文件体积失控:
```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 文件来替换旧文件。**
### 重写触发条件
```conf
auto-aof-rewrite-percentage 100 # AOF 比上次 rewrite 后增大的百分比
auto-aof-rewrite-min-size 64mb # AOF 最小绝对大小(避免刚启动就 rewrite)
```
示例:当前 AOF = 128MB,上次 rewrite 后大小 = 64MB
- 增量 = 128 - 64 = 64MB
- 增量占比 = 64 / 64 = 100% -> 触发 rewrite
- 但如果当前 AOF < 64MB -> 不触发
> [!QUESTION] 如果设置 auto-aof-rewrite-min-size 太大或太小会发生什么?
> - **太大**:AOF 膨胀了很久才触发重写,磁盘空间浪费严重
> - **太小**:频繁重写,增加不必要的 CPU 和 IO 开销
>
> 这本质上是一个「文件瘦身频率」的工程权衡题。
### 重写过程(类似 RDB fork)
重写并不是简单地"删减旧 AOF",而是**从内存中的真实数据出发,生成全新的精简 AOF 文件**。整个过程分三步:
```mermaid
sequenceDiagram
participant P as "主进程(继续服务)"
participant C as "重写子进程"
participant Old as "appendonly.aof(旧)"
participant Tmp as "aof_rewrite_tmp(新)"
participant Buf as "aof_rewrite_buf"
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"
```
> [!tip] 关键理解:为什么需要"重写缓冲区"?
> 子进程在遍历内存生成新 AOF 的过程中(可能持续几十秒甚至几分钟),主进程还在不断接收新写命令。这些"重写期间的增量命令"被暂存在 `aof_rewrite_buf` 中,等子进程遍历完内存后,再将这些增量追加到新文件末尾——这样才能保证新 AOF 不丢任何数据。
> [!warning] 重写的内存开销
> fork 后重写子进程使用 COW(Copy-On-Write)机制,和 RDB 的 BGSAVE 一样。如果你的实例内存很大(100GB+)且写入频繁,fork 可能导致额外几 GB 的内存占用。在内存紧张的环境中,建议控制 AOF 文件大小、及时触发重写,避免单次重写数据量过大。
## AOF 开启方式
```conf
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
ls /var/lib/redis/appendonlydir/
# total 256M
# manifest.aof <- 文件清单(记录哪些文件组成完整 AOF)
# base.rdb <- 最近一次重写生成的 RDB 全量快照
# incr-0000000000000003.aof <- 增量 AOF(记录重写之后的新写操作)
```
> [!question] 为什么 Redis 7 要把单文件拆成目录结构?
> 旧版单个 AOF 文件有一个痛点:重写时需要先生成临时文件,再 `rename` 替换。如果 AOF 有 50GB,rename 操作虽然原子但磁盘 IO 开销巨大。拆成多文件后,重写只需替换 `base.rdb` 和新增 `incr-*.aof`,IO 开销大幅降低,也更方便做增量备份。
## 混合持久化(Hybrid Persistence)
混合持久化是 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 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 增量命令。即使 AOF 增量段有几十 MB,相比从头解析几十 GB 的纯 AOF 命令也快得多
2. **文件体积小**:RDB 本身就是压缩的二进制格式,比等量数据的纯 AOF 文本命令小 70%~90%
> [!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 对比
| 维度 | AOF (`everysec`) | RDB(典型配置) |
|------|-----------------|----------------|
| 数据安全性 | ⭐⭐⭐ 最多丢 1s | ⭐ 可能丢数分钟 ~ 小时 |
| 恢复速度 | ⭐⭐ 较慢(需逐条 replay) | ⭐⭐⭐ 快(二进制直接加载) |
| CPU 开销 | ⭐⭐ 每秒一次 fsync | ⭐ 仅在 fork 时短暂增长 |
| 磁盘 IO | ⭐⭐ 持续写入日志 | ⭐ 仅在快照间隔触发 |
| 文件大小 | ⭐ 较大(逐条记录) | ⭐⭐⭐ 较小(二进制压缩) |
| 数据丢失窗口 | <= 1 秒 | = save 间隔时间 |
> [!QUESTION] 什么时候该用 RDB 而不是 AOF?
> 如果你的业务场景是**缓存而非数据库**——数据可以重新构建或允许短暂丢失,那 RDB 就够了。只有当数据丢失会造成长期损失(如订单、余额)时,才需要上 AOF。
## AOF 故障恢复
断电或异常退出时,AOF 文件可能出现**尾部截断**(最后一条命令不完整)。Redis 启动时会自动检测并尝试修复。
### 常见故障场景与修复
```bash
# 场景一: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 失败时的行为
```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 的核心价值就是数据安全,当底层磁盘出现问题时,继续运行只会导致更多数据静默损坏。**宁可停机也不能让用户在"不确定"的数据上继续业务**——这和数据库遇到 WAL 写入失败时的处理逻辑是一样的。
> [!warning] AOF 不是万能的
> - AOF 修复工具只能处理末尾截断,中间损坏只能截断丢失后面的数据
> - 定期做冷备份(RDB 快照传到 OSS/S3)仍然是必须的兜底手段
> - 生产环境建议部署**主从架构**:即使主节点 AOF 出问题,从节点仍有完整数据
## AOF vs RDB 选型决策树
```mermaid
flowchart TD
Start{"你的首要需求是?"} -->|"数据安全第一"| AOF["AOF everysec"]
Start -->|"恢复速度第一"| RDB["RDB 仅"]
Start -->|"两者兼顾"| HYBRID["混合持久化"]
HYBRID --> Check{"数据重要性极高?"}
Check -->|"是"| ALWAYS["AOF always<br/>+ 每天冷备"]
Check -->|"否"| EVERYSEC["AOF everysec<br/>+ 混合持久化"]
style HYBRID fill:#dfd,stroke:#090
style ALWAYS fill:#fdd,stroke:#900
style EVERYSEC fill:#dfd,stroke:#090
```
> [!TIP] 生产环境黄金组合
> `AOF everysec + 混合持久化 + 定时 rdb 冷备到对象存储`——几乎覆盖所有常见场景。
## 关联笔记
- [[hhs/Redis/04-RDB持久化]] — RDB 快照机制
- [[hhs/Redis/06-主从与哨兵]] — 哨兵监控中 AOF 状态检查
- [[hhs/Redis/09-运维调优]] — AOF 文件膨胀治理