前言
上一篇把 MySQL 5.7 到 TiDB 7.5 的 DM 全量和增量迁移做完以后,数据已经同步到 TiDB,增量也能正常跟上。数据已经迁过去,后面要考虑的是业务什么时候改连接。医院系统和普通测试程序不一样,挂号、收费、药房、医嘱这些业务只要在切换过程中两边同时写,后面再想把账对清楚会很麻烦。
这次主要是做一次业务切换测试。切换过程中,MySQL 还是原来的业务库,DM 持续把数据同步到 TiDB。切换窗口到了以后先停业务写入,等 DM 把最后一段 binlog 同步完,再做一次数据核对。确认没问题以后停止 DM,把应用连接改到 TiDB。
应用开始写 TiDB 以后,原来的 MySQL 数据就不会自己跟着变化了。如果这个时候发现 TiDB 上的业务有问题,直接把连接改回 MySQL,新产生的数据就会丢。回退测试使用 TiCDC 把 TiDB 新产生的数据反向同步回 MySQL。
所有患者编号、就诊号、收费流水和医嘱号都是模拟数据,环境都是本地的虚拟机里做数据库切换过程测试。
一、切换前的状态
环境继续沿用上一篇。
| 项目 | 地址 | 说明 |
|---|---|---|
| MySQL | 192.168.56.130:3306 |
当前业务库,MySQL 5.7.44 |
| DM-master | 192.168.56.111:8261 |
v7.5.7 |
| DM-worker | 192.168.56.112:8262 |
v7.5.7 |
| TiDB Server 1 | 192.168.56.101:4000 |
v7.5.7 |
| TiDB Server 2 | 192.168.56.102:4000 |
v7.5.7 |
| TiCDC | 192.168.56.113:8300 |
v7.5.7 |
| 数据库 | his_source |
模拟医院业务库 |
测试库里还是上一篇那几张表:
outpatient_visit 门诊就诊
charge_detail 收费明细
doctor_order 医嘱
interface_message_log 接口消息
切换开始前,应用只允许写 MySQL。TiDB 此时只是 DM 的下游,不允许测试程序自己往里面写数据。如果迁移期间 MySQL 和 TiDB 两边都有人直接改业务表,那么 DM 再正常,最后的数据也不好判断是谁写进去的。
先看 DM:
tiup dmctl \
--master-addr 192.168.56.111:8261 \
query-status his-migrate
任务已经处于:
unit: Sync
增量同步正常。DM 的 query-status 会显示上游当前 binlog、DM 已同步到的 binlog,以及 GTID 信息。synced 可以辅助判断任务是否追平,但官方也提醒 checkpoint 不是实时刷新,所以正式切换时不能只盯着一个 synced=true。
二、切换当天先把数据库外面的东西停下来
数据库切换前,我先停模拟 HIS 程序,但没有马上停 DM。程序停止以后,MySQL 里还可能有刚刚提交的事务,这些 binlog 还需要 DM 继续往 TiDB 同步。
先检查 MySQL 里还有没有业务连接:
SELECT
ID,
USER,
HOST,
DB,
COMMAND,
TIME,
STATE
FROM information_schema.PROCESSLIST
WHERE USER = 'his_app';
测试程序退出以后,确认没有残留的业务会话。为了避免有人又连回来,测试环境把业务账号锁掉:
ALTER USER 'his_app'@'192.168.56.%'
ACCOUNT LOCK;
MySQL 5.7 支持 ACCOUNT LOCK。账号锁定以后新的连接会被拒绝,不过已经存在的连接不会因为这条语句自动消失,所以前面的会话检查不能省。
随后把 MySQL 设置成:
SET GLOBAL super_read_only = ON;
检查:
SELECT
@@global.read_only,
@@global.super_read_only;
MySQL 5.7 开启 super_read_only 后会同时开启 read_only,普通客户端和具有 SUPER 权限的客户端都不能再修改业务数据,复制线程不受这个限制。此时 MySQL 只保留读取和 DM 拉取 binlog,不再接受业务写入。
三、把 MySQL 最后的 binlog 位置记下来
业务已经停写以后,先记录 MySQL 的最终位置。
SHOW MASTER STATUS\G
再看 GTID:
SELECT @@GLOBAL.gtid_executed\G
把 binlog 文件、位置和 GTID 都保存下来,例如这里记录为:
mysql-bin.000018
Position: 4286157
这个时间点以后 MySQL 已经没有业务写入,所以它的 binlog 位置应该稳定下来。
再看 DM:
tiup dmctl \
--master-addr 192.168.56.111:8261 \
query-status his-migrate
这里先不看 TiDB 里的表,确认:
masterBinlog
syncerBinlog
masterBinlog 和 syncerBinlog 已经追到同一个位置。使用 GTID 时,也要看:
masterBinlogGtid
syncerBinlogGtid
DM 已同步的 GTID 要覆盖刚才在 MySQL 记录的最终 GTID。如果 MySQL binlog 已经不动,DM 的位置还在慢慢往前走,说明最后一段数据还没有同步完,等它追到源库最后位置再继续。
四、切换前再核一次数据
DM 追平后,我没有马上改连接。源 MySQL 已经停写,这时候正适合做最后一次静态数据核对,先看几张业务表的数量。
MySQL:
SELECT 'outpatient_visit' AS table_name,
COUNT(*) AS cnt
FROM his_source.outpatient_visit
UNION ALL
SELECT 'charge_detail',
COUNT(*)
FROM his_source.charge_detail
UNION ALL
SELECT 'doctor_order',
COUNT(*)
FROM his_source.doctor_order;
TiDB 执行相同 SQL。
收费数据再看金额:
SELECT
DATE(charge_time) AS charge_date,
COUNT(*) AS charge_count,
SUM(amount) AS total_amount
FROM his_source.charge_detail
GROUP BY DATE(charge_time)
ORDER BY charge_date;
医嘱按状态看一遍:
SELECT
order_status,
COUNT(*) AS cnt
FROM his_source.doctor_order
GROUP BY order_status
ORDER BY order_status;
门诊记录也查:
SELECT
visit_status,
COUNT(*) AS cnt
FROM his_source.outpatient_visit
GROUP BY visit_status
ORDER BY visit_status;
再看关联数据有没有断:
SELECT COUNT(*) AS orphan_charge
FROM his_source.charge_detail c
LEFT JOIN his_source.outpatient_visit v
ON c.visit_no = v.visit_no
WHERE v.visit_no IS NULL;
医嘱:
SELECT COUNT(*) AS orphan_order
FROM his_source.doctor_order o
LEFT JOIN his_source.outpatient_visit v
ON o.visit_no = v.visit_no
WHERE v.visit_no IS NULL;
医院业务不能只看 COUNT(*)。收费明细数量一样,金额不同,仍然有问题。医嘱总数一样,ACTIVE 和 EXECUTED 状态数量变了,也不能算通过。最后再用 sync-diff-inspector 做完整的数据比对。
./sync_diff_inspector \
--config=./his-diff.toml
源端已经停写,这时做 MySQL 与 TiDB 的静态比对,结果更容易判断。TiDB 官方也建议 DM 同步完成后使用 sync-diff-inspector 检查上下游数据。
五、DM 追平以后才停止任务
数据确认没有问题后,再停 DM:
tiup dmctl \
--master-addr 192.168.56.111:8261 \
stop-task his-migrate
执行:
tiup dmctl \
--master-addr 192.168.56.111:8261 \
query-status his-migrate
已经查不到任务。stop-task 并不会把这次迁移的 checkpoint 一起删掉,相关 checkpoint 仍保留在 dm_meta。如果使用 --remove-meta 重新启动,才是把原来的迁移元信息清掉重新开始。
DM 在这里停掉,还有一个原因。后面要建立 TiDB → MySQL 的反向同步,如果 MySQL → TiDB 的 DM 还在运行,同时 TiDB → MySQL 的 TiCDC 也开始同步,很容易把反向写回 MySQL 的数据又被 DM 读到,再送回 TiDB。这次测试不做双向回环,方向切换后不再启动原来的 DM。
六、自增表在开放写入前先清一次缓存
上一篇已经提到过这个问题,这次正式放进切换步骤里。DM 同步 MySQL 数据时,自增 ID 是作为明确的数值写入 TiDB。
比如 MySQL 已经有:
id = 100000
DM 会把这个 100000 直接写到 TiDB。
业务切到 TiDB 后,应用执行:
INSERT INTO outpatient_visit (...)
VALUES (...);
不再指定 id,这时候变成 TiDB 自己分配自增值。
TiDB 官方明确把 DM 增量同步结束后的这种场景列为需要清理自增 ID 缓存的情况,否则后面隐式分配的 ID 有可能和之前明确写入的 ID 冲突。
先找出自增表:
SELECT
TABLE_NAME,
COLUMN_NAME
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'his_source'
AND EXTRA LIKE '%auto_increment%';
当前测试库有:
outpatient_visit
charge_detail
doctor_order
分别执行:
ALTER TABLE his_source.outpatient_visit
AUTO_INCREMENT = 0;
ALTER TABLE his_source.charge_detail
AUTO_INCREMENT = 0;
ALTER TABLE his_source.doctor_order
AUTO_INCREMENT = 0;
然后查看:
SHOW WARNINGS;
这里的:
AUTO_INCREMENT = 0
不会让下一条业务数据真的从 0 开始。它的用途是清理 TiDB Server 当前缓存的自增 ID,TiDB 会重新选择可用的自增范围。官方文档对 DM 切换后的这个使用方法有专门说明。
七、回退这件事要在第一笔 TiDB 写入之前想清楚
如果切换完只是登录 TiDB 查询几张表,这时候发现问题,回退还比较简单。因为 TiDB 没有产生新数据,把应用重新指向 MySQL,解锁账号,恢复 MySQL 写入即可。
分界线在 TiDB 收到第一笔业务写入以后。从这一刻开始,两边数据已经可能不同。
假设上午 9:30 切到 TiDB,9:31 新挂了一个号,9:32 收了一笔费,9:33 开了一条医嘱,9:34 发现某个功能有问题。如果这个时候直接把应用连接改回原来的 MySQL,那么 9:31 到 9:33 之间的数据 MySQL 根本没有。对于医院业务,这种回退方式显然不能用,所以这次在开放 TiDB 写入前,先把 TiDB 到 MySQL 的反向同步准备好。
八、v7.5 的反向同步改用 TiCDC
查社区历史文章时,能看到一些早期方案使用:
TiDB Binlog
Pump
Drainer
把 TiDB 数据同步回 MySQL。这个思路在当时没有问题,但工具不能照搬。TiDB 7.5 官方已经明确说明,从 v7.5.0 开始,TiDB Binlog 的数据同步功能不再提供技术支持,建议使用 TiCDC。
这次反向链路使用:
TiDB
↓
TiCDC
↓
MySQL 5.7
TiCDC v7.5 支持把增量数据同步到 MySQL 兼容数据库。
九、反向同步账号提前准备好
这个账号是在切换窗口之前创建的,不临时在现场建。
MySQL:
CREATE USER 'cdc_sink'@'192.168.56.%'
IDENTIFIED BY 'CdcTest_2026!';
GRANT SELECT,
INDEX,
INSERT,
UPDATE,
DELETE,
CREATE,
DROP,
ALTER,
CREATE VIEW
ON his_source.*
TO 'cdc_sink'@'192.168.56.%';
FLUSH PRIVILEGES;
TiCDC 下游为 MySQL 时需要 SELECT、INDEX、INSERT、UPDATE、DELETE、CREATE、DROP、ALTER、CREATE VIEW 等权限。
业务账号:
his_app
此时仍然处于锁定状态。
因为 TiCDC 要往 MySQL 写数据,所以 MySQL 的:
super_read_only
不能继续保持 ON。
确认应用已经停掉、业务账号也锁住以后执行:
SET GLOBAL super_read_only = OFF;
再确认:
SELECT
@@global.read_only,
@@global.super_read_only;
MySQL 现在虽然恢复为可写,但普通业务程序仍然进不来,只有专门准备的同步账号和 DBA 管理账号能够使用。这比一边开着业务账号、一边等 TiCDC 建链安全得多。
十、记录 TiCDC 的起始 TSO
建立反向同步之前,先在 TiDB 记录一个时间点。
BEGIN;
SELECT TIDB_CURRENT_TSO();
ROLLBACK;
TIDB_CURRENT_TSO() 返回当前 TiDB 的 TSO,把这个值保存为 CUTOVER_TSO。反向同步只需要处理这个时间点之后 TiDB 新产生的变化。这时 MySQL 和 TiDB 的最终数据已经核对过,两边以这个时间点作为切换基线。
十一、创建 TiDB 到 MySQL 的 TiCDC 任务
这次只同步:
his_source
创建配置:
[filter]
rules = ['his_source.*']
TiCDC 支持按照库表规则过滤需要同步的数据。
创建 changefeed:
tiup cdc:v7.5.7 cli changefeed create \
--server=http://192.168.56.113:8300 \
--sink-uri="mysql://cdc_sink:CdcTest_2026%21@192.168.56.130:3306/" \
--changefeed-id="his-backout" \
--start-ts="${CUTOVER_TSO}" \
--config=reverse.toml
密码中的:
!
在 URI 里做了编码处理。
TiCDC 的 --start-ts 用来指定从哪个 TiDB TSO 开始读取增量数据,MySQL sink 也是官方支持的下游类型。
查询状态:
tiup cdc:v7.5.7 cli changefeed query \
--server=http://192.168.56.113:8300 \
--changefeed-id="his-backout" \
--simple
这里主要看:
state
checkpoint
state=normal 表示任务正常。
checkpoint 表示这个时间之前的 TiDB 事务已经成功写入 MySQL。
反向链路正常以后,才准备让应用写 TiDB。
十二、切换应用连接
测试程序原来的连接是:
jdbc:mysql://192.168.56.130:3306/his_source
修改为:
jdbc:mysql://192.168.56.101:4000/his_source
个人测试环境直接连接 TiDB Server,这里只看数据库切换过程。正式环境不会让核心应用长期绑定某一台 TiDB Server,应该通过已经验证过的统一数据库入口接入。应用账号已经提前在 TiDB 建好,并按照原业务需求配置权限。
这里没有直接迁移 MySQL 的 mysql.user。DM 迁的是业务库,不等于 MySQL 用户、权限、定时任务这些外围配置也自动跟着完成。
十三、用一组完整的模拟业务确认切换后的写入
应用切到 TiDB 后,不能只执行:
SELECT 1;
就认为切换已经完成。测试里模拟了一次门诊业务,患者编号使用 TESTP009901,只用于本次切换验证。
建立一次门诊就诊
INSERT INTO his_source.outpatient_visit
(visit_no,
patient_no,
dept_code,
visit_type,
visit_time,
visit_status,
update_time)
VALUES
('T202608310901',
'TESTP009901',
'CARD',
'OUTPATIENT',
'2026-08-31 09:41:12',
'WAITING',
NOW());
写入门诊收费
INSERT INTO his_source.charge_detail
(charge_no,
visit_no,
item_type,
item_code,
item_name,
qty,
unit_price,
amount,
charge_time,
charge_status)
VALUES
('C202608310901',
'T202608310901',
'REG',
'TEST-REG01',
'普通门诊诊查费',
1,
15.00,
15.00,
'2026-08-31 09:42:06',
'PAID');
写入一条模拟医嘱
INSERT INTO his_source.doctor_order
(order_no,
visit_no,
order_type,
order_code,
order_name,
start_time,
order_status,
update_time)
VALUES
('O202608310901',
'T202608310901',
'LAB',
'TEST-LAB01',
'测试检验项目',
'2026-08-31 09:43:15',
'ACTIVE',
NOW());
这些 SQL 只用于验证数据库写入和表间关联,不代表真实 HIS 的业务流程。医院挂号、收费和医嘱的业务规则复杂得多,完整回归要由对应应用系统完成。
十四、反向 TiCDC 有没有真正把数据送回 MySQL
TiDB 写完以后,再取一个 TSO:
BEGIN;
SELECT TIDB_CURRENT_TSO();
ROLLBACK;
保存为 VERIFY_TSO,然后查看 TiCDC:
tiup cdc:v7.5.7 cli changefeed query \
--server=http://192.168.56.113:8300 \
--changefeed-id="his-backout" \
--simple
等 checkpoint-ts 推进到 VERIFY_TSO 之后。TiCDC 官方定义的 checkpoint 就是已经成功写入下游的最大事务 TSO。
再到原 MySQL 查询:
SELECT
visit_no,
patient_no,
visit_status
FROM his_source.outpatient_visit
WHERE visit_no = 'T202608310901';
收费:
SELECT
charge_no,
visit_no,
amount,
charge_status
FROM his_source.charge_detail
WHERE charge_no = 'C202608310901';
医嘱:
SELECT
order_no,
visit_no,
order_status
FROM his_source.doctor_order
WHERE order_no = 'O202608310901';
这三组测试数据都应该已经出现在 MySQL。这里不能只确认 TiCDC state = normal,还要看 checkpoint 是否真正越过业务写入时间。
十五、反向同步不是“实时双主”
TiCDC 可以把 TiDB 的变化同步到 MySQL,但它不是把两个数据库变成可以随便同时写的双主数据库。
TiCDC MySQL sink 保证单行更新顺序,但并不保证下游事务按照上游事务完全相同的顺序执行,整体提供的是最终一致性。
所以医院业务回退时不能这样做:
TiDB 还在写
MySQL 也恢复业务写
然后等两边自己同步
这种方式风险太大。准备回退时,还是要重新停业务,让 TiDB 不再产生新写入,等 TiCDC 把最后的数据送到 MySQL,再做数据核对。无论切换方向,改连接前都要先把写入口收成一个。
十六、模拟一次回退
为了确认回退步骤可用,测试里故意执行了一次回切。先停止模拟 HIS,确认 TiDB 没有业务连接以后,把 TiDB 设置为只读:
SET GLOBAL tidb_super_read_only = ON;
TiDB 的 tidb_super_read_only 会让整个 TiDB 集群进入只读状态,普通 INSERT、UPDATE、DELETE 会被拒绝。从 v6.2.0 开始,事务提交前也会重新检查只读状态。不过这个变量在多个 TiDB Server 之间是最终一致生效的,所以设置以后要分别连接:
192.168.56.101:4000
192.168.56.102:4000
确认:
SELECT @@global.tidb_super_read_only;
都已经为:
ON
同时确认应用连接已经清掉,这时候 TiDB 不再产生新的业务数据。
十七、等最后一段 TiDB 数据同步回 MySQL
TiDB 停写以后,再记录当前 TSO:
BEGIN;
SELECT TIDB_CURRENT_TSO();
ROLLBACK;
记为:
ROLLBACK_TSO
继续检查 TiCDC:
tiup cdc:v7.5.7 cli changefeed query \
--server=http://192.168.56.113:8300 \
--changefeed-id="his-backout" \
--simple
等 checkpoint 推进到这个时间以后,说明 TiDB 在停写之前的事务已经全部发送到 MySQL。TiCDC 官方也建议通过 checkpoint 判断上游变化是否已经同步完成,这时候再做数据核对。
门诊:
SELECT COUNT(*)
FROM his_source.outpatient_visit;
收费金额:
SELECT
COUNT(*),
SUM(amount)
FROM his_source.charge_detail;
医嘱状态:
SELECT
order_status,
COUNT(*)
FROM his_source.doctor_order
GROUP BY order_status;
还要分别确认测试中新增加的:
T202608310901
C202608310901
O202608310901
生产医院业务不能靠几条抽样 SQL 决定回切,还要做完整的 sync-diff-inspector 校验和业务侧核对。
十八、确认 MySQL 数据完整以后才解锁业务账号
MySQL 此时已经通过 TiCDC 收到了 TiDB 切换后新增的数据,业务账号之前一直是:
ACCOUNT LOCK
现在才能解开:
ALTER USER 'his_app'@'192.168.56.%'
ACCOUNT UNLOCK;
应用连接改回:
jdbc:mysql://192.168.56.130:3306/his_source
再启动测试程序。
TiDB 保持:
tidb_super_read_only = ON
不要两边一起开放业务写入。TiCDC 任务也暂时不删除,测试环境里保留一段时间,方便检查反向同步过程和后续数据状态。
十九、回退以后不能直接把原来的 DM 又启动起来
最开始 DM 的方向是:
MySQL
↓
TiDB
后来业务切到 TiDB,TiCDC 又把新的业务数据同步回:
TiDB
↓
MySQL
回退完成以后,MySQL 已经包含一部分原来由 TiDB 产生、再经 TiCDC 写回来的数据。
如果这个时候不做任何分析,直接把旧的:
his-migrate
DM 任务重新启动,DM 可能再次读取这些 MySQL binlog,把已经存在于 TiDB 的数据重新处理。
DM 虽然有 safe mode 和可重入机制,但这种场景不能靠工具自动替 DBA 判断。
DM 官方也明确说明,异常恢复过程中可能重复执行部分 binlog。安全模式会把 INSERT 转成 REPLACE,把 UPDATE 转成 DELETE + REPLACE 来保证可重入;DM 本身提供的是至少一次处理逻辑,并不保证每条事件只执行一次。
发生回退后,原来的迁移任务要重新评估同步起点和数据状态,不能把 start-task 当成恢复按钮直接执行。
二十、切换时只盯一个写入口
做完这次测试,发现命令并不多。切换时要一直盯着的就是当前谁允许写。
MySQL 还是业务库时:
MySQL 允许写
TiDB 只接受 DM 写入
准备切换:
停止业务
MySQL 禁止业务写
DM 追平
最终核对数据
方向正式改变以后:
停止 DM
清理 TiDB 自增缓存
建立 TiDB → MySQL 的 TiCDC
确认反向通道正常,再让:
TiDB 接受业务写
MySQL 只接受 TiCDC 写
回退时先停 TiDB 业务写入,等 TiCDC 把最后的数据同步到 MySQL。核对数据以后,再把业务入口切回 MySQL。只要任何一个阶段出现MySQL 和 TiDB 同时接受业务写入,后面的处理就会复杂很多。