Redis 持久化与恢复:确认过的写入,断电后还剩多少
「开启了 AOF」不等于「宕机不丢数据」,「生成了 RDB」也不等于「能按时恢复」。持久化配置要回答四个问题:允许丢多少、写入要多慢、重启要多久、备份能不能真的恢复出来。
先看一次演练。客户端每秒顺序写入 1000 条带序号的 key,每收到一个 OK 就记下序号;第 6 秒杀掉 Redis 进程,再重启,数一数确认过的写入还剩多少。故障分两种:
- 进程崩溃:对
redis-server发SIGKILL,操作系统还在,page cache 里的数据还在; - 断电:用 LazyFS(一个会丢弃未 fsync 数据的 FUSE 文件系统)模拟操作系统或整机掉电,还没 fsync 到磁盘的部分全部丢失。
| 配置 | 进程崩溃 | 断电 |
|---|---|---|
| 无持久化 | 5,703 条全丢 | 5,920 条全丢 |
| RDB(默认 save 规则) | 5,753 条全丢 | 5,921 条全丢 |
AOF,appendfsync no | 0 | 未实测,取决于内核回写 |
AOF,appendfsync everysec | 0 | 5 次分别丢 737、582、906、906、910 条 |
AOF,appendfsync always | 0 | 3 次都是 0 |
同样是 everysec,进程崩溃一条不丢,断电却丢了将近 1 秒的已确认写入。本文用 Redis 8.10.1 的实测说明这些数字从哪里来,以及怎样把持久化配置、重启时间和备份恢复连成一个可以演练的方案。
一、先说结论
- 客户端收到
OK时,命令已经write()进了 page cache,但不一定 fsync 到了磁盘。 进程崩溃不丢 page cache,所以三种 AOF 策略都不丢;断电会丢掉所有未 fsync 的部分,everysec实测丢 0.58—0.91 秒。 always的代价是延迟和吞吐。 实测单连接写入从 20,801 次/秒降到 3,584 次/秒,p50 从 0.047ms 升到 0.271ms;32 个连接时能合并 fsync,吞吐是everysec的 27%。- RDB 只保护到上一次快照。 默认 save 规则下,前 60 秒写入不足 1 万条就不会生成快照,这 6 秒的演练里 RDB 什么也没留下。
- AOF 忠实记录误操作,所以它不是备份。 误执行
FLUSHALL后重启,重放出来的是空库;Redis 8.10 的BACKUP命令可以在不停写的情况下封存一份独立备份,实测恢复出的正是封存那一刻的数据。 - AOF 能容忍尾部截断,但不校验内容。 截掉尾部 5 字节,默认配置丢掉半条命令后照常启动;命令头损坏则拒绝启动,
redis-check-aof --fix会把损坏点之后的一半数据一起截掉;值内部的损坏不会被发现。
二、先定义恢复目标
| 目标 | 问题 | 由什么决定 |
|---|---|---|
| RPO | 故障后最多丢多少已确认的写入 | 故障类型、appendfsync、快照间隔、复制方式 |
| RTO | 多久恢复服务 | 数据量、文件格式、重启与加载耗时、切换流程 |
| 写入延迟 | 正常情况下每次写入多付出多少 | fsync 频率、磁盘、并发度 |
| 备份独立性 | 误操作、软件缺陷之后能不能回到之前 | 备份是否与运行中的实例分离、有没有演练过 |
四项不能只看其中一项。always 让断电 RPO 接近 0,但写入延迟涨了 5 倍多;只开 RDB 重启最快,但 RPO 是一个快照间隔;AOF 和副本都保护不了误操作。
三、一条写命令经过哪些缓冲层
Redis 执行完写命令后,把它追加到内存中的 aof_buf;在本轮事件循环回复客户端之前,把 aof_buf write() 到 AOF 文件,也就是写进了操作系统的 page cache。appendfsync 决定什么时候调用 fsync 把 page cache 刷到磁盘:
always:每轮事件循环写完就 fsync,fsync 完成后才回复客户端。同一轮里的多个客户端、一个 pipeline 里的多条命令共享一次 fsync;everysec(默认):后台线程每秒 fsync 一次,回复不等 fsync;no:Redis 不主动 fsync,何时落盘由内核决定。官方文档提到 Linux 通常约 30 秒回写一次,实际取决于内核参数。
所以文档里「everysec 最多丢 1 秒」说的是断电或内核崩溃这类会丢 page cache 的故障。只是 Redis 进程挂掉时,page cache 仍在,实测三种策略都没有丢失已确认的写入。
断电实验用 LazyFS 实现:它把没有 fsync 的写入留在自己的缓存里,收到指令时整体丢弃,效果等同于掉电时 page cache 消失。everysec 的 5 次断电里,恢复出的都是从第 1 条开始、没有空洞的连续前缀,丢掉的是最后一次 fsync 之后的 582—910 条(每秒 1000 条,对应 0.58—0.91 秒)。always 的 3 次断电,确认过的写入一条不少。
always 的代价实测如下(普通磁盘,redis-benchmark 写 64 字节的值):
| 配置 | 1 个连接 | p50 / p99 | 32 个连接 | p50 / p99 |
|---|---|---|---|---|
| 无持久化 | 21,786 次/秒 | 0.047 / 0.063ms | 194,175 次/秒 | 0.087 / 0.167ms |
appendfsync no | 20,419 次/秒 | 0.047 / 0.071ms | 201,005 次/秒 | 0.087 / 0.159ms |
appendfsync everysec | 20,801 次/秒 | 0.047 / 0.071ms | 187,793 次/秒 | 0.087 / 0.175ms |
appendfsync always | 3,584 次/秒 | 0.271 / 0.455ms | 50,314 次/秒 | 0.615 / 0.911ms |
everysec 与不开持久化几乎没有差别;always 的每次写入都要等一次 fsync,单连接吞吐只剩 17%。连接多了以后,一次 fsync 能覆盖同一轮事件循环里的多个写入,吞吐恢复到 everysec 的 27%。这些数字来自容器所在的虚拟磁盘,换成云盘或本地 NVMe,fsync 延迟会差一个数量级以上,必须在目标环境重测。
四、multi-part AOF:base、incr 与 manifest
Redis 7.0 起,AOF 不再是一个文件,而是一个目录(appenddirname,默认 appendonlydir):
appendonly.aof.1.base.rdb # base:重写那一刻的快照,默认用 RDB 格式
appendonly.aof.1.incr.aof # incr:之后追加的写命令
appendonly.aof.manifest # 清单:当前有效的是哪几个文件实测写入 20 万个 key 后执行 BGREWRITEAOF,并在重写期间再写入 5 万个。重写完成后清单变为:
file appendonly.aof.2.base.rdb seq 2 type b
file appendonly.aof.2.incr.aof seq 2 type i startoffset 200000旧的 1.base.rdb 和 1.incr.aof 在后台被删除,重启后 DBSIZE 为 250,000。重写期间主进程直接追加到新的 incr 文件,不再像 7.0 之前那样把新写入缓存在内存里、最后再合并,所以重写不会额外占用一份写入缓冲,也不会在结束时集中写盘。
备份 AOF 时要注意这一点:目录里的文件会被整体替换。官方建议在复制目录前关闭自动重写(auto-aof-rewrite-percentage 0),确认 aof_rewrite_in_progress 为 0 再复制;Redis 8.10 起可以直接用下文的 BACKUP 命令。
五、RDB:快照、fork 与写时复制
RDB 是某一时刻的全量快照。BGSAVE 时主进程 fork 出子进程,子进程把内存中的数据写成文件,主进程继续服务。fork 之后父子进程共享内存页,只有主进程修改某一页时才复制一份(copy-on-write)。
实测 100 万个 key(used_memory 约 159MB):
| 场景 | fork 耗时 | 写时复制 |
|---|---|---|
空闲时 BGSAVE | 1.7ms | 1.0MB |
4 个客户端持续随机覆盖写时 BGSAVE | 2.1ms | 63.0MB |
写时复制的量取决于快照期间被修改的内存页数,而不是数据集大小。写入越分散、快照越慢,峰值内存越接近数据集的两倍。这是 maxmemory 不能设到物理内存上限的原因之一,见 Redis 内存满了会怎样。fork 本身的耗时和页表大小有关,数据集到几十 GB 时会到几十甚至几百毫秒,对延迟的影响见 Redis 为什么突然变慢。
RDB 的另一面是 RPO:默认 save 规则是 3600 1 300 100 60 10000,即 1 小时内至少 1 次修改、5 分钟内至少 100 次、1 分钟内至少 1 万次才触发快照。开篇的演练只跑了 6 秒,还没有触发任何快照,所以无论哪种故障都全部丢失。
重启时间是 RDB 的优势。同样 100 万个 key,四种文件的加载耗时:
| 文件 | 大小 | 加载耗时 |
|---|---|---|
dump.rdb | 30.8MB | 0.55s |
| AOF,base 为 RDB 格式(默认) | 30.8MB | 0.60s |
AOF,重写成纯命令格式(aof-use-rdb-preamble no) | 137.8MB | 0.76s |
| AOF,没有重写过,全部在 incr 中 | 137.8MB | 0.76s |
默认的 RDB 格式 base 让 AOF 重启几乎和 RDB 一样快;从没重写过的 AOF 要逐条重放命令,文件大 4.5 倍,加载慢约四分之一。这里的数据都是简单的 SET,复杂结构、Lua 脚本较多时,逐条重放的差距会更大。
六、怎么组合
| 数据的性质 | 建议 | 能接受的故障后果 |
|---|---|---|
| 纯缓存,可以从数据库重建 | 关闭持久化,或只开 RDB 加快预热 | 重启后回源重建,需要预热与限流,见 多级缓存、预热与热点数据 |
| 可以重建但重建代价高(排行榜、计数) | AOF everysec(默认 base 为 RDB) | 断电丢约 1 秒写入,事后按业务对账补回 |
| 关键状态,不允许丢已确认的写入 | AOF always,或者 everysec 加 WAITAOF 等待本地与副本落盘 | 写入延迟上升;同时要有副本与独立备份 |
WAITAOF numlocal numreplicas timeout(7.2 起)让客户端在单个关键写入之后等待它被本地或副本 fsync,而不必把整个实例改成 always。它和 WAIT 一样只报告「已经完成了多少」,超时后写入不会被撤销,调用方要自己决定重试还是补偿。
七、AOF 坏了怎么办
在 1 万个 key 的 AOF 上分别截断尾部、破坏一条命令的命令头、改写一个值的内部:
| 破坏方式 | 默认配置下的结果 |
|---|---|
| incr 文件尾部截掉 5 字节 | 日志提示 Truncating the AOF ... at offset,丢掉最后半条命令后启动,DBSIZE 9,999 |
同上,aof-load-truncated no | 拒绝启动,提示用 redis-check-aof --fix 或打开 aof-load-truncated |
| 第 5000 条命令的命令头被写入 7 个字节 | 拒绝启动:Bad file format reading the append only file |
对上一行执行 redis-check-aof --fix | 从损坏处截断到文件末尾,启动后只剩 5,000 个 key |
| 第 5000 个值的内部被写入 7 个字节 | 正常启动,DBSIZE 10,000,c:5000 的值变成了 GARBAGE… |
有两点值得注意:
--fix是有损操作。 它的做法是从第一个无法解析的位置截断,损坏越靠前,丢的越多。拒绝启动时的日志还会提示另一个选项aof-load-corrupt-tail-max-size,它允许在损坏部分不超过指定字节数时跳过尾部,作用同样是丢数据。执行任何修复前都要先复制原文件。- AOF 没有逐条校验和。 只要损坏后的字节仍然符合协议格式,Redis 就会把错误的值加载进来。RDB 文件带有 CRC64 校验(
rdbchecksum yes),可以用redis-check-rdb检查,这也是定期保留 RDB 格式备份的理由之一。
八、备份不是复制,AOF 也不是备份
replica 会同步所有写入,包括误删;AOF 会记录所有写入,包括误删。实测误执行 FLUSHALL 后重启,重放 AOF 得到的是 DBSIZE 0。能回到误操作之前的,只有独立保存、不会被后续写入修改的备份。
Redis 8.10 新增的 BACKUP 命令族,在 multi-part AOF 的基础上生成一份自包含的备份:
BACKUP START # 生成新的 base 快照,开始把之后的写入累积到备份用的 incr
BACKUP LIST # 列出已固定的文件,可以先开始复制 base
BACKUP SEAL # 封存:硬链接 incr、写出独立的 manifest
BACKUP CLEANUP # 复制完成后释放这些文件恢复时在新实例上用只在启动时生效的 preload-file 指向备份的 manifest:
redis-server --preload-file aof:/restore/appendonly.aof.manifest --appendonly yes实测在 BACKUP START 之前写入 a 类 key,START 与 SEAL 之间写入 b 类 key,SEAL 之后写入 c 类 key,然后误执行 FLUSHALL。用封存的备份恢复出 a 1,000 个、b 1,000 个、c 0 个:备份反映的是 SEAL 那一刻,而不只是 START 时的快照。
生产上的要点是把封存后的文件复制到实例所在机器之外,保留多个时间点,并定期在隔离环境中恢复一次。
九、恢复 Runbook
- 先止损:停止向故障实例写入;如果还有副本在运行,确认它没有被故障实例的空数据覆盖(没有持久化的 primary 重启后会以空库出现,并把空库同步给副本)。
- 保留现场:复制整个数据目录(
dump.rdb、appendonlydir/)和日志,之后所有操作都在副本上进行。 - 检查文件:
redis-check-rdb、redis-check-aof(不带--fix);看清损坏位置和影响范围,再决定是截断、修复还是用备份。 - 隔离恢复:在不接业务流量的新实例上加载文件或
preload-file,记录加载耗时。 - 校验:key 数量、关键业务 key 的抽样值、业务侧的对账数据;不要只看 Redis 能启动。
- 切流与观察:切换后观察错误率、延迟、内存与持久化状态(
aof_last_write_status、rdb_last_bgsave_status)。 - 补写:RPO 窗口内丢失的写入,从上游消息、数据库或业务日志补回。
十、常见误区
- 「开了 AOF 就不会丢数据」:
everysec在断电时实测丢了 0.58—0.91 秒;进程崩溃不丢,是因为 page cache 还在。 - 「AOF 最多丢一秒」:这是断电场景下
everysec的典型窗口,不是所有故障的上界;no取决于内核,RDB 取决于快照间隔,复制切换还有另一层丢失(见 Redis Sentinel 故障切换)。 - 「AOF 是一个文件,复制下来就是备份」:Redis 7 起是一组文件,重写时会整体替换;而且它会原样记录误操作。
- 「
redis-check-aof --fix能修好文件」:它截掉损坏点之后的所有内容,实测 1 万个 key 只剩 5,000 个。 - 「有副本就不用备份」:副本和 AOF 一样会同步误操作。
小结
持久化配置先从故障类型出发:进程崩溃靠 page cache 就能保住 AOF 已经 write() 的内容;断电要看 fsync 的节奏,everysec 约 1 秒、always 几乎为 0,但单连接吞吐只剩约六分之一。重启时间看文件格式,默认 RDB 格式的 base 让 AOF 与 RDB 差不多快。误操作只能靠独立备份,Redis 8.10 的 BACKUP 与 preload-file 让在线备份和恢复变成两条命令,但它仍然需要被复制出去、被定期演练。
主节点整机故障时,Sentinel 能把服务切到副本上,但异步复制还有一个丢失窗口,见 Redis Sentinel 故障切换。
配套实验
- codesphere-labs/cache/redis-persistence-recovery:5 种配置的进程崩溃与断电(LazyFS)、写延迟、multi-part AOF 重写、fork 与写时复制、四种文件的加载耗时、AOF 截断与损坏、
BACKUP与preload-file恢复(验证记录)
参考资料