跳转至

Redis 持久化:RDB / AOF / 混合持久化

💡 一句话概述

Redis 的数据在内存里,断电就没了,所以需要把它落到磁盘:RDB 是「拍照」(定时把整个内存状态存成二进制快照,恢复快但两次快照之间的数据会丢),AOF 是「记流水账」(每条写命令追加到文件,重启时重放,安全但文件大恢复慢),混合持久化是「照片 + 流水账」(AOF 重写时前半段用 RDB 二进制、后半段用增量命令,既快又不丢)——这是生产环境的默认答案。


🔑 核心概念

  1. RDB(快照) — 把某一时刻内存里的**全部键值状态**序列化成一个紧凑的二进制文件(dump.rdb),恢复时直接载入,不执行任何命令。存的是「结果」。
  2. AOF(追加日志) — 把每一条**写命令**以 RESP 协议文本追加到文件末尾,恢复时从头到尾**重放**这些命令。存的是「过程」。
  3. AOF 重写 — 不是压缩旧文件,而是**遍历当前内存**,为每个还存在的 key 生成一条能重建其最终值的最小命令。本质是把「命令序列」转换成「键值状态快照」。
  4. 混合持久化(aof-use-rdb-preamble yes) — AOF 重写时,文件前半段用 RDB 二进制写全量快照,后半段用 AOF 文本记录重写期间的增量写入。加载时先快速载入 RDB 段,再重放少量增量。
  5. appendfsync — 决定「多久把缓冲区真正刷到磁盘」的旋钮,它**直接等于你允许丢多少数据**:always 每条命令刷、everysec 每秒刷(默认)、no 交给操作系统。

📝 详解

1. 为什么 Redis 需要持久化(以及什么时候真的可以不要)

Redis 把所有数据放在内存里,这是它快的根本原因,也是它脆弱的根本原因。只要发生下面任意一件事,内存里的数据就彻底没了:

  • 机器断电、内核 panic、云主机被强制回收
  • kill -9 redis、OOM Killer 把它干掉、进程 segfault
  • 发版重启、容器被驱逐(K8s Pod 重启后本地磁盘也可能没了)

持久化要解决的就是一件事:进程/机器死掉之后,重启能把数据找回来。

但这里有个很多人搞反的前提 —— Redis 并不是非持久化不可。

如果你的 Redis 只是缓存:DB 是唯一真相源,缓存里的每一条数据都能从 DB 查回来,那丢了就丢了,顶多重启后一段时间命中率低、DB 压力大一点。这种场景下关掉持久化反而是对的:省掉 fork 的卡顿、省掉 fsync 的 IO、省掉磁盘空间和重写时的内存峰值。

真正的分水岭是这一个问题:

Redis 里有没有「别处查不到」的数据?

你在 Redis 里存了什么 丢了会怎样 要不要持久化
商品详情缓存、用户信息缓存、Session 副本 回 DB 查一次就行,短暂压力大 ❌ 可以不开,或只开 RDB 图个省事
分布式锁(SETNX lock:xxx) 锁凭空消失 → 两个客户端同时进临界区 ✅ 必须,但光靠持久化还不够(见第 8 节)
秒杀库存预扣、限流计数器 库存超卖、限流形同虚设 ✅ 必须开 AOF
Write-Behind 异步落库的中间态(先写缓存、后台批量刷 DB) 这批写永久丢失,DB 里再没有 ✅✅ 必须,而且要想清楚 Redis 能不能当唯一真源
延迟队列、排行榜、消息 ACK 状态 任务丢失、无法重放 ✅ 必须开 AOF

一句话判断

缓存丢了叫「回源」,数据丢了叫「事故」。 只要你把 Redis 从「缓存」升级成了「存储」,持久化就从可选项变成了必选项。


2. RDB:给内存拍张照

RDB(Redis Database)就是字面意思:把内存里的数据在某一刻的样子,完整地拍下来存成一个二进制文件。

它有两个命令,差别大到能决定你的服务是不是会挂:

SAVE BGSAVE
谁来干活 主进程自己 fork 出来的子进程
主进程状态 完全阻塞,不响应任何请求 几乎不受影响(只有 fork 那一下)
10GB 实例耗时 几十秒,期间服务不可用 子进程慢慢写,主进程照常服务
生产环境 🚫 绝对禁用 ✅ 标准做法

SAVE 阻塞的原因很直白:Redis 处理命令是**单线程**的,主进程跑去遍历整个内存写文件了,就没人处理客户端请求了。这不是性能下降,是**服务完全停摆**。

BGSAVE 与写时复制(COW)

BGSAVE 的魔法在于 fork():

  1. 主进程调用 fork(),操作系统创建出一个子进程
  2. 子进程**继承父进程的全部内存数据**——但不是真的复制一份,而是父子共享同一批物理内存页,页表项被标记为只读
  3. 子进程遍历这份内存,把数据写成一个临时 RDB 文件,写完 fsync 再 rename 原子替换掉旧的 dump.rdb
  4. 主进程继续正常处理请求

问题来了:子进程正在读,父进程同时在写,怎么保证子进程拍到的是「fork 那一刻」的一致快照?

答案是 COW(Copy-On-Write,写时复制):父进程要修改某一页时,触发缺页中断,内核先把这一页**复制一份**给父进程改,子进程仍然指向原来那份没被动过的旧页。

fork() 瞬间:父子共享同一批物理页,页表项标记为只读

  父进程页表          物理内存          子进程页表
  ┌────────┐        ┌────────┐        ┌────────┐
  │ A      │───────►│ A: k=1 │◄───────│ A      │
  │ B      │───────►│ B: x=2 │◄───────│ B      │
  └────────┘        └────────┘        └────────┘

父进程执行 SET k 3(要写页 A)→ 缺页中断 → 内核复制出一份 A'

  ┌────────┐        ┌──────────┐
  │ A      │───────►│ A' : k=3 │   ← 父进程改自己的副本
  └────────┘        └──────────┘
                    ┌──────────┐
  子进程仍指向 ────►│ A  : k=1 │   ← 子进程看到的还是 fork 那一刻的旧值
                    └──────────┘

  页 B 没人写 → 父子继续共享,一份都不多占
sequenceDiagram
    participant C as 客户端
    participant M as Redis 主进程
    participant K as Linux 内核
    participant S as 子进程
    C->>M: BGSAVE
    M->>K: fork()
    K->>S: 创建子进程(共享物理页,标记只读)
    M-->>C: Background saving started(立即返回)
    S->>S: 遍历内存 → 写临时 RDB 文件
    C->>M: SET k v(正常写入)
    M->>K: 修改某个内存页
    K->>K: COW:复制该页给父进程
    Note over M,S: 子进程眼里的数据永远停在 fork 那一刻
    S->>S: fsync 临时文件 → rename 覆盖 dump.rdb
    S->>M: 子进程退出,主进程收到 SIGCHLD

RDB 的四种触发方式

触发方式 说明
save m n 配置 m 秒内发生了 n 次写,就自动 BGSAVE。可配多条,任一满足即触发
手动 BGSAVE redis-cli BGSAVE,或代码里调
执行 SHUTDOWN 没开 AOF 时,Redis 退出前会做一次 SAVE(开了 AOF 就直接退出,反正日志能恢复)
主从全量同步 从节点第一次连上来(或复制积压缓冲区不够用),主节点**自动 BGSAVE** 生成 RDB 发给从节点,期间的写入用复制缓冲区补上

最后一条经常被忽略,但很重要:哪怕你配了 save "" 关掉 RDB 持久化,主从全量同步时主节点照样会 fork 一次。

RDB 配置片段

# ===== RDB 快照 =====
# 900 秒内至少 1 次写 → 快照
# 300 秒内至少 10 次写 → 快照
# 60 秒内至少 10000 次写 → 快照
save 900 1
save 300 10
save 60 10000

# save ""                      # 完全关闭 RDB(纯缓存场景)

stop-writes-on-bgsave-error yes   # BGSAVE 失败就拒绝写入,防止你以为在存其实没存
rdbcompression yes                # 用 LZF 压缩字符串对象,CPU 换空间,建议开
rdbchecksum yes                   # 文件尾加 CRC64 校验,加载时验完整性
dbfilename dump.rdb               # 文件名
dir /var/lib/redis                # 数据目录(AOF 也放这里)
rdb-save-incremental-fsync yes    # 增量 fsync,避免一次性刷 4GB 造成长时间卡顿

RDB 优缺点

优点 缺点
文件**紧凑**,是二进制编码 + 可选压缩,同样数据体积通常只有 AOF 的几分之一 两次快照之间的数据会全部丢失——save 900 1 意味着最坏情况丢 15 分钟
恢复极快:直接把二进制反序列化进内存,不需要执行任何命令,10GB 数据可能只要几十秒 fork 大内存实例有卡顿风险:fork 要复制页表,耗时和内存大小成正比,几 GB 的实例可能卡几十到几百毫秒
天生适合**备份和灾难恢复**:单个文件、可以直接拷走、可以按时间点归档(dump-20260907.rdb) COW 在写入高峰期可能复制大量页,内存峰值接近 2 倍
对主进程性能影响小(脏活都在子进程) 快照频率是「丢多少数据」和「fork 多频繁」之间的硬权衡,没有两全
# 运维时最常用的两个观测指标
redis-cli INFO persistence | grep -E "rdb_last_bgsave_status|rdb_changes_since"
redis-cli INFO stats | grep latest_fork_usec      # 上一次 fork 耗时(微秒),这个值大就说明要拆分实例了

3. AOF:把每条写命令记成流水账

AOF(Append Only File)的思路完全反过来:不存数据,只存「你对数据做了什么」。

每执行一条写命令,Redis 就把它以 RESP 协议格式追加到 AOF 文件末尾。重启时,Redis 打开这个文件,从第一条命令开始**逐条重新执行**一遍,内存状态就被重建出来了。

客户端发来 SET mikey hello,AOF 文件里追加的就是这段 RESP 文本:

*3\r\n        ← 这条命令有 3 个参数
$3\r\nSET\r\n ← 第 1 个:长度 3 的 "SET"
$5\r\nmikey\r\n
$5\r\nhello\r\n

注意几个细节:

  • 只记写命令。GET、MGET、TTL 这些读命令不会进 AOF(它们不改数据)
  • 记的是命令,不是数据变化。INCRBY counter 1 记的就是这条命令本身,不是「counter 从 5 变成 6」
  • 会做一些等价改写:过期 key 被删除时会追加 DEL,EXPIRE 到期的 key 会记录删除动作

写命令的完整路径:四个环节

理解 appendfsync 之前,必须先看清一条写命令从执行到落盘要过几道关:

客户端 SET k v
      │
      ▼
┌──────────────────┐  ① 执行命令,改内存 —— 到这一步客户端就已经收到 OK 了
│  Redis 主进程     │─────────────────────────► 内存数据集
│                  │
│                  │  ② 把命令以 RESP 文本追加进 aof_buf(用户态缓冲区)
└──────────────────┘
      │
      │  ③ write():aof_buf → OS page cache(内核态缓存,还没到磁盘!)
      ▼
┌──────────────────┐
│   OS Page Cache  │
└──────────────────┘
      │
      │  ④ fsync():page cache → 磁盘(只有这一步做完,断电才真的不丢)
      ▼
┌──────────────────┐
│  appendonly.aof  │
└──────────────────┘

appendfsync 这个配置,就是在决定「第 ④ 步多久做一次」

三种 appendfsync

取值 行为 丢失窗口 性能 适用
always 每条写命令都 fsync 之后才回复客户端 Redis 进程崩溃:0;整机断电 + 磁盘 write-back 缓存:可能丢最后几条 最慢。每条命令一次同步磁盘 IO,QPS 可能掉一个数量级 数据绝对不能丢,且能接受吞吐牺牲
everysec 后台线程每秒 fsync 一次(默认) 最多 1 秒 的写入 快。绝大多数时候只是内存追加 生产默认,性价比最高
no Redis 从不主动 fsync,完全交给 OS 决定 取决于内核刷盘策略,通常 几十秒 最快 基本别用

everysec 还有个容易踩的细节:如果上一次的 fsync 还没完成,主线程在写新命令时**会被阻塞**。所以当磁盘 IO 抖动时,everysec 也可能出现毛刺。这时可以配:

no-appendfsync-on-rewrite yes   # BGSAVE / BGREWRITEAOF 期间暂停 fsync,避免「重写占满 IO + fsync 阻塞」双重打击

代价是这段时间的刷盘策略退化成 no,整机断电可能丢更多数据。

AOF 配置片段

# ===== AOF =====
appendonly yes                          # 打开 AOF(默认 no)
appendfilename "appendonly.aof"         # Redis 6.x 及以前的文件名
# appenddirname "appendonlydir"         # Redis 7.0+ 多文件 AOF 的目录(见第 5 节)
appendfsync everysec                    # always / everysec / no
no-appendfsync-on-rewrite no            # 重写期间是否暂停 fsync
auto-aof-rewrite-percentage 100         # AOF 比上次重写后增长 100%(翻倍)就自动重写
auto-aof-rewrite-min-size 64mb          # 但文件至少要有 64MB 才重写(避免小文件反复重写)
aof-use-rdb-preamble yes                # 混合持久化(Redis 4.0 引入,5.0 起默认 yes)
aof-rewrite-incremental-fsync yes       # 重写时分块 fsync,避免一次性刷大文件造成延迟尖峰

AOF 优缺点

优点 缺点
数据安全性高:丢失窗口从「上次快照到现在」压缩到「1 秒」甚至「0」 文件大:文本格式,同样数据比 RDB 大好几倍(重写后能缓解,但仍偏大)
可读、可审计:appendonly.aof 是文本,能直接 grep 出「谁在什么时候删了这个 key」,排查误操作很有用 恢复慢:要逐条解析并执行命令,几 GB 的 AOF 重放可能要几分钟到十几分钟
文件损坏可修:redis-check-aof --fix 能截掉尾部损坏的部分(RDB 二进制损坏基本没救) 写放大:每条命令多一次内存追加 + 定期 fsync,吞吐略低于关持久化
追加写是顺序 IO,对磁盘友好 重写期间有 fork + 缓冲区开销,大实例要小心内存
# AOF 文件损坏时的修复工具(会丢掉损坏点之后的所有命令,先备份!)
redis-check-aof --fix /var/lib/redis/appendonlydir/appendonly.aof.1.incr.aof

4. AOF 重写:从「命令序列」到「键值状态快照」

AOF 是「只追加」的,所以它必然无限膨胀。想想这个场景:一个计数器每秒 INCR 一次,跑一天就是 86400 条命令写进文件——但恢复时你真正需要的只有一条:SET counter 86400。

这就是 AOF 重写(Rewrite)要解决的问题。

重写的本质:不是压缩文件,是重新生成

关键认知:AOF 重写不会去读旧的 AOF 文件。

它做的是:遍历当前内存里的数据集,为每个还存在的 key 生成一条能重建其最终值的最小命令,写进一个新文件,然后原子替换掉旧文件。

所以重写的本质是一次**数据结构的转换**:

重写前:AOF 是「命令序列」(log 结构,一条时间线)
        → 记录「怎么改」,包含大量互相覆盖的中间过程

重写后:AOF 是「键值状态快照」(hashmap 结构的等价描述)
        → 记录「改成什么样」,只保留最终结果

一个具体例子

假设 AOF 里按时间顺序追加了这 5 条命令:

SET k 1        ← 中间态,被后面覆盖
INCRBY k 5     ← 中间态,k 变成 6
SET k 9        ← 中间态,被后面覆盖
DEL k          ← 中间态,key 被删了
SET k 2        ← 最终态

触发 BGREWRITEAOF 之后,Redis 遍历内存,发现只有一个 key k,值是 2。于是新文件里**只有一条命令**:

SET k 2

前 4 条全部消失。恢复效果完全一致(k = 2),文件从 5 条变成 1 条。

再看一个更能说明问题的例子:

SET user:1001 zhangsan
SET user:1001 zhangsanfeng
SET user:1002 lisi
DEL user:1001
INCR view:homepage
SET counter 5
INCRBY counter 1
DEL counter

8 条命令,重写后:

key 重写后写入什么 为什么
user:1001 什么都不写 最后被 DEL 了,内存里根本不存在这个 key
user:1002 SET user:1002 lisi 唯一一次写入,仍是最终值
view:homepage SET view:homepage <当前计数值> 计数器不用保留那串 INCR,直接写终值
counter 什么都不写 也被 DEL 了

8 条 → 2 条。注意 user:1001 那一点:已经被删除的 key 连一条 DEL 都不用写,因为「不存在」本身就是它的状态,重放一个空数据库时它自然就不存在。

重写期间主进程还在写,怎么办?

重写是子进程干的活(fork + COW,和 BGSAVE 一样),但主进程不能停下来——业务还在写。如果只让子进程写新文件,那重写期间的写入就丢了。

Redis(7.0 之前)的做法是**双写 + 增量补齐**:

sequenceDiagram
    participant C as 客户端
    participant M as 主进程
    participant S as 子进程(重写)
    M->>S: BGREWRITEAOF → fork()
    S->>S: 遍历内存,为每个 key 生成最小重建命令 → 写临时新 AOF
    C->>M: 写命令 W1
    M->>M: ① 追加到【旧 AOF】(万一重写失败,旧文件还能恢复)
    M->>M: ② 追加到【AOF 重写缓冲区】(aof_rewrite_buf)
    C->>M: 写命令 W2
    M->>M: 同样双写
    S->>M: 重写完成,通知主进程
    M->>M: ③ 把重写缓冲区里的增量(W1、W2)追加到新 AOF 末尾
    M->>M: ④ fsync 新文件 → rename 原子替换旧 AOF → 删除旧文件

要点:

  1. 双写:重写期间每条写命令同时进「旧 AOF」和「重写缓冲区」。旧 AOF 是保险——重写崩了还能退回原样。
  2. 增量补齐:子进程写完 base 之后,主进程把缓冲区里的增量命令追加到新文件末尾,保证一条不漏。
  3. 原子替换:rename 是原子操作,不存在「一半新一半旧」的中间状态。

重写期间的内存开销

重写缓冲区是**内存里的一块链表**。如果重写很慢(实例大、磁盘 IO 差),而业务写入很快,这个缓冲区会一直涨,加上 COW 复制的页,内存峰值可能接近平时的 2 倍,极端情况被 OOM Killer 干掉。大实例上线前一定要压测这个场景,并且给足内存余量。

自动重写的触发条件

参数 含义 默认
auto-aof-rewrite-percentage 当前 AOF 体积比**上次重写后**增长了多少百分比就触发。100 = 翻倍 100
auto-aof-rewrite-min-size 但文件至少要这么大才触发,避免几十 KB 的小文件反复重写 64mb
no-appendfsync-on-rewrite 重写期间是否暂停 fsync no

两个条件是「与」关系:增长超过 100% 且 文件超过 64MB,才自动重写。设成 0 表示禁用自动重写。

# 手动触发(生产上建议低峰期执行)
redis-cli BGREWRITEAOF
# Background append only file rewriting started

# 观察重写状态
redis-cli INFO persistence | grep -E "aof_rewrite_in_progress|aof_current_size|aof_base_size|aof_pending_rewrite"

5. 混合持久化:RDB 的头 + AOF 的尾

到这里矛盾已经很清楚了:

  • RDB 恢复快(二进制直接载入),但**不安全**(两次快照之间全丢)
  • AOF 安全(最多丢 1 秒),但**恢复慢**(几 GB 命令要逐条重放)

Redis 4.0 引入了混合持久化(aof-use-rdb-preamble yes),5.0 起默认开启。它的做法非常聪明:

在 AOF 重写的时候,新文件的前半部分不用 RESP 文本写命令,而是直接用 RDB 二进制格式写一份全量快照;后半部分才用 AOF 格式追加重写期间产生的增量命令。

这样你既拿到了 AOF 的数据安全性(增量命令一条不漏,丢的还是 1 秒),又拿到了 RDB 的加载速度(全量部分是二进制,直接载入不用重放)。

AOF 文件结构

appendonly.aof(混合持久化,Redis 4.0+)
┌────────────────────────────────────────────────────────────┐
│ ① 前半段:RDB 二进制格式(全量快照)                          │
│                                                            │
│    "REDIS0009" │ 紧凑编码的键值对 ... │ EOF │ CRC64 校验     │
│                                                            │
│    ← 重写子进程遍历内存生成,占文件绝大部分体积                │
│    ← 载入方式:直接反序列化,不执行任何命令,快                │
├────────────────────────────────────────────────────────────┤
│ ② 后半段:AOF 文本格式(增量命令)                            │
│                                                            │
│    *3\r\n$3\r\nSET\r\n$1\r\nk\r\n$1\r\nv\r\n               │
│    *2\r\n$6\r\nAPPEND\r\n...                               │
│                                                            │
│    ← 重写期间主进程新写入的命令,条数很少                      │
│    ← 载入方式:逐条重放,慢但量小,可忽略                      │
└────────────────────────────────────────────────────────────┘

Redis 靠文件头是不是 "REDIS" 这个 magic string 来判断:
是 → 混合格式(RDB 段 + AOF 段);不是 → 纯 RESP 文本 AOF

加载流程

Redis 启动
    │
    ▼
┌───────────────────────────────────────────┐
│ appendonly yes?                           │
│   是 → 加载 AOF(数据更全,优先级高于 RDB)  │
│   否 → 加载 dump.rdb                       │
└───────────────────────────────────────────┘
    │(走 AOF 分支)
    ▼
┌───────────────────────────────────────────┐
│ 读文件头 5 字节                             │
│   "REDIS" → 混合持久化,走下面两步           │
│   "*"     → 纯 AOF,逐条重放到文件尾         │
└───────────────────────────────────────────┘
    │
    ▼
┌───────────────────┐  秒级(二进制反序列化)  ┌─────────────────────┐
│ 第 1 步:载入 RDB 段│ ─────────────────────► │ 内存里已有 99% 的数据 │
└───────────────────┘                        └─────────────────────┘
    │
    ▼
┌───────────────────┐  毫秒级(命令条数少)    ┌─────────────────────┐
│ 第 2 步:重放 AOF 段│ ─────────────────────► │ 补齐最后一点增量      │
│     增量命令        │                        │ → 数据完整可用 ✅     │
└───────────────────┘                        └─────────────────────┘

效果对比:10GB 数据、纯 AOF 重放可能要 10 分钟;混合持久化下 RDB 段几十秒载入完,增量段只有重写期间那点命令,几乎瞬间完成。启动时间从「分钟级」压到「秒级」。

Redis 7.0:多文件 AOF(Multi Part AOF)

Redis 7.0 把「一个巨大的 AOF 文件」拆成了「一组文件 + 一个清单」:

appendonlydir/                          ← 由 appenddirname 指定
├── appendonly.aof.1.base.rdb           ← base:全量部分(RDB 格式)
├── appendonly.aof.1.incr.aof           ← incr:base 之后的增量命令
├── appendonly.aof.2.incr.aof           ← 新一轮重写产生的增量文件
└── appendonly.aof.manifest             ← 清单:谁是 base、哪些 incr 有效

命名规则:appendonly.aof.<序号>.<base|incr>.<rdb|aof>

重写流程变成:

  1. 子进程生成新的 base 文件
  2. 主进程的增量命令**直接写进当前的 incr 文件**——不再需要 7.0 之前那个独立的「AOF 重写缓冲区」
  3. 新 base 写完 → 原子改写 manifest(把新 base 登记进去,旧文件标记为可删)
  4. 删除旧的 base / incr 文件

好处很实在:

7.0 之前 7.0 多文件 AOF
重写期间的增量 堆在内存里的重写缓冲区 直接落 incr 文件,内存压力小得多
替换旧文件 rename 一个大文件 改一个很小的 manifest,更轻量、更不容易出错
文件损坏范围 整个 AOF 可能受影响 只影响单个 incr 文件

配置兼容性

Redis 7.0 引入了 appenddirname(默认 appendonlydir)来指定这组文件的目录,appendfilename(默认 appendonly.aof)从「文件名」变成了「文件名前缀」——真正的文件叫 appendonly.aof.1.base.rdb 这种。aof-use-rdb-preamble 依然是控制 base 段用 RDB 还是 AOF 格式的开关,默认 yes。


6. 三种方案对比与选型

横向对比

维度 只开 RDB 只开 AOF(everysec,未开混合) RDB + AOF + 混合持久化
数据安全性 🔴 低:丢「上次快照 → 宕机」之间的**全部**写入,可能几分钟到几十分钟 🟢 高:正常最多丢 1 秒 🟢 高:等同 AOF(RDB 段只是 AOF 文件的一部分,不影响安全性)
文件体积 🟢 最小,二进制 + 可压缩 🔴 最大,RESP 文本;重写后接近 RDB 但仍偏大 🟡 中:AOF ≈ RDB 体积 + 少量增量命令
恢复速度 🟢 最快,直接反序列化 🔴 最慢,逐条重放命令 🟢 快:RDB 段快速载入 + 少量增量重放
对运行性能影响 fork 卡顿(大实例明显);快照期间 CPU/IO 升高 每条命令多一次内存追加;后台 fsync 线程;重写时 fork + 缓冲区 同 AOF,但重写后文件更小、要 fsync 的数据更少
可审计性 🔴 二进制,看不出谁改了什么 🟢 文本,可 grep 🟡 base 段二进制,incr 段可 grep
适合做什么 备份、灾难恢复、主从全量同步的传输载体 数据不能丢的业务 生产默认推荐

选型建议

你的 Redis 用来干什么 建议配置
纯缓存,DB 是唯一真源,重启回填即可 只开 RDB(save 900 1 之类),甚至 save "" + appendonly no 追求极致性能
缓存,但不想重启后 DB 被打崩(省掉预热期) 开 RDB,快照频率调高一点;或直接上混合持久化
分布式锁 / 秒杀库存预扣 / 限流计数 / Write-Behind 待落库中间态 必须开 AOF,appendfsync everysec 起步,最好 aof-use-rdb-preamble yes
钱、订单状态这种绝对不能丢的 别指望 Redis。就算 appendfsync always,那也只是**一台机器**的保证。真源必须在 MySQL,Redis 只做加速
生产环境的通用答案 RDB + AOF + 混合持久化全开,everysec,自动重写配好
graph TD
    A["Redis 里有没有 DB 查不到的数据?"] -->|没有,纯缓存| B["只开 RDB<br/>甚至 save '' + appendonly no"]
    A -->|有:锁 / 计数 / 待落库中间态| C["必须开 AOF"]
    C --> D{"能容忍丢 1 秒吗?"}
    D -->|能,绝大多数业务| E["appendfsync everysec<br/>+ aof-use-rdb-preamble yes"]
    D -->|不能| F["appendfsync always<br/>吞吐可能掉一个数量级,先想清楚值不值"]
    B --> G["重启靠 DB 回填 + 缓存预热"]
    E --> H["RDB 同时保留,用于备份和灾难恢复"]
    F --> H

两个都开时,重启用哪个?

优先用 AOF。 只要 appendonly yes,Redis 启动时就加载 AOF,完全不看 dump.rdb。原因很简单:AOF 的数据更全(RDB 快照可能是几小时前的,AOF 是 1 秒前的)。RDB 此时的角色退化为**备份和主从同步的载体**。

反过来,如果你只开 RDB 却手动关掉了它,或者 AOF 文件损坏被删,Redis 会回退去加载 RDB —— 这时候你会拿到一份**很旧**的数据,比「什么都没有」更危险,因为业务以为数据是对的。


7. 和 MySQL 日志的类比:哪里像、哪里不像

面试里经常被问「Redis 的 AOF 相当于 MySQL 的什么」。答案是 binlog,但这个类比**只对了一半**。

像的地方

共同点 说明
都记录**写操作** AOF 记 Redis 命令,binlog 记 SQL 语句(statement)或行变更(row)
都是**追加写**的日志文件 只往后加,不原地改
都能**重放** AOF 重放命令恢复内存;binlog 重放做主从复制、做时间点恢复
都有**刷盘频率旋钮** appendfsync ↔ sync_binlog / innodb_flush_log_at_trx_commit
都是**逻辑日志** 记的是「做了什么操作」,不是「哪个磁盘扇区变成了什么」

不像的地方

① 时间点恢复(PITR)能力完全不同

这是最关键的区别。

  • binlog 有精确位点。每个事务在 binlog 里都有「文件号 + 字节偏移(position)」。mysqlbinlog --start-position=1234 --stop-position=5678 可以精确到字节地重放任意区间,配合全量备份就能恢复到「误删表之前的那一毫秒」。--start-datetime/--stop-datetime 是时间近似过滤(可能切在事务中间,不如 position 精确)。
  • AOF 没有原生的时间点回放。它的设计目标就是「从文件头顺序重放到文件尾」,命令里**没有时间戳,没有位点索引**。想恢复到历史某一刻,只能**手动截断文件**(找到那条命令的字节位置,把后面的删掉)再重放——纯手工活,没有工具支持。
# MySQL:原生 PITR,标准操作
mysqlbinlog --start-position=154 --stop-position=8923 binlog.000123 | mysql -uroot -p

# Redis:想做同样的事?只能自己 dd / truncate 文件,然后祈祷没切错位置

② 防膨胀机制不同:显式重写 vs 隐式覆盖

Redis AOF InnoDB redo log
文件结构 无限追加,会一直长 固定大小环形缓冲,写满回头覆盖最旧的
防膨胀方式 显式重写:BGREWRITEAOF,把命令序列压成状态快照,生成新文件替换旧文件 隐式覆盖:旧记录对应的脏页刷盘后(checkpoint 推进),那段空间就可以被循环复用
需要人工干预吗 需要配阈值、需要关注重写耗时和内存 完全自动,运维基本不碰

为什么 redo 敢覆盖而 AOF 不敢?因为**职责不同**:

  • redo log 只服务于**崩溃恢复**——它要保证的是「已提交但脏页还没落盘的那部分事务能重做」。脏页一旦落盘,对应的 redo 记录就没用了,覆盖掉完全安全。
  • AOF 是 Redis 唯一的数据本体(开了 AOF 就不看 RDB 了)。它不能覆盖任何东西,只能整体重写成一个更小的等价文件。

③ 物理日志 vs 逻辑日志

  • redo log 是物理日志:记的是「第 X 号数据页的第 Y 偏移处的字节,改成了 Z」。它是 InnoDB 引擎内部的,跟 SQL 长什么样没关系。
  • AOF / binlog 是逻辑日志:记的是「执行了 SET k v」/「执行了 UPDATE t SET ...」。

④ 所在层次不同

  • binlog 在 **MySQL Server 层**产生,任何存储引擎都有,主要用于主从复制、PITR、CDC(Canal/Maxwell)、审计
  • redo log 在 InnoDB 引擎层,只管崩溃恢复
  • AOF 就是 Redis 自己的持久化,跟 MySQL 的日志没有任何代码或协议上的关系,只是思想相通

三种日志横向对比

维度 Redis AOF MySQL binlog InnoDB redo log
记录内容 写命令(RESP 文本) SQL(statement)或行变更(row) 数据页的物理变更(页号 + 偏移 + 新值)
日志类型 逻辑 逻辑 物理
所在层 Redis 自身 Server 层(跨引擎) InnoDB 引擎层
文件结构 追加,会膨胀 追加,靠 rotate + 过期清理 固定大小环形缓冲
防膨胀 显式重写(命令序列 → 状态快照) 不压缩,只删旧文件 隐式覆盖(checkpoint 推进后复用)
主要用途 自身持久化、崩溃恢复 主从复制、PITR、CDC、审计 崩溃恢复(WAL),保证已提交不丢
时间点恢复 ❌ 无原生位点,只能手动截断 ✅ --start/--stop-position 精确到字节 ❌ 只做崩溃点前向重做,不回放历史
刷盘旋钮 appendfsync always/everysec/no sync_binlog = 0/1/N innodb_flush_log_at_trx_commit = 0/½

刷盘策略对照表

这两个旋钮的思想是一模一样的:用刷盘频率换「安全 vs 性能」。

安全等级 Redis MySQL 含义
最安全 appendfsync always innodb_flush_log_at_trx_commit=1 + sync_binlog=1 每次提交都 fsync,进程崩溃不丢数据
平衡 appendfsync everysec trx_commit=2 + sync_binlog=N 攒一批再刷,进程崩溃不丢、机器断电可能丢 1 秒/1 批
最快 appendfsync no trx_commit=0 交给 OS,MySQL/Redis 进程崩了都可能丢

面试话术

「AOF 和 binlog 都是可重放的逻辑追加日志,这是它们像的地方。不像的地方有三点:binlog 有 position 能做精确 PITR,AOF 只能从头重放、要定点恢复得手动截断文件;AOF 靠显式重写把命令序列压成状态快照来防膨胀,redo log 是固定环形缓冲、脏页刷盘后隐式覆盖所以天生不膨胀;redo 是物理日志记页变更,AOF 和 binlog 是逻辑日志记操作。」


8. 经典坑:持久化与分布式锁

这是把上面所有知识点串起来的一个真实事故场景。

故障时序

前提:Redis 一主一从 + 哨兵,只开了 RDB,用 SETNX 做分布式锁。

sequenceDiagram
    participant A as 客户端 A
    participant M as Redis 主节点
    participant R as 从节点(异步复制)
    participant B as 客户端 B
    A->>M: SETNX lock:order 1 EX 30 → OK,拿到锁
    Note over M: 只开 RDB,且还没到快照点<br/>这把锁只存在于内存里
    M--xR: 还没来得及异步复制过去
    M->>M: 主节点宕机 💥
    R->>R: 哨兵把它提升为新主
    Note over R: 内存里没有 lock:order 这个 key
    B->>R: SETNX lock:order 1 EX 30 → OK
    Note over A,B: A 和 B 同时持有同一把锁 → 互斥彻底失效

结果:两个客户端同时在临界区里跑「扣库存」,超卖。

开 AOF 能解决多少?

分两种情况,别混:

故障类型 只开 RDB 开 AOF(everysec) 开 AOF(always)
主节点进程崩溃 / 重启,还是同一台机器 锁丢失(回到上次快照) 最多丢 1 秒内的锁 锁保住 ✅
主节点整机报废,从节点升主 锁丢失 ⚠️ 锁可能仍然丢失 ⚠️ 锁可能仍然丢失

第二行是重点:持久化保的是「这台机器重启后数据还在」,复制保的是「数据在别的机器上也有一份」,这是两件完全不同的事。 Redis 主从复制是**异步**的,主节点回完 OK 就开始复制,但复制没完成前主节点挂了,从节点升主后照样没有这把锁——哪怕主节点的 AOF 里明明记着它(但主节点已经死了,没人读那份 AOF)。

缓解手段(按代价从低到高):

  1. WAIT 命令:加锁后执行 WAIT 1 100,阻塞到至少 1 个从节点确认收到写命令。这是**伪同步**,能大幅降低概率,但仍不是强一致(从节点确认了也可能还没写进自己的内存)。
  2. Redlock:不依赖主从,改用多个完全独立的实例。
  3. 换存储:用 etcd / ZooKeeper 这种基于 Raft / ZAB 共识的组件做锁,它们提供**单调递增的 fencing token**,这是 Redis 方案给不了的。

Redlock 是怎么工作的

部署 N 个(通常 5 个)完全独立的 Redis 实例
—— 注意:不是主从、不是集群,是 5 台互不相干的单机 Redis

加锁流程:
  ① 客户端记录当前时间 T1
  ② 依次向 N 个实例发 SETNX lock:key <唯一value> EX 30
     每个请求的超时设得极小(5~50ms),避免卡在挂掉的实例上
  ③ 记录结束时间 T2,耗时 = T2 - T1
  ④ 判定成功需要同时满足:
        - 成功数 ≥ N/2 + 1(多数派,5 个里至少 3 个)
        - 耗时 < 锁的有效期
     锁的真实有效期 = 30s - 耗时
  ⑤ 失败或超时 → 向所有 N 个实例发 DEL 释放(包括没加上的,防止残留)

释放流程:
  向所有实例执行「比对 value 再 DEL」的 Lua 脚本

直觉上这很稳:挂 2 台还能工作,多数派保证了「不会出现两个客户端同时拿到多数派」。

但 Redlock 是有争议的

分布式系统专家 Martin Kleppmann 在《How to do distributed locking》(2016) 里提出了著名批评,Redis 作者 antirez 也写了回应文章。核心争论点:

批评一:没有 fencing token(栅栏令牌)

这是最致命的一条,而且**和持久化配置无关**:

时刻 1:客户端 A 拿到锁(TTL 30s)
时刻 2:A 发生长时间 GC 暂停 / 网络分区 / 虚拟机被挂起 —— 30 秒过去了
时刻 3:锁自动过期,客户端 B 拿到锁,开始写数据
时刻 4:A 从暂停中醒来,它「以为」自己还持有锁,继续写数据
        → A 和 B 同时写,数据损坏

AOF 配 always 也救不了这个场景,因为问题不在「锁有没有被持久化」,而在「客户端无法知道自己持有的锁是否已经失效」。

Kleppmann 的解法是 fencing token:每次成功加锁都返回一个**单调递增的整数**,存储层(比如写文件的那个服务)拒绝所有 token 比「已见过的最大值」更小的写入请求。这样即使 A 醒过来,它的旧 token 也会被拒绝。

Redis 的单实例和 Redlock 都不提供 fencing token。 你可以用 INCR 自己造一个,但存储侧必须配合校验,改造成本不小。

批评二:依赖时钟假设

Redlock 的安全性论证建立在「各个 Redis 实例的时钟不会突然跳变」之上。但现实中:

  • NTP 校时导致时间**向前跳**(不是缓慢漂移,是突然跳 5 秒)→ 锁提前过期
  • 虚拟机被 hypervisor 挂起后恢复 → 时钟断层
  • 运维手动改了系统时间
  • 某个实例发生长时间 GC 暂停,它眼里的「TTL 还剩多久」是错的

任何一个实例的时钟出问题,多数派的安全性论证就塌了。

antirez 的回应要点:Redlock 用的是**相对时间**(TTL 倒计时),不是绝对时间戳;只要不出现「大幅度、非预期的时钟跳变」,它就是安全的;而且可以在部署层面约束(禁用 NTP 大步进、用单调时钟)。

批评三:这是过度设计

Kleppmann 提出应该先区分你加锁是为了什么:

锁的目的 例子 推荐方案
效率锁(efficiency) 避免 10 个 worker 重复处理同一个任务,偶尔重复一次能接受 单实例 Redis SETNX 就够,Redlock 是过度设计
正确性锁(correctness) 两个人同时扣同一笔库存 = 数据永久损坏 用带 fencing token 的共识系统:etcd / ZooKeeper

落到实践

  1. 单实例 SETNX + 唯一 value + Lua 释放,能覆盖 90% 的场景。别一上来就 Redlock。
  2. 锁不是唯一防线,业务侧必须做幂等。扣库存用「带版本号的 CAS」或 DB 层的 UPDATE ... WHERE stock >= n,把 Redis 锁当作「减少冲突的优化」而不是「正确性的唯一保证」。
  3. TTL 一定要设,且要大于业务最长耗时(配 watchdog 续期),否则锁提前过期照样出事。
  4. 如果这个锁错了会导致**数据永久损坏**,别用 Redis,用 etcd。

💻 代码示例

redis.conf 推荐配置(生产通用)

########## RDB ##########
save 900 1
save 300 10
save 60 10000
stop-writes-on-bgsave-error yes
rdbcompression yes
rdbchecksum yes
dbfilename dump.rdb
dir /var/lib/redis
rdb-save-incremental-fsync yes

########## AOF ##########
appendonly yes
appendfsync everysec                  # 最多丢 1 秒,性能/安全的最佳平衡
no-appendfsync-on-rewrite no
auto-aof-rewrite-percentage 100       # 增长 100% 触发重写
auto-aof-rewrite-min-size 64mb        # 且至少 64MB
aof-rewrite-incremental-fsync yes

########## 混合持久化 ##########
aof-use-rdb-preamble yes              # Redis 5.0+ 默认已开启,显式写上更清楚

########## Redis 7.0+ 多文件 AOF ##########
# appenddirname "appendonlydir"       # 7.0 起用目录代替单文件,appendfilename 已废弃

########## 大内存实例的保命配置 ##########
maxmemory 8gb
maxmemory-policy allkeys-lru          # 纯缓存用 allkeys-lru;当存储用要改成 noeviction
# 另外:宿主机务必关闭透明大页(THP),否则 COW 会一次复制 2MB 而不是 4KB
# echo never > /sys/kernel/mm/transparent_hugepage/enabled

redis-cli 常用运维命令

# —— RDB ——
redis-cli BGSAVE
# Background saving started
redis-cli LASTSAVE
# (integer) 1757145600            ← 上次成功快照的 Unix 时间戳
redis-cli INFO stats | grep latest_fork_usec
# latest_fork_usec:48213          ← fork 耗时 48ms,实例再大就要考虑拆分了

# —— AOF ——
redis-cli BGREWRITEAOF
# Background append only file rewriting started
redis-cli CONFIG GET appendfsync
# 1) "appendfsync"
# 2) "everysec"
redis-cli CONFIG GET aof-use-rdb-preamble
# 1) "aof-use-rdb-preamble"
# 2) "yes"
redis-cli INFO persistence | grep -E "aof_enabled|aof_current_size|aof_rewrite_in_progress|loading"

# —— 在线改配置(不用重启)——
redis-cli CONFIG SET appendfsync always      # 临时提高安全等级
redis-cli CONFIG REWRITE                     # 把内存中的配置写回 redis.conf,否则重启失效

# —— 文件体检 ——
redis-check-aof /var/lib/redis/appendonlydir/appendonly.aof.1.incr.aof
redis-check-rdb /var/lib/redis/dump.rdb

Go:go-redis 实现 SETNX 分布式锁

package redlock

import (
    "context"
    "errors"
    "time"

    "github.com/google/uuid"
    "github.com/redis/go-redis/v9"
)

// 释放锁的 Lua 脚本:先比对 value 再 DEL
// 为什么必须用 Lua?因为 GET + DEL 是两步操作:
//   你 GET 确认是自己的锁 → 就在这一瞬间锁过期了 → 别人 SETNX 成功
//   → 你接着 DEL → 删掉了别人的锁 💥
// Lua 脚本在 Redis 里是原子执行的,中间不会被插入其他命令
const releaseScript = `
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end
`

// 续期用的 Lua:只有 value 还是自己的才延长 TTL
const renewScript = `
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("PEXPIRE", KEYS[1], ARGV[2])
end
return 0
`

var ErrLockNotAcquired = errors.New("lock not acquired")

// Lock 单实例 Redis 分布式锁
type Lock struct {
    rdb   *redis.Client
    key   string
    value string // 唯一标识,用于安全释放,防止误删别人的锁
    ttl   time.Duration
}

// Acquire 抢锁,本质就是一条命令:SET key value NX EX ttl
//   NX —— key 不存在才设置(这就是「互斥」的来源)
//   EX —— 同时设置过期时间(防止持锁进程崩溃后锁永久不释放 → 死锁)
// NX 和 EX 必须在一条命令里完成,分成 SETNX + EXPIRE 两步是经典错误:
// 两步之间进程崩了,锁就永远不会过期
func Acquire(ctx context.Context, rdb *redis.Client, key string, ttl time.Duration) (*Lock, error) {
    l := &Lock{
        rdb:   rdb,
        key:   key,
        value: uuid.NewString(),
        ttl:   ttl,
    }
    ok, err := l.rdb.SetNX(ctx, l.key, l.value, ttl).Result()
    if err != nil {
        return nil, err
    }
    if !ok {
        return nil, ErrLockNotAcquired // 被别人占着,调用方决定重试还是快速失败
    }
    return l, nil
}

// Release 释放锁,幂等,且只删自己的
func (l *Lock) Release(ctx context.Context) (bool, error) {
    n, err := l.rdb.Eval(ctx, releaseScript, []string{l.key}, l.value).Int64()
    if err != nil {
        return false, err
    }
    return n == 1, nil
}

// WaitReplicas 加锁后等待至少 n 个从节点确认收到写命令(伪同步复制)
//
// ⚠️ 这跟「持久化」是两件事:
//   - AOF 保证的是「这台 Redis 重启后锁还在」
//   - WAIT 降低的是「主节点整机挂掉、从节点升主后锁丢了」的概率
// 两者都不能提供强一致。WAIT 返回的 n 只代表「副本收到了命令」,
// 不代表「副本已经写进内存/落盘」,所以它只是把丢锁概率压低,不是消除。
func (l *Lock) WaitReplicas(ctx context.Context, n int, timeout time.Duration) (int64, error) {
    return l.rdb.Wait(ctx, n, timeout).Result()
}

// Watchdog 后台续期:业务耗时可能超过 TTL 时用来续命
//
// ⚠️ 续期本身也有 Kleppmann 指出的风险:如果进程发生长时间 GC 暂停,
// watchdog 线程也被冻住了,锁照样会过期,而业务代码醒来后还以为自己持有锁。
// 所以关键业务一定要在存储侧做 fencing(版本号 / 单调递增 token / DB 层 CAS)兜底。
func (l *Lock) Watchdog(ctx context.Context, interval time.Duration) {
    ticker := time.NewTicker(interval)
    defer ticker.Stop()
    for {
        select {
        case <-ctx.Done():
            return
        case <-ticker.C:
            l.rdb.Eval(ctx, renewScript, []string{l.key}, l.value, l.ttl.Milliseconds())
        }
    }
}

使用示例(秒杀扣库存):

func SeckillDeduct(ctx context.Context, rdb *redis.Client, skuID string, userID int64) error {
    key := "lock:stock:" + skuID

    lock, err := Acquire(ctx, rdb, key, 30*time.Second)
    if errors.Is(err, ErrLockNotAcquired) {
        return errors.New("系统繁忙,请重试") // 快速失败,别让请求堆积
    }
    if err != nil {
        return err
    }
    defer lock.Release(ctx) // 一定要 defer 释放

    // 真正的扣减:用 Lua 保证「判断 + 扣减」原子
    // 光有分布式锁还不够 —— Redis 的 MULTI/EXEC 不会回滚,
    // 条件判断和写入必须塞进一个 Lua 脚本里才是原子的
    n, err := rdb.Eval(ctx, `
        local stock = tonumber(redis.call("GET", KEYS[1]))
        if stock == nil or stock <= 0 then return -1 end
        redis.call("DECR", KEYS[1])
        return stock - 1
    `, []string{"stock:" + skuID}).Int64()
    if err != nil {
        return err
    }
    if n < 0 {
        return errors.New("已售罄")
    }

    // 落库(DB 才是唯一真源,Redis 只是加速层)
    // UPDATE stock SET num = num - 1 WHERE sku_id = ? AND num >= 1
    // 这条 SQL 的 WHERE num >= 1 就是最后一道 fencing,
    // 哪怕 Redis 锁真的失效了、两个请求都进来了,DB 也只会让一个成功
    return deductInDB(ctx, skuID, userID)
}

持久化配置如何影响这把锁

持久化配置 主节点进程重启后 主节点整机报废、从节点升主后
只开 RDB 🔴 锁丢失(回到上次快照) 🔴 锁丢失
AOF everysec 🟡 1 秒内的锁丢失 🔴 锁可能丢失(异步复制没追上)
AOF always + WAIT 1 100 🟢 锁保住 🟡 概率大幅降低,但仍非强一致
etcd / ZooKeeper 🟢 锁保住 🟢 共识保证,且有 fencing token

结论:开 AOF 是必要的,但不是充分的。 锁的正确性最终要靠业务侧的幂等和 DB 层的 CAS 兜底。


⚠️ 常见陷阱

陷阱一:生产环境用 SAVE

现象:执行 SAVE 后整个 Redis 服务卡住几十秒,所有客户端超时,上游服务雪崩。

原因:Redis 处理命令是单线程的,SAVE 让**主进程自己**去遍历内存写文件,这期间没有任何人处理客户端请求。10GB 的实例可能卡几十秒。

解决:生产环境**永远用 BGSAVE(fork 子进程干活,主进程照常服务)。同时注意 SHUTDOWN 命令在没开 AOF 时会隐式执行一次 SAVE——优雅停机脚本里要用 SHUTDOWN NOSAVE。另外别忽略:**主从全量同步时主节点会自动 BGSAVE,所以哪怕你配了 save "",加从节点的那一刻照样会 fork。

陷阱二:以为 appendfsync everysec 就绝对不丢

现象:跟业务方承诺「开了 AOF 一条都不会丢」,结果机器断电后丢了数据,被追着问。

原因:everysec 的字面意思就是**最多丢 1 秒**。而且这 1 秒还不只是「Redis 没来得及 fsync」——写命令先进 aof_buf,再 write() 到 OS page cache,最后才 fsync() 到磁盘。整机断电时,page cache 里没刷下去的部分一样会丢,这跟 Redis 进程崩不崩没关系。即使配 always,如果磁盘自带 write-back 缓存又没有电池/FUA 支持,fsync 返回成功也不代表数据真的在盘片上了。

解决:① 老老实实告诉业务方「最多丢 1 秒」,不要承诺零丢失;② 真正零丢失的数据不要放 Redis,放 MySQL(trx_commit=1 + sync_binlog=1);③ 对丢 1 秒也敏感的场景(分布式锁),要在业务侧做 fencing 兜底,而不是指望刷盘策略。

陷阱三:只开 RDB 却拿 Redis 当分布式锁 / 唯一真源

现象:秒杀超卖、任务被两个 worker 重复执行、Write-Behind 的一批写永久丢失。

原因:只开 RDB 时,save 900 1 意味着**最坏情况丢 15 分钟**的数据。锁 key、库存计数、待落库的中间态,全都在这个丢失窗口里裸奔。更隐蔽的是主从场景:主节点内存里有锁,但异步复制还没送到从节点,主节点一挂、从节点升主,锁凭空消失,另一个客户端立刻能拿到同一把锁。

解决:① 只要 Redis 里有「别处查不到」的数据,必须开 AOF(everysec 起步)+ 混合持久化;② 主从切换丢锁的问题持久化解决不了,需要 WAIT 伪同步、Redlock 或干脆换 etcd;③ Write-Behind 架构里绝不能把缓存当唯一真源——必须有 AOF 兜底 + 消息队列做补偿,否则未落库的写会永久蒸发。

陷阱四:AOF 重写 / BGSAVE 期间内存翻倍,大实例被 OOM 干掉

现象:业务高峰期 Redis 突然被内核 OOM Killer 杀掉,日志里看到 Out of memory 或者 Can't save in background: fork: Cannot allocate memory。

原因:三重内存叠加—— 1. fork 本身要内存:内核需要为子进程复制一份页表,内存越大页表越大,而且这一步是**阻塞主进程**的(几 GB 实例可能卡几十到几百毫秒,看 latest_fork_usec) 2. COW 复制页:重写/快照期间业务还在写,被写过的页会被复制一份。写入越密集、耗时越长,复制的页越多,峰值可能接近平时的 2 倍 3. AOF 重写缓冲区(7.0 之前):重写期间的新命令还要额外堆在内存里

另外如果开了**透明大页(THP)**,COW 每次复制的是 2MB 而不是 4KB,内存放大和延迟都会严重恶化。

解决:① maxmemory 别设成机器物理内存的 100%,留出至少 1 倍余量给 COW;② 关闭 THP(echo never > /sys/kernel/mm/transparent_hugepage/enabled);③ 把自动重写挪到低峰期,或者干脆关掉自动重写改成定时任务手动触发;④ 监控 latest_fork_usec、mem_fragmentation_ratio、aof_pending_rewrite;⑤ 单实例别做太大,超过 10GB 就该考虑拆分或用集群;⑥ Redis 7.0 的多文件 AOF 去掉了重写缓冲区,能明显缓解这个问题。


🏋️ 练习题

练习 1:AOF 重写到底是「把旧文件压缩一遍」还是「重新生成一份」?给定命令序列 SET k 1 → INCRBY k 5 → SET k 9 → DEL k → SET k 2,重写后文件里是什么?为什么被删除的 key 连一条 DEL 都不用写?
答案

是重新生成,完全不读旧 AOF 文件。

重写的做法是:fork 出子进程后,遍历当前内存里的数据集,为每个**还存在**的 key 生成一条能重建其最终值的最小命令,写进新文件,最后 fsync + rename 原子替换旧文件。

所以给定序列重写后只剩一条:

SET k 2

前 4 条全部消失——SET k 1、INCRBY k 5、SET k 9 的结果都被后面的命令覆盖了,DEL k 的结果又被最后的 SET k 2 覆盖了。

被删除的 key 为什么不写 DEL:因为重放是从一个**空数据库**开始的。「这个 key 不存在」这件事,在空数据库里天然就成立,不需要任何命令去表达它。写一条 DEL k 反而是多余的动作。

本质上,AOF 重写完成了一次**数据结构转换**:从「命令序列 / log 结构(记录怎么改)」转换成「键值状态快照 / hashmap 结构的等价描述(记录改成什么样)」。这就是 AOF 能被大幅压缩的根本原因——大量历史命令对恢复最终状态是冗余的。

练习 2:RDB 和 AOF 都开着,Redis 重启时用哪个恢复?为什么?如果 AOF 文件损坏被删掉了会发生什么?
答案

优先用 AOF。 只要 appendonly yes,Redis 启动时就加载 AOF 文件,完全不看 dump.rdb。

原因:AOF 的数据更全。RDB 快照可能是几小时前的(save 900 1 最坏情况落后 15 分钟),而 AOF(everysec)最多落后 1 秒。用 AOF 恢复的数据一定包含 RDB 里的全部内容。开了混合持久化的话,加载过程是「先按 RDB 二进制快速载入全量段,再重放少量 AOF 增量段」,速度也接近纯 RDB。

此时 RDB 的角色退化为:定期备份的归档载体 + 主从全量同步的传输格式,不再承担崩溃恢复。

AOF 损坏被删的后果:Redis 找不到 AOF,会**回退去加载 RDB**。这时候你会拿到一份**很旧的数据**,而业务完全不知道——它以为一切正常,实际上可能已经退回到几小时前的状态。这比「启动失败、什么都没有」更危险,因为错误是静默的。

所以:① AOF 文件一定要监控(aof_last_bgrewrite_status、aof_last_write_status);② 损坏时用 redis-check-aof --fix 修复(它会截掉尾部损坏部分,那部分数据丢失)而不是直接删;③ RDB 和 AOF 都要有独立的备份策略,不能只靠一个。

练习 3:混合持久化(aof-use-rdb-preamble yes)解决了什么矛盾?它和「RDB、AOF 两个都开」有什么区别?为什么说它是「AOF 文件内部」的事?
答案

解决的矛盾:RDB 恢复快但不安全(丢两次快照之间的数据),AOF 安全但恢复慢(几 GB 命令要逐条重放,可能十几分钟)。

混合持久化的做法:在 AOF 重写的时候,新 AOF 文件的前半部分用 **RDB 二进制格式**写一份全量快照,后半部分用 **AOF 文本格式**追加重写期间产生的增量命令。

[ RDB 格式全量快照 | AOF 格式增量命令 ]

加载时:Redis 先看文件头是不是 magic string "REDIS"——是就按 RDB 二进制反序列化载入全量(秒级),然后重放后面少量增量命令(毫秒级)。既拿到了 AOF 的数据安全性(增量一条不漏,丢的还是 1 秒),又拿到了 RDB 的加载速度。

和「两个都开」的区别:这是最关键的一点。「RDB + AOF 都开」是**两个独立的文件**——dump.rdb 和 appendonly.aof,恢复时只用 AOF,RDB 白白浪费磁盘和 fork 开销。而混合持久化是**一个 AOF 文件内部的格式优化**:RDB 二进制内容就存在 AOF 文件里,作为它的前半段。

所以说它是「AOF 文件内部」的事——它不产生新文件,不改变恢复优先级(还是加载 AOF),只是让 AOF 重写生成的那份文件**前半段换个更紧凑、加载更快的编码方式**。Redis 7.0 的多文件 AOF 把这个结构显式化了:appendonly.aof.N.base.rdb(base 段,RDB 格式)+ appendonly.aof.N.incr.aof(incr 段,命令格式)+ appendonly.aof.manifest(清单)。


🔗 相关链接