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 测试配置。