第 14 章

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

注意两点:

  1. EXPIRE 被转换为 PEXPIREAT:相对过期时间转为绝对毫秒时间戳,避免重放时因时间偏差导致 TTL 错误。
  2. 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 格式,可读)            │
└─────────────────────────────────────────────────────┘

重启加载流程

  1. 读取文件头,检测是 RDB 前缀(REDIS)还是 AOF(*
  2. 若是 RDB 前缀:调用 rdbLoadRio() 快速加载快照部分
  3. RDB 加载完成后,继续读取剩余的 AOF 命令并重放
  4. 恢复完成

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

优势

加载顺序: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)
本章评分
4.6  / 5  (23 评分)

💬 留言讨论