Redis-07 持久化:RDB 与 AOF
1. 为什么需要持久化
Redis 把数据放在内存里,进程一退出数据就全没了。持久化就是把内存数据写到磁盘,让重启后能恢复。
Redis 提供两种机制,思路完全不同:
| RDB | AOF | |
|---|---|---|
| 本质 | 数据快照(某一时刻的全量数据) | 命令日志(所有写操作的记录) |
| 文件内容 | 二进制紧凑格式 | RESP 协议文本 |
| 文件大小 | 小(有压缩) | 大(但重写后会缩小) |
| 恢复速度 | 快(直接加载数据) | 慢(要重放所有命令) |
| 数据安全性 | 差(可能丢几分钟) | 好(最多丢 1 秒) |
| 对性能的影响 | fork 时有毛刺 | 每次写都要 append |
| 类比 | 全量备份 / 虚拟机快照 | MySQL 的 binlog |
理解这两者的关键区别:RDB 记录"数据是什么样",AOF 记录"数据是怎么变成这样的"。
2. RDB 快照
2.1 触发方式
手动触发
SAVE # ⚠️ 在主线程里同步生成 RDB,期间【完全阻塞】所有请求。生产禁用
BGSAVE # fork 子进程在后台生成,主线程只在 fork 的瞬间阻塞
SAVE 存在的唯一理由是极端情况下的兜底(比如内存不足无法 fork 时)。任何时候都不要在生产环境执行 SAVE。
自动触发
# 格式:save <秒> <变更 key 数>
# 含义:在 <秒> 内如果至少有 <变更 key 数> 个 key 发生变化,就触发 BGSAVE
save 3600 1 # 1 小时内有 1 个变更
save 300 100 # 5 分钟内有 100 个变更
save 60 10000 # 1 分钟内有 10000 个变更
# 完全关闭 RDB
save ""
三条规则是 OR 关系,满足任意一条就触发。这个设计很聪明:写入越频繁,快照越频繁(丢的数据更少);写入很少时也能保证至少每小时存一次。
检查在 serverCron 里做(每 100ms 一次),条件是「距上次成功保存的时间 > 秒数」且「server.dirty(变更计数器)>= 变更数」。
其他自动触发时机
- 主从复制的全量同步:主节点会执行
BGSAVE生成 RDB 发给从节点(或用无盘复制repl-diskless-sync直接走 socket); - 执行
SHUTDOWN(且配置了save规则):会先生成 RDB 再退出,保证数据不丢; - 执行
DEBUG RELOAD:生成 RDB 并重新加载(用于测试); FLUSHALL:会生成一个空的 RDB(覆盖旧文件);- AOF 重写(
aof-use-rdb-preamble yes时):重写的前半段就是一个 RDB。
2.2 相关配置
dbfilename dump.rdb
dir /usr/local/redis/data # 工作目录,RDB 和 AOF 都放这
# bgsave 失败后是否拒绝写入(默认 yes)
stop-writes-on-bgsave-error yes
# 用 LZF 压缩字符串对象(默认 yes)
rdbcompression yes
# 文件末尾加 CRC64 校验和(默认 yes,约 10% 性能开销)
rdbchecksum yes
# 7.0+ RDB 文件的删除是否同步(默认 no,即异步删除避免阻塞)
rdb-del-sync-files no
stop-writes-on-bgsave-error yes 的双刃剑:
它的本意是保护——如果快照持续失败(磁盘满、权限错误),说明数据无法落盘,此时继续接受写入等于在积累"注定会丢的数据",所以干脆拒绝写入让你及时发现。
但副作用是:磁盘一满,Redis 立刻变成只读,业务大面积报 MISCONF Redis is configured to save RDB snapshots, but it is currently not able to persist on disk。
如果你的 Redis 是纯缓存(数据丢了能重建),建议设为 no,避免磁盘问题导致业务不可写。如果是数据存储用途,保持 yes 并做好磁盘监控。
2.3 fork 与写时复制(COW)
这是 RDB 的核心机制,也是面试必考点。
问题
生成快照需要遍历所有数据写入文件,这可能耗时几秒到几十秒。如果在主线程做(SAVE),服务就停摆了。但如果一边写文件一边继续接受写请求,快照的数据就"不一致"了(前半段是旧数据,后半段是新数据)。
解决方案:fork + COW
1. 主线程调用 fork() 创建子进程
├─ 子进程获得父进程内存的一份【逻辑副本】
└─ 但物理上【不复制任何数据页】,父子进程共享同一批物理内存页,
只是内核把这些页都标记为【只读】
2. 子进程遍历这份"冻结在 fork 时刻"的数据,写入临时 RDB 文件
(子进程看到的数据永远是 fork 那一瞬间的状态,天然一致)
3. 主线程继续处理请求。当它要【修改】某个内存页时:
├─ CPU 检测到写只读页 → 触发【缺页异常】(page fault)
├─ 内核复制这一页(4KB)到新的物理页
├─ 把父进程的页表指向新页,并恢复可写
└─ 主线程在新页上完成修改
↑ 这就是【写时复制 Copy-On-Write】
4. 子进程写完,把临时文件 rename 成 dump.rdb(原子替换),退出
5. 主线程通过 wait3 回收子进程,更新 dirty 计数和 lastsave 时间
fork 到底阻塞多久
fork 本身是阻塞的,但它不复制数据,只复制页表(page table)。
- Linux 的页表是多级结构,一个进程的页表大小大约是
内存大小 / 4096 * 8 字节; - 经验值:每 1GB 内存的 fork 耗时约 20~100ms(取决于 CPU 和是否用了大页);
- 所以一个 10GB 的实例,fork 可能阻塞 200ms~1 秒——这是单线程 Redis 最大的单点延迟来源之一。
127.0.0.1:6379> INFO stats
latest_fork_usec:183021 # 最近一次 fork 耗时(微秒)= 183ms
total_forks:127 # 累计 fork 次数
latest_fork_usec 是最重要的持久化监控指标。超过 100ms(100000)就要警惕,超过 1 秒必须优化。
优化 fork 耗时
- 控制单实例内存:建议 不超过 10GB(更保守的建议是 4~8GB)。用 Cluster 分片而不是堆单实例;
- 关闭 THP(透明大页):这是必须做的。开启 THP 后内存页从 4KB 变 2MB,虽然页表变小了(fork 更快),但COW 的复制粒度也变成 2MB——改一个 key 要复制 2MB 内存,放大 512 倍。实测会导致重写期间内存暴涨和明显延迟毛刺:
echo never > /sys/kernel/mm/transparent_hugepage/enabled
- 设置
vm.overcommit_memory = 1:否则内核按最坏情况估算(认为 fork 需要和父进程一样多的内存),当已用内存超过物理内存一半时 fork 直接失败; - 避免虚拟化环境的 fork 惩罚:Xen 等虚拟化平台的 fork 比物理机慢很多(官方文档明确提到)。用 KVM 或物理机;
- 降低 fork 频率:调整
save规则、auto-aof-rewrite-percentage,避免频繁触发。
COW 期间的内存开销
理论最坏情况:如果 fork 期间父进程把所有内存页都改了一遍,内存会翻倍。
实际情况:写入通常集中在少部分热数据上,典型的额外开销是 10%~30%。但要注意几个会加剧 COW 的因素:
- 写入量大(高 QPS 的写场景);
hz高导致过期删除频繁(删除也是写);- 渐进式 rehash(会写大量页)——这就是为什么第 4 篇提到"有子进程时 dict 扩容的阈值从 1 提到 5";
- LRU/LFU 字段更新——读操作也会写
robj.lru字段!所以纯读也会触发 COW(这是一个很多人不知道的点)。
所以 maxmemory 必须留足余量(物理内存的 60%~70%)。
2.4 RDB 文件格式
+-------+-------------+-----------+------------------+-----+-----+-------+
| REDIS | RDB版本(4B) | 辅助字段 | DB 0 的数据 | ... | EOF | CRC64 |
+-------+-------------+-----------+------------------+-----+-----+-------+
魔数 "0011" redis-ver等 SELECTDB + RESIZEDB + 键值对
每个键值对的编码:
[过期时间(可选)] [类型标识 1字节] [key(长度编码的字符串)] [value]
几个值得知道的细节:
- 辅助字段(AUX):存
redis-ver(生成它的 Redis 版本)、redis-bits(32/64 位)、ctime、used-mem、repl-id、repl-offset等元信息。这让 RDB 能自描述; - 长度编码:用最少的字节表示长度(6bit/14bit/32bit/64bit 四档);
- 整数编码:如果字符串其实是整数,直接存 int8/int16/int32,省空间;
- LZF 压缩:长度 > 20 字节的字符串会尝试用 LZF 压缩(
rdbcompression yes); - CRC64 校验:文件末尾 8 字节校验和。设为 0 表示不校验。
rdbchecksum no能省约 10% CPU,但就失去了损坏检测能力; - RDB 版本号:Redis 只能加载版本号 不高于自己支持的 RDB 文件。所以高版本 Redis 能读低版本的 RDB,反过来不行——这是版本降级时的大坑。
检查 RDB 文件:
redis-check-rdb /data/dump.rdb
# [offset 0] Checking RDB file dump.rdb
# [offset 26] AUX FIELD redis-ver = '7.2.5'
# ...
# [offset 12345] Checksum OK
# \o/ RDB looks OK! \o/
2.5 RDB 的优缺点
优点:
- 文件紧凑,体积远小于 AOF,适合做冷备份和灾备(直接 scp 到异地);
- 恢复速度快:直接把二进制数据加载进内存,比 AOF 逐条重放命令快几个数量级(10GB 数据 RDB 可能几十秒,AOF 可能十几分钟);
- 对性能影响小(除了 fork 那一下):子进程负责写文件,主线程照常服务;
- 快照点数据一致:fork 保证了子进程看到的是一个精确时刻的完整状态。
缺点:
- 会丢数据:两次快照之间的写入全部丢失。极端情况下可能丢几分钟到一小时;
- fork 有延迟毛刺:大实例的 fork 可能阻塞几百毫秒到秒级;
- COW 有内存开销:需要预留内存余量;
- 不适合高频执行:每次都是全量,数据量大时 CPU 和磁盘 IO 开销都不小。
3. AOF 追加日志
3.1 基本原理
AOF(Append Only File)记录每一条修改数据的写命令,恢复时重新执行一遍。
127.0.0.1:6379> SET name tom
127.0.0.1:6379> INCR counter
AOF 文件内容(就是 RESP 协议格式):
*2\r\n$6\r\nSELECT\r\n$1\r\n0\r\n
*3\r\n$3\r\nSET\r\n$4\r\nname\r\n$3\r\ntom\r\n
*2\r\n$4\r\nINCR\r\n$7\r\ncounter\r\n
用 RESP 格式而不是自定义格式的好处:恢复时可以直接复用现成的命令解析器和执行路径(Redis 启动时创建一个"伪客户端",把 AOF 内容当作命令输入喂进去),实现极其简洁。
3.2 三个关键点
(1)AOF 是"先执行命令,后写日志"
这跟 MySQL 的 WAL(Write-Ahead Log,先写日志后改数据)正好相反。
Redis:命令执行成功 → 写入 aof_buf → (稍后) 刷盘
MySQL:写 redo log(prepare)→ 改数据 → 写 binlog → 提交 redo log
为什么 Redis 选择后写日志:
- 优点一:不用做命令语法检查。因为记录的是"已经成功执行"的命令,一定是合法的,恢复时不会因为记录了错误命令而失败;
- 优点二:不阻塞当前命令。命令执行完就能返回客户端,写盘可以延后。
代价(很重要):
- 可能丢数据:命令执行完、还没写盘(或写盘还没 fsync)时进程崩溃,这条命令就丢了。客户端已经收到成功响应,但数据没了;
- 无法做事务回滚:因为日志是事后记的,没有"undo"信息。这也是 Redis 事务不支持回滚的底层原因之一(第 8 篇)。
(2)写入的是"实际执行的效果"而非原始命令
某些命令有不确定性,直接记录原始命令会导致恢复后数据不一致,Redis 会做转换:
| 原始命令 | AOF 里记录的 |
|---|---|
EXPIRE key 100 |
PEXPIREAT key <绝对毫秒时间戳> |
SETEX key 100 v |
SET key v PXAT <绝对时间戳> |
SPOP key(随机弹出) |
SREM key <实际弹出的成员> |
INCRBYFLOAT key 1.1 |
SET key <计算后的结果>(避免浮点精度累积误差) |
HINCRBYFLOAT |
HSET key field <结果> |
| 过期删除 | 显式的 DEL/UNLINK |
| Lua 脚本(7.0+ 默认) | 脚本产生的实际写命令(effects replication),而不是 EVAL 本身 |
GETEX key EX 100 |
PEXPIREAT key <ts> |
这套机制叫**「效果复制」(effects replication)**,同样用于主从复制。5.0 之前 Lua 脚本是按脚本本身复制的(verbatim),如果脚本里用了 math.random 或 TIME,主从节点执行结果就会不一致——所以那时 Redis 强制要求脚本必须是确定性的(禁用了随机函数和时间函数)。改成效果复制后,脚本可以随便用随机数了。
(3)AOF 缓冲区与刷盘时机
写入不是直接 write 到文件,而是两级:
命令执行 → 写入 server.aof_buf(内存缓冲区,一个 SDS)
↓ 在事件循环的 beforeSleep 里
write() 到操作系统的【page cache】(内核缓冲区)
↓ 按 appendfsync 策略
fsync() 真正落到物理磁盘
关键区分 write 和 fsync:
write()只是把数据交给内核的 page cache,很快(微秒级),但数据还在内存里,机器断电就丢;fsync()要求内核把 page cache 的数据真正刷到磁盘介质,很慢(机械盘几毫秒,SSD 几百微秒),但数据才算安全。
3.3 三种刷盘策略
appendfsync everysec # always | everysec | no
| 策略 | fsync 时机 | 性能 | 最多丢多少数据 | 说明 |
|---|---|---|---|---|
| always | 每条写命令都 fsync | 最差(QPS 可能降到几千) | 理论上不丢(但仍有极小窗口) | 金融级要求才用 |
| everysec | 每秒 fsync 一次(后台线程) | 好(默认) | 约 1 秒(实际可能 2 秒,见下) | 推荐 |
| no | 完全交给操作系统 | 最好 | 由 OS 决定(Linux 默认 30 秒脏页回写) | 基本等于没有持久化保证 |
everysec 的实现细节
everysec 的 fsync 是由 bio 后台线程执行的(这就是第 5 篇提到的 bio 线程之一),主线程不等它完成。
但有个微妙的地方(flushAppendOnlyFile 里):
主线程要 write() 时,先检查上一次的 fsync 是否还在进行:
├─ 如果后台 fsync 已完成 → 正常 write
├─ 如果后台 fsync 仍在进行中:
│ ├─ 记下推迟开始的时间,本次 write 【推迟】(先不写,等下一轮)
│ └─ 如果推迟已超过 2 秒 → 【强制阻塞地 write】,主线程在此等待
所以:
everysec实际最多可能丢 2 秒数据(不是 1 秒)——因为 write 本身可能被推迟;- 磁盘 IO 打满时,主线程会被 AOF 阻塞。这时
INFO persistence的aof_delayed_fsync计数器会增长:
127.0.0.1:6379> INFO persistence
aof_delayed_fsync:12 # 因等待 fsync 而延迟 write 的次数,持续增长说明磁盘慢
aof_delayed_fsync 持续增长是磁盘性能不足的明确信号。
always 真的不丢数据吗
不完全。always 的语义是"每次事件循环结束前 fsync",但:
- 如果一次事件循环处理了多条命令(pipeline),它们会被一起 fsync;
- 极端情况下,命令执行成功、返回给客户端、但在
beforeSleep的 fsync 之前进程被kill -9,这条命令仍会丢失。
所以准确说法是:always 把丢失窗口缩小到"单次事件循环"级别,而不是绝对不丢。真正的零丢失需要配合主从同步复制(WAIT 命令)或者接受更慢的方案。
3.4 AOF 重写
为什么需要重写
AOF 记录所有写命令,会无限增长。而且很多命令是"冗余"的:
SET counter 1
INCR counter # 2
INCR counter # 3
...
INCR counter # 1000000
这 100 万条命令的最终效果,等于一条 SET counter 1000000。
再比如:
LPUSH list a
LPUSH list b
RPOP list # a 被弹出
DEL list # 前面所有操作都白做了
AOF 重写就是:读取当前内存里的数据状态,生成一份"能构造出这个状态的最小命令集",替换掉旧的臃肿文件。
注意重写不是"分析压缩旧 AOF 文件",而是"遍历当前内存数据重新生成"。这是个常见误解。
触发方式
BGREWRITEAOF # 手动触发
# 自动触发条件(两个条件同时满足):
auto-aof-rewrite-percentage 100 # 当前 AOF 大小比上次重写后大了 100%(即翻倍)
auto-aof-rewrite-min-size 64mb # 且当前 AOF 大小超过 64MB
第二个条件的作用是避免文件很小时(比如 1MB 涨到 2MB)频繁重写。
127.0.0.1:6379> INFO persistence
aof_base_size:52428800 # 上次重写后的大小
aof_current_size:104857600 # 当前大小
aof_pending_rewrite:0 # 是否有重写在排队(比如 RDB 正在跑,重写要等)
aof_rewrite_in_progress:0 # 是否正在重写
aof_rewrite_scheduled:0 # 是否已调度但未开始
aof_last_bgrewrite_status:ok # 上次重写结果
aof_rewrite_time_sec:-1 # 上次重写耗时
重写流程(7.0 之前)
7.0 之前的重写有个棘手问题:重写期间的新写入怎么办?
1. 主线程 fork 出子进程(同样是 COW)
2. 子进程遍历自己看到的数据快照,把每个 key 转成"最少的命令"写入临时 AOF 文件
(对集合类型还会拆成多条命令,避免单条命令参数过多:
一个 100 万元素的 set 会被拆成多条 SADD,每条最多 64 个元素)
3. 【关键】重写期间主线程收到的新写命令,要同时写入两个地方:
├─ 原来的 aof_buf(保证旧 AOF 文件仍然完整可用)
└─ 【AOF 重写缓冲区 aof_rewrite_buf】(记录重写期间的增量)
4. 子进程写完临时文件,向父进程发信号
5. 主线程把 aof_rewrite_buf 的内容【追加】到临时文件末尾
↑ 这一步是【阻塞】的!如果重写期间写入量很大,缓冲区可能有几百 MB,
追加会造成明显阻塞
6. 原子 rename 替换旧 AOF 文件
7.0 之前的三个痛点:
- 重写缓冲区占用额外内存(可能几百 MB 到 GB);
- 第 5 步追加缓冲区时阻塞主线程;
- 数据被写了两次(旧 AOF + 重写缓冲区),磁盘 IO 翻倍。
7.0 的多部分 AOF(Multi-Part AOF)
7.0 用一个漂亮的设计彻底解决了上述问题:把 AOF 拆成多个文件。
appendonlydir/ ← appenddirname 配置
├── appendonly.aof.1.base.rdb ← BASE 文件:全量快照(RDB 格式)
├── appendonly.aof.1.incr.aof ← INCR 文件:增量命令
└── appendonly.aof.manifest ← 清单文件:记录有哪些文件、什么类型、序号
manifest 文件内容示例:
file appendonly.aof.1.base.rdb seq 1 type b
file appendonly.aof.1.incr.aof seq 1 type i
新的重写流程:
1. 打开一个【新的 INCR 文件】,此后的新写入直接写进它
2. fork 子进程,把内存数据写成【新的 BASE 文件】
3. 子进程完成后,更新 manifest:指向新的 BASE + 新的 INCR
4. 删除旧的 BASE 和旧的 INCR 文件
改进点:
| 7.0 之前 | 7.0+ | |
|---|---|---|
| 重写缓冲区 | 需要(占内存) | 不需要(新写入直接进新 INCR 文件) |
| 追加缓冲区的阻塞 | 有 | 无 |
| 写放大 | 数据写两遍 | 只写一遍 |
| 文件结构 | 单个大文件 | BASE + INCR + manifest |
这是 7.0 一个非常实在的改进,大幅降低了重写期间的内存和延迟风险。
重写期间的注意事项
# 重写期间是否禁止主进程的 fsync(默认 no)
no-appendfsync-on-rewrite no
no(默认):重写期间主线程照常 fsync。数据最安全,但主线程的 fsync 和子进程的大量磁盘写入会抢 IO,可能造成延迟升高;yes:重写期间主线程不做 fsync(相当于临时降级成appendfsync no)。避免了 IO 争抢,但如果这期间宕机,可能丢失整个重写期间的数据(可能是几十秒的量)。
建议:机械盘或 IO 紧张时可以设 yes 换取稳定延迟;SSD 且 IO 有余量时保持 no。
另外:RDB 和 AOF 重写不会同时进行。如果 BGSAVE 正在跑,BGREWRITEAOF 会被标记为 aof_rewrite_scheduled,等 RDB 完成后再执行。因为两个 fork 同时存在会让内存开销和 IO 压力翻倍。
3.5 混合持久化(4.0+)
aof-use-rdb-preamble yes # 默认 yes,强烈建议保持
开启后,AOF 重写生成的 BASE 部分用 RDB 格式而不是命令格式:
7.0 之前的单文件形态:
+--------------------------+--------------------------+
| RDB 格式的全量数据 | 重写后新增的 AOF 命令 |
+--------------------------+--------------------------+
7.0+ 的多部分形态:
appendonly.aof.1.base.rdb ← RDB 格式(这就是混合持久化)
appendonly.aof.1.incr.aof ← 命令格式
好处:
- 文件更小:RDB 是压缩的二进制格式,比等价的 AOF 命令小很多;
- 恢复快得多:加载 RDB 部分是直接反序列化数据(快),只有增量的命令部分需要逐条重放。10GB 的数据可能从"AOF 重放 15 分钟"变成"RDB 加载 30 秒 + 少量命令重放"。
代价:BASE 部分不再是人类可读的文本,无法直接用编辑器查看或手工修改(但 7.0 的多部分设计下,增量 INCR 文件仍然是可读的文本)。
aof-use-rdb-preamble no 时 BASE 部分仍用命令格式,只有在特殊需求(比如需要审计所有历史命令)时才这么设。
4. 数据恢复
4.1 加载优先级
Redis 启动时的加载逻辑:
if (appendonly == yes):
加载 AOF(7.0+ 读 manifest → 加载 BASE → 依次加载各 INCR)
← 【完全忽略 RDB 文件】
else:
加载 RDB
关键点:只要开启了 AOF,就只加载 AOF,RDB 文件会被完全忽略。
原因:AOF 通常比 RDB 更新(记录了最近的写入),所以更可靠。
这是一个经典的运维事故来源:
场景:实例原本只开 RDB,数据 10GB。运维想加上 AOF 更安全,于是:
1. 修改配置 appendonly yes
2. 重启 Redis
3. 【数据全部丢失!】
原因:重启时因为 appendonly=yes,Redis 去加载 AOF 文件,
但 AOF 文件还不存在(从没生成过),于是加载了一个空数据集,
然后立刻创建一个空的 AOF 文件。RDB 里的 10GB 数据被彻底忽略。
更糟的是如果这时触发了 bgsave,空数据还会把 RDB 覆盖掉。
正确的开启 AOF 方式(不重启,动态开启):
# 4.0+ 支持动态开启,会立即触发一次 AOF 重写(从当前内存生成完整 AOF)
redis-cli CONFIG SET appendonly yes
# 确认重写完成
redis-cli INFO persistence | grep -E 'aof_enabled|aof_rewrite_in_progress|aof_last_bgrewrite_status'
# aof_enabled:1
# aof_rewrite_in_progress:0
# aof_last_bgrewrite_status:ok
# 确认 AOF 文件已生成且大小合理
ls -la /data/appendonlydir/
# 最后把配置写回文件,避免下次重启回退
redis-cli CONFIG REWRITE
4.2 文件损坏的修复
如果 Redis 在写 AOF 的过程中被 kill -9 或断电,AOF 文件末尾可能只有半条命令。
# 是否容忍 AOF 文件末尾被截断(默认 yes)
aof-load-truncated yes
yes:加载到截断处就停止,记录一条 warning 日志继续启动。这是合理的默认值——丢掉最后一条不完整的命令,其他数据都能用;no:直接拒绝启动,报错要求人工处理。
如果是文件中间损坏(不是末尾截断),Redis 会拒绝启动,需要手动修复:
# 1. 先备份!(redis-check-aof --fix 会直接修改原文件)
cp -r /data/appendonlydir /data/appendonlydir.bak
# 2. 检查
redis-check-aof /data/appendonlydir/appendonly.aof.1.incr.aof
# AOF analyzed: filename=..., size=1024, ok_up_to=1000, ok_up_to_line=50, diff=24
# AOF is not valid. Use the --fix option to try fixing it.
# 3. 修复(会截断掉从第一个错误处开始的所有内容)
redis-check-aof --fix /data/appendonlydir/appendonly.aof.1.incr.aof
# 7.0+ 也可以直接检查整个 manifest
redis-check-aof --fix /data/appendonlydir/appendonly.aof.manifest
# 4. RDB 的检查(注意:redis-check-rdb 只能检查,不能修复)
redis-check-rdb /data/dump.rdb
注意 --fix 的做法是"从第一个错误处开始截断掉后面所有内容",所以如果错误在文件前半部分,会丢失大量数据。这也是为什么修复前必须备份。
4.3 从备份恢复
# RDB 恢复
systemctl stop redis
cp /backup/dump-20260729.rdb /data/dump.rdb
chown redis:redis /data/dump.rdb
# ⚠️ 如果配置里 appendonly yes,必须先临时改成 no,否则 RDB 不会被加载!
systemctl start redis
# AOF 恢复(7.0+)
systemctl stop redis
rm -rf /data/appendonlydir
cp -r /backup/appendonlydir-20260729 /data/appendonlydir
chown -R redis:redis /data/appendonlydir
systemctl start redis
用 RDB 恢复但配置开了 AOF 的正确流程:
# 1. 停止 Redis
# 2. 把 RDB 放到 dir 目录
# 3. 临时禁用 AOF:修改配置 appendonly no
# 4. 启动(此时加载 RDB)
# 5. 验证数据正确:DBSIZE、抽查关键 key
# 6. 动态开启 AOF(会从内存生成新的 AOF)
redis-cli CONFIG SET appendonly yes
# 7. 等重写完成后写回配置
redis-cli CONFIG REWRITE
4.4 用主从做在线恢复
如果实例数据损坏但还有正常的副本,最快的恢复方式是用主从复制:
# 在损坏的实例上(或新起一个干净实例)
redis-cli -p 6380 REPLICAOF <正常节点IP> 6379
# 等待同步完成
redis-cli -p 6380 INFO replication | grep master_link_status
# master_link_status:up
# 同步完成后断开,变成独立实例
redis-cli -p 6380 REPLICAOF NO ONE
这比从磁盘备份恢复快得多,而且数据是最新的。
4.5 无损迁移数据
# 方式一:redis-cli --rdb 从运行中的实例拉一份 RDB(走复制协议)
redis-cli -h source_host --rdb /tmp/dump.rdb
# 方式二:单个 key 的迁移
redis-cli -h src DUMP mykey # 序列化
redis-cli -h dst RESTORE mykey 0 "<序列化数据>" # 反序列化
# 或者一步到位(原子)
redis-cli -h src MIGRATE dst_host 6379 mykey 0 5000
# 方式三:全量在线迁移工具
# redis-shake(阿里开源,支持全量+增量)
# redis-port、redis-migrate-tool
5. RDB 与 AOF 的选择
5.1 对比总表
| 维度 | RDB | AOF |
|---|---|---|
| 数据完整性 | 差(丢两次快照之间的数据) | 好(everysec 最多丢 1~2 秒) |
| 文件体积 | 小 | 大(重写后改善,混合持久化后接近 RDB) |
| 恢复速度 | 快 | 慢(除非开混合持久化) |
| 对写性能的影响 | fork 时毛刺 | 每次写要 append + 定期 fsync |
| 磁盘 IO | 周期性的大量写 | 持续的小量写 |
| 内存开销 | fork 的 COW | 7.0 前有重写缓冲区;重写时也有 COW |
| 适合场景 | 冷备、灾备、快速重启、主从全量同步 | 需要低数据丢失的场景 |
| 可读性 | 二进制,不可读 | 文本可读(可人工修复/审计) |
| 版本兼容 | 高版本能读低版本,反之不行 | 兼容性好(就是命令) |
5.2 四种配置方案
方案一:都不开(纯缓存)
save ""
appendonly no
适合:纯缓存场景,数据全部能从 DB 重建,追求极致性能。
注意:这种配置下重启会丢失全部数据,要考虑缓存预热(否则重启后大量请求穿透到 DB,可能把 DB 打挂)。另外主从复制的全量同步仍然需要生成 RDB(除非用无盘复制)。
方案二:只开 RDB
save 3600 1
save 300 100
save 60 10000
appendonly no
适合:能容忍丢失几分钟数据、追求快速重启和小体积备份的场景。
方案三:只开 AOF
save ""
appendonly yes
appendfsync everysec
aof-use-rdb-preamble yes
适合:数据比较重要、但不想承担 RDB 的 fork 频率。
注意:即使 save "",主从全量同步和 SHUTDOWN 时 Redis 仍会用到 RDB 机制。
方案四:两个都开(推荐)
# RDB:低频快照,主要用于灾备和快速恢复
save 3600 1
save 300 100
# AOF:保证数据完整性
appendonly yes
appendfsync everysec
aof-use-rdb-preamble yes
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb
no-appendfsync-on-rewrite no
这是生产环境最常见的配置。逻辑是:
- 平时靠 AOF 保证数据不丢(最多 1 秒);
- RDB 作为定期的全量冷备,可以直接拷到异地做灾备,也是最后的兜底(如果 AOF 出了问题,还有 RDB 可用);
- 重启时加载 AOF(因为开了 AOF),有混合持久化所以速度也不慢。
代价是两个都开会有更多的磁盘 IO 和 fork,需要监控。
5.3 什么都不如主从 + 哨兵
必须强调:持久化解决的是"单机重启后能恢复",不解决"单机挂了服务不可用"。
真正的高可用架构是:
持久化(AOF + RDB) → 保证单机数据不丢
主从复制 → 保证有实时副本
哨兵 / Cluster → 保证故障自动转移
定期备份到异地 → 保证机房级灾难可恢复
只有持久化没有主从,机器宕机后要等重启 + 加载数据(10GB 可能几分钟),期间服务完全不可用。
6. 数据丢失窗口分析
面试常问"Redis 会丢多少数据",需要分层分析:
6.1 单机层面
| 配置 | 崩溃时最多丢失 |
|---|---|
| 无持久化 | 全部 |
| 只有 RDB(save 60 10000) | 最多 60 秒的写入(如果没达到 10000 变更,可能丢更多,最坏一小时) |
AOF appendfsync no |
由 OS 决定,Linux 默认脏页回写间隔 30 秒 → 最多约 30 秒 |
AOF appendfsync everysec |
约 1~2 秒(write 可能被推迟 2 秒) |
AOF appendfsync always |
单次事件循环的量(接近 0,但非绝对零) |
6.2 主从层面(更重要的丢失来源)
Redis 的主从复制是异步的:主节点执行完命令立即返回客户端成功,然后才异步地把命令发给从节点。
客户端 --SET k v--> 主节点
├─ 执行命令
├─ 【立即返回 +OK 给客户端】
└─ 异步发送给从节点 ← 如果这时主节点宕机,这条数据就丢了
所以:即使 appendfsync always,主从切换仍然可能丢数据——因为从节点可能还没收到最后那批命令,而它被提升为新主节点了。
这是分布式层面的丢失,比单机持久化的丢失窗口通常更大(取决于主从延迟,正常几毫秒,网络抖动时可能几秒)。
6.3 WAIT 命令:半同步复制
WAIT numreplicas timeout
WAIT 1 1000 表示"阻塞等待至少 1 个从节点确认收到了之前的所有写命令,最多等 1000 毫秒",返回实际确认的从节点数。
127.0.0.1:6379> SET important-data value
OK
127.0.0.1:6379> WAIT 1 1000
(integer) 1 # 有 1 个从节点确认收到了
用法:对关键写操作,写完立刻 WAIT,如果返回值小于期望值就说明复制没跟上,业务可以选择报错或重试。
但要清楚 WAIT 的局限:
- 不是真正的同步复制:它只保证从节点"收到了并写入了自己的复制缓冲区/内存",不保证从节点已经 fsync 到磁盘;
- 不提供一致性保证:即使
WAIT返回成功,主节点宕机后如果哨兵选了另一个更落后的从节点做主,数据仍然会丢; - 性能代价大:每次写都要等一个 RTT,吞吐会明显下降;
- 超时也返回:
WAIT超时后返回实际确认数,不会报错,业务必须检查返回值。
7.0 还引入了 WAITAOF numlocal numreplicas timeout,可以等待"本地 AOF fsync 完成"和"从节点 AOF fsync 完成",比 WAIT 的保证更强:
WAITAOF 1 1 1000 # 等本地 AOF 落盘 + 1 个从节点 AOF 落盘
结论:如果业务真的一条数据都不能丢,不应该用 Redis 做主存储——应该写数据库(MySQL 的组提交 + 双 1 配置),Redis 只做缓存和加速。
7. 监控与排查
7.1 关键指标
127.0.0.1:6379> INFO persistence
# ---------- RDB ----------
rdb_changes_since_last_save:1234 # 距上次保存的变更数(越大说明丢的越多)
rdb_bgsave_in_progress:0 # 是否正在 bgsave
rdb_last_save_time:1785657600 # 上次成功保存的时间戳
rdb_last_bgsave_status:ok # 上次结果,err 要立刻排查!
rdb_last_bgsave_time_sec:3 # 上次耗时(秒)
rdb_current_bgsave_time_sec:-1 # 当前已耗时
rdb_last_cow_size:16777216 # 【重要】上次 bgsave 的 COW 内存量
# ---------- AOF ----------
aof_enabled:1
aof_rewrite_in_progress:0
aof_rewrite_scheduled:0 # 因 RDB 正在跑而排队等待
aof_last_bgrewrite_status:ok
aof_last_write_status:ok # 【重要】最近一次写 AOF 的结果
aof_rewrite_time_sec:-1
aof_last_cow_size:8388608 # 上次重写的 COW 内存量
aof_base_size:52428800
aof_current_size:104857600
aof_pending_rewrite:0
aof_buffer_length:0 # AOF 缓冲区当前长度(持续增长说明磁盘写不过来)
aof_delayed_fsync:0 # 【重要】因等待 fsync 而推迟 write 的次数
# ---------- fork ----------
127.0.0.1:6379> INFO stats
latest_fork_usec:183021 # 【最重要】最近一次 fork 耗时(微秒)
total_forks:127
7.2 必须告警的指标
| 指标 | 阈值 | 含义与处理 |
|---|---|---|
rdb_last_bgsave_status |
!= ok | 快照失败!检查磁盘空间、权限、vm.overcommit_memory |
aof_last_bgrewrite_status |
!= ok | 重写失败,同上 |
aof_last_write_status |
!= ok | AOF 写入失败,数据正在丢失,最高优先级 |
latest_fork_usec |
> 100000(100ms) | fork 慢,检查实例大小、THP、虚拟化 |
aof_delayed_fsync |
持续增长 | 磁盘性能不足,考虑换 SSD 或调整策略 |
rdb_changes_since_last_save |
异常大 | 快照太久没成功,丢失风险高 |
aof_current_size |
异常大 | 重写没触发(检查配置)或重写一直失败 |
7.3 常见问题排查
问题一:bgsave 失败,日志报 Cannot allocate memory
原因:fork 时内核认为内存不足
解决:
1. echo 1 > /proc/sys/vm/overcommit_memory (最常见的原因)
2. 检查是否真的内存不足(free -m)
3. 降低 maxmemory
问题二:磁盘满导致 Redis 变成只读
日志:MISCONF Redis is configured to save RDB snapshots, but it is
currently not able to persist on disk.
原因:stop-writes-on-bgsave-error yes + bgsave 持续失败
应急:
1. 清理磁盘空间(这是根本)
2. 紧急恢复写入:CONFIG SET stop-writes-on-bgsave-error no
⚠️ 这只是让业务先跑起来,数据依然没有落盘保护
问题三:重写期间延迟飙升
原因:子进程的大量磁盘写入和主线程的 fsync 抢 IO
解决:
1. CONFIG SET no-appendfsync-on-rewrite yes (牺牲重写期间的安全性)
2. 把 AOF 和 RDB 放到独立的磁盘
3. 用 SSD
4. 错峰:把自动重写关掉,用 crontab 在低峰期手动 BGREWRITEAOF
问题四:从节点内存比主节点高很多
可能原因:
1. 主节点有大量过期 key 还没传播 DEL(第 6 篇)
2. 从节点的 client-output-buffer 或复制缓冲区占用
3. 主从的编码不同:主节点某个 hash 是 hashtable 编码,
从节点全量同步时按 RDB 重建,可能变成 listpack(反之亦然)
实际上更常见的是相反情况:从节点重建后编码更省,内存更低
4. 内存碎片率不同
问题五:重启后数据没了
排查顺序:
1. 确认 appendonly 配置:如果是 yes,Redis 只看 AOF 不看 RDB!
2. 确认 dir 配置是否正确(Redis 用相对路径时,工作目录变了会找不到文件)
redis-cli CONFIG GET dir
3. 确认文件权限(redis 用户能否读)
4. 看启动日志:是否有 "DB loaded from disk" 或 "DB loaded from append only file"
5. 确认是否被 FLUSHALL(查 AOF 文件里是否有 FLUSHALL 命令)
7.4 备份策略
#!/bin/bash
# 生产级 RDB 备份脚本示例
BACKUP_DIR=/backup/redis
DATE=$(date +%Y%m%d_%H%M%S)
REDIS_CLI="redis-cli -h 127.0.0.1 -p 6379"
# 1. 触发快照并等待完成
LAST_SAVE=$($REDIS_CLI LASTSAVE)
$REDIS_CLI BGSAVE
while [ "$($REDIS_CLI LASTSAVE)" == "$LAST_SAVE" ]; do
sleep 1
done
# 2. 确认成功
STATUS=$($REDIS_CLI INFO persistence | grep rdb_last_bgsave_status | cut -d: -f2 | tr -d '\r')
if [ "$STATUS" != "ok" ]; then
echo "BGSAVE failed!" && exit 1
fi
# 3. 拷贝(RDB 是原子 rename 生成的,拷贝时不会读到半个文件)
DIR=$($REDIS_CLI CONFIG GET dir | tail -1)
cp "$DIR/dump.rdb" "$BACKUP_DIR/dump_$DATE.rdb"
# 4. 压缩并清理 7 天前的备份
gzip "$BACKUP_DIR/dump_$DATE.rdb"
find $BACKUP_DIR -name 'dump_*.rdb.gz' -mtime +7 -delete
# 5. 同步到异地(对象存储 / 另一个机房)
# aws s3 cp ... / ossutil cp ...
备份要点:
- 在从节点上做备份,避免影响主节点(fork 开销转移到从节点);
- RDB 文件可以直接拷贝——因为它是通过"写临时文件 + 原子 rename"生成的,不会拷到半成品;
- 备份要异地存储,同机房的备份挡不住机房级故障;
- 定期做恢复演练——没验证过的备份等于没有备份。
8. 高频面试题
Q1:RDB 和 AOF 的区别?各自的优缺点?
本质区别:RDB 记录"数据是什么样"(快照),AOF 记录"数据是怎么变成这样的"(命令日志)。可以类比为"全量备份"和"binlog"。
RDB:
- 优点:文件紧凑体积小、恢复速度快(直接反序列化)、适合冷备灾备、对性能影响小(除 fork);
- 缺点:会丢数据(两次快照之间的写入)、fork 有延迟毛刺、COW 有内存开销、不适合高频执行。
AOF:
- 优点:数据完整性好(everysec 最多丢 1~2 秒)、文本可读可人工修复、写入是持续小量 IO;
- 缺点:文件体积大(重写后改善)、恢复慢(要重放命令,除非开混合持久化)、每次写有额外开销。
生产推荐两个都开:AOF 保证数据不丢,RDB 作定期全量冷备和最后兜底。
Q2:为什么 BGSAVE 用 fork 而不用线程?COW 是怎么工作的?
用 fork 而不是线程的原因:需要一个数据的一致性快照。如果用线程,它和主线程共享同一份内存,主线程边写它边读,会得到一个"半新半旧"的不一致快照。而 fork 出的子进程有独立的地址空间(逻辑上的数据副本),看到的永远是 fork 那一瞬间的状态。
COW(写时复制)流程:
fork()时不复制任何数据页,只复制页表,并把父子进程的所有共享页都标记为只读;- 子进程遍历这份"冻结的"数据写 RDB 文件;
- 父进程要修改某页时,CPU 因写只读页触发缺页异常,内核**复制这一页(4KB)**到新物理页,把父进程页表指向新页并恢复可写,父进程在新页上修改;
- 子进程始终看着老页,数据一致。
关键补充:
- fork 本身是阻塞的(要复制页表),经验值每 GB 内存约 20~100ms,10GB 实例可能阻塞 200ms~1s。用
INFO stats的latest_fork_usec监控; - COW 的额外内存开销典型是 10%~30%,最坏理论翻倍。所以
maxmemory要设为物理内存的 60%~70%; - 读操作也会触发 COW!因为 LRU/LFU 要更新
robj.lru字段——这是很多人不知道的点。
Q3:为什么必须关闭 THP(透明大页)?
THP 把内存页从 4KB 变成 2MB,本意是减少 TLB miss 提升性能。
但对 Redis 有害:COW 的复制粒度是"页"。4KB 页时改一个 key 只复制 4KB;2MB 页时要复制整个 2MB——复制量放大 512 倍。
后果:
- RDB/AOF 重写期间内存暴涨(可能几倍于预期);
- 每次缺页异常要复制 2MB,造成明显的延迟毛刺(可达几十毫秒);
- 极端情况下 fork 后内存不足被 OOM Killer 杀掉。
Redis 启动时会检测并打印警告。必须:
echo never > /sys/kernel/mm/transparent_hugepage/enabled
echo never > /sys/kernel/mm/transparent_hugepage/defrag
Q4:AOF 为什么是"先执行命令后写日志"?和 MySQL 的 WAL 有什么区别?
MySQL 是 WAL(Write-Ahead Log):先写日志再改数据,目的是崩溃后能靠日志恢复或回滚。
Redis 是 “后写日志”:命令执行成功后才写 AOF。
Redis 这样做的好处:
- 不需要做命令合法性检查:记录的是已成功执行的命令,一定合法,恢复时不会因为记录了错误命令而失败(如果先写日志,就得先校验命令,还得处理"日志写了但执行失败"的情况);
- 不阻塞当前命令:执行完就能返回客户端,刷盘可以延后到
beforeSleep。
代价(重点):
- 可能丢数据:命令执行完、客户端已收到成功响应、但 AOF 还没 fsync 时崩溃,这条数据就丢了;
- 无法回滚:日志是事后记的,没有 undo 信息。这是 Redis 事务不支持回滚的底层原因之一。
Q5:AOF 的三种刷盘策略?everysec 真的只丢 1 秒吗?
首先要区分 write(写到内核 page cache,微秒级,断电就丢)和 fsync(真正刷到磁盘介质,毫秒级)。
| 策略 | fsync 时机 | 最多丢失 |
|---|---|---|
always |
每次事件循环结束前 | 单次事件循环的量(接近 0 但非绝对) |
everysec |
每秒一次(bio 后台线程执行) | 约 1~2 秒 |
no |
交给 OS(Linux 默认脏页回写 30 秒) | 约 30 秒 |
everysec 为什么可能丢 2 秒而不是 1 秒:
flushAppendOnlyFile 在 write 之前会检查"上一次的后台 fsync 是否还在进行":
- 如果还在进行中,本次 write 会被推迟(先不写,等下一轮事件循环);
- 但如果推迟已经超过 2 秒,就强制阻塞地 write(主线程在此等待)。
所以磁盘慢的时候,实际丢失窗口可能接近 2 秒,而且主线程会被 AOF 阻塞。监控指标是 INFO persistence 的 aof_delayed_fsync——持续增长说明磁盘性能不足。
Q6:AOF 重写是怎么做的?7.0 的多部分 AOF 解决了什么问题?
重写的本质:不是"压缩分析旧 AOF 文件",而是遍历当前内存里的数据,生成能重建这个状态的最小命令集。所以 100 万次 INCR 会变成一条 SET counter 1000000。
7.0 之前的流程与痛点:
1. fork 子进程,遍历数据快照写临时 AOF
2. 重写期间主线程的新写命令要【写两份】:
├─ 原 aof_buf(保证旧文件仍完整可用)
└─ AOF 重写缓冲区 aof_rewrite_buf(记录增量)
3. 子进程完成后,主线程把 aof_rewrite_buf 【追加】到临时文件 ← 阻塞!
4. rename 替换
三个痛点:重写缓冲区占额外内存(可能几百 MB~GB)、第 3 步追加时阻塞主线程、数据写两遍导致 IO 翻倍。
7.0 的多部分 AOF(Multi-Part AOF):把 AOF 拆成多个文件放在 appendonlydir/ 目录里:
- BASE 文件:全量快照(开混合持久化时是 RDB 格式);
- INCR 文件:增量命令;
- manifest 文件:记录有哪些文件、类型和序号。
新流程:先打开一个新的 INCR 文件让新写入直接进去 → fork 子进程写新 BASE → 完成后更新 manifest → 删旧文件。
结果:不需要重写缓冲区、没有追加阻塞、数据只写一遍。这是 7.0 一个非常实在的改进。
Q7:什么是混合持久化?为什么推荐开启?
aof-use-rdb-preamble yes(4.0+,默认开启)。
开启后,AOF 重写生成的 BASE 部分用 RDB 二进制格式而非命令格式(7.0 前是单文件的"前半段 RDB + 后半段 AOF 命令",7.0+ 是 appendonly.aof.N.base.rdb + appendonly.aof.N.incr.aof)。
好处:
- 文件体积小得多(RDB 是压缩的二进制);
- 恢复速度快几个数量级:加载 BASE 是直接反序列化(快),只有增量部分需要逐条重放命令。10GB 数据可能从"AOF 重放 15 分钟"变成"RDB 加载 30 秒 + 少量命令"。
代价:BASE 部分不再人类可读。但 7.0 的设计下增量 INCR 文件仍是可读文本,兼顾了可观测性。
Q8:Redis 重启时先加载 RDB 还是 AOF?有什么坑?
只要 appendonly yes,就只加载 AOF,RDB 文件被完全忽略(因为 AOF 通常更新更可靠)。
经典事故:
实例原本只开 RDB,有 10GB 数据。运维想加 AOF 更安全:
1. 改配置 appendonly yes
2. 重启
3. 数据全丢!
因为重启时 Redis 去加载 AOF,但 AOF 文件不存在(从没生成过),
于是加载了空数据集,然后创建一个空 AOF。RDB 里的 10GB 被忽略。
如果这时触发 bgsave,空数据还会把 RDB 覆盖掉。
正确做法:动态开启,不要重启(4.0+):
redis-cli CONFIG SET appendonly yes # 会立即触发一次重写,从当前内存生成完整 AOF
# 确认 aof_last_bgrewrite_status:ok 且文件已生成
redis-cli CONFIG REWRITE # 写回配置文件,避免下次重启回退
同理,用 RDB 备份恢复时如果配置开了 AOF,必须先临时把 appendonly 改成 no,加载完验证数据后再动态开启 AOF。
Q9:Redis 最多会丢多少数据?
必须分两层回答:
单机层面(持久化):
- 无持久化:全部;
- RDB:两次快照之间(可能几分钟到一小时);
- AOF
no:约 30 秒(OS 脏页回写间隔); - AOF
everysec:约 1~2 秒; - AOF
always:接近 0(但非绝对零)。
分布式层面(主从复制,这个更容易被忽略且通常影响更大):
Redis 的主从复制是异步的——主节点执行完命令立即返回客户端成功,然后才异步发给从节点。如果主节点在发送前宕机,这批数据就永久丢失(新主节点上没有)。
所以即使 appendfsync always,主从切换仍可能丢数据。
缓解手段:
WAIT numreplicas timeout:阻塞等待 N 个从节点确认。但它只保证从节点收到并写入内存,不保证 fsync,而且性能代价大;WAITAOF numlocal numreplicas timeout(7.0+):保证更强,能等待本地和从节点的 AOF 落盘。
最终结论:如果业务一条数据都不能丢,就不该用 Redis 做主存储。应该写数据库(MySQL 双 1 配置 + 组提交),Redis 只做缓存和加速。
Q10:SAVE 和 BGSAVE 的区别?什么时候会自动触发 BGSAVE?
SAVE:在主线程同步生成 RDB,期间完全阻塞所有客户端请求。10GB 数据可能阻塞几十秒。生产环境绝对禁用(应该 rename 掉)。
BGSAVE:fork 子进程在后台生成,主线程只在 fork 那一瞬间阻塞。
自动触发 BGSAVE 的时机:
- 满足
save <秒> <变更数>规则(多条是 OR 关系,serverCron每 100ms 检查); - 主从全量同步:主节点为从节点生成 RDB(或用
repl-diskless-sync无盘复制直接走 socket); SHUTDOWN(且配置了 save 规则):先存盘再退出;DEBUG RELOAD;FLUSHALL(生成空 RDB);- AOF 重写(开混合持久化时,BASE 部分就是 RDB)。
注意:BGSAVE 和 BGREWRITEAOF 不会同时执行。如果一个在跑,另一个会被排队(aof_rewrite_scheduled),因为两个 fork 并存会让内存和 IO 压力翻倍。
Q11:stop-writes-on-bgsave-error yes 有什么风险?
它的本意是保护:如果快照持续失败(磁盘满、权限错误),说明数据无法落盘,继续接受写入等于积累"注定会丢的数据",所以干脆拒绝写入让你及时发现问题。
风险:磁盘一满,Redis 立刻变成只读,业务大面积报错:
MISCONF Redis is configured to save RDB snapshots, but it is currently
not able to persist on disk.
建议:
- 纯缓存场景(数据能从 DB 重建)→ 设为
no,避免磁盘问题引发业务不可写; - 数据存储场景 → 保持
yes,但必须做磁盘空间监控告警(这才是根本解法)。
应急恢复:CONFIG SET stop-writes-on-bgsave-error no 让业务先跑起来,同时立刻清理磁盘。
Q12:为什么建议单个 Redis 实例不超过 10GB?
四个原因,都和持久化相关:
- fork 耗时线性增长:每 GB 约 20~100ms,10GB 就是 200ms~1s 的阻塞。20GB 实例的 fork 可能卡住 2 秒,客户端大面积超时;
- COW 内存开销:10GB 实例在重写期间可能额外用 1~3GB(甚至更多),要求机器有足够余量;
- 恢复时间长:10GB 的 RDB 加载要几十秒,AOF 重放可能十几分钟。这段时间实例不可用(主从切换时更要命);
- 全量同步压力大:新从节点接入要传输 10GB 数据,占满网络带宽,且主节点要 fork + 生成 RDB。
正确做法:用 Cluster 分片把数据分散到多个小实例(每个 4~8GB),既降低单点风险又能利用多核。
Q13:no-appendfsync-on-rewrite 设成 yes 有什么影响?
它控制"AOF 重写期间,主线程是否还做 fsync"。
no(默认):重写期间主线程照常 fsync。数据最安全,但子进程的大量磁盘写入和主线程 fsync 会抢 IO,可能造成延迟明显升高(尤其机械盘);yes:重写期间主线程不做 fsync(临时降级成appendfsync no)。避免 IO 争抢,延迟稳定,但如果这期间宕机,可能丢失整个重写期间的数据(可能是几十秒的量,远超正常的 1 秒)。
建议:机械盘或 IO 紧张时设 yes 换稳定延迟;SSD 且 IO 有余量时保持 no。更好的办法是把 AOF 放到独立磁盘、或者关掉自动重写改用 crontab 在低峰期手动 BGREWRITEAOF。
Q14:AOF 里为什么记录的是 PEXPIREAT 而不是 EXPIRE?
因为 EXPIRE key 100 是相对时间,含义依赖于"执行的时刻"。如果 AOF 里原样记录 EXPIRE key 100,那么恢复时(可能是 3 小时后)重放这条命令,这个 key 会从恢复时刻起再活 100 秒——本该已经过期的 key 又活过来了。
所以 Redis 统一把过期相关命令转成 PEXPIREAT key <绝对毫秒时间戳> 再写入 AOF 和传播给从节点。恢复时如果这个时间戳已经过去,key 会被直接判定为过期。
这属于一套更普遍的机制——「效果复制」(effects replication),把"有不确定性的命令"转换成"确定的实际效果":
| 原始命令 | 记录的 |
|---|---|
EXPIRE key 100 |
PEXPIREAT key <ts> |
SPOP key(随机) |
SREM key <实际弹出的成员> |
INCRBYFLOAT key 1.1 |
SET key <计算结果>(避免浮点误差累积) |
| 过期删除 | 显式的 DEL/UNLINK |
| Lua 脚本(7.0+ 默认) | 脚本产生的实际写命令而非 EVAL |
Q15:能只在从节点上开持久化吗?
可以,而且是一种常见的优化:主节点关闭持久化(或只保留低频 RDB),从节点开启 AOF + RDB。
好处:把 fork、COW、磁盘 IO 的开销全部转移到从节点,主节点专心处理请求,延迟更稳定。
风险(必须知道):
-
主节点重启会导致数据全部丢失——它自己没有持久化文件。更糟的是:如果主节点重启后变成一个空实例,而从节点还在从它复制,从节点的数据会被清空(全量同步一个空数据集)!
防范:主节点重启后绝不能让它自动作为主节点恢复服务。必须由哨兵/人工确认拓扑,或者先让它作为从节点从有数据的节点同步。
-
必须关闭主节点的自动重启(
Restart=always要改掉),避免它悄悄重启后清空整个集群的数据。
因为这个风险很大,Redis 官方文档明确不推荐"主节点完全关闭持久化"。折中方案是:主节点保留 RDB(低频,如 save 3600 1)作为兜底,AOF 只在从节点开。
小结
- RDB 是数据快照(“数据长什么样”),AOF 是命令日志(“怎么变成这样的”);生产推荐两个都开——AOF 保数据完整性,RDB 作定期冷备和兜底。
SAVE阻塞主线程,生产禁用;BGSAVE用 fork。fork 需要的是一致性快照,这是不能用线程的原因。- COW:fork 只复制页表并把页标记只读,父进程写时触发缺页异常复制该页。fork 本身阻塞,每 GB 约 20~100ms(监控
latest_fork_usec);COW 额外内存典型 10%~30%;读操作也会触发 COW(更新robj.lru)。 - 必须做的系统配置:
vm.overcommit_memory = 1(否则 fork 失败)、关闭 THP(否则 COW 粒度从 4KB 变 2MB,放大 512 倍)。 - AOF 是"先执行后写日志"(与 MySQL WAL 相反),好处是不用校验命令、不阻塞执行;代价是可能丢数据且无法回滚(这是 Redis 事务不支持回滚的底层原因之一)。
- 区分
write(进 page cache,微秒级) 和fsync(真落盘,毫秒级);三种策略always/everysec/no,everysec实际可能丢 2 秒(write 被推迟),监控aof_delayed_fsync。 - AOF 重写是"遍历当前内存重新生成最小命令集",不是压缩旧文件。7.0 的多部分 AOF(BASE + INCR + manifest)消除了重写缓冲区的内存开销、追加时的阻塞和双写放大。
- 混合持久化
aof-use-rdb-preamble yes:BASE 用 RDB 格式,恢复速度快几个数量级。 - 只要
appendonly yes就只加载 AOF、完全忽略 RDB——这是"改配置重启后数据全丢"事故的根源。正确做法是CONFIG SET appendonly yes动态开启 +CONFIG REWRITE。 - 效果复制:
EXPIRE→PEXPIREAT、SPOP→SREM、INCRBYFLOAT→SET、Lua→实际写命令,保证恢复和主从的确定性。 - 数据丢失要分两层看:单机持久化窗口(everysec 约 1~2 秒)+ 异步主从复制的丢失(主节点写完就返回,没传到从节点就宕机则永久丢失)。
WAIT/WAITAOF能缓解但代价大。真正不能丢数据就别把 Redis 当主存储。 - 单实例建议 不超过 10GB:fork 耗时、COW 开销、恢复时间、全量同步压力全都随内存线性恶化。用 Cluster 分片。
- 必须告警的指标:
rdb_last_bgsave_status、aof_last_bgrewrite_status、aof_last_write_status、latest_fork_usec > 100ms、aof_delayed_fsync持续增长。 - 持久化只解决"重启能恢复",不解决"宕机不可用"——高可用要靠主从 + 哨兵/Cluster + 异地备份,且备份必须做恢复演练。
xingliuhua