宿主机重启,Redis 丢了 5 分钟的数据
4 月 20 号凌晨,云厂商的一台宿主机异常重启,我们的 Redis 主节点正好在上面。进程恢复之后,运营来反馈:03:12 到 03:17 之间领取的优惠券,用户端显示"未领取",但业务库里记录是有的。
核对下来丢了 2347 条。我们 Redis 5.0.4 只开了 RDB,当时我还不太理解"丢了 5 分钟"这个数字是怎么来的,查了一遍配置才明白。
为什么会丢:RDB 是定时快照
RDB 的思路是"把某一刻内存里的数据完整地拍一张照片存成二进制文件"。触发方式有三种:
- 配置文件里的
save规则(定时 + 变更次数) - 手动执行
SAVE(阻塞主线程)或BGSAVE(fork 子进程) - 主从复制时主节点自动触发
我们的配置是 Redis 的默认模板:
save 900 1 # 900 秒内至少 1 个 key 变化
save 300 10 # 300 秒内至少 10 个 key 变化
save 60 10000 # 60 秒内至少 10000 个 key 变化
dbfilename dump.rdb
dir /data/redis
翻事故时的 Redis 日志:
2186:M 20 Apr 2019 03:12:41.107 * 10000 changes in 60 seconds. Saving...
2186:M 20 Apr 2019 03:12:41.112 * Background saving started by pid 31842
31842:C 20 Apr 2019 03:12:44.883 * DB saved on disk
2186:M 20 Apr 2019 03:12:44.951 * Background saving terminated with success
# 03:17:12 进程被杀,中间再没有任何保存记录
最后一次落盘是 03:12:44,进程 03:17:12 挂掉,中间这 4 分 28 秒的写入全部在内存里,没来得及写盘。RDB 的丢失窗口就是"两次快照之间的时间间隔",配置里最短的 save 60 10000 意味着最坏情况下丢一分钟的数据,我们运气不好,正好赶上写入量没到 10000 的阈值,规则一直没被触发。
RDB 还有个容易被忽略的开销:BGSAVE 要 fork 子进程,用的 copy-on-write 机制。子进程共享父进程的内存页,父进程修改某个页时才复制一份。理论上复制量不大,但 fork 系统调用本身在内存大时耗时可观。用这个命令能看最近一次 fork 的耗时(微秒):
127.0.0.1:6379> INFO stats | grep latest_fork_usec
latest_fork_usec:124318
我们 Redis 内存 8.2G,fork 花了 124 毫秒。这 124 毫秒里主线程是阻塞的,P99 延迟会有一个明显的尖刺。这也是为什么不建议把 save 配得太密。
AOF:把写命令记下来
AOF(Append Only File)的思路完全不同:不存数据快照,而是把每一条会修改数据的命令以文本协议格式追加到文件末尾。恢复的时候,把文件里的命令从头到尾重放一遍。
看一眼 AOF 文件的内容,就是 Redis 协议本身:
$ redis-cli set coupon:1001:user:88 1
$ tail -5 appendonly.aof
*3
$3
SET
$22
coupon:1001:user:88
$1
1
AOF 的写入分两步:命令先写进 aof_buf 缓冲区,再由 flushAppendOnlyFile 决定什么时候 write 到文件、什么时候 fsync 到磁盘。真正决定丢多少数据的是 appendfsync 这个参数:
| appendfsync | 行为 | 丢失窗口 | 适用场景 |
|---|---|---|---|
| always | 每条命令都 fsync | 只丢当前这条 | 金融级,性能很差 |
| everysec(默认) | 后台线程每秒 fsync 一次 | 最多 1 秒 | 绝大多数场景 |
| no | 只 write,由操作系统决定 fsync 时机 | 最多一个页缓存周期,通常 30 秒 | 不推荐 |
everysec 还有个细节:如果后台 fsync 超过 2 秒还没完成,Redis 主线程会阻塞等待(因为它怕继续写会让缓冲区膨胀得更厉害)。这个在磁盘 IO 打满的时候会发生,我们监控过一次,当时另一个进程在做大量日志压缩。
实测:三种配置的性能差别
在测试机上用 redis-benchmark 跑了对比(8 核 16G,SSD,50 并发,10 万次请求):
redis-benchmark -n 100000 -c 50 -t set,get -q
| 配置 | SET QPS | GET QPS | P99 延迟 |
|---|---|---|---|
| 完全关闭持久化 | 118,300 | 121,900 | 0.42 ms |
| RDB(save 60 10000) | 96,100 | 105,200 | 1.15 ms |
| AOF everysec | 88,700 | 101,400 | 1.32 ms |
| AOF always | 12,500 | 97,800 | 8.24 ms |
always 的 SET 性能掉到十分之一,因为每条命令都要等一次磁盘 fsync。SSD 上单次 fsync 大约 0.08ms,看起来不多,但它把 Redis 的单线程优势全废了。我们没用这个模式。
从 RDB 换成 AOF everysec,SET 吞吐大概掉 8%,换来的是丢失窗口从"最多 60 秒"变成"最多 1 秒"。这笔交易我认为非常划算。
混合持久化
AOF 有个明显缺点:文件会一直变大。同样是 8000 万个 key,RDB 是压缩二进制,AOF 是命令文本,能差好几倍。而且 Redis 重启时重放 AOF 特别慢。
我们测过 8GB 数据集的启动加载时间:
| 方式 | 文件大小 | 加载耗时 |
|---|---|---|
| RDB | 2.1 GB | 42 秒 |
| 纯 AOF | 7.4 GB | 3 分 20 秒 |
| 混合持久化 | 2.3 GB | 55 秒 |
Redis 4.0 引入的混合持久化把两者的优点合在了一起:执行 BGREWRITEAOF 时,新的 AOF 文件前半段是 RDB 格式的二进制数据,后面追加的是重写期间的增量命令。文件内容长这样:
$ head -c 100 appendonly.aof | xxd | head -3
00000000: 5245 4449 5330 3030 39fa 0972 6564 6973 REDIS0009..redis
00000010: 2d76 6572 0535 2e30 2e34 fa0a 7265 6469 -ver.5.0.4..redi
...
# 前半段是 RDB,往后才是 *3\r\n$3\r\nSET\r\n 这种命令格式
开启只需要一行,Redis 5.0 里已经默认打开了:
appendonly yes
appendfilename "appendonly.aof"
appendfsync everysec
auto-aof-rewrite-percentage 100 # 比上次重写后大 100% 就触发
auto-aof-rewrite-min-size 64mb # 且不小于 64MB
aof-use-rdb-preamble yes # 混合持久化,4.0+ 支持,5.0 默认开启
我们最终的主节点配置就是这个。恢复速度接近 RDB,数据安全接近 AOF。
AOF 的日常运维
几个实际会用到的命令和操作。
手动触发重写(别用 BGREWRITEAOF 之外的方式):
127.0.0.1:6379> BGREWRITEAOF
Background append only file rewriting started
如果 AOF 文件在写入过程中机器宕机,最后一条命令可能只写了一半,Redis 启动时会报错拒绝加载:
Bad file format reading the append only file: make a backup, then use
./redis-check-aof --fix <filename>
按提示修复,它会扫描文件并截断最后那条不完整的命令:
$ redis-check-aof --fix appendonly.aof
0x 1a4f3c2: Expected prefix '*', got: '$'
AOF analyzed: size=27483921, ok_up_to=27483885, diff=36
This will shrink the AOF from 27483921 bytes to 27483885 bytes
Continue? [y/N]: y
Successfully truncated AOF
最多丢最后一条命令,可以接受。修复前一定先备份一份 AOF 文件,我有同事直接跑 fix 结果把文件搞坏了,还好之前做了 cp。
我们最后的方案
事故之后做了这几件事:
- 主节点开 AOF(everysec + 混合持久化),从节点保留 RDB 用于做冷备和快速拉起
- 把
latest_fork_usec和aof_delayed_fsync(fsync 被延迟的次数)加进了监控,超过阈值告警 - 优惠券领取这种"不能丢"的数据,改成先写 MySQL 再同步到 Redis,用 Canal 订阅 binlog 补偿,Redis 只当缓存用
- 写了个每月一次的恢复演练脚本,把 RDB 拉到测试机恢复一遍,确认备份是有效的
最后一条是 DBA 提醒我的:备份不做恢复演练,等于没有备份。我们第一次演练就发现,其中一个从节点的 RDB 文件三周前就停止更新了,磁盘满了没人知道。
小结
- RDB 的丢失窗口是"两次快照之间",AOF everysec 的丢失窗口是 1 秒。前者适合做冷备和快速恢复,后者保证数据安全。
- 不要二选一,开混合持久化(
aof-use-rdb-preamble yes)两个都要:恢复速度接近 RDB,数据安全接近 AOF。 appendfsync always会让写性能掉到十分之一,除非业务真的不能丢一条数据,否则用 everysec。- 持久化不是备份。RDB 文件要定期拉到别的机器,还要定期演练恢复。
最后补一句,我们这次丢数据还暴露出另一个问题:主从之间是异步复制,主节点挂掉时如果数据没同步到从节点,即使从节点有 RDB 也救不回来。持久化解决的是"进程重启丢数据",复制解决的是"机器挂了丢数据",两件事要分开考虑。