前言
上一篇文章里,我使用 BR 对 TiDB 7.5 测试集群做了一次快照备份,随后删除 hospital_lab.patient_visit 表,再从全量备份中单独恢复这张表。
恢复后的表有 10 条记录,与执行快照备份时的状态一致。快照完成后新增的两条记录没有恢复,这不是数据丢失,也不是 BR 执行异常,而是快照恢复本身的正常结果。
TiDB 的快照备份基于 MVCC,将指定快照包含的数据写入备份存储。使用这份备份恢复时,目标集群会回到快照对应的数据状态。快照之后产生的数据不在这份备份中,自然也不会随着快照一起恢复。
这就留下了一个比较现实的问题。
假设每天凌晨执行一次全量备份,业务在白天持续写入数据,下午又发生了误删除。如果直接恢复凌晨的快照,误删除之前正常产生的数据也会缺失。快照备份解决了“回到某个备份点”的问题,却不能单独满足“尽量恢复到误操作发生之前”的要求。
因此,这次我继续在同一套测试环境中启用日志备份,并配合全量快照完成一次 PITR,也就是 Point-in-time recovery 验证。
整个过程围绕一条简单的时间线展开:
- 全量快照中保存 10 条记录;
- 快照完成后再增加 2 条有效记录;
- 随后模拟一次错误删除;
- 最后把集群恢复到新增数据已经提交、错误删除尚未发生的时间点。
如果恢复结果正确,目标集群里应该有 12 条记录。前面新增的两条数据要保留下来,后面的误删除则不应生效。
一、实验环境
源集群继续使用前面搭建的 TiDB 7.5 测试环境,恢复端另外准备了一套相同版本的空集群。
| 角色 | 主机或地址 | 说明 |
|---|---|---|
| 源集群 PD | 192.168.56.101:2379 |
日志备份和快照备份来源 |
| 源集群 TiDB | 192.168.56.101:4000 |
执行业务测试 SQL |
| 恢复集群 PD | 192.168.66.101:2379 |
PITR 恢复目标 |
| 恢复集群 TiDB | 192.168.66.101:4000 |
恢复后进行数据校验 |
| BR 中控机 | 192.168.56.110 |
运行 BR 命令 |
| MinIO | 192.168.56.120:9000 |
保存快照和日志备份 |
| 存储桶 | tidb-br |
TiDB 备份专用 Bucket |
| TiDB/BR 版本 | v7.5.7 |
源端、恢复端与 BR 保持一致 |
| 时区 | Asia/Shanghai |
所有节点均为 +0800 |
源集群和恢复集群都由 3 个 PD、3 个 TiKV 和 2 个 TiDB Server 组成。恢复集群中没有业务库,专门用于本次 PITR 验证。
TiDB 7.5 的 PITR 只支持集群粒度恢复,并且要求目标端是全新的空集群,不能直接把单个数据库或单张表回退到指定时间点。
上一篇快照恢复使用的是 NFS。日志备份这里没有继续沿用 local://,而是改用 MinIO 提供的 S3 兼容接口。TiDB 的外部存储 URI 支持通过 endpoint 指定 S3 兼容服务地址,并使用 force-path-style 进行路径式访问。
MinIO 中的目录结构规划如下:
s3://tidb-br/pitr-lab/
├── log-backup/
└── snapshot-20260806-093000/
log-backup 用于持续保存变更日志,快照备份则使用带时间的独立目录。这样既方便确认恢复链路,也便于后续制定保留和清理策略。
BR 中控机已在 ~/.aws/credentials 中配置 MinIO 访问凭证,命令中不再直接写入 Access Key 和 Secret Key。
为了减少后续命令长度,我先定义两个存储地址:
export LOG_STORAGE='s3://tidb-br/pitr-lab/log-backup?endpoint=http://192.168.56.120:9000&force-path-style=true'
export SNAPSHOT_STORAGE='s3://tidb-br/pitr-lab/snapshot-20260806-093000?endpoint=http://192.168.56.120:9000&force-path-style=true'
开始测试前,分别确认源集群、恢复集群和 BR 中控机都能访问 MinIO 的 192.168.56.120:9000 端口。
二、先确认上一篇恢复后的数据状态
上一篇单表恢复完成后,hospital_lab.patient_visit 表回到了快照时的 10 条记录。
本次实验开始前再次检查:
SELECT
COUNT(*) AS row_count,
SUM(total_amount) AS amount_sum,
MIN(visit_id) AS min_visit_id,
MAX(visit_id) AS max_visit_id
FROM hospital_lab.patient_visit;
结果如下:
+-----------+------------+--------------+--------------+
| row_count | amount_sum | min_visit_id | max_visit_id |
+-----------+------------+--------------+--------------+
| 10 | 444.00 | 10001 | 10010 |
+-----------+------------+--------------+--------------+
这 10 条记录是本次 PITR 的基础数据。
上一篇快照恢复后新增的 20001 和 20002 两条记录没有出现,原因也在这里得到再次确认:那两条数据是在快照生成之后才写入的,恢复全量快照时不会被自动补入。只有同时具备对应时间范围的日志备份,并在恢复过程中应用这些日志,它们才可能回到目标集群。
三、启动日志备份任务
日志备份需要先于后续全量快照启动,确保从日志任务开始时间到目标恢复时间之间的数据变更能够形成连续的恢复链路。
执行:
tiup br:v7.5.7 log start \
--task-name pitr_lab \
--pd "192.168.56.101:2379" \
--storage "${LOG_STORAGE}" \
--send-credentials-to-tikv=true
一个 TiDB 集群同一时间只能运行一个日志备份任务。任务启动后,日志备份会持续运行在各 TiKV 节点上,将 KV 变更分批写入备份存储。
查询任务状态:
tiup br:v7.5.7 log status \
--task-name pitr_lab \
--pd "192.168.56.101:2379"
任务信息中最先看到的是:
name: pitr_lab
status: ● NORMAL
start: 2026-08-06 09:20:14.1 +0800
storage: s3://tidb-br/pitr-lab/log-backup
NORMAL 只能说明日志备份任务当前处于正常运行状态。后续判断数据是否已经写入存储,还要继续查看 checkpoint[global]。
日志备份的 global checkpoint 表示:所有 TiKV 中,小于该时间点的日志数据都已经写入目标存储。它比“任务是否存活”更能反映当前实际可恢复到哪里。
四、日志任务运行后执行全量快照
确认日志任务进入正常状态后,于 2026 年 8 月 6 日 09:30 执行一次全量快照:
tiup br:v7.5.7 backup full \
--pd "192.168.56.101:2379" \
--storage "${SNAPSHOT_STORAGE}" \
--ratelimit 64 \
--send-credentials-to-tikv=true \
--log-file "/home/tidb/br-log/snapshot-20260806-093000.log"
这次没有指定 --backupts,因此 BR 使用备份开始时对应的快照时间点。
官方的 PITR 实践也是由持续运行的日志备份配合定期全量快照组成。恢复时选择目标时间之前最近的一份全量快照,再应用这份快照之后、目标时间之前的日志数据。
快照完成后检查日志:
grep -iE "error|fatal|panic" \
/home/tidb/br-log/snapshot-20260806-093000.log
然后读取快照对应的 BackupTS:
tiup br:v7.5.7 validate decode \
--field="end-version" \
--storage "${SNAPSHOT_STORAGE}" \
| tail -n 1
快照中仍然是前面确认过的 10 条记录:
SELECT COUNT(*)
FROM hospital_lab.patient_visit;
+----------+
| COUNT(*) |
+----------+
| 10 |
+----------+
到这里,PITR 所需的基础快照已经准备完成。
五、在快照之后写入两条有效数据
快照结束后,向 patient_visit 表写入两条新的门诊记录:
BEGIN;
INSERT INTO hospital_lab.patient_visit
(visit_id, patient_no, department, visit_time,
total_amount, visit_status)
VALUES
(20001, 'P020001', '急诊科',
'2026-08-06 10:01:12', 66.00, 'FINISHED'),
(20002, 'P020002', '内科',
'2026-08-06 10:01:18', 88.00, 'FINISHED');
COMMIT;
检查当前数据:
SELECT
COUNT(*) AS row_count,
SUM(total_amount) AS amount_sum,
MIN(visit_id) AS min_visit_id,
MAX(visit_id) AS max_visit_id
FROM hospital_lab.patient_visit;
结果变为:
+-----------+------------+--------------+--------------+
| row_count | amount_sum | min_visit_id | max_visit_id |
+-----------+------------+--------------+--------------+
| 12 | 598.00 | 10001 | 20002 |
+-----------+------------+--------------+--------------+
其中,原来 10 条数据的金额合计为 444.00,新写入的两条金额分别为 66.00 和 88.00,因此当前合计为 598.00。
这两条数据不在 09:30 的全量快照里。它们能否恢复,完全取决于日志备份是否覆盖对应时间范围。
随后记录数据库时间和时区:
SELECT
NOW(6) AS current_time,
@@global.time_zone AS global_time_zone,
@@session.time_zone AS session_time_zone;
返回:
current_time : 2026-08-06 10:03:30.215624
global_time_zone : SYSTEM
session_time_zone : SYSTEM
操作系统时间为:
date '+%F %T %z'
2026-08-06 10:03:31 +0800
本次目标恢复时间确定为:
2026-08-06 10:03:30+0800
这个时间晚于两条新记录的提交时间,同时早于接下来执行的错误删除。
真实故障中,恢复点不能只凭印象决定。通常还需要结合应用日志、审计记录、操作记录和业务人员确认,找到最后一笔正确业务与第一笔错误操作之间的边界。
六、模拟错误删除
在 10:04 左右执行错误删除:
DELETE FROM hospital_lab.patient_visit
WHERE visit_id >= 10006;
当前会话为自动提交模式,语句完成后删除立即生效。
再次查看数据:
SELECT
COUNT(*) AS row_count,
SUM(total_amount) AS amount_sum,
MIN(visit_id) AS min_visit_id,
MAX(visit_id) AS max_visit_id
FROM hospital_lab.patient_visit;
结果为:
+-----------+------------+--------------+--------------+
| row_count | amount_sum | min_visit_id | max_visit_id |
+-----------+------------+--------------+--------------+
| 5 | 141.80 | 10001 | 10005 |
+-----------+------------+--------------+--------------+
被删除的数据包括原表中 10006 至 10010 的 5 条记录,以及刚才新增的 20001、20002。当前只剩下 10001 至 10005。
为了进一步确认恢复边界,又在错误删除后插入一条标记数据:
INSERT INTO hospital_lab.patient_visit
(visit_id, patient_no, department, visit_time,
total_amount, visit_status)
VALUES
(30001, 'P030001', '测试科室',
'2026-08-06 10:05:20', 1.00, 'AFTER_DELETE');
此时源集群中共有 6 条记录:
+-----------+------------+--------------+--------------+
| row_count | amount_sum | min_visit_id | max_visit_id |
+-----------+------------+--------------+--------------+
| 6 | 142.80 | 10001 | 30001 |
+-----------+------------+--------------+--------------+
至此,实验中形成了三个不同的数据状态:
| 时间阶段 | 应有记录数 | 说明 |
|---|---|---|
| 09:30 全量快照 | 10 | 基础数据 |
| 10:03:30 目标恢复点 | 12 | 包含 20001、20002 |
| 错误删除及后续写入后 | 6 | 只剩 5 条原数据和 30001 |
PITR 最终应恢复出中间的 12 条数据。
七、确认日志已经覆盖目标时间
错误操作完成后,再次查询日志备份状态:
tiup br:v7.5.7 log status \
--task-name pitr_lab \
--pd "192.168.56.101:2379"
第一次查询时,任务状态虽然仍为 NORMAL,但 global checkpoint 只推进到:
checkpoint[global]: 2026-08-06 10:01:47.518 +0800
这个时间早于目标恢复点 10:03:30。
此时不能立即执行 PITR。日志任务仍然正常运行,但目标时间之前的变更还没有全部刷新到 MinIO。
等待数分钟后重新检查:
checkpoint[global]: 2026-08-06 10:08:16.736 +0800
现在 global checkpoint 已经超过目标恢复时间,说明所有小于 10:08:16.736 的日志变更都已进入备份存储,目标恢复点具备完整的日志覆盖。
我又直接从 MinIO 中读取日志备份元信息:
tiup br:v7.5.7 log metadata \
--storage "${LOG_STORAGE}"
br log metadata 不需要连接源集群,可以直接查询存储中的最早和最新可恢复时间。当源集群已经不可访问时,这项检查尤其有用。
确认日志最大时间已经晚于 2026-08-06 10:03:30+0800 后,才进入恢复阶段。
八、检查恢复目标集群
TiDB 7.5 的 PITR 要求恢复到全新的空集群,而且只支持集群级恢复。它不能像快照恢复那样通过 --db 和 --table 直接筛选一张表。
连接恢复集群:
mysql -h 192.168.66.101 -P 4000 -u root -p
检查版本:
SELECT VERSION();
确认源端、目标端和 BR 都使用 v7.5.7。
再确认恢复集群中不存在测试数据库:
SELECT SCHEMA_NAME
FROM INFORMATION_SCHEMA.SCHEMATA
WHERE SCHEMA_NAME = 'hospital_lab';
查询结果为空。
同时检查恢复集群各 TiKV 节点到 MinIO 的网络:
curl -I http://192.168.56.120:9000
恢复端需要能够访问全量快照目录和日志备份目录。BR 会先导入快照,再应用快照之后的日志,因此两部分数据缺少任何一段都无法完成目标时间点恢复。
九、执行 PITR
在 BR 中控机上执行:
tiup br:v7.5.7 restore point \
--pd "192.168.66.101:2379" \
--storage "${LOG_STORAGE}" \
--full-backup-storage "${SNAPSHOT_STORAGE}" \
--restored-ts "2026-08-06 10:03:30+0800" \
--send-credentials-to-tikv=true \
--log-file "/home/tidb/br-log/pitr-restore-20260806.log"
这条命令中:
--pd指向恢复集群;--storage指向日志备份;--full-backup-storage指向目标时间之前的全量快照;--restored-ts指定需要恢复到的时间,并明确带上+0800时区。
BR 会先恢复全量快照,再应用从快照 BackupTS 到 2026-08-06 10:03:30+0800 之间的日志。官方实践示例采用的也是这一恢复流程。
恢复结束后检查日志:
grep -iE "error|fatal|panic" \
/home/tidb/br-log/pitr-restore-20260806.log
再检查恢复摘要:
grep -E "Full Restore success summary|restore log success summary" \
/home/tidb/br-log/pitr-restore-20260806.log
快照恢复和日志恢复都出现成功摘要后,继续进入数据校验。
十、验证恢复结果
连接恢复集群:
mysql -h 192.168.66.101 -P 4000 -u root -p
确认数据库和表已经恢复:
SHOW DATABASES LIKE 'hospital_lab';
SHOW TABLES FROM hospital_lab;
检查表结构:
SHOW CREATE TABLE hospital_lab.patient_visit\G
随后查询汇总数据:
SELECT
COUNT(*) AS row_count,
SUM(total_amount) AS amount_sum,
MIN(visit_id) AS min_visit_id,
MAX(visit_id) AS max_visit_id
FROM hospital_lab.patient_visit;
恢复结果为:
+-----------+------------+--------------+--------------+
| row_count | amount_sum | min_visit_id | max_visit_id |
+-----------+------------+--------------+--------------+
| 12 | 598.00 | 10001 | 20002 |
+-----------+------------+--------------+--------------+
这与 10:03:30 时的数据状态一致。
检查快照之后写入的数据
SELECT
visit_id,
patient_no,
department,
total_amount,
visit_status
FROM hospital_lab.patient_visit
WHERE visit_id IN (20001, 20002)
ORDER BY visit_id;
两条记录均存在:
+----------+------------+------------+--------------+--------------+
| visit_id | patient_no | department | total_amount | visit_status |
+----------+------------+------------+--------------+--------------+
| 20001 | P020001 | 急诊科 | 66.00 | FINISHED |
| 20002 | P020002 | 内科 | 88.00 | FINISHED |
+----------+------------+------------+--------------+--------------+
这说明日志恢复已经补齐快照之后、目标时间之前的数据变更。
检查被错误删除的数据
SELECT visit_id, patient_no, department
FROM hospital_lab.patient_visit
WHERE visit_id BETWEEN 10006 AND 10010
ORDER BY visit_id;
10006 至 10010 的 5 条数据全部存在。由于目标恢复时间早于错误删除的提交时间,这次 DELETE 没有进入恢复结果。
检查目标时间之后的标记数据
SELECT *
FROM hospital_lab.patient_visit
WHERE visit_id = 30001;
查询结果为空。
30001 是目标恢复时间之后写入的数据,不应出现在恢复集群中。
最后执行表和索引一致性检查:
ADMIN CHECK TABLE hospital_lab.patient_visit;
本次恢复结果可以归纳为:
| 数据或操作 | 恢复结果 |
|---|---|
| 全量快照中的 10 条数据 | 已恢复 |
| 快照后、目标时间前新增的 2 条数据 | 已恢复 |
| 目标时间后的错误删除 | 未应用 |
目标时间后新增的 30001 |
未恢复 |
这说明恢复结果不是简单回到 09:30 的全量快照,而是回到了指定的 10:03:30。
十一、这次实践中几个容易忽略的问题
1. 快照恢复与 PITR 的结果不能混为一谈
上一篇只使用全量快照,因此恢复结果停留在快照生成时的 10 条数据。快照后新增的两条记录不在备份中,不会凭空出现在恢复结果里。
本次同时使用快照和日志备份,BR 先恢复 10 条基础数据,再通过日志补入 20001、20002,最终得到目标时间点的 12 条数据。
这两次结果都符合官方定义,并不存在相互矛盾。
2. 日志任务正常不代表已经覆盖当前时间
status: NORMAL 只表示任务处于运行状态。判断某个时间点能否用于恢复,必须查看 global checkpoint 或存储中的 log-max-date。
日志备份以小批量方式定期刷新数据,官方给出的典型刷新周期约为 3~5 分钟。因此,刚提交的数据不会立即成为可恢复数据。
3. 时间和时区必须明确
本次在 --restored-ts 中显式写入 +0800,并提前确认数据库、PD、BR 中控机和操作系统时区一致。
如果应用日志使用 UTC,数据库使用北京时间,而恢复命令又没有明确时区,最终恢复点很可能偏移数小时。
4. PITR 不是在原集群中撤销一条 SQL
PITR 会恢复整个集群到指定时间点,并且要求目标是新的空集群。它不支持直接回滚原集群中的某条 DELETE,也不支持只恢复一张表。
真实业务中如果只需要找回少量数据,更稳妥的方式通常是:
先将 PITR 数据恢复到隔离集群,完成数据库和业务校验,再通过受控方式把所需数据回迁到生产环境。
5. 日志备份也需要监控和清理
日志文件会持续增长,不能只启动任务而不再管理。
日常至少要关注任务状态、global checkpoint、checkpoint gap、备份存储容量和任务告警。TiDB 也提供 tidb_log_backup_last_checkpoint 等指标,用于监控日志备份的最新 Checkpoint TS。
清理过期日志前,应先执行 br log truncate --dry-run 查看影响范围,并确保仍保留一份不晚于日志保留起点的全量快照。否则,快照和日志之间出现断点后,备份文件即使都在,也无法组成完整的 PITR 恢复链路。官方实践同样建议将持续日志备份、定期快照和统一保留策略配合使用。
结语
上一篇快照恢复中,patient_visit 表回到了备份时的 10 条记录。快照完成后新增的 20001 和 20002 没有恢复,是因为它们不属于那份快照。
这次开启日志备份以后,结果发生了变化。
全量快照中的 10 条数据先被恢复,快照之后正常写入的两条数据又通过日志恢复补了回来;随后发生的错误删除,以及目标时间之后新增的 30001,都没有进入恢复结果。
最终得到的 12 条数据,正是 2026-08-06 10:03:30+0800 时的集群状态。
实际做完以后,我觉得 PITR 最容易出问题的并不是 restore point 命令本身,而是时间线。
快照是什么时候生成的,正常数据什么时候提交,误操作什么时候发生,global checkpoint 推进到了哪里,恢复命令使用的时区是否一致,这些条件只要有一个判断错误,即使命令显示执行成功,得到的也可能不是业务真正需要的状态。
快照备份解决了固定恢复点的问题,日志备份则把快照之间的数据变化连接起来。两者形成连续链路以后,PITR 才真正具备意义。
不过,备份能力最终仍然要靠恢复验证来确认。日志任务一直显示正常、对象存储中不断产生文件,并不能代替实际恢复。只有定期选择一套空集群,按照完整流程恢复并校验数据,才能知道这条恢复链路在需要时是否真的可用。