MySQL 增量备份实践:文件都在,为什么仍可能恢复失败?

做数据库备份时,最容易观察到的是文件:全量文件生成了,增量文件也按时落盘,任务列表里一片绿色。

但真正需要恢复时,我们要回答的是另一组问题:

  • 全量备份对应哪个 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 服务、调度器或数据库,就可以验证一个恢复计划是否违反了已定义的规则。

未来增加图形界面、命令行或新的调度入口时,也可以复用同一套判断。

备份系统最终要交付的,是一条有明确基线、连续边界、可追溯依赖,并经过实际恢复验证的路径。文件列表只是这条路径的一部分。

相关推荐
2401_891409263 小时前
量化数据13:港股分钟K线、Level2逐笔Tick、十档盘口和港股通历史数据的批量提取
数据库
w-w0w-w10 小时前
SQL注入
网络·数据库·sql
tianyuanwo10 小时前
NetBox 数据库详解:当“关系型数据库”被装进一个网络运维的领域模型里
运维·数据库·netbox
一条泥憨鱼10 小时前
【计算机网络】短连接、长连接与连接池:从 TCP 握手到池化复用的完整链路
网络·数据库·网络协议·tcp/ip·计算机网络
IvorySQL10 小时前
深入 PostgreSQL WAL:从崩溃恢复到逻辑复制
数据库·postgresql
幽灵诶11 小时前
Mysql(基于测试的学习)
数据库·mysql
砚底藏山河12 小时前
python量化自定义周期 K 线合成
java·开发语言·数据库·python·金融
宇宙无敌暴龙战士66612 小时前
MySQL 8 库表操作学习笔记:SQL 基础、库表管理、约束与用户授权全流程
数据库·sql·mysql·学习笔记·后端开发
猿究院_夏燊12 小时前
MySQL 的四种隔离级别
java·数据库·mysql