1. 先把"恢复"这件事的边界划清楚
数据库恢复技术这四个字,很多人第一反应是"备份和还原",这个理解只对了一小半。真正的恢复技术,解决的是"当事务跑到一半、机器突然没了、磁盘上躺着一堆半新半旧的数据页时,怎么让数据库回到一个逻辑上自洽的状态"。它跟备份的关系,更像是"备份给了你一个起点,日志和恢复流程把你从起点推到你想去的那个时刻"。没有恢复机制的备份,只是一个静态快照;没有备份的恢复机制,只能扛住断电,扛不住磁盘报废。
我见过不少团队,备份脚本写得漂漂亮亮,每周全备一次,问起来 RTO 是多少、RPO 是多少,答不上来。等到真出事,才发现从备份点到现在这段时间的数据,全靠日志归档,而归档链路早就断了三个月没人发现。所以这篇内容我打算把恢复技术从"原理层—机制层—实操层—排查层"完整走一遍,既讲清楚 redo 和 undo 为什么要这么分工,也给出可以照着做的崩溃模拟实验、时间点恢复步骤和容量估算方法。刚接触数据库运维的朋友能看懂流程,做过几年的人可以重点看参数计算和排查部分。
下面所有涉及具体命令的地方,我用db_ctl、start_db这类占位写法,你替换成自己环境里对应的等价命令即可,原理是通用的。
1.1 三类故障对应三种完全不同的应对手段
讨论恢复之前,得先把"故障"分类。不同类型的故障,能用的手段、愿意付出的代价、允许的数据损失窗口,完全不一样。把它们混在一起谈,方案必然做拧。
| 故障类型 | 典型场景 | 影响范围 | 主要应对手段 | 数据损失窗口 |
|---|---|---|---|---|
| 事务级故障 | 约束冲突、类型转换失败、死锁被选为牺牲者、应用主动回滚 | 单个事务 | 事务回滚(undo) | 0 |
| 系统级故障 | 进程被 kill、机器断电、内核崩溃、容器被驱逐 | 整个实例的内存状态 | 崩溃恢复(日志重放 + 回滚) | 0(已提交事务不丢) |
| 介质级故障 | 磁盘坏道、文件系统损坏、数据文件被误删、存储卷丢失 | 数据文件或整个库 | 基础备份 + 归档日志重放 | 取决于备份与归档频率 |
| 人为误操作 | UPDATE 漏了 WHERE、DROP 了表、批量脚本跑错库 | 逻辑数据 | 时间点恢复(PITR) | 取决于归档粒度 |
这张表最关键的一列是"数据损失窗口"。事务级和系统级故障,理论上窗口是零——已经提交的事务必须还在,没提交的必须消失得干干净净。这是数据库给你画的饼,也是它必须兑现的承诺。介质故障和人为误操作就不一样了,你能把数据找回来到哪一秒,完全取决于你备份和归档做得多细、多及时。很多人吃亏就吃在把"系统级故障能扛"当成了"什么都能扛",结果一次误删就傻眼了。
这里面还有一个容易被忽略的点:故障的判定方式会影响恢复策略。系统崩溃时,数据库重启后是通过"日志尾部有没有完整的提交记录"来判断该重做还是该回滚的,这个判断依赖日志记录的完整性校验。所以日志文件本身的写入可靠性(比如写入后是否 fsync、是否有页校验)直接决定了恢复能不能正确判断边界。有些环境为了追求写入性能把日志落盘的刷盘策略调松了,压测时看着很美,真断电的时候就可能出现"日志尾部残缺"的情况,这时候数据库只能保守地截断到最后一条完整记录,那条已经"提交成功"的事务就丢了。这类问题在测试环境几乎永远复现不出来,只有在真实掉电时才会暴露。
1.2 为什么说日志才是恢复的地基,备份不是
有个类比我一直在用:数据文件像是一本已经抄写好的账本,日志像是一卷还在不断加长的流水条。账本上的字可能写错了、写了一半、或者整页被撕了,但流水条是顺序追加的,只要它还在,你就能照着它把账本重抄一遍。备份则是把账本复印一份放别处。复印本能防止账本被撕,但它没法告诉你"撕掉的那一页之后又记了哪些账"。
这个类比对应到技术上就是两点结论。第一,恢复的正确性依赖日志的完整性和顺序性,日志必须是顺序写的,一条接一条,LSN(日志序列号)单调递增,任何一条断了,后面的就没法用。第二,日志的写入成本必须足够低,低到每秒钟几十兆、上百兆都不心疼,因为它是高频操作;而数据页的写入是随机的,成本高得多。所以几乎所有正经的存储引擎都采用 WAL(Write-Ahead Logging,预写日志)的思路:先顺序写日志,再把数据页慢慢刷到磁盘上去,用顺序写的高吞吐去吸收随机写的低吞吐。
这里有个常见的误解,觉得"只要开了归档,恢复就万无一失"。归档只是把生成的日志搬运到别处,如果生成端本身出了问题——比如日志被循环覆盖了、归档进程卡住导致积压、日志目录写满导致实例自我保护停机——那归档链路再完善也是空转。我在实际环境里遇到过最典型的情况是:归档任务依赖一个定时脚本,脚本里没做失败重试,某天网络抖了一下,一个归档文件传了一半失败,脚本继续往下走,后面几天都是"成功"状态,只有那一个文件是缺的。等真要做时间点恢复时,重放到那个位置就断了。所以归档这件事,必须配套"断档检测"和"缺口告警",而不是只看任务退出码。
2. 日志与检查点:恢复机制的底层设计拆解
要真正吃透恢复,绕不开日志格式和检查点这两块。很多人能背出"分析、重做、回滚"三个阶段,但被问到"为什么重做阶段要从检查点开始而不是从头开始"、"为什么有时候恢复要重放几个 G 的日志有时候只要几十兆",就说不清了。这两个问题的答案都在检查点的设计里。
2.1 WAL 的三条铁律与 LSN 的作用
WAL 有三条规则,我称之为铁律,任何一条破了,恢复的正确性就没了保障。
第一条:事务提交前,它的日志必须已经落盘。这条保证了持久性。应用程序收到"提交成功"的那一刻,日志已经在磁盘上了,哪怕下一秒断电,重启后也能通过重做把这条事务的效果恢复出来。如果为了性能把这条放松了(比如改成异步刷日志),那"提交成功"就变成了一个概率承诺,掉电时可能丢最近若干毫秒的事务。
第二条:数据页刷盘之前,它对应的日志必须已经落盘。这条是关键中的关键。假设一个事务把某页从值 A 改成 B,日志记了"把第 N 页改成 B",然后数据页被刷到磁盘,但日志还在内存缓冲区里没落盘。此时断电,磁盘上的页是 B,日志里没有这次修改的记录。重启后数据库不知道这页为什么是 B,如果后续有回滚操作需要把 B 改回 A,它找不到依据。这条规则保证了"日志永远比数据页更超前",所以恢复时总能从日志里找到数据页变化的解释。
第三条:日志必须顺序写、LSN 单调递增。这既是性能要求也是正确性要求。LSN 是整个恢复体系的坐标轴,它标记了每一条日志记录在时间线上的位置。数据页的页头里会存一个 pageLSN,记录"最后一次修改这个页的日志记录是谁"。重做阶段就是拿日志里的 LSN 和页头里的 pageLSN 比较:日志 LSN 大于 pageLSN,说明这个修改还没落到页上,需要重做;小于等于,说明已经落过了,跳过。这个比较机制让恢复可以幂等——同一个日志重放两遍不会出问题,因为第二遍会被 pageLSN 挡掉。这一点在恢复过程中途再次崩溃的场景下非常重要。
注意:不要把 pageLSN 的幂等机制和业务层的幂等混为一谈。前者保证物理页不会被重复修改,后者是业务逻辑层面的问题,数据库不负责。
LSN 还有一个妙用,就是判断"复制延迟"。日志的生成端和消费端各有一个当前 LSN,两者的差值就是落后量。但这个差值是以字节为单位,不是时间,换算成时间需要结合写入速率。有些监控直接拿字节差当延迟展示,写入速率一变,数字就失真了,看板上显示的"延迟 200MB"到底是一秒还是十分钟,得结合生成速率才判断得出来。
2.2 检查点怎么设计才不至于拖垮线上
如果只有日志没有检查点,恢复会变成一场灾难:重启时需要从日志的最开头一路重放到最后,日志攒了三个月就是三个月的数据量,恢复时间可能是几小时甚至几天。检查点的作用就是定期在日志里打一个"书签",告诉恢复流程"从这个位置往后重放就够了"。
检查点的核心是记录两样东西:一是当前的活跃事务表(哪些事务还没提交、各自的 undo 起点在哪),二是脏页表(哪些页被改过但还没刷盘、每页对应的最早未刷盘 LSN 是多少)。恢复的分析阶段就是读这个快照,然后从"脏页表里最小的那个 LSN"开始重做。所以恢复需要重放的数据量,取决于"最后一次检查点时的脏页表中最老的那个修改"到"日志末尾"的距离。
这直接引出两个优化方向。方向一是控制脏页的年龄,别让一个页被改了之后长时间不刷盘,否则它会一直把重做起点往前拖。方向二是控制检查点的频率和每次检查点的刷盘压力。这里有个经典的两难:检查点越频繁,脏页刷得越勤,恢复越快,但平时对 IO 的干扰越大;检查点越稀疏,平时性能越好,但恢复时间不可控。
主流做法是"模糊检查点"配合"平滑刷脏"。模糊检查点不要求把所有脏页一次性刷完,只记录快照,脏页在后台慢慢刷;同时把刷脏动作分摊到检查点间隔内完成,避免在检查点瞬间产生 IO 尖峰。落到参数上,通常有三个东西要一起调:检查点间隔(多久触发一次)、单次检查点的最大日志跨度(超过就强制触发)、刷脏的平滑系数(把刷脏压力摊到多长的时间窗口里)。
下面是一组我常用的起始参数思路,不是固定值,得按你环境的写入速率调:
# 占位示例:检查点相关配置的调整思路 checkpoint_interval = 5min # 检查点触发间隔 checkpoint_completion = 0.8 # 把刷脏压力摊到间隔的 80% 时间内完成 max_log_before_checkpoint = 2GB # 日志跨度超过这个值就强制检查点 dirty_page_ratio_warn = 0.15 # 脏页占比超过 15% 开始告警参数背后的账是这么算的:假设你的实例日志生成速率是 20MB/s,检查点间隔 5 分钟,那么两次检查点之间会产生 6GB 日志。乐观情况下,恢复只需要重放"最后一次检查点之后"的部分,也就是最多 6GB 再加上检查点时刻还没刷完的脏页对应的那部分,保守估个 8GB 到 10GB。如果重放速率是 200MB/s,恢复时间在 40 到 50 秒。这个数字对很多业务是可接受的。但如果你的检查点间隔设成了 30 分钟,日志生成速率还是 20MB/s,那要重放的就是 36GB 以上,恢复时间超过三分钟,对核心业务可能就是超时了。
2.3 undo 与 redo 的分工,以及混合日志的取舍
redo 和 undo 经常被放在一起说,但它们解决的是两个方向的问题,这个方向感非常重要。
redo 是"往前推":把已经提交但还没落到数据页上的修改,重新应用一遍,保证持久性。它处理的对象是"日志里有提交记录"的事务,只重做不撤销。undo 是"往回拉":把已经写到数据页上、但事务最终没提交的修改,反向撤销掉,保证原子性。它处理的对象是"日志里没有提交记录"的事务。
恢复结束时的判定标准很干脆:所有已提交事务的效果都体现在数据页上,所有未提交事务的效果都从数据页上抹掉。听起来简单,实际实现里有个麻烦:undo 本身也是一个写操作,如果回滚到一半又崩了怎么办?总不能从头再回滚一遍吧?
解决方案是"补偿日志记录"(CLR)。每一次 undo 操作,先写一条 CLR 到日志里,记录"我把某页从 B 改回了 A",然后再改数据页。这样重新恢复时,看到 CLR 就知道这段回滚已经做过了,跳过就行。同时每个事务维护一个"最后一条 undo 记录的位置",回滚时从这个位置往前找,找到一条就更新一次。这套机制让回滚也变成了可重入的、可中断的操作。
至于日志的粒度,主流实现基本都走"物理 + 逻辑"的混合路线:对页的定位是物理的(第几个文件、第几个页、页内偏移),对页内内容的描述是逻辑的(插入了哪条记录、把哪个字段从什么改成什么)。纯物理日志体积太大,纯逻辑日志在恢复时又需要保证页内容的一致性(逻辑操作要求页的初始状态必须符合预期),混合路线在体积和正确性之间取了个平衡。这个取舍直接影响日志生成速率,也就直接影响检查点间隔和恢复时间的计算——你算容量的时候,日志速率这个数字不是拍脑袋来的,得实测。
3. 崩溃恢复的完整流程拆解与实操验证
原理讲完,得动手。这一节我按"三阶段流程 + 一次可复现的崩溃实验 + 参数计算"三块走,你把实验做一遍,对恢复的理解会比看十篇文章都实在。
3.1 分析、重做、回滚:三阶段到底各自在干什么
恢复的三个阶段不是并列关系,是流水线,前一阶段的输出是后一阶段的输入。
分析阶段从最后一次成功的检查点开始扫日志,目标是重建两张表。一张是脏页表,记录"哪些页在检查点之后被改过、每页最早的那个修改对应的 LSN 是多少";另一张是活跃事务表,记录"哪些事务在检查点之后还活着、它们的 undo 起点在哪、当前状态是已提交还是未提交"。扫到日志末尾,分析阶段能得出两个关键结论:重做起点(脏页表里最小的 LSN)和待回滚事务列表。顺便说一句,崩溃的那一刻正在执行的事务,日志里是没有提交记录的,它们会自动进入待回滚列表。
重做阶段从重做起点开始,一条条往下扫,对每条日志判断"这个操作影响的数据页,pageLSN 是不是比我小"。小了就重做,大了或者相等就跳过。这里有个细节容易被忽略:重做阶段是不区分事务提交状态的,未提交事务的修改也要重做。为什么?因为这些修改可能已经落到数据页上了,不重做的话,后续的 undo 就没有一个确定的起点。先把所有修改恢复到崩溃前的物理状态,再统一回滚,逻辑才自洽。
回滚阶段针对活跃事务表里的未提交事务,按日志逆序执行 undo,每执行一步写一条 CLR。回滚完成后,数据库才算真正处于一致状态,可以对外提供服务。这三个阶段的耗时分布通常很不均匀:分析阶段很短(只扫检查点之后的日志),重做阶段最长(取决于日志量),回滚阶段取决于未提交事务改了多少数据。如果崩溃前正好有个大事务在跑(比如批量更新了几百万行没提交),回滚阶段可能比重做还长,这是很多"恢复特别慢"问题的真实原因。
注意:恢复期间不要手工去动日志文件或者数据文件,也不要急着把库拉起来接流量。有些环境为了缩短停机时间,会在恢复还没结束时就开放只读连接,结果读到的是半恢复状态的数据,比停机更麻烦。
3.2 一次模拟崩溃的实验记录
光看原理容易飘,我建议每个人都做一遍这个实验。实验环境随便找个测试实例,别在线上做。
第一步,准备一张有主键的表,灌入几万行数据,确认基线状态:
-- 建表并灌入基线数据 CREATE TABLE recover_demo ( id BIGINT PRIMARY KEY, amount NUMERIC(12,2) NOT NULL, note VARCHAR(64) ); INSERT INTO recover_demo (id, amount, note) SELECT g, 100.00, 'baseline' FROM generate_series(1, 50000) AS g; -- 记下基线 SELECT count(*), sum(amount) FROM recover_demo;第二步,开两个会话。会话 A 开启事务,更新一批数据,但不提交;会话 B 更新另一批数据并提交。两个会话都执行完之后,先别急着杀进程,观察一下日志目录的文件大小变化,记下当前 LSN 位置(大多数数据库都提供了查询当前日志位置的函数或视图)。
-- 会话 A:不提交 BEGIN; UPDATE recover_demo SET amount = amount + 10 WHERE id BETWEEN 1 AND 20000; -- 会话 B:提交 BEGIN; UPDATE recover_demo SET amount = amount + 1 WHERE id BETWEEN 40001 AND 45000; COMMIT;第三步,模拟系统级故障。不要用正常关闭命令,那会触发一次干净的关闭检查点,你要的是"突然没了"的效果。最贴近真实的做法是在操作系统层面直接杀掉数据库的主进程,或者干脆对测试虚拟机做一次强制断电(虚拟化平台一般都有这个选项)。
# 模拟系统级故障:直接杀掉主进程,不给优雅关闭的机会 kill -9 $(cat /path/to/data/postmaster.pid | head -1) # 或者,更彻底一点,在测试虚拟机上强制断电第四步,重启实例,观察恢复日志。启动时的日志输出里会明确告诉你:从哪个 LSN 开始重做、重做了多少页、回滚了几个事务、花了多长时间。这几行日志非常有价值,建议截图存档,作为你理解"恢复时间到底跟什么相关"的第一手材料。
# 启动实例,观察恢复过程输出 db_ctl start 2>&1 | tee /tmp/recovery_trace.log # 关注这几类信息: # redo start at LSN ... # redo done, applied N pages # undo N transactions # database system is ready to accept connections第五步,验证结果。这是实验的重点,别跳过。预期结果是:会话 B 提交的那 5000 行加 1 生效了,会话 A 未提交的那 20000 行加 10 完全没生效。
-- 验证已提交事务的持久性 SELECT count(*), sum(amount) FROM recover_demo WHERE id BETWEEN 40001 AND 45000; -- 预期:amount 总和比基线多 5000 -- 验证未提交事务的原子性 SELECT count(*), sum(amount) FROM recover_demo WHERE id BETWEEN 1 AND 20000; -- 预期:amount 总和与基线完全一致,没有 +10 的痕迹我做过很多次这个实验,有两次结果不符合预期,原因都值得说。一次是测试实例开了一个"关闭时不等待刷盘"的配置,导致会话 B 的提交在崩溃时其实没落盘,重启后那 5000 行的修改消失了——这说明"提交成功"的语义完全依赖落盘策略,配置改一下,承诺就变了。另一次是恢复日志显示回滚了一个事务,但数据看起来没变,查了半天发现是测试表太小,整个页在检查点里已经被刷过了,undo 阶段的写入被 pageLSN 挡掉了,属于正常现象。
3.3 把恢复时间算成一道算术题
恢复时间不能靠感觉,得能算。我用的公式很朴素:
恢复时间 ≈ 分析时间 + 重做时间 + 回滚时间
分析时间的量级通常很小,跟日志扫描速度有关,一般几十秒以内,可以先忽略。重做时间的估算方式是:重做时间 = 需要重放的日志量 ÷ 重放速率。需要重放的日志量约等于"最后一次检查点时的最老脏页对应的 LSN"到"日志末尾"的距离,工程上可以近似成"检查点间隔 × 日志生成速率"。重放速率受限于磁盘的顺序读带宽和 CPU 的解析能力,SSD 环境实测通常在 100MB/s 到 500MB/s 之间,保守取值 200MB/s 比较稳。
回滚时间的估算更麻烦一点,它跟未提交事务改了多少数据页成正比:回滚时间 ≈ 未提交事务的脏页数 × 单页回滚耗时。单页回滚耗时其实就是一次随机写,SSD 上大概几十微秒到一两百微秒。假设崩溃时有个大事务改了两百万个页还没提交,按 100 微秒算,回滚就是 200 秒。这种情况下,真正拖慢恢复的不是重做,是回滚。
按这个思路做一张估算表,把不同配置下的恢复时间摆出来,比空谈"我们的恢复能力很强"有说服力得多:
| 日志生成速率 | 检查点间隔 | 需重放日志量 | 重做时间(200MB/s) | 崩前大事务脏页数 | 回滚时间(100μs/页) | 估算总恢复时间 |
|---|---|---|---|---|---|---|
| 10 MB/s | 5 min | 3 GB | 约 15 s | 10 万 | 约 10 s | 约 25 s |
| 20 MB/s | 5 min | 6 GB | 约 30 s | 50 万 | 约 50 s | 约 80 s |
| 20 MB/s | 30 min | 36 GB | 约 180 s | 200 万 | 约 200 s | 约 380 s |
| 50 MB/s | 5 min | 15 GB | 约 75 s | 50 万 | 约 50 s | 约 125 s |
这张表最想传达的一点是:大事务是恢复时间的隐形杀手。很多团队把精力全放在调检查点间隔上,结果一次批量任务把回滚时间拉到几百秒,前面的优化全白搭。所以运维规范里通常会有"单事务影响行数不超过 N 万行"这样的硬性约束,不是洁癖,是为了保住恢复时间。批量任务要拆成小批次提交,代价是失去了单事务的原子性,但换来的是恢复时间可控,这笔账大多数场景划得来。
4. 备份与时间点恢复的落地方案
崩溃恢复解决的是"机器没了但磁盘还在"的问题。磁盘也没了呢?误删了表呢?这时候就轮到备份和归档日志上场了。这一块的理论不复杂,难的是把流程做扎实,做到需要的时候真的能用。
4.1 全量、增量、日志归档怎么组合才不留缺口
备份策略的核心是三个变量的平衡:备份窗口(能容忍备份占用多久资源)、存储成本、恢复目标(RTO 和 RPO)。一般会做三级组合。
基础备份是周期性的全量物理备份,通常是每周或每天一次。它提供恢复的起点,也决定了"恢复最坏情况要准备多大空间"。物理备份的优势是恢复快——直接把文件拷回去就行,不用重放 SQL;劣势是跟平台和版本强绑定,跨大版本基本不能用。逻辑备份(导出 SQL 或自定义格式)的优势是灵活、可跨版本、可以只导单表,劣势是恢复慢,大数据量下可能需要几小时。
增量备份在两次基础备份之间做,只备份变化的部分,把恢复的起点往前推,缩短需要重放的日志量。增量备份的实现方式各家用得不一样,有的是文件级差异,有的是块级变化追踪,有的是在基础备份之上记录的页级变更。做增量备份最需要注意的是"链"的完整性——增量备份依赖它之前的那次基础备份,如果基础备份被清了,后面所有增量都废了。所以清理策略必须按链来,不能只看单个文件的时间戳。
日志归档是连续进行的,把生成端产生的日志源源不断搬到归档存储。它决定了 RPO 能做到多细:归档延迟是 1 分钟,RPO 理论上就是 1 分钟左右(还要算上日志在本地生成到被搬运的时间)。归档最怕的不是慢,是断。断档的检测方法很简单也很有效:定期检查归档目录里日志文件的编号是否连续,中间有没有洞。有些环境日志编号会回绕,那就得同时记录时间戳和大小做交叉验证。
| 备份类型 | 频率 | 恢复速度 | 存储占用 | 主要风险 |
|---|---|---|---|---|
| 基础物理备份 | 每天或每周 | 快 | 大 | 备份期间 IO 压力、备份文件损坏 |
| 增量备份 | 每几小时 | 中 | 中 | 依赖链断裂、链过长导致恢复步骤多 |
| 日志归档 | 连续 | 取决于重放量 | 中到大 | 断档、延迟、归档盘写满 |
| 逻辑备份 | 每天 | 慢 | 中 | 恢复耗时长、大对象处理麻烦 |
一个常见的错误是把日志归档和增量备份当成二选一。它们解决的不是同一个问题:增量备份缩短的是"从基础备份到恢复起点"的距离,日志归档决定的是"恢复终点能精确到哪一秒"。两个都要,缺一个就会在某个场景下卡住。
4.2 时间点恢复的完整实操步骤
时间点恢复(PITR)是人为误操作场景下的救命手段,流程必须练熟,出事的时候是没时间现学的。
第一步,立刻止损,别再写入。发现误操作后,第一反应不应该是"赶紧回滚",而是"先别让情况变糟"。如果误操作还在跑,先终止它;如果可能,把实例切到只读或者直接把应用侧流量摘掉。为什么要这么做?因为每多写一条日志,你要重放的日志就多一截,恢复目标点的选择空间就小一分,而且恢复出来的数据跟当前数据的差异也越大。
第二步,确认恢复目标点。这是整个流程里最需要经验的一步。目标点要选在误操作那条语句开始执行之前,而不是执行之后。怎么确定?从应用日志、审计日志或者数据库的语句日志里找到误操作的开始时间,往前推几秒作为安全边界。如果误操作是个跑了几分钟的大批量更新,那目标点要推到它开始之前。这一步判断错了,恢复出来还是脏数据,白折腾一场。
-- 确认误操作的时间边界(示例:从审计表里查) SELECT session_id, statement_start, statement_text FROM audit_log WHERE statement_start >= '2024-01-01 10:00:00' AND statement_text ILIKE '%recover_demo%' ORDER BY statement_start;第三步,准备恢复环境。强烈建议不要在原实例上直接恢复,而是新起一个实例,把基础备份恢复过去,再把归档日志重放到目标点。恢复出来的实例先只读启动,验证数据没问题了,再决定怎么把数据导回生产。为什么不在原实例上做?因为原实例上的当前数据可能还有价值(比如误操作之后又产生了新的正确数据),直接覆盖就没有回头路了。另外恢复过程本身要占大量 IO 和空间,在原实例上做会影响还在跑的业务。
# 占位示例:新实例上执行时间点恢复 # 1. 用基础备份还原数据目录 restore_base_backup --backup-id 20240101_full --target /data/restore_env # 2. 配置恢复目标点和归档日志来源 # recovery_target_time 精确到秒 # restore_command 指向归档目录 cat >> /data/restore_env/recovery.conf <<'EOF' restore_command = 'copy /archive/wal/%f %p' recovery_target_time = '2024-01-01 09:59:30' recovery_target_action = 'promote' EOF # 3. 启动,观察重放进度 db_ctl start --datadir /data/restore_env 2>&1 | tee /tmp/pitr.log第四步,验证恢复结果。验证不能只看"库起来了",至少要做三层检查:行数和聚合值(总量对不对)、关键业务记录的抽样比对(具体数据对不对)、业务侧的只读查询回归(应用能不能正常读)。我一般会准备一份固定的验证 SQL 清单,每次恢复演练都跑一遍,这样不同时间点的验证结果可以横向对比,也能发现"某次恢复看起来成功了但数据其实少了一块"这种隐蔽问题。
-- 恢复后的三层验证 -- 1. 聚合值对比基线 SELECT count(*), sum(amount) FROM recover_demo; -- 2. 抽样比对关键记录 SELECT * FROM recover_demo WHERE id IN (1, 1000, 25000, 50000); -- 3. 检查是否有异常的空值或越界值 SELECT count(*) FROM recover_demo WHERE amount IS NULL OR amount < 0;第五步,把数据导回生产。这一步的方式取决于数据量和影响面。数据量小、影响面小的,直接导出目标记录,在业务低峰期写回;数据量大或者表结构有变化的,走"整表替换"或者"临时表 + 交换"的方式。不管用哪种,都要先在恢复环境里把 SQL 写好、测好,生产上只做执行,别在生产上现场调试。
4.3 把恢复演练做成一件有记录的事
备份的可靠性不能靠信心,得靠演练。我见过太多"备份一直很成功"的团队,第一次真恢复就翻车——备份文件是完整的,但恢复脚本里引用的路径早就变了;或者备份是逻辑备份,恢复时才发现某个大对象没导出来。
演练要有节奏和记录。频率上,核心库一个月一次,非核心库一个季度一次比较合适。记录上,每次演练要留下这些东西:用了哪个备份、重放了多少日志、恢复耗时、验证清单的执行结果、遇到的问题。这份记录积累半年,你就能画出自己环境的"实际恢复时间分布",而不是拍脑袋估。
| 演练项目 | 检查内容 | 判定标准 |
|---|---|---|
| 备份文件可读性 | 能否正常解压、校验和是否匹配 | 校验通过,无损坏块 |
| 恢复流程完整性 | 从空环境开始能否走完全流程 | 无需人工干预即可完成 |
| 恢复耗时 | 到可用状态的总时间 | 不超过 RTO 目标 |
| 数据一致性 | 聚合值、抽样记录、约束检查 | 全部通过 |
| 归档链完整性 | 恢复目标点之前有无断档 | 无缺口 |
| 文档准确性 | 步骤、路径、参数是否与现网一致 | 无过期内容 |
5. 常见故障场景排查实录
前面讲的是"应该怎么做",这一节讲"做的时候会碰到什么"。都是我或者身边同行真实踩过的,按现象分类整理。
5.1 恢复特别慢,卡在某个阶段不动了
这是最常被问的问题。排查时先看恢复日志停在哪个阶段,三个阶段的原因完全不同。
卡在分析阶段,通常是日志文件太多或者单个日志文件损坏需要跳过。日志文件数量跟检查点间隔直接相关,间隔设得太长,日志目录里堆了几万个文件,光打开和扫描就要很久。这种情况短期可以清理过期日志(确保它们已经被归档),长期还是得把检查点间隔调回来。
卡在重做阶段,原因按出现频率排:一是需要重放的日志量本来就大(检查点太稀),二是磁盘顺序读性能不行(归档日志放在慢速存储上,或者恢复时和别的 IO 抢带宽),三是存在大量随机读(某些实现的重做需要回查数据页,如果数据页不在缓存里就会退化成随机 IO)。前两个是配置问题,第三个是架构问题,处理方法不一样。我遇到过一次恢复慢了六倍,最后定位到是恢复目标机的磁盘做了条带化但条带大小设错了,顺序读性能只有预期的一半。
卡在回滚阶段,基本就是大事务。日志里能看到"undo transaction X"一直不结束,用系统视图查一下这个事务改了多少页,心里就有数了。这种情况没有捷径,只能等。想避免只能从规范上限制单事务的影响行数。
注意:恢复过程中千万不要因为"看起来卡住了"就强行中断重启。中断后重启会从上次的进度继续,但如果中间状态没处理好(比如 CLR 没写完整),可能引入新的问题。真觉得异常,先看日志的最后一个 LSN 有没有在推进,推进就说明没卡,只是慢。
5.2 误删误改之后的抢救顺序
误操作的抢救,顺序错了会白白损失数据窗口。我总结的顺序是:停写 → 定位 → 定目标 → 另起环境恢复 → 验证 → 回灌。
最忌讳的是发现误删后第一反应去执行"反向 SQL"试图撤销。如果误操作是个带了条件的 UPDATE,反向 SQL 理论上可行,但实际操作里很难保证条件完全对称,很容易改出新问题。而且反向 SQL 本身就是一次写操作,会覆盖掉可以用于恢复的数据状态。更稳妥的做法永远是走 PITR。
还有一种情况是"表被 DROP 了"。如果实例还活着,数据文件可能还在磁盘上(只是元数据里删了),这时候立刻停写,然后从基础备份 + 日志恢复到误操作之前。别想着去手工解析数据文件,除非你有非常成熟的工具和足够的经验,否则成功率不高,而且容易把仅存的数据也破坏掉。我在实际环境里见过有人为了"抢救"直接对数据文件做十六进制编辑,最后把整个库都搞坏了,本来走 PITR 半小时就能解决的事,变成了两天。
5.3 复制场景下的恢复陷阱
有主从复制的环境,恢复会多出几个坑。
第一个坑是"主库日志被提前清理"。从库落后太多,主库那边日志已经循环覆盖了,从库就永远追不上了,只能重建。所以日志保留策略必须考虑从库的最大容忍延迟,保留时间要大于"从库最长可能落后多久"。有些环境为了省空间把保留时间设得很短,结果一次网络抖动导致从库落后两小时,直接触发重建。
第二个坑是"从库恢复后位置对不上"。从库做了崩溃恢复之后,它的日志位置可能跟主库的预期不一致,重新连接时会被拒绝或者要求重置。这时候要么重新从主库拉一份基础备份,要么手工指定正确的起始位置。手工指定风险较高,建议还是走重新拉取。
第三个坑是"切换时的数据丢失"。主库故障,从库提升为主库,但主库上有一部分日志还没来得及传到从库,这部分数据就丢了。要减少这个窗口,就得把日志传输做成同步或者半同步模式,代价是写入延迟增加。这个取舍没有标准答案,得按业务对数据丢失的容忍度决定。
| 现象 | 可能原因 | 排查动作 | 处理方式 |
|---|---|---|---|
| 恢复卡在 redo 不推进 | 磁盘带宽被占满 | 观察 IO 利用率和队列深度 | 停掉其他 IO 任务,或换更快的存储重试 |
| 恢复卡在 undo 时间很长 | 崩溃前有大事务 | 查活跃事务的修改页数 | 等待完成,事后限制单事务规模 |
| 启动报日志损坏 | 掉电导致日志尾部残缺 | 看具体损坏的 LSN 位置 | 从完整记录处截断,接受少量数据丢失 |
| 归档链有缺口 | 归档任务失败未重试 | 检查文件编号连续性 | 从其他副本补齐,无法补齐则只能恢复到缺口前 |
| 恢复后数据比预期少 | 恢复目标点选早了 | 核对误操作开始时间 | 调整目标点重新恢复 |
| 从库追不上主库 | 主库日志已清理 | 对比主从的日志位置差 | 重建从库 |
6. 恢复体系建设的经验与踩坑
技术细节讲得差不多了,最后说说体系层面的事。恢复能力不是某个功能开关,是一整套流程加监控加演练的组合,缺一环就会在关键时刻掉链子。
6.1 把恢复能力变成几个能看的数
第一组数是 RTO 和 RPO,但它们太粗,落地时需要拆。我会关注这几个:最近一次恢复演练的实际耗时、归档延迟的 P95、检查点间隔内实际产生的日志量、崩溃恢复重做的日志量分布、单事务最大影响行数。这几个数能提前暴露问题——比如归档延迟的 P95 从 10 秒涨到 5 分钟,说明归档链路出问题了,但备份任务可能还显示成功;比如单事务最大影响行数突然从几万涨到几百万,说明有个批量任务改了逻辑,下次崩溃恢复就要遭殃。
第二组数是"演练成功率"和"演练发现问题数"。后者往往比前者有价值,一次演练什么都没发现,可能是演练太简单了;一次演练发现三个问题,说明这套演练设计得有效。我一般会把演练中发现的问题分类记录:文档类(步骤过期)、脚本类(路径或参数不对)、环境类(磁盘空间不够)、数据类(校验不通过)。按类统计,能看出团队的短板在哪。
第三组数是"缺口"。归档缺口次数、备份失败次数、未验证的备份比例。这三个数理想情况下都应该是零,非零就是待办事项。
6.2 几个我踩过的坑,写下来给后来人
第一个坑,备份成功不等于备份可用。早年我负责的一个环境,备份任务每天跑,状态一直是绿色,直到一次真恢复,发现备份文件大小一直是 0 字节,因为备份脚本里的输出路径写错了,写到了一个不存在的目录,脚本吞掉了错误码继续返回成功。从那以后我所有的备份任务都加了"备份完成后做一次轻量校验"的步骤,最简单的做法是读一下备份文件的头尾、校验和对比,复杂一点的直接拿备份起一个临时实例验证。
第二个坑,时间点恢复的精度取决于日志切换频率。有些环境日志切换不频繁,一个日志文件写满了才切下一个,导致恢复目标点的精度被粗化到"日志文件边界"。你指定恢复到一个具体秒,实际恢复到的是那个秒之前最后一个日志文件的末尾。对误操作抢救来说,这个误差可能是几分钟,够不够用要看场景。如果业务对精度要求高,得把日志切换频率调高。
第三个坑,恢复环境的磁盘空间要按"备份展开后 + 重放期间的增长"来算。只按备份文件大小算空间,大概率不够。重放日志期间,数据文件会增长(新增的页),临时文件也会有占用,再加上如果开了归档恢复可能需要额外的空间。我一般按备份解压后大小的 1.5 倍来预留,宁可多留。
第四个坑,文档一定要跟着环境变。恢复文档最怕的就是"写在三年前,路径全变了"。我的做法是把恢复步骤尽量脚本化,文档里写的是"执行哪个脚本、传入什么参数",而不是一长串手工命令。脚本改了文档自然跟着改,脚本没改但环境变了,脚本会报错而不是静默跑偏。
第五个坑,别在没有验证的情况下清归档。归档日志会占不少空间,磁盘紧张的时候容易有人想着"清一批旧的"。但归档日志的保留时间必须覆盖"最老的备份",否则那个备份就失去了时间点恢复能力。清理策略应该以"保留最近 N 次基础备份及其依赖的全部日志"为准,而不是简单地"删掉 30 天前的"。
6.3 恢复能力还能往哪延伸
上面讲的都是单实例的恢复。业务规模上去之后,会碰到一些新的问题:跨多个库的一致性恢复(比如订单库和库存库要恢复到同一个逻辑时间点)、超大规模实例的恢复时间压缩(几十 TB 的库,重放日志要几小时)、以及"部分恢复"的需求(只想恢复一张表,不想恢复整库)。这些场景的处理思路差别挺大,比如跨库一致性通常要靠全局的快照点来对齐,部分恢复通常靠逻辑备份或者从物理备份里抽取单表的数据文件再加日志重放,每一个都够单独写一篇。如果后面有机会,我再把这些展开聊。
我个人在实际操作中的体会是,恢复这件事最大的成本从来不是技术,是"没准备好"。日志机制、检查点、备份策略这些东西,文档里都写着,配起来也不难,难的是把它们串成一条能走通的链路,并且定期验证这条链路还通着。真正出事的时候,能救你的不是你对 ARIES 算法理解得多深,而是你三个月前做的那次演练里,那份照着执行就能跑通的脚本。