Redis 持久化:RDB、AOF 和何时会丢失数据

1. 概述

Redis 在 RAM 中存储所有数据。如果进程崩溃,只有在 Redis 之前将数据写入磁盘时才能保留数据。本文介绍了两种持久化机制:RDB(快照)和 AOF(只追加文件),以及如何选择和配置它们。本文还列举了即使启用了持久化也可能丢失数据的场景。文章最后提供了 Spring Boot 代码来检查持久化状态,以及使用 Testcontainers 进行的测试。

2. RDB:定期快照

RDB 在某个时刻将整个数据集写入一个二进制文件(默认为 dump.rdb)。在 redis.conf 中配置:

# 如果 3600 秒内有至少 1 个更改,
# 如果 300 秒内有至少 100 个更改,或 60 秒内有至少 10000 个更改,则创建快照
save 3600 1 300 100 60 10000

dbfilename dump.rdb
dir /data

# 如果最后一次 BGSAVE 失败,则拒绝写入命令
stop-writes-on-bgsave-error yes
rdbcompression yes

当满足 save 条件时,Redis 调用 fork()。子进程将快照写入临时文件,然后将其重命名为 dump.rdb。父进程继续正常为客户端服务。由于操作系统的写时复制(copy-on-write)机制,两个进程共享内存页面。只有当父进程修改某一页时,才会复制该页面。

你也可以手动触发快照:

redis-cli BGSAVE
redis-cli LASTSAVE
Background saving started
(integer) 1760169600

BGSAVE 在后台运行。LASTSAVE 返回最后一次成功快照的 Unix 时间戳。避免在生产环境使用 SAVE:该命令会阻塞整个服务器直到写入完成。

RDB 的优点:

  • 文件紧凑,非常适合备份或复制到其他环境。
  • 重启速度快,因为 Redis 只需加载一个二进制文件。
  • 对快照之间的吞吐量影响几乎为零。

缺点:如果 Redis 崩溃,最后一次快照后的所有更改都会丢失。使用上面的配置,在最坏的情况下,你可能会丢失几分钟的数据。

注意:在大型数据集(数十 GB)上执行 fork() 可能会导致 Redis 停顿数百毫秒。如果在快照期间进行大量写入,写时复制可能导致 RAM 增加近两倍。请预留内存空间并在 Linux 上启用 vm.overcommit_memory = 1。

3. AOF:记录每条写入命令

AOF 将所有改变数据的命令(SET、INCR、HSET 等)追加到日志文件末尾。启动时,Redis 重新执行这个日志以重建数据集。

appendonly yes
appenddirname "appendonlydir"
appendfilename "appendonly.aof"

# always | everysec | no
appendfsync everysec

auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb
aof-use-rdb-preamble yes

3.1. appendfsync 策略

写入文件并不意味着数据已经在磁盘上。数据可能仍在内核的页面缓存中,直到执行 fsync() 命令才会写入磁盘。这就是决定你会丢失多少数据的地方:

值 何时 fsync 崩溃时最大丢失 性能
always 每条写入命令后 几乎不丢失 最慢
everysec 每秒,在后台线程中 约 1 秒(可能长达 2 秒) 良好,默认值
no 由 OS 决定,通常约 30 秒 数十秒 最快

使用 everysec 时,如果由于磁盘缓慢,上一次 fsync 在 2 秒后仍未完成,Redis 将阻塞写入命令以避免进一步延迟。因此,实际的数据丢失窗口可能是 2 秒,而不是 1 秒。

还需要区分两种故障类型。如果只是 Redis 进程崩溃,已 write() 的数据仍在页面缓存中,内核稍后会将其写入磁盘。但如果机器断电或内核恐慌,未 fsync 的部分将丢失。

3.2. AOF 重写和多部分 AOF

AOF 文件随时间不断增长。例如,100 万条 INCR counter 命令只需用一条 SET counter 1000000 命令替换。当文件大小是上次重写的两倍(auto-aof-rewrite-percentage 100)并且达到最少 64 MB 时,Redis 会自动重写 AOF。

从 Redis 7 开始,AOF 位于 appendonlydir 目录中并包含多个文件:

appendonlydir/
├── appendonly.aof.1.base.rdb    # 基础,由于 aof-use-rdb-preamble 使用 RDB 格式
├── appendonly.aof.1.incr.aof    # 基础后产生的写入命令
└── appendonly.aof.manifest      # 描述哪些文件属于当前 AOF

启用 aof-use-rdb-preamble yes 时,基础文件采用 RDB 格式写入,加载速度很快。增量部分仍然是命令日志,因此数据丢失较少。这是“混合”模式,自 Redis 4.0 起为默认设置。

4. 应该选择哪种模式?

需求 建议配置
纯缓存,数据可从数据库重建 全部关闭:save ""、appendonly no
接受丢失几分钟数据,需要紧凑备份 仅 RDB
会话、购物车、限流需要持久化 AOF everysec + RDB 备份
不能丢失任何命令(罕见) AOF always,但应考虑使用其他数据库

对大多数应用来说,合理的选择是启用两者:使用 AOF everysec 来减少数据丢失,使用 RDB 获得定期备份文件。当两者同时启用时,Redis 在启动时优先加载 AOF,因为 AOF 更完整。

注意:如果你只使用 Redis 作为缓存,请关闭持久化。为缓存启用 RDB 只会增加 fork()、磁盘 I/O 和 RAM 消耗,而不带来任何好处。

5. 从 Spring Boot 应用程序检查持久化

添加依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

以下类读取持久化状态、请求快照并等待副本确认:

@Component
public class PersistenceInspector {

    private final StringRedisTemplate template;

    public PersistenceInspector(StringRedisTemplate template) {
        this.template = template;
    }

    public Properties persistenceInfo() {
        return template.execute((RedisCallback<Properties>) conn ->
                conn.serverCommands().info("persistence"));
    }

    public void triggerSnapshot() {
        template.execute((RedisCallback<Object>) conn ->
                conn.execute("BGSAVE", "SCHEDULE".getBytes()));
    }

    public long waitForReplicas(int replicas, long timeoutMs) {
        Long acked = template.execute((RedisCallback<Long>) conn ->
                (Long) conn.execute("WAIT",
                        String.valueOf(replicas).getBytes(),
                        String.valueOf(timeoutMs).getBytes()));
        return acked == null ? 0 : acked;
    }
}

每个方法的说明:

  • persistenceInfo() 调用 INFO persistence 并返回包含 aof_enabled、rdb_last_bgsave_status 等字段的 Properties。
  • triggerSnapshot() 使用 BGSAVE SCHEDULE。如果 AOF 重写正在进行,Redis 将安排快照而不是返回错误。
  • waitForReplicas() 调用 WAIT 并返回已确认前面写入命令的副本数。第 6 节将说明为什么需要这个命令。

启用 AOF 时 INFO persistence 的部分输出:

loading:0
rdb_changes_since_last_save:42
rdb_bgsave_in_progress:0
rdb_last_save_time:1760169600
rdb_last_bgsave_status:ok
aof_enabled:1
aof_rewrite_in_progress:0
aof_last_bgrewrite_status:ok
aof_last_write_status:ok

你应该监控三个字段,并在值不是 ok 时发出警报:rdb_last_bgsave_status、aof_last_write_status 和 aof_last_bgrewrite_status。字段 rdb_changes_since_last_save 表示如果仅依赖 RDB,你将丢失多少更改。

5.1. 使用 Testcontainers 进行测试

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>
@SpringBootTest
@Testcontainers
class RedisPersistenceTest {

    @Container
    @ServiceConnection(name = "redis")
    static GenericContainer<?> redis = new GenericContainer<>(DockerImageName.parse("redis:7.4"))
            .withExposedPorts(6379)
            .withCommand("redis-server", "--appendonly", "yes", "--appendfsync", "everysec");

    @Autowired
    StringRedisTemplate template;

    @Autowired
    PersistenceInspector inspector;

    @Test
    void aofShouldBeEnabledWithEverySecPolicy() {
        template.opsForValue().set("order:1001", "PAID");

        Properties info = inspector.persistenceInfo();
        List<String> fsync = template.execute((RedisCallback<List<String>>) conn ->
                conn.serverCommands().getConfig("appendfsync")
                        .values().stream().map(Object::toString).toList());

        assertThat(info.getProperty("aof_enabled")).isEqualTo("1");
        assertThat(info.getProperty("aof_last_write_status")).isEqualTo("ok");
        assertThat(fsync).containsExactly("everysec");
    }

    @Test
    void backgroundSnapshotShouldSucceed() throws InterruptedException {
        template.opsForValue().set("customer:42", "Lan");
        long before = Long.parseLong(inspector.persistenceInfo().getProperty("rdb_last_save_time"));

        Thread.sleep(1100); // 确保新时间戳与旧时间戳不同
        inspector.triggerSnapshot();

        Properties info = inspector.persistenceInfo();
        for (int i = 0; i < 50 && Long.parseLong(info.getProperty("rdb_last_save_time")) <= before; i++) {
            Thread.sleep(100);
            info = inspector.persistenceInfo();
        }

        assertThat(info.getProperty("rdb_last_bgsave_status")).isEqualTo("ok");
        assertThat(Long.parseLong(info.getProperty("rdb_last_save_time"))).isGreaterThan(before);
    }
}

第一个测试启动 Redis 7.4 并配置 AOF everysec,确保配置已生效。@ServiceConnection 自动将 spring.data.redis.host/port 指向容器,因此你无需声明任何其他属性。第二个测试等待最多 5 秒让 rdb_last_save_time 增加,然后验证快照已成功完成。

6. 何时仍会丢失数据?

启用持久化还不够。以下是生产环境中最常见的场景。

1. 两次快照之间崩溃。如果仅使用 RDB,你将丢失 rdb_last_save_time 之后的所有写入。

2. fsync 窗口。使用 AOF everysec 时,机器崩溃时会丢失约 1-2 秒的数据。使用 no 时,这个数字可能高达数十秒。

3. 异步复制的故障转移。主服务器在副本收到命令之前向客户端返回 OK。如果主服务器死亡,Sentinel 或 Cluster 将副本提升为主服务器,未及时复制的命令将消失,即使主服务器配置了 AOF always 也不例外。WAIT 有助于降低这种风险:

template.opsForValue().set("payment:7788", "CAPTURED");
long acked = inspector.waitForReplicas(1, 100);
if (acked < 1) {
    log.warn("payment:7788 尚未被副本确认");
}

WAIT 1 100 最多阻塞 100 毫秒,直到至少 1 个副本确认。WAIT 仍然不能使 Redis 成为强一致性系统:副本可能已收到命令但尚未 fsync。将此视为降低数据丢失概率的方式,而非保证。

4. 主服务器使用空数据集重启。假设主服务器禁用持久化,自动重启并以空数据集启动。副本将与主服务器同步并清除自己的数据。如果主服务器未启用持久化,不要让它自动重启。

5. 容器未挂载卷。Redis 仍会写入 dump.rdb 和 AOF,但到容器的文件系统中。删除并重新创建容器时,这些文件随之丢失。

services:
  redis:
    image: redis:7.4
    command: ["redis-server", "--appendonly", "yes"]
    volumes:
      - redis-data:/data
volumes:
  redis-data:

在 Kubernetes 中,等效的情况是使用 emptyDir 而不是 PersistentVolumeClaim。

6. 磁盘满或 fork() 失败。当 BGSAVE 失败且启用了 stop-writes-on-bgsave-error yes 时,Redis 用错误 MISCONF 拒绝所有写入命令。这种行为可以保护数据,但会导致应用程序出错。许多人通过禁用此选项来响应,之后 Redis 继续运行但没有持久化。

7. 意外运行 FLUSHALL。使用 AOF 时,FLUSHALL 命令只是增量文件末尾的一行。如果 AOF 尚未重写,你可以停止 Redis、删除该行并重启。如果重写已运行,则无法恢复。因此,在生产环境中应使用 ACL 重命名或阻止此命令。

8. 驱逐。这不是持久化错误,但结果相同。使用 maxmemory-policy allkeys-lru 时,当 RAM 满时 Redis 会删除键。不能丢失的数据需要 noeviction。

注意:不要将 Redis 用作金融或订单数据的唯一存储位置。如果你需要保证“返回 OK 就不会丢失”,请使用关系数据库或具有共识的系统。Redis 应该充当缓存或加速层。

7. 结论

RDB 提供紧凑的备份文件和快速启动,但可能丢失数分钟的数据。AOF 配合 everysec 可将丢失降低到约 1-2 秒,代价是额外的 I/O。混合模式 (aof-use-rdb-preamble) 结合了两者的优点。大多数实际风险实际上超出了这两种机制:异步复制、卷未挂载、磁盘满、FLUSHALL 和驱逐。监控 INFO persistence 中的字段,对重要的写入命令使用 WAIT,并像第 5 节那样使用 Testcontainers 测试配置。

redispersistencerdbaofspring-boot