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):
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:
echo never > /sys/kernel/mm/transparent_hugepage/enabled
# 持久化写入 /etc/rc.local 或 systemd service
fork 调用本身在主线程完成,OS 复制页表期间主线程阻塞,表现为 Redis 对所有客户端的命令响应在那段时间内暂停。页表复制完成后主线程立即恢复,子进程接管磁盘写入(后台异步)。监控指标看 latest_fork_usec(INFO persistence 输出)。
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 顺序加载,多余的旧文件直接删除,彻底消除单文件覆盖的一致性风险。
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 频率决定了"最坏情况下丢多少"。
| 策略 | 执行时机 | 最大丢失窗口 | 性能影响 |
|---|---|---|---|
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 秒之间的任意值。
有人认为: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 | AOF(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 无限堆积。
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 选最旧的,把准确度和开销都控制在可接受范围内。
八种淘汰策略
| 策略 | 淘汰范围 | 算法 | 典型场景 |
|---|---|---|---|
noeviction(默认) | — | 不淘汰,写命令返回 OOM 错误 | 不允许丢数据的场景 |
allkeys-lru | 全部 key | 近似 LRU | 通用缓存,不确定哪些 key 有 TTL |
allkeys-lfu | 全部 key | LFU(4.0+) | 有稳定热点的场景,抗扫描污染 |
allkeys-random | 全部 key | 随机 | 访问均匀、无冷热区分时 |
volatile-lru | 有 TTL 的 key | 近似 LRU | 持久 key 不允许被淘汰 |
volatile-lfu | 有 TTL 的 key | LFU | 同上 + 热点保留 |
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 都标记成"最近访问",把真正的热点从淘汰候选池里挤掉。
一个场景:有约 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,热点安全。
·自测
- RDB bgsave 期间父进程收到写命令,数据会进 RDB 文件吗?为什么?
appendfsync everysec模式下,Redis 宕机最多丢失多少数据?原因是什么?- Redis 7.0 的多段 AOF 比旧版单文件 AOF 解决了什么具体风险?manifest 文件的作用是什么?
- 设计判别:一个同时作为缓存和持久化存储的实例(要求重启后数据尽量不丢、且恢复速度尽量快),持久化策略怎么配?
展开全部答案
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 触发次数本身)。