AOF 持久化:写后日志与 rewrite 机制
第14章 AOF 持久化:写后日志与 Rewrite 机制
14.1 AOF 的设计哲学
AOF(Append-Only File)是 Redis 的命令日志持久化方式。与 WAL(Write-Ahead Logging,先写日志再执行)不同,Redis AOF 采用 写后日志(Write-After-Log):
WAL(数据库常见方式):
写日志 → 执行命令 → 响应客户端
优点:可以利用日志做崩溃恢复,命令已验证才写
缺点:命令执行前必须等日志落盘,延迟高
Redis AOF(写后日志):
执行命令 → 写日志 → 响应客户端
优点:不阻塞命令执行,不需要语法预检查(已成功执行的才记)
缺点:如果执行后、写日志前崩溃,这批命令丢失
这个设计选择使 Redis 的命令执行路径极短,但 AOF 日志不能用于事务回滚(不像 SQL 的 undo log),只能用于重放恢复。
14.2 AOF 文件格式
AOF 文件是标准的 RESP 格式,可以直接用文本编辑器打开。
# 执行以下命令:
redis-cli SET hello world
redis-cli EXPIRE hello 100
redis-cli RPUSH mylist a b c
# AOF 文件内容(近似):
*2\r\n$6\r\nSELECT\r\n$1\r\n0\r\n
*3\r\n$3\r\nSET\r\n$5\r\nhello\r\n$5\r\nworld\r\n
*3\r\n$9\r\nPEXPIREAT\r\n$5\r\nhello\r\n$13\r\n1704067300000\r\n
*5\r\n$5\r\nRPUSH\r\n$6\r\nmylist\r\n$1\r\na\r\n$1\r\nb\r\n$1\r\nc\r\n
注意两点:
- EXPIRE 被转换为 PEXPIREAT:相对过期时间转为绝对毫秒时间戳,避免重放时因时间偏差导致 TTL 错误。
- SELECT 命令:切换数据库时自动插入。
14.3 三种 fsync 策略
14.3.1 策略详解
AOF 的数据安全性由 appendfsync 配置决定:
策略1:appendfsync always
// flushAppendOnlyFile() 中:
if (server.aof_fsync == AOF_FSYNC_ALWAYS) {
// 每条命令执行后同步调用 fsync()
// 阻塞主线程直到数据写入磁盘
redis_fsync(server.aof_fd);
server.aof_last_fsync = server.unixtime;
}
特点:最安全,每条命令保证写入磁盘。代价是主线程被 fsync() 阻塞,磁盘 IOPS 成为瓶颈。
策略2:appendfsync everysec(默认)
if (server.aof_fsync == AOF_FSYNC_EVERYSEC &&
server.unixtime > server.aof_last_fsync) {
// 由后台线程(bio.c)异步执行 fsync
// 主线程只做 write(),不等待 fsync 完成
if (!sync_in_progress) {
aof_background_fsync(server.aof_fd);
}
}
特点:每秒 fsync 一次,由后台 I/O 线程(bio 线程)执行,不阻塞主线程。最多丢失1秒内的数据。
策略3:appendfsync no
// 只调用 write(),完全依赖 OS 决定何时将 page cache 刷到磁盘
// Linux 默认每 30 秒刷一次,配置 vm.dirty_expire_centisecs 可调整
特点:最快,几乎无额外延迟。但 OS 崩溃可能丢失 30 秒内的数据。
14.3.2 性能对比数据
测试环境:Redis 7.2,NVMe SSD,Intel Xeon,50个并发连接,纯写 SET 命令。
| appendfsync | TPS | p99 延迟 | 最大丢失数据 |
|---|---|---|---|
| always | ~15,000 | 2-10 ms | 0条(1条) |
| everysec | ~400,000 | 0.5-1 ms | ~1秒内数据 |
| no | ~500,000 | 0.3-0.8 ms | OS决定(通常30秒) |
always 的 TPS 之所以只有 1.5 万,是因为 NVMe SSD 的随机写 IOPS 约为 30 万,但 fsync 需要等待 write barrier,实际每次约 60-80µs。
14.3.3 no-appendfsync-on-rewrite 选项
# 默认 no(关闭该选项):AOF rewrite 期间仍然执行 fsync
# 设为 yes:AOF rewrite 子进程运行期间,主进程暂停 fsync
# 原因:子进程在大量写磁盘时,主进程的 fsync 会竞争 I/O,导致延迟尖刺
no-appendfsync-on-rewrite no
14.4 AOF 缓冲区机制
14.4.1 双缓冲区设计
AOF 写入涉及两个缓冲区:
server.aof_buf ← 主缓冲区(SDS):每次命令执行后追加
server.aof_rewrite_buf_blocks ← Rewrite 缓冲区:AOF rewrite 期间捕获新命令
// aof.c: feedAppendOnlyFile() — 命令执行后调用
void feedAppendOnlyFile(struct redisCommand *cmd, int dictid,
robj **argv, int argc) {
sds buf = catAppendOnlyGenericCommand(sdsempty(), argc, argv);
// 写入主 AOF 缓冲区
server.aof_buf = sdscatlen(server.aof_buf, buf, sdslen(buf));
// 如果正在进行 AOF rewrite,同时写入 rewrite 缓冲区
if (server.child_type == CHILD_TYPE_AOF)
aofRewriteBufferAppend((unsigned char*)buf, sdslen(buf));
sdsfree(buf);
}
14.4.2 AOF 刷盘时机
// server.c: beforeSleep() — 每次 epoll_wait 返回前调用
void beforeSleep(struct aeEventLoop *eventLoop) {
// ...
if (server.aof_state != AOF_OFF)
flushAppendOnlyFile(0); // 0 = 非强制模式
// ...
}
// server.c: serverCron() — 每100ms
void flushAppendOnlyFile(int force) {
ssize_t nwritten;
// 没有数据则跳过
if (sdslen(server.aof_buf) == 0) {
if (server.aof_fsync == AOF_FSYNC_EVERYSEC &&
server.aof_fd != -1 &&
server.unixtime > server.aof_last_fsync &&
!(sync_in_progress = aofFsyncInProgress())) {
aof_background_fsync(server.aof_fd);
}
return;
}
// 写入内核 page cache
nwritten = aofWrite(server.aof_fd,
server.aof_buf,
sdslen(server.aof_buf));
if (nwritten != (ssize_t)sdslen(server.aof_buf)) {
// 写入失败处理(ENOSPC 等)
server.aof_last_write_status = C_ERR;
}
// 清空已写入的缓冲区
sdsrange(server.aof_buf, nwritten, -1);
server.aof_current_size += nwritten;
// 执行 fsync(根据策略)
// ...
}
14.5 AOF Rewrite 机制
14.5.1 为什么需要 Rewrite
AOF 文件会无限增长。考虑以下场景:
SET counter 1
SET counter 2
SET counter 3
... (执行10000次)
SET counter 10000
AOF 中保存了 10000 条命令,但最终状态只需要 1 条 SET counter 10000。Rewrite 将冗余命令压缩为当前状态的最小表示。
14.5.2 触发条件
# 自动触发条件(同时满足):
# 1. AOF 文件大于 auto-aof-rewrite-min-size(默认 64MB)
# 2. AOF 文件增长超过上次 rewrite 后大小的 auto-aof-rewrite-percentage 倍(默认 100%,即翻倍)
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb
# 手动触发
BGREWRITEAOF
检查逻辑(serverCron()):
if (server.aof_state == AOF_ON &&
!hasActiveChildProcess() &&
server.aof_current_size > server.aof_rewrite_min_size) {
long long base = server.aof_rewrite_base_size ?: 1;
long long growth = (server.aof_current_size * 100 / base) - 100;
if (growth >= server.aof_rewrite_perc) {
rewriteAppendOnlyFileBackground();
}
}
14.5.3 Rewrite 完整流程
阶段1:主进程 fork() 子进程
├── 子进程:基于当前内存快照生成新 AOF
│ 遍历所有 DB,每个 key-value 生成最简洁的 RESP 命令
│ 写入 temp-rewriteaof-<pid>.aof 临时文件
│
└── 主进程:继续处理客户端命令
├── 写入 server.aof_buf(原 AOF 文件,保证已有数据安全)
└── 写入 server.aof_rewrite_buf_blocks(增量缓冲区)
阶段2:子进程完成写入,通知主进程
└── 主进程(rewriteAppendOnlyFileBackground 回调):
├── 将 server.aof_rewrite_buf_blocks 的内容追加到新 AOF 文件
│ (捕获了 rewrite 期间的所有新命令)
├── fsync 新 AOF 文件
└── rename(新文件, appendonly.aof) ← 原子替换
阶段3:原 AOF 文件被新文件替换
├── 主进程切换 aof_fd 到新文件
└── 关闭旧文件描述符(旧文件自动删除,引用计数归零)
14.5.4 子进程生成 AOF 的策略
// aof.c: rewriteAppendOnlyFile()
int rewriteAppendOnlyFile(char *filename) {
rio aof;
FILE *fp = fopen(tmpfile, "w");
rioInitWithFile(&aof, fp);
// 遍历所有 DB
for (j = 0; j < server.dbnum; j++) {
// 写 SELECT j
char selectcmd[] = "*2\r\n$6\r\nSELECT\r\n";
rioWrite(&aof, selectcmd, sizeof(selectcmd)-1);
// 遍历该 DB 的所有 key
dict *d = server.db[j].dict;
di = dictGetIterator(d);
while ((de = dictNext(di)) != NULL) {
robj *key = dictGetKey(de);
robj *o = dictGetVal(de);
long long expiretime = getExpire(server.db+j, key);
// 跳过已过期的 key
if (expiretime != -1 && expiretime < now) continue;
// 根据值类型生成对应命令
switch (o->type) {
case OBJ_STRING:
rewriteListObject(&aof, key, o); // 生成 SET 命令
break;
case OBJ_LIST:
rewriteListObject(&aof, key, o); // 生成 RPUSH 命令
break;
case OBJ_HASH:
rewriteHashObject(&aof, key, o); // 生成 HSET 命令
break;
case OBJ_SET:
rewriteSetObject(&aof, key, o); // 生成 SADD 命令
break;
case OBJ_ZSET:
rewriteSortedSetObject(&aof, key, o); // 生成 ZADD 命令
break;
case OBJ_STREAM:
rewriteStreamObject(&aof, key, o);
break;
}
// 写过期时间:PEXPIREAT key <timestamp_ms>
if (expiretime != -1) {
char cmd[64];
int len = snprintf(cmd, sizeof(cmd),
"*3\r\n$9\r\nPEXPIREAT\r\n");
// ...
}
}
dictReleaseIterator(di);
}
fflush(fp); fsync(fileno(fp)); fclose(fp);
rename(tmpfile, filename);
return C_OK;
}
大 List/Hash/Set/ZSet 的分批写入:
单个 key 的数据量可能很大(比如包含 100 万个成员的 Set),直接一条 SADD 命令会超过 RESP 的最大限制或导致加载时阻塞。Redis 按 AOF_REWRITE_ITEMS_PER_CMD(默认 64)分批:
// 将 Set {m1, m2, ..., m128} 拆为两条 SADD:
*66\r\n$4\r\nSADD\r\n$6\r\nmyset\r\n$2\r\nm1\r\n...$2\r\nm64\r\n
*66\r\n$4\r\nSADD\r\n$6\r\nmyset\r\n$2\r\nm65\r\n...$3\r\nm128\r\n
14.6 混合持久化(aof-use-rdb-preamble)
14.6.1 设计动机
纯 AOF rewrite 的问题:即使是 rewrite 后的 AOF,也需要逐条重放 RESP 命令来恢复数据,速度比 RDB 加载慢 5-10 倍。
混合持久化(Redis 4.0 引入)解决了这个问题:
# 启用混合持久化(推荐)
aof-use-rdb-preamble yes
14.6.2 混合 AOF 文件结构
┌─────────────────────────────────────────────────────┐
│ RDB 格式数据(rewrite 时刻的内存快照) │
│ REDIS0011...(二进制,不可读) │
├─────────────────────────────────────────────────────┤
│ 增量 AOF 命令(rewrite 完成后到现在的所有写命令) │
│ *3\r\n$3\r\nSET\r\n...(RESP 格式,可读) │
└─────────────────────────────────────────────────────┘
重启加载流程:
- 读取文件头,检测是 RDB 前缀(
REDIS)还是 AOF(*) - 若是 RDB 前缀:调用
rdbLoadRio()快速加载快照部分 - RDB 加载完成后,继续读取剩余的 AOF 命令并重放
- 恢复完成
14.6.3 Rewrite 时的生成流程
// aof.c: rewriteAppendOnlyFileRio()
int rewriteAppendOnlyFileRio(rio *aof) {
if (server.aof_use_rdb_preamble) {
// 先写 RDB 格式快照(调用 rdbSaveRio())
if (rdbSaveRio(aof, &error, RDB_SAVE_AOF_PREAMBLE, NULL) == C_ERR) {
return C_ERR;
}
// 然后正常 AOF rewrite 流程(从这里开始是 RESP 格式)
// 但此时内存快照已写入,只需写 rewrite_buf 中的增量命令
} else {
// 纯 AOF 模式:遍历所有 key 生成 RESP 命令
rewriteAppendOnlyFile(...);
}
}
14.7 Multi-Part AOF(Redis 7.0)
Redis 7.0 引入了多文件 AOF 结构,解决单文件的局限性:
<dir>/appendonlydir/
appendonly.aof.1.base.rdb ← BASE 文件:RDB 格式快照(最新 rewrite)
appendonly.aof.1.incr.aof ← INCR 文件:base 之后的增量 AOF 命令
appendonly.aof.2.incr.aof ← 可能有多个 INCR 文件(rewrite 期间)
appendonly.aof.manifest ← 清单文件:记录所有文件和顺序
清单文件格式:
file appendonly.aof.1.base.rdb seq 1 type b
file appendonly.aof.1.incr.aof seq 1 type i
file appendonly.aof.2.incr.aof seq 2 type i
优势:
- Rewrite 期间新命令写入新的 INCR 文件,无需双写
aof_rewrite_buf - 原子 rename 换 BASE 文件,无需停止写入
- 减少 Rewrite 期间的内存使用(不需要
aof_rewrite_buf缓冲区)
加载顺序:base 文件(RDB 格式)→ incr 文件(按 seq 排序,逐个重放)
14.8 AOF 文件修复
# 检查 AOF 文件
redis-check-aof appendonly.aof
# [offset 0] Checking AOF integrity
# [offset 512] This looks like an RDB file, not AOF
# 或:
# [offset 1024] Bad file format reading the append only file
# Truncated file: 1024/2048 bytes not in file
# 修复 AOF(截断不完整的最后一条命令)
redis-check-aof --fix appendonly.aof
# Successfully truncated AOF appendonly.aof to offset 1024
# 修复混合 AOF
redis-check-aof --fix appendonly.aof
# 工具会自动检测 RDB 前缀,分别处理两部分
手动修复 AOF(恢复误执行 FLUSHALL):
# 1. 停止 Redis
redis-cli SHUTDOWN NOSAVE
# 2. 在 AOF 文件中找到 FLUSHALL 命令并删除
grep -n "FLUSHALL" appendonly.aof
# 找到行号,假设在第 1000 行
# 使用文本编辑器删除该命令对应的 RESP 块(从 *N 到最后一个 \r\n)
# 3. 验证修改后的 AOF 文件
redis-check-aof appendonly.aof
# 4. 重启 Redis(从修复后的 AOF 加载)
redis-server redis.conf
# 注意:Multi-Part AOF 下,FLUSHALL 可能在 INCR 文件中
# 需要找到对应的 INCR 文件进行修复
14.9 配置参数速查
# 开启/关闭 AOF
appendonly yes
# AOF 文件名(单文件模式)
appendfilename "appendonly.aof"
# Multi-Part AOF 目录(Redis 7.0+)
appenddirname "appendonlydir"
# fsync 策略
appendfsync everysec
# Rewrite 期间是否暂停 fsync(减少延迟尖刺,可接受少量风险)
no-appendfsync-on-rewrite no
# 自动 rewrite 触发条件
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb
# 混合持久化(推荐开启)
aof-use-rdb-preamble yes
# AOF 加载时遇到尾部不完整数据的处理方式
# yes:截断不完整部分,继续加载(推荐)
# no:报错退出
aof-load-truncated yes
# 时间戳注释(Redis 7.0+,便于手动定位特定时间的命令)
aof-timestamp-enabled no
14.10 AOF 监控指标
redis-cli INFO persistence
# 关键指标解释:
aof_enabled: 1 # AOF 已开启
aof_rewrite_in_progress: 0 # 是否正在 rewrite
aof_rewrite_scheduled: 0 # 是否有计划中的 rewrite
aof_last_rewrite_time_sec: 45 # 上次 rewrite 耗时(秒)
aof_current_rewrite_time_sec: -1 # 当前 rewrite 耗时(-1 表示未在进行)
aof_last_bgrewrite_status: ok # 上次 rewrite 状态
aof_current_size: 67108864 # 当前 AOF 大小(字节)= 64MB
aof_base_size: 33554432 # 上次 rewrite 后的大小 = 32MB
# 当 current/base >= 2.0 时触发 rewrite
aof_pending_rewrite: 0
aof_buffer_length: 0 # server.aof_buf 当前大小
aof_rewrite_buffer_length: 0 # rewrite 缓冲区大小
aof_pending_bio_fsync: 0 # 等待后台 fsync 的数量
aof_delayed_fsync: 0 # 因 rewrite 暂停的 fsync 次数
aof_last_cow_size: 2097152 # 上次 rewrite 的 COW 复制量(2MB)