RDB 与 AOF 保存的是 Redis 状态的不同表示。选择之前应回答两个问题:允许丢失多少近期数据,以及可以接受多长恢复时间?单独一个持久化开关无法回答这些问题。真正需要的是可恢复的文件、明确的写入确认策略,以及验证过具体数据值的恢复流程,而不只是能够启动的进程。

快照记录一个时点,不保证固定间隔

RDB 是紧凑的时点数据集。BGSAVE 派生子进程,先写临时文件,完成后再替换旧快照;父进程继续处理请求,但派生进程、写时复制增加的内存以及磁盘工作仍有成本。SAVE 则同步保存并阻塞其他客户端,本文只把它用于很小的隔离测试数据,不应照搬到繁忙服务上。

例如 save 60 1000 同时要求经过指定时间并达到修改次数条件,不是“无论有多少修改,每六十秒都必定生成一份快照”,也不保证保存能按时成功完成。实际恢复暴露窗口取决于最近一份可用快照的状态有多旧;漏掉或失败的保存会扩大这个窗口。因此,要检查成功保存的记录与结果,不能只看配置中的时间数字。

AOF 改变恢复数据的表示

AOF 记录数据变更,并按 appendfsync 策略同步。everysec 通常暴露大约最近一秒的写入,no 把刷盘时机交给操作系统;always 在有效配置允许的情况下先同步再回复,代价是额外的同步开销。这些策略不保护整块存储设备丢失,也不能保证故障切换时被提升的副本包含每一条已经在原节点落盘的写入。

现代 AOF 是 manifest、基础文件和增量文件组成的集合,基础文件可以使用 RDB 格式。因此,“AOF 启动必定从头回放庞大的文本历史”不准确;重写还会去掉重建当前状态不再需要的历史操作。文件大小、启动耗时、重写开销和请求延迟,应结合实际数据集测量,不能仅凭 RDB 或 AOF 的名称排序。

两者同时启用,不等于自动合并

在没有额外启动覆盖的通常配置下,启用了 AOF 的 Redis 会选择 AOF 恢复,而不是先独立加载 dump.rdb,再把另一份日志叠加上去。AOF 内部的 RDB 格式基础文件属于其自身文件集合。另行保留的 RDB 仍可用于备份和明确选择的恢复操作,但不会自动补齐缺损的 AOF 文件。

已有的 RDB 部署也不能仅修改 appendonly 然后直接重启。应遵循官方的在线转换流程,等待初始 AOF 成功生成,并把配置持久化,之后再考虑重启。下面的两个测试分别从空目录开始,目的在于比较恢复语义,绝不是迁移已有数据库的操作清单,更不应替换成生产目录运行。

坏掉的样子

BGSAVE 请求成功只代表请求被接受,不代表快照已经成功完成。存储写入失败、无法创建子进程或磁盘空间不足,都可能让实际可用恢复点停留在很久之前。需要结合 INFO persistence、错误日志和资源余量判断;即使 Redis 仍能回复请求,后台持久化也可能已经失败或显著恶化尾延迟。

如果唯一文件副本与服务位于同一台机器、共享同一个故障范围,那么两种格式都还不能算独立备份。应在机器之外保留一致副本,并实际恢复验证。AOF 会保存应用误删的结果,快照也可能已包含同一个错误。历史留存和应用层数据核验,不能被“进程已经可以接受连接”代替。

快速验证

准备 POSIX 环境、C 编译器、make、curl、tar,以及 Python 3.10 以上版本。实验已在 macOS、Redis 8.10.1、redis-py 8.1.0、Python 3.14.4 上执行。以下步骤只用 libc 构建 Redis 核心,将客户端装进临时虚拟环境,不改变系统级安装。

redis_lab=$(mktemp -d)
curl --fail --location --output "$redis_lab/redis.tar.gz" https://download.redis.io/releases/redis-8.10.1.tar.gz
tar -xzf "$redis_lab/redis.tar.gz" -C "$redis_lab"
make -C "$redis_lab/redis-8.10.1" build redis -j2 MALLOC=libc
export REDIS_SERVER="$redis_lab/redis-8.10.1/src/redis-server"
python3 -m venv "$redis_lab/venv"
"$redis_lab/venv/bin/python" -m pip install redis==8.1.0

在同一个 shell 中,用 "$redis_lab/venv/bin/python" 执行 Python 代码。每组测试都有独立目录和 Unix socket,不监听 TCP,不安排自动快照。两组都主动保存一份小快照,只有第二组启用 AOF,并使用 always、保持重写期间同步。SIGKILL 只作用于代码创建的子进程;数据文件保留到重新启动并核验完成,随后清理临时目录。

from contextlib import contextmanager
import os
from pathlib import Path
import signal
import subprocess
from tempfile import TemporaryDirectory
import time

import redis
from redis.backoff import NoBackoff
from redis.retry import Retry


@contextmanager
def instance(directory, aof):
    endpoint = str(Path(directory) / "redis.sock")
    process = subprocess.Popen([
        os.environ["REDIS_SERVER"], "--port", "0",
        "--unixsocket", endpoint, "--unixsocketperm", "700",
        "--dir", directory, "--dbfilename", "dump.rdb", "--save", "",
        "--appendonly", "yes" if aof else "no", "--appendfsync", "always",
        "--appenddirname", "appendonlydir", "--appendfilename", "appendonly.aof",
        "--aof-use-rdb-preamble", "yes", "--no-appendfsync-on-rewrite", "no",
        "--auto-aof-rewrite-percentage", "0",
    ], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
    client = redis.Redis(unix_socket_path=endpoint, decode_responses=False,
                         socket_timeout=2, retry=Retry(NoBackoff(), 0))
    try:
        deadline = time.monotonic() + 5
        while True:
            assert process.poll() is None, "Redis exited during startup"
            try:
                if client.ping():
                    break
            except redis.ConnectionError:
                if time.monotonic() >= deadline:
                    raise
                time.sleep(0.02)
        yield client, process
    finally:
        client.close()
        if process.poll() is None:
            process.terminate()
        try:
            _, errors = process.communicate(timeout=5)
        except subprocess.TimeoutExpired:
            process.kill()
            process.communicate()
            raise
        assert process.returncode in (0, -signal.SIGKILL), errors.decode()


def crash(process):
    assert process.poll() is None
    process.kill()
    assert process.wait(timeout=5) == -signal.SIGKILL


for aof in (False, True):
    with TemporaryDirectory(prefix="redis-restore-", dir="/tmp") as directory:
        with instance(directory, aof) as (client, process):
            assert client.set("version", "snapshot")
            assert client.incr("counter") == 1
            assert client.save()
            snapshot = (Path(directory) / "dump.rdb").read_bytes()
            assert snapshot.startswith(b"REDIS")
            assert client.set("version", "after-snapshot")
            assert client.set("later", "present")
            assert client.incr("counter") == 2
            if aof:
                assert client.info("persistence")["aof_last_write_status"] == "ok"
            crash(process)

        assert (Path(directory) / "dump.rdb").read_bytes() == snapshot
        with instance(directory, aof) as (client, process):
            expected = ([b"after-snapshot", b"present", b"2"] if aof
                        else [b"snapshot", None, b"1"])
            assert client.mget("version", "later", "counter") == expected
            assert client.dbsize() == (3 if aof else 2)
            assert (Path(directory) / "dump.rdb").read_bytes() == snapshot
            print("RDB + AOF" if aof else "RDB only", expected)
print("an unchanged standalone RDB does not override enabled AOF recovery")

RDB-only 恢复出的 version 是 snapshot,later 键不存在,计数器是一;启用 AOF 的那组得到 after-snapshot、后来写入的 later 键,以及计数器二。两组的独立 RDB 文件字节在进程被杀和检查重启期间都保持不变,说明第二组不是因为偷偷又保存了一份新快照才恢复更新的数据。

计数器刻意没有先用 SET 初始化:快照里已有一,如果错误地把两次递增再回放到它上面,结果就会变成三,而不是正确的二。这让实验能够检查状态语义,而不只是确认进程启动或键数量相同。

SIGKILL 跳过正常关闭,但并没有让操作系统崩溃,也没有清空文件系统缓存。因此,该实验建立的是快照恢复边界和通常的 AOF 启动选择,并未验证断电、备份传输完整性、副本故障切换或生产数据量下的恢复时间。作出更强承诺之前,应为这些情形分别安排验收。

来源

  • Redis 持久化,快照、AOF 启动选择、在线转换与备份,已核验 2026-09-08。
  • Redis SAVE,同步快照行为与阻塞成本,已核验 2026-09-08。
  • Redis BGSAVE,后台保存及请求被接受的含义,已核验 2026-09-08。
  • Redis INFO,持久化进度与结果字段,已核验 2026-09-08。
  • Redis 8.10.1 配置,快照触发条件与 AOF 策略,已核验 2026-09-08。
  • Redis 8.10.1 构建说明,仅构建核心与选择分配器,已核验 2026-09-08。