Skip to content

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 no0未实测,取决于内核回写
AOF,appendfsync everysec05 次分别丢 737、582、906、906、910 条
AOF,appendfsync always03 次都是 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本轮事件循环page cachewrite() 之后磁盘fsync 之后回复客户端OKeverysec / no:write() 后即回复always:fsync 后才回复进程崩溃(SIGKILL)page cache 由内核保管,不丢实测:no、everysec、always 都没有丢失已确认的写入断电 / 内核崩溃page cache 中未 fsync 的部分丢失everysec:5 次分别丢 0.58—0.91 秒的已确认写入always:3 次都不丢;no:取决于内核回写(未实测)
图 1 · 回复客户端之前,命令已经 write() 进了 page cache;进程崩溃不会丢它,断电会丢掉所有还没 fsync 的部分。appendfsync 决定的是 fsync 的时机

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 / p9932 个连接p50 / p99
无持久化21,786 次/秒0.047 / 0.063ms194,175 次/秒0.087 / 0.167ms
appendfsync no20,419 次/秒0.047 / 0.071ms201,005 次/秒0.087 / 0.159ms
appendfsync everysec20,801 次/秒0.047 / 0.071ms187,793 次/秒0.087 / 0.175ms
appendfsync always3,584 次/秒0.271 / 0.455ms50,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):

text
appendonly.aof.1.base.rdb     # base:重写那一刻的快照,默认用 RDB 格式
appendonly.aof.1.incr.aof     # incr:之后追加的写命令
appendonly.aof.manifest       # 清单:当前有效的是哪几个文件
重写前1.base.rdb1.incr.aofmanifest → {1.base, 1.incr}重写中1.base + 1.incr仍被 manifest 引用2.incr.aof主进程继续追加temp-rewrite.rdb子进程写入 base重写后2.incr.aof2.base.rdbmanifest → {2.base, 2.incr}1.base、1.incr后台删除实测:20 万个 key 重写期间又写入 5 万个,DBSIZE 25 万原子替换
图 2 · 重写开始时主进程先切到新的 incr 继续追加,子进程在后台生成新的 base;两者都就绪后原子替换 manifest,再删除旧文件。任何时刻 manifest 指向的文件组都是完整的

实测写入 20 万个 key 后执行 BGREWRITEAOF,并在重写期间再写入 5 万个。重写完成后清单变为:

text
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 耗时写时复制
空闲时 BGSAVE1.7ms1.0MB
4 个客户端持续随机覆盖写时 BGSAVE2.1ms63.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.rdb30.8MB0.55s
AOF,base 为 RDB 格式(默认)30.8MB0.60s
AOF,重写成纯命令格式(aof-use-rdb-preamble no)137.8MB0.76s
AOF,没有重写过,全部在 incr 中137.8MB0.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 也不是备份 ​

写 a(1000)BACKUP START写 b(1000)BACKUP SEAL写 c(1000)FLUSHALL重启,重放 AOFDBSIZE 0:FLUSHALL 也被重放切换到 replicaFLUSHALL 已复制过去,同样是空库(原理推导,未单独实测)preload-file 恢复备份a 1000、b 1000、c 0:SEAL 那一刻的数据
图 3 · AOF 和 replica 都忠实地记录了误操作,重启或切换后得到的仍是空库;只有独立保存的备份能回到误操作之前,BACKUP SEAL 那一刻的数据被完整恢复

replica 会同步所有写入,包括误删;AOF 会记录所有写入,包括误删。实测误执行 FLUSHALL 后重启,重放 AOF 得到的是 DBSIZE 0。能回到误操作之前的,只有独立保存、不会被后续写入修改的备份。

Redis 8.10 新增的 BACKUP 命令族,在 multi-part AOF 的基础上生成一份自包含的备份:

text
BACKUP START   # 生成新的 base 快照,开始把之后的写入累积到备份用的 incr
BACKUP LIST    # 列出已固定的文件,可以先开始复制 base
BACKUP SEAL    # 封存:硬链接 incr、写出独立的 manifest
BACKUP CLEANUP # 复制完成后释放这些文件

恢复时在新实例上用只在启动时生效的 preload-file 指向备份的 manifest:

text
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 ​

  1. 先止损:停止向故障实例写入;如果还有副本在运行,确认它没有被故障实例的空数据覆盖(没有持久化的 primary 重启后会以空库出现,并把空库同步给副本)。
  2. 保留现场:复制整个数据目录(dump.rdb、appendonlydir/)和日志,之后所有操作都在副本上进行。
  3. 检查文件:redis-check-rdb、redis-check-aof(不带 --fix);看清损坏位置和影响范围,再决定是截断、修复还是用备份。
  4. 隔离恢复:在不接业务流量的新实例上加载文件或 preload-file,记录加载耗时。
  5. 校验:key 数量、关键业务 key 的抽样值、业务侧的对账数据;不要只看 Redis 能启动。
  6. 切流与观察:切换后观察错误率、延迟、内存与持久化状态(aof_last_write_status、rdb_last_bgsave_status)。
  7. 补写: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 故障切换。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权