做数据库备份时,最容易观察到的是文件:全量文件生成了,增量文件也按时落盘,任务列表里一片绿色。
但真正需要恢复时,我们要回答的是另一组问题:
- 全量备份对应哪个 binlog 坐标?
- 第一份增量是否恰好从这个坐标开始?
- 后续增量有没有缺口、重复或混入其他备份链?
- 这些文件最终应该按什么顺序恢复?
备份文件存在,只证明有文件;能否恢复,还取决于这些文件之间的关系。
在维护数据库备份功能的过程中,我把其中的区间规划和恢复链校验提取成了一个独立 Python 库:db-incremental-core。
这篇文章围绕几个容易忽略的问题展开,介绍如何把"看起来有备份"变成一组可以检查、可以测试的恢复条件。讨论的是全量快照加 binlog 的逻辑恢复方案,坐标示例均为演示数据。
一、先解决全量基线:应该从哪里开始增量?
假设一次全量导出经历了下面的过程:
text
10:00 建立一致性快照,对应 binlog 位置 100
10:01 业务继续写入
10:03 全量导出结束,此时 binlog 已推进到 300
如果在导出结束后查询当前坐标,再把 300 保存为全量备份的基线,会发生什么?
全量文件反映的是快照时刻的数据,而后续增量却从 300 开始。快照之后、位置 300 之前的变更,就可能被跳过去。
问题出在:事后查询到的坐标,与这份全量快照并不对应。
因此,全量备份需要保存与快照配套的 binlog 坐标。MySQL 的 mysqldump --source-data=2 可以把相关坐标作为注释写入导出文件;与 --single-transaction 配合使用时,还需要满足存储引擎和导出期间 DDL 等一致性条件。MySQL 官方说明
导出内容中的坐标形式类似:
sql
-- CHANGE REPLICATION SOURCE TO SOURCE_LOG_FILE='mysql-bin.000001', SOURCE_LOG_POS=100;
规划库提供了一个专门解析这类信息的入口:
python
from db_incremental_core import parse_snapshot_position
baseline = parse_snapshot_position(
"-- CHANGE REPLICATION SOURCE TO "
"SOURCE_LOG_FILE='mysql-bin.000001', SOURCE_LOG_POS=100;"
)
print(baseline)
这里有一个值得坚持的处理原则:
如果快照里没有取得可信坐标,就记录"没有基线",不要拿后来的当前坐标补上。
缺少信息可以被系统识别和处理;填入一个看起来正常、实际不对应的数据,反而会掩盖问题。
二、增量任务必须有固定的终点
确定全量基线后,下一步是定义每次增量的范围。
例如:
text
全量基线:100
增量 A:[100, 200)
增量 B:[200, 350)
增量 C:[350, 500)
这里使用左闭右开的区间 [start, end)。相邻两段共享边界,便于表达连续性:
python
previous.end == current.start
MySQL 的 mysqlbinlog 按事件位置处理起止参数:起点包含从该位置开始的事件,终点在遇到位置达到或超过指定值的事件时停止。位置是文件内的字节偏移,生产环境必须使用有效事件边界,并保证事务完整性,不能照搬本文的演示数字。mysqlbinlog 参数说明
固定终点还解决了一个任务记录问题。
假设本次任务读取的是:
text
[100, 200)
读取结束后,业务已经把服务器推进到 260。如果此时重新查询当前位置,并把 260 写成这份增量的结束坐标,下一次任务就可能从 260 开始。
文件实际只读到 200,元数据却声称覆盖到了 260,恢复链便出现了隐藏缺口。
因此,一次增量任务应该先确定范围,再执行读取,最后保存这次任务实际采用的范围。
三、把区间检查从命令执行中拆出来
在项目里,我把"生成计划"和"执行命令"分开处理。
规划阶段只接收坐标和日志清单:
python
from db_incremental_core import (
BinlogPosition,
build_binlog_read_args,
plan_incremental_backup,
)
plan = plan_incremental_backup(
start=BinlogPosition("mysql-bin.000001", 100),
end=BinlogPosition("mysql-bin.000001", 200),
available_files=["mysql-bin.000001"],
allow_rotation=False,
)
args = build_binlog_read_args("mysql", plan)
print(args)
得到的参数是:
text
[
'--read-from-remote-server',
'--skip-gtids',
'--start-position=100',
'--stop-position=200',
'mysql-bin.000001'
]
这里没有数据库地址、密码,也不会启动子进程。
这样的拆分让几个问题变得容易验证:
- 起止日志是否在调用方提供的清单中。
- 结束坐标是否倒退。
- 本次是否发生日志轮转。
- 当前策略是否允许跨文件读取。
数据库连接、子进程退出码、文件写入和归档校验,则由上层应用处理。
还有一个边界必须说清楚:available_files 是调用方提供的有序日志清单。规划器依赖它,无法自行证明服务端没有缺失中间日志。
输入清单是否可靠,也是备份系统需要承担的责任。
四、恢复链不能只按备份时间排序
一种直观的恢复方式是:
找到一份全量备份,再把后面的增量按时间排序执行。
时间适合展示,却不足以证明依赖关系。
同一个数据库可能有多条全量链,也可能存在重试、失败记录或并行任务。恢复时需要的是明确的父子关系。
可以把每份备份表示成一个节点:
| 字段 | 表达的信息 |
|---|---|
id |
当前备份记录 |
kind |
全量或增量 |
parent_id |
依赖的上一份备份 |
start、end |
覆盖的 binlog 区间 |
chain_id |
所属备份链 |
status |
任务状态 |
archive |
对应的归档标识 |
下面构造一条最小恢复链:
python
from db_incremental_core import BackupPoint, BinlogPosition, plan_restore
baseline = BinlogPosition("mysql-bin.000001", 100)
full = BackupPoint(
id=1,
kind="full",
database="business",
parent_id=None,
start=None,
end=baseline,
status="success",
archive="full.sql.gz",
chain_id="chain-a",
)
delta = BackupPoint(
id=2,
kind="incremental",
database="business",
parent_id=1,
start=baseline,
end=BinlogPosition("mysql-bin.000001", 200),
status="success",
archive="incremental.sql.gz",
chain_id="chain-a",
)
restore = plan_restore(
[full, delta],
selected_id=2,
target_database="business",
)
print([node.id for node in restore]) # [1, 2]
这段代码中的 archive 只是字符串标识,并没有创建文件。规划通过,说明传入的这组元数据满足当前规则。
plan_restore 接收的也是已经组装好的有序链。历史记录查询、沿父节点追溯等工作,仍由调用方完成。

图 1:plan_restore 的实际源码,包含全量根节点、链 ID、父子依赖与坐标连续性检查。截图对应提交 4c7f472。 查看对应源码
用一个错误证明检查确实有效
把增量起点从 100 改成 101:
python
from dataclasses import replace
broken_delta = replace(
delta,
start=BinlogPosition("mysql-bin.000001", 101),
)
plan_restore(
[full, broken_delta],
selected_id=2,
target_database="business",
)
此时会得到:
text
ValueError: 增量恢复链 binlog 坐标不连续
这里的 101 只用于模拟元数据不一致。
它说明了这类检查的价值:错误可以在恢复计划阶段暴露,无需等到 SQL 已经开始执行。
五、为什么坐标相同,还要检查 chain_id?
假设同一个数据库存在两条备份链:
text
链 A:全量 A → 增量 A1 → 增量 A2
链 B:全量 B → 增量 B1
两次全量备份可能恰好记录了相同坐标。如果只检查坐标,错误拼接的节点就可能看起来能够衔接。
chain_id 为链提供了额外的身份约束。即使数据库名和边界坐标相同,来自不同链的节点也应该被识别出来。
当前实现允许 chain_id 为空,因此它的作用取决于调用方是否正确填写。新建备份链时,应分配明确的链标识,并在后续节点中持续保存。
同样,binlog 文件名也不是数据库实例的唯一身份。不同实例可能拥有相同的文件名和位置。业务系统还需要保存并核对源实例身份;当前核心数据模型没有替调用方完成这项检查。
恢复链的可靠性,来自多项约束共同成立,不能压在某一个字段上。
六、遇到日志轮转,应该支持跨文件还是转全量?
日志轮转后,区间可能变成:
text
起点:mysql-bin.000001:800
终点:mysql-bin.000003:200
这时需要的日志可能包括:
text
mysql-bin.000001
mysql-bin.000002
mysql-bin.000003
项目里保留了两种选择。
一种是保守策略:检测到文件变化后,重新执行全量备份。decide_backup_mode 采用了这种处理方式,也支持根据增量次数和链龄重新建立全量基线。
另一种是显式允许轮转:规划时传入完整、有序的日志清单,并保存跨文件信息。
需要注意,当前恢复规划器对跨文件清单主要检查起止文件是否匹配,并不会独立证明中间文件完整。读取和归档阶段仍然需要核验完整范围。
选择哪种策略,取决于实际系统能承担多少复杂度:
- 业务量较小、全量成本可接受时,保守策略更容易维护。
- 全量成本较高时,跨文件增量更有价值,但日志保留、归档和恢复验证也要一起完善。
还有一点不能混淆:
重新做一次全量,可以为后续备份建立新链,无法补回已经丢失的历史日志。
如果旧链中间的日志已经被清理,旧恢复点能否到达,仍需要单独判断。
七、恢复到测试库,改个库名就够了吗?
验证恢复时,很自然会想到新建一个库:
text
原库:business
测试库:business_test
然后通过客户端指定 business_test,希望所有数据都写进去。
但 ROW 格式的 binlog 事件携带数据库和表的信息,客户端选择的默认数据库不能当作数据库名重写机制。
因此,这个项目对增量链采用了明确约束:只允许还原到同名数据库,验证时使用另一个隔离实例上的同名空库。
这属于当前实现的支持范围。MySQL 提供了 mysqlbinlog --rewrite-db,但它对不同事件和 SQL 的处理存在限制,例如带数据库限定名的语句不会因此全部完成重写,不能简单加一个参数就宣称改名恢复已经可靠。官方重写规则说明
对恢复工具而言,明确支持范围,比提供一个没有充分验证的选项更有用。
八、规划通过之后,还剩哪些工作?
恢复规划器可以检查节点关系,但它没有打开归档文件。
例如:
python
archive="full.sql.gz"
这段元数据无法告诉我们:
- 文件是否仍然存在。
- 压缩包是否损坏。
- 内容是否被截断。
- 生成文件的命令是否真正执行成功。
- 文件是否与当前备份记录对应。
因此,应用层应该把元数据校验和归档实物校验放在处理目标数据库之前。
一个合理的执行顺序是:
text
查询并组装恢复链
↓
校验节点关系、坐标与目标约束
↓
检查全部归档的存在性和完整性
↓
确认隔离恢复实例
↓
执行全量导入与增量重放
↓
校验恢复结果
界面上的状态也值得细分。
"备份生成成功"和"恢复演练通过"表达的是不同事实。如果都显示成一个"成功",用户很难判断当前究竟验证到了哪一步。
九、这部分逻辑如何测试?
这类规则适合先通过不连接数据库的测试验证。
在本文的 WSL 源码验证环境中,项目现有测试的执行结果为:
text
10 passed in 0.05s
测试覆盖了缺失日志、坐标倒退、父子关系错误、跨文件清单、恢复目标限制,以及同库多链隔离等场景。其中还构造了包含全量根节点在内的 50 节点链,检查中间断链能否被发现。
还可以在第四节的 full、delta 基础上,运行下面三个故障示例:
python
from dataclasses import replace
cases = [
(
"坐标断链",
[full, replace(delta, start=BinlogPosition("mysql-bin.000001", 101))],
"business",
),
(
"混入其他链节点",
[full, replace(delta, chain_id="chain-b")],
"business",
),
(
"增量目标库改名",
[full, delta],
"business_test",
),
]
for name, chain, target in cases:
try:
plan_restore(chain, selected_id=2, target_database=target)
except ValueError as exc:
print(f"{name}:拒绝,{exc}")
else:
raise AssertionError(f"{name}:预期拒绝,但实际通过")

图 2:本次在 WSL 中重新执行的真实输出,10 项测试通过,三个错误恢复场景均被拒绝。为便于阅读,将原始输出排版成图;这不是数据库恢复验收截图。
这些测试证明的是规划规则的行为。数据库工具兼容性、归档读取和实际重放结果,需要通过隔离实例上的恢复演练验证。
在项目源码根目录运行现有测试:
bash
python3 -m pytest -q -p no:cacheprovider
将本文的 Python 示例保存为脚本后,也可以通过源码目录直接运行,无需先安装核心库:
bash
PYTHONPATH=src python3 /path/to/backup_chain_demo.py
以上命令要求 Python 3.11+,测试命令还需要安装 pytest;库的运行时核心仅使用 Python 标准库。源码与安装说明见 项目仓库。本文对应核心库版本为 0.1.1。
把这部分逻辑提取成独立核心后,最直接的收益是:不需要启动 Web 服务、调度器或数据库,就可以验证一个恢复计划是否违反了已定义的规则。
未来增加图形界面、命令行或新的调度入口时,也可以复用同一套判断。
备份系统最终要交付的,是一条有明确基线、连续边界、可追溯依赖,并经过实际恢复验证的路径。文件列表只是这条路径的一部分。