Redis 深度教程 · 03

持久化与内存生命周期

前两章解释了 Redis 为什么快、为什么每条命令天然原子。数据全在内存,引出两个"万一":进程挂了怎么不丢(持久化),内存满了留谁(淘汰)。这两个问题的答案,都从基石二——单线程不能停下来做快照——直接推导出来。

本章你将建立的 schema

  • RDB 为什么要 fork 子进程、copy-on-write(COW,写时复制)如何保证快照一致性、大实例 fork 延迟从哪里来
  • 多段 AOF(7.0):appendonlydir/ 下 base + incr.aof + manifest 三段结构;混合持久化把两者最优点合并
  • fsync 三种策略的丢失窗口量化:always / everysec / no
  • RDB vs AOF 选型取舍,以及生产为什么常两者并开
  • 过期删除的惰性 + 定期两路,以及副本为何不自行过期
  • 内存淘汰八种策略;近似 LRU 与 LFU 的机制,以及为什么用近似而非精确
本章 = 基石二的推论

基石二:单线程串行执行。主线程不能停下来做快照,所以要 fork 一个子进程——子进程在 fork 那刻持有内存的视图,父进程继续服务写请求。同理,主线程不能长时间扫描所有过期 key,所以过期删除拆成「惰性」和「定时采样」两路。把这条逻辑线拉通,持久化和内存管理的所有取舍就都有了推导源头。

3.1RDB:fork 子进程做快照

RDB 在某个时刻把整个内存数据集序列化成一个紧凑的二进制文件;靠 fork 出子进程来完成,父进程借助 copy-on-write 继续服务,快照保持那一刻的一致性视图。

为什么需要它

主线程不能暂停来做全量序列化——那会让所有客户端等待几秒乃至几十秒。fork 把进程的虚拟地址空间克隆给子进程,操作系统并不真的复制物理内存,只是复制页表(page table);父子进程初始时共享同一套物理页面。子进程顺序写磁盘的同时,父进程照常接收写命令:写到哪个页,OS 才触发 COW、为父进程复制那一页,子进程依然看到 fork 时的旧值。快照因此一致、主线程因此不停。

fork 由 save 或 bgsave 触发,也可由 redis.conf 的 save 规则自动触发。save <seconds> <changes> 的含义是:在 seconds 秒内发生了至少 changes 次写操作,则自动执行一次 bgsave。默认三条规则(7.x):

redis.conf · RDB 触发规则bash
save 3600 1      # 1 小时内至少 1 次写 → bgsave
save 300  100    # 5 分钟内至少 100 次写 → bgsave
save 60   10000  # 1 分钟内至少 10000 次写 → bgsave

# 关闭自动 RDB(只用 AOF):
save ""

fork 的代价:页表复制与 THP

fork 本身不复制物理内存,但必须复制进程的页表。页表大小正比于虚拟地址空间的映射条数,而非数据量本身。一个 20 GB 的实例,页表可能占几十到几百 MB;fork 时主线程在这段时间内阻塞(操作系统在内核态完成页表复制),延迟通常在 数十毫秒到数百毫秒量级,大实例偶发 1–2 秒。

透明大页(Transparent Huge Pages,THP)放大这个问题:THP 把普通 4 KB 页合并成 2 MB 的大页,COW 时粒度也变成 2 MB——父进程只改了几个字节,OS 却要复制整个 2 MB 大页,写入放大可达 500 倍。生产环境 Redis 官方建议关闭 THP:

关闭透明大页(OS 级)bash
echo never > /sys/kernel/mm/transparent_hugepage/enabled
# 持久化写入 /etc/rc.local 或 systemd service
fork 延迟是「主线程停顿」,不是后台耗时

fork 调用本身在主线程完成,OS 复制页表期间主线程阻塞,表现为 Redis 对所有客户端的命令响应在那段时间内暂停。页表复制完成后主线程立即恢复,子进程接管磁盘写入(后台异步)。监控指标看 latest_fork_usec(INFO persistence 输出)。

时间轴 → 主进程 fork() 主线程阻塞 (页表复制) 继续服务写请求 子进程 顺序写 dump.rdb(持有 fork 时刻视图) COW 触发 父进程写某页 → OS 复制该页 子进程仍见旧值 写命令到达 共享物理页 共享物理页 父新副本 COW 复制 子见旧页 (快照一致) 物理内存
图 3.1fork + copy-on-write 时序。父进程 fork 后立即恢复服务,写命令触发 COW 在物理层为父进程单独复制被修改的页;子进程始终看到 fork 时刻的内存视图,快照因此一致。注意:fork 调用本身(页表复制)在主线程同步完成,这段时间内主线程对外表现为短暂阻塞。
想一想 · fork 之后写入越多越好还是越少越好

fork 触发 bgsave 期间,父进程的写入量越大,RDB 文件的数据越新吗?实际上对 RDB 有什么影响?

展开思路与答案

RDB 文件始终记录 fork 那一刻的内存视图,与 fork 之后父进程写入多少无关——子进程看不到父进程 fork 后的写入。fork 后写入越多,唯一的副作用是 COW 复制的物理页越多,内存占用峰值越高(极端情况下内存翻倍)。RDB 文件的"新旧程度"只取决于上一次 bgsave 是什么时候触发的,与 bgsave 期间的写入量无关。

3.2AOF:追加写命令 + 重写压缩

AOF(Append Only File)把每条写命令以 RESP 格式追加到文件末尾;重写(rewrite)在不中断服务的情况下把文件压缩成等价的最小命令集。

为什么需要它

RDB 两次快照之间的写入在宕机时全部丢失,间隔越长丢失越多。AOF 以命令日志的方式记录每次写操作,宕机恢复时重放日志即可还原数据——理论上丢失窗口可以压到一秒以内(everysec)甚至零(always)。代价是文件比 RDB 大、恢复时要重放所有命令、重放速度慢。

多段 AOF(Redis 7.0)

7.0 之前 AOF 是单个文件(appendonly.aof),rewrite 要把整个文件原地重写,存在覆盖失败导致丢数据的风险。7.0 引入多段 AOF(Multi-Part AOF),AOF 数据拆成三类文件,统一放在 appendonlydir/ 目录下:

  • base 文件(appendonly.aof.1.base.rdb 或 .aof):某次 rewrite 时的全量基线快照。当 aof-use-rdb-preamble yes(默认)时,base 用 RDB 格式写入,称为混合持久化——base 紧凑、恢复快,增量部分仍用 AOF 命令格式。
  • 增量文件(appendonly.aof.1.incr.aof):base 建好之后,新写入的命令追加到这里。
  • manifest 清单文件(appendonly.aof.manifest):记录当前哪个 base、哪些 incr 是有效的,恢复时按 manifest 顺序加载,多余的旧文件直接删除,彻底消除单文件覆盖的一致性风险。
appendonlydir/ manifest appendonly.aof.manifest 记录有效 base + incr 列表 base(全量基线) .base.rdb(混合) rewrite 时生成 incr(增量) .incr.aof base 之后的命令 恢复顺序(由 manifest 驱动) ① 读 manifest → ② 加载 base(RDB 反序列化)→ ③ 重放 incr.aof 旧 base / incr 文件直接删除,无需原地覆盖
图 3.2Redis 7.0 多段 AOF 文件结构。注意:manifest 是唯一的一致性锚点——恢复时先读 manifest 再按顺序加载文件,不依赖文件名或修改时间,rewrite 完成前旧文件不会被删除,消除了单文件原地覆盖的风险。
混合持久化(aof-use-rdb-preamble)

aof-use-rdb-preamble yes(7.0 及以上默认开启)让 rewrite 生成的 base 文件采用 RDB 格式,而不是重新把所有 key 用 AOF 命令格式写一遍。好处:base 紧凑(RDB 是二进制格式,比 RESP 命令日志小得多),加载 base 时走 RDB 反序列化路径,比重放命令快数倍;incr 部分仍是 AOF 命令,保留了增量的精确性。这就是"混合持久化"——RDB 的空间效率 + AOF 的持久精度,生产推荐保持默认开启。

3.3fsync 策略与丢失窗口

AOF 追加写命令后,数据先到 OS 页缓存(page cache),需要 fsync 才真正落盘;fsync 策略决定丢失窗口的上限。

为什么需要它

write() 系统调用把数据写入内核页缓存,进程即认为写入完成;OS 决定何时把脏页刷到磁盘。若宕机时磁盘上没有的数据,重放 AOF 时就不存在这部分命令,数据因此丢失。fsync 强制 OS 立即把缓存中的脏数据刷盘,代价是额外的磁盘 I/O。不同的 fsync 频率决定了"最坏情况下丢多少"。

appendfsync 三种策略
策略执行时机最大丢失窗口性能影响
always每条写命令执行后立即 fsync理论 0(宕机前已落盘)最慢;每条命令都付一次磁盘 I/O 延迟,QPS 通常降 10–100 倍
everysec后台线程每秒调用一次 fsync约 1 秒内的写入推荐默认;吞吐量接近 no,丢失窗口有明确上界
no不主动 fsync,由 OS 决定取决于 OS flush 间隔(Linux 默认 30 秒)最快;丢失窗口完全不可控

everysec 由一个后台 I/O 线程(bio 线程)负责,每隔 1 秒调用一次 fsync(fd)。宕机发生在最坏位置时,上次 fsync 到宕机之间最多 1 秒的写入未落盘,因此说"最多丢约 1 秒的数据"。注意:这里的 1 秒是统计上界,实际丢失量取决于宕机时刻距上次 fsync 的间隔,可以是 0 到 1 秒之间的任意值。

想一想 · everysec 为什么最多丢 1 秒而不是 0

有人认为:bio 线程每秒 fsync,宕机发生在 fsync 刚完成后,那这 1 秒内的写入不是都在 page cache 里没落盘吗?为什么说"最多约 1 秒"而不是精确 1 秒?

展开思路与答案

说"约 1 秒"是因为宕机时刻是随机的:如果宕机发生在 fsync 刚完成后 0.01 秒,只丢 0.01 秒;如果发生在 fsync 前 0.01 秒,丢 0.99 秒。统计上界是 1 秒,但期望丢失量约 0.5 秒。之所以不是 0,是因为 write() 成功后数据仅在 page cache,主进程认为"写完了"就去处理下一条命令,但这批数据要等 bio 线程的下次 fsync 才真正落盘——这段时间内宕机就会丢失。always 模式不经过 bio 线程,主线程每条命令写完后直接调用 fsync,丢失窗口理论为 0,代价是每条命令都串行等待磁盘。

3.4RDB vs AOF 选型

RDB 紧凑、恢复快、有丢失间隔;AOF 持久精度高、文件大、重放慢。生产常两者并开,用混合格式取最优解。

RDB vs AOF 全维度对比
维度RDBAOF(everysec + 混合)
文件大小紧凑二进制,同等数据量最小比 RDB 大;混合格式 base 接近 RDB,incr 较小
恢复速度反序列化 RDB,通常是最快路径先加载 base(RDB),再重放 incr.aof;比纯 AOF 快,比纯 RDB 慢少许
最大丢失窗口两次 bgsave 之间,默认可达数分钟到 1 小时everysec ≈ 1 秒;always ≈ 0
主线程影响fork 带来毫秒到秒级暂停;bgsave 期间大量 COW 拉高内存追加写命令几乎零开销;rewrite 也靠 fork,有同样的 fork 延迟
数据完整性风险RDB 文件写一半宕机 → 文件损坏,需从上上次恢复manifest 保证多段 AOF 的原子切换,7.0 以上风险极低
生产推荐两者并开(appendonly yes + 保留 RDB)+ aof-use-rdb-preamble yes;优先用 AOF 恢复(更新),RDB 作兜底备份
为什么不只选其中一个

只用 RDB:丢失窗口不可接受(分钟级),bgsave 触发频率高则 fork 代价高,低则丢失多。只用 AOF:唯一的完整备份在 AOF 文件,一旦文件损坏(truncate、误删)无兜底;纯 AOF 恢复比 RDB 慢,大实例重启时间长。两者并开后:AOF 负责精度,RDB 负责兜底与快速恢复——两个目标分别由最合适的机制满足,而非用一个机制勉强兼顾两个目标。

3.5过期删除:惰性 + 定期两路

过期 key 的删除走两条路:访问时惰性检查,以及 serverCron 定期采样;副本不自行过期,等主库传播 DEL/UNLINK。

为什么需要它

主线程不能在每次 serverCron 时扫描所有过期 key——实例里可能有数千万个 key,全扫一遍会让主线程阻塞数十秒。把过期删除拆成「用时才判」和「小批量定期清理」两路,是「单线程不能长时间做一件事」这个约束的直接推论。

惰性删除(lazy expiration)

任何读写命令访问某个 key 时,Redis 先调用 expireIfNeeded() 检查该 key 是否已过期。若过期,立即删除并返回 nil(或空结果),命令本身不再继续执行。这路删除零额外开销——只有被访问的 key 才被检查;代价是长时间不访问的已过期 key 会积压在内存里。

定期删除(active expiration)

serverCron 按 hz(默认 10,即每秒执行 10 次)调度过期清理逻辑。每轮从设有过期时间的 key 字典(expires dict)中随机采样 20 个 key,删掉其中已过期的;若这一轮删掉的比例超过 25%,说明过期 key 密度高,立即再来一轮,直到比例降到 25% 以下或达到单次时间上限(默认 25ms)。这个机制保证:既不长期占用主线程,也不会因为惰性路径的懒惰让过期 key 无限堆积。

redis.conf · 过期相关配置bash
hz 10                  # serverCron 每秒调度次数,提高可加快过期清理(但增加 CPU)
dynamic-hz yes         # 自适应调整 hz(负载高时升,空闲时降);7.x 默认开启
lazyfree-lazy-expire yes  # 过期删除改为异步(bio 线程),避免大 key 过期阻塞主线程

副本不自行过期

Replica(副本)上的 key 不会被主动过期删除,只能等主库(primary)传播 DEL 或 UNLINK 命令过来才删除。原因:副本没有写权限,若副本自行删除会造成主从数据不一致(主库还在、副本先删)。但副本上访问一个已过期但主库尚未传播 DEL 的 key 时,4.0 以后副本会根据本地时钟判断是否过期,若过期则返回 nil——读行为一致,但删除操作仍由主库主导。

时钟偏移导致副本提前过期

副本判断过期依赖本地时钟,若主从之间存在系统时钟偏差,副本可能比主库更早或更晚认为某个 key 过期,导致读结果短暂不一致。生产环境务必通过 NTP 保持主从时钟同步,偏差控制在毫秒级以内。

3.6内存淘汰:近似 LRU 与 LFU

内存达到 maxmemory 上限时,Redis 按 maxmemory-policy 决定淘汰哪些 key;近似 LRU 和 LFU 用采样池降低精度换取极低的内存和 CPU 开销。

为什么需要它

Redis 是纯内存数据库,写入不受控时内存会耗尽,进程被 OOM Killer 杀掉。maxmemory 设定上限,超限时触发淘汰。精确 LRU 需要一个全局有序链表维护访问时间顺序,每次访问都要把节点移到链表头——对于千万级 key 的实例,这个链表本身就会占用大量内存,且每次写命令都要操作链表,与单线程的串行性相互放大。近似方案用少量元数据(每 key 24 位时钟)+每次淘汰时随机采样一小批 key 选最旧的,把准确度和开销都控制在可接受范围内。

八种淘汰策略

maxmemory-policy 八种选项
策略淘汰范围算法典型场景
noeviction(默认)—不淘汰,写命令返回 OOM 错误不允许丢数据的场景
allkeys-lru全部 key近似 LRU通用缓存,不确定哪些 key 有 TTL
allkeys-lfu全部 keyLFU(4.0+)有稳定热点的场景,抗扫描污染
allkeys-random全部 key随机访问均匀、无冷热区分时
volatile-lru有 TTL 的 key近似 LRU持久 key 不允许被淘汰
volatile-lfu有 TTL 的 keyLFU同上 + 热点保留
volatile-random有 TTL 的 key随机同上 + 无冷热偏好
volatile-ttl有 TTL 的 key优先淘汰 TTL 最短的希望先让即将过期的 key 先走

近似 LRU:24 位时钟 + 采样池

每个 redisObject 有一个 24 位的 LRU 时钟字段,记录最近一次访问的秒级时间戳(低 24 位,约 194 天回绕一次)。触发淘汰时,Redis 随机采样 maxmemory-samples(默认 5)个 key,加入一个候选池(eviction pool),保留其中访问时间最老的 key;下次淘汰再采样,与池中已有候选合并选最旧的淘汰。

把 maxmemory-samples 调到 10,准确度已非常接近真正的 LRU,但内存和 CPU 开销仍是 O(采样数) 而非 O(key 总数)。

LFU(Redis 4.0):Morris 计数器 + 时间衰减

LFU 把 LRU 时钟字段复用为一个 8 位字段,分成两部分:

  • 高 5 位(ldt):上次访问的分钟级时间戳,用于衰减计算——lfu-decay-time(默认 1 分钟)控制每过多少分钟计数减 1。
  • 低 3 位(counter)——这里文档有误,实际 LFU 使用原来 lru 字段的高 16 位存时间、低 8 位存计数。更准确地说:低 8 位作为 Morris 概率计数器,饱和在 255(实际访问次数对应约 100 万次)——每次访问不是无条件加 1,而是以 1 / (counter × lfu-log-factor + 1) 的概率加 1,使计数增长随访问次数呈对数曲线。lfu-log-factor 默认 10,让计数器在约 100 万次访问时饱和到 255。

LFU 的核心优势:对一次性大扫描(全表遍历、批量导入)有天然抵抗力——新访问的 key 计数从 0 开始,不会立刻把老热点挤走;而 LRU 只看最近一次访问时间,一次全表扫描会把所有扫过的 key 都标记成"最近访问",把真正的热点从淘汰候选池里挤掉。

想一想 · allkeys-lru 还是 allkeys-lfu

一个场景:有约 10 个稳定热点 key(被频繁访问),每天凌晨有一批批量任务扫描几十万个 key 做统计,这些 key 平时几乎不访问。用 allkeys-lru 还是 allkeys-lfu?为什么?

展开思路与答案

用 allkeys-lfu。LRU 只看最近访问时间:凌晨批量扫描后,几十万个冷 key 的 lru 时钟全部更新为"最近访问",比真正的热点 key 更新;如果此时内存压力触发淘汰,LRU 会把真正的热点当成"长时间未访问"优先淘汰,导致热点缓存失效、业务延迟尖刺。LFU 的 counter 由访问频率决定:热点 key 的 counter 经过长期积累很高,一次扫描把冷 key 的 counter 从 0 涨到几,无法与热点的 counter 竞争;淘汰时 LFU 优先淘汰 counter 最低的冷 key,热点安全。

写命令 SET / HSET 等 主线程执行 写入内存数据集 AOF buffer 命令追加到 page cache fsync(bio 线程) everysec → 每秒落盘 fork 子进程 bgsave → dump.rdb 同步写入 触发规则满足 磁盘 磁盘
图 3.3一条写命令经过 AOF 与 RDB 两条持久化路径。注意:AOF 路径是同步的——每条命令执行后立即追加到 buffer,由 bio 线程异步 fsync;RDB 路径是异步 + 周期性的,由 fork 子进程独立完成,主线程不等待。

·自测

  1. RDB bgsave 期间父进程收到写命令,数据会进 RDB 文件吗?为什么?
  2. appendfsync everysec 模式下,Redis 宕机最多丢失多少数据?原因是什么?
  3. Redis 7.0 的多段 AOF 比旧版单文件 AOF 解决了什么具体风险?manifest 文件的作用是什么?
  4. 设计判别:一个同时作为缓存和持久化存储的实例(要求重启后数据尽量不丢、且恢复速度尽量快),持久化策略怎么配?
展开全部答案

1. 不会进入这次 bgsave 生成的 RDB 文件。fork 之后子进程持有 fork 那刻的内存视图,父进程后续写命令触发 COW,子进程看不到这些变更——它只写 fork 时刻的数据到 dump.rdb。这些新写入会在下一次 bgsave 或 AOF 中体现。

2. 最多丢约 1 秒内的写入。原因:write() 系统调用把命令追加到 OS page cache,bio 线程每秒执行一次 fsync 把 page cache 刷盘;宕机发生在上次 fsync 完成后到下次 fsync 之前,最坏情况间隔约 1 秒,这期间的写入在磁盘上不存在,重放 AOF 时不可恢复。之所以不是 0:write() 返回后主线程认为"写完",但数据仅在 page cache,需等下次 fsync 才真正落盘。

3. 旧版单文件 AOF 在 rewrite 时需要原地覆盖,若覆盖到一半进程崩溃,文件可能处于既不是旧 AOF 也不是新 AOF 的损坏状态,数据丢失或无法恢复。多段 AOF 通过 manifest 清单文件原子切换:rewrite 完成后才更新 manifest 指向新的 base + incr,旧文件在 manifest 更新完成前不会被删除;恢复时以 manifest 为权威来源,消除了覆盖过程中宕机的一致性风险。manifest 文件的作用:记录当前有效的 base 文件和 incr 文件列表及其顺序,是 AOF 数据集的"目录",加载时按 manifest 顺序依次处理,多余的历史文件直接忽略并清理。

4. 推荐配置:appendonly yes(开启 AOF)+ appendfsync everysec(丢失窗口约 1 秒,性能可接受)+ aof-use-rdb-preamble yes(混合持久化,base 用 RDB 格式,恢复快)+ 同时保留 RDB(save 规则保持默认,作为兜底备份)。恢复时优先用 AOF(数据更新),RDB 作为 AOF 文件损坏时的兜底。如果能接受约 1 秒丢失,这套配置同时满足"恢复快"(base RDB + 少量 incr 重放比纯 AOF 快得多)和"丢失少"(AOF everysec)两个目标。

进阶挑战

估算并缓解 20 GB 实例的 fork 延迟

一个 20 GB 数据集的 Redis 实例,开启了透明大页(THP)。执行 bgsave 时监控到 latest_fork_usec 超过 2 秒,同时 bgsave 期间有持续写入(约 500 MB/s 的写吞吐)。分析这 2 秒延迟的两个主要来源,并给出三条缓解措施。

提示(不给全解)

延迟来源一:页表复制。 20 GB 数据集对应的页表大小正比于映射的页数;在 4 KB 普通页下,20 GB ÷ 4 KB ≈ 500 万个页表条目,复制这张页表本身就需要几十到几百毫秒。想一想:THP 把页大小变成 2 MB,同等数据量页表条目数如何变化?页表复制时间如何变化?

延迟来源二:COW 缺页处理。 fork 后父进程继续写入,每次写到一个共享页面时触发 COW:OS 为父进程分配新物理页、复制旧内容、更新页表。THP 下每次 COW 复制 2 MB 而非 4 KB,写入放大约 500 倍。500 MB/s 写吞吐在 THP 下意味着巨大的 COW 开销。

三条缓解方向(各想一条机制):① 关闭 THP(降低 COW 粒度);② 控制 bgsave 期间的写入量(减少触发 COW 的次数);③ 提高 AOF rewrite 频率、减少依赖 bgsave 的频率(减少 bgsave 触发次数本身)。