前言
前面搭建 TiDB 7.5 测试集群以后,陆续做了快照备份、单表恢复、日志备份和 PITR,这些内容基本都偏 DBA 运维。但数据库最后还是要面对应用。
TiDB 兼容 MySQL 协议,用 MySQL Client、JDBC、Navicat 这类工具连接并不困难。但应用能够连上数据库,只说明连接这一关过了。医院系统实际运行时,挂号、收费、药房、医嘱、检验这些业务大量依赖 SQL、事务和表结构,真正容易出问题的地方往往藏在这些细节里。
为了把问题看得具体一些,我另外准备了一套 MySQL 8.0 测试环境,表结构和数据按照医院常见业务做模拟,再把相同的 SQL 放到 TiDB 7.5 上验证。
这里所有患者编号、就诊号、药品编码和业务数据均为测试数据,操作也只是测试环境记录,不作为医院生产系统切换方案。涉及挂号、收费、药品、医嘱等业务的正式迁移,仍然需要应用厂商、业务科室和数据库人员共同做完整回归、并发测试、数据核对、故障切换和回退演练。
这次主要看几个容易被“兼容 MySQL”四个字掩盖的问题。
一、连接成功以后,先把默认配置对一下
TiDB 测试集群使用 v7.5.7:
| 项目 | 配置 |
|---|---|
| TiDB | v7.5.7 |
| TiDB Server | 2 个 |
| PD | 3 个 |
| TiKV | 3 个 |
| TiDB Server 1 | 192.168.56.101:4000 |
| TiDB Server 2 | 192.168.56.102:4000 |
| 测试库 | his_compat |
| 源端 | MySQL 8.0 测试实例 |
连接 TiDB:
mysql -h 192.168.56.101 -P 4000 -u root -p
查看版本:
SELECT VERSION();
TiDB 7.5.7 返回:
8.0.11-TiDB-v7.5.7
这个版本字符串是 TiDB 的正常行为,官方 version 系统变量文档给出的示例也是 8.0.11-TiDB-v7.5.7。
但迁移评估不能停在版本号这里。
源 MySQL 和 TiDB 都执行下面这组查询:
SELECT VERSION();
SELECT @@sql_mode;
SHOW VARIABLES
WHERE Variable_name IN (
'character_set_server',
'collation_server',
'lower_case_table_names',
'explicit_defaults_for_timestamp'
);
TiDB 7.5 和 MySQL 8.0 有一些默认值本来就不一样。
TiDB 默认字符集是 utf8mb4,这一点和 MySQL 8.0 一致;TiDB 默认排序规则是 utf8mb4_bin,MySQL 8.0 默认则是 utf8mb4_0900_ai_ci。另外 TiDB 的 lower_case_table_names 固定为 2,而 Linux 上的 MySQL 默认通常是 0。
这些配置平时不显眼,换库的时候却可能直接改变 SQL 的结果。
所以应用适配之前,先把源库实际参数查出来,比根据“MySQL 8.0 默认应该是什么”来猜要可靠得多。
二、医院业务编号不要和 AUTO_INCREMENT 混在一起
医院系统里的编号很多。
门诊号、住院号、就诊号、处方号、收费流水号、医嘱号、检验申请号,都有自己的业务含义。
测试时建了一张门诊就诊表:
CREATE DATABASE his_compat;
USE his_compat;
CREATE TABLE outpatient_visit (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
visit_no VARCHAR(32) NOT NULL,
patient_no VARCHAR(32) NOT NULL,
dept_code VARCHAR(20) NOT NULL,
visit_time DATETIME NOT NULL,
visit_status VARCHAR(20) NOT NULL,
UNIQUE KEY uk_visit_no (visit_no)
);
这里故意保留两个编号:
id
visit_no
id 是数据库内部主键。
visit_no 才是业务就诊号。
先连接第一个 TiDB Server:
mysql -h 192.168.56.101 -P 4000 -u root -p
插入测试数据:
INSERT INTO outpatient_visit
(visit_no, patient_no, dept_code, visit_time, visit_status)
VALUES
('T202608220001', 'TESTP000001', 'CARD',
'2026-08-22 08:01:12', 'FINISHED'),
('T202608220002', 'TESTP000002', 'ORTH',
'2026-08-22 08:02:35', 'WAITING'),
('T202608220003', 'TESTP000003', 'PED',
'2026-08-22 08:03:20', 'FINISHED');
换到第二个 TiDB Server:
mysql -h 192.168.56.102 -P 4000 -u root -p
继续写入:
INSERT INTO his_compat.outpatient_visit
(visit_no, patient_no, dept_code, visit_time, visit_status)
VALUES
('T202608220004', 'TESTP000004', 'NEURO',
'2026-08-22 08:04:16', 'WAITING'),
('T202608220005', 'TESTP000005', 'OPHTH',
'2026-08-22 08:05:42', 'FINISHED');
TiDB 默认不会让两个 TiDB Server 每次生成 ID 时都去申请一个全局号码。
为了减少分布式环境中的通信开销,每个 TiDB Server 会批量缓存自增 ID,默认一次申请 30000 个。因此 TiDB 能保证系统自动分配的 ID 唯一,但默认只保证单个 TiDB Server 内的自增值单调递增。两个 TiDB Server 分别写入时,ID 可能出现比较大的跳跃。
官方文档给出的典型例子是:
TiDB Server A 缓存 1 ~ 30000
TiDB Server B 缓存 30001 ~ 60000
具体环境里最终拿到什么号码由当时的缓存状态决定,不能把 1、30001 当成固定结果。
这件事放到医院业务里很好理解。
下面这种 SQL:
SELECT MAX(id)
FROM outpatient_visit;
只能得到最大的数据库主键。
它不能严谨地表示:
最后完成挂号的患者
并发事务本身就可能存在提交先后差异,分布式自增 ID 又进一步说明了业务顺序不能依赖代理主键。
如果要查最近一次就诊,应按照业务时间和稳定排序字段处理:
SELECT *
FROM outpatient_visit
ORDER BY visit_time DESC, id DESC
LIMIT 1;
业务流水号则继续使用:
visit_no
这样的独立字段。
AUTO_ID_CACHE=1 也不等于绝对连续
TiDB v6.4.0 以后提供了中心化的自增 ID 分配服务。
建表时可以设置:
CREATE TABLE test_auto_id (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
remark VARCHAR(100)
) AUTO_ID_CACHE 1;
TiDB 7.5 下,这种模式能够保证跨 TiDB Server 分配的 ID 唯一并保持单调递增。
不过官方文档也明确写了:中心化 ID 服务的主节点异常切换时,为了避免 ID 重复,可能丢弃少量已经预留的号码,因此仍可能出现跳号。
所以“单调递增”和“号码绝不间断”不能当成一回事。
像收费流水号、处方号、医保结算流水号这类有业务规则的编号,还是应该由业务系统按自己的规则生成。
三、以后用 DM 搬数据,自增 ID 还有一个地方要防
这个问题和后面的 MySQL 到 TiDB 数据迁移关系很大。
DM 做增量同步时,上游 MySQL 已经生成的自增 ID 会显式写入 TiDB。
业务正式切换到 TiDB 后,应用如果继续使用:
INSERT INTO table_name (...) VALUES (...);
不再显式指定自增 ID,这时写入方式就从:
显式 ID
变成:
TiDB 自动分配 ID
TiDB 官方文档专门把 DM 增量同步结束后的这个场景列了出来。
如果 TiDB Server 手里还缓存着旧的自增 ID 范围,后续隐式分配的 ID 有可能和 DM 已经显式同步过来的值冲突。官方给出的处理方式是,在确认迁移数据和切换状态之后清除自增 ID 缓存,例如:
ALTER TABLE outpatient_visit AUTO_INCREMENT = 0;
这个操作会清除集群中各 TiDB Server 对该表缓存的自增 ID。
这条命令不能看到以后就直接往生产库执行。
正式迁移时要先确认:
SELECT MAX(id)
FROM outpatient_visit;
再结合 DM 状态、应用停写时间和切换方案确认自增值。
但这个检查必须写进迁移操作单里,否则源端一直显式写 ID,切换以后突然改成 TiDB 自动分配,确实存在主键冲突风险。
四、门诊候诊、医嘱列表,SQL 里没有 ORDER BY 就别相信当前顺序
医院系统里很多页面都和顺序有关。
比如:
门诊候诊
急诊患者
待执行医嘱
检验申请
检查预约
收费明细
建几条模拟候诊数据:
UPDATE outpatient_visit
SET visit_status = 'WAITING'
WHERE visit_no IN (
'T202608220002',
'T202608220004',
'T202608220005'
);
如果查询写成:
SELECT
visit_no,
patient_no,
dept_code,
visit_time
FROM outpatient_visit
WHERE visit_status = 'WAITING';
多执行几次,测试数据少的时候很可能一直看到相同顺序。
这很容易让人误以为数据库天然会按照主键或者插入时间返回。
SQL 语义本身没有这个保证。
TiDB 官方开发文档明确说明,没有 ORDER BY 时,结果集顺序不保证稳定。TiDB 数据分布在多个 TiKV 和 Region 上,存储层又会并行读取,这种不稳定比单实例数据库更容易暴露出来。
候诊列表如果业务规则是“先登记先处理”,SQL 应该把规则写出来:
SELECT
visit_no,
patient_no,
dept_code,
visit_time
FROM outpatient_visit
WHERE visit_status = 'WAITING'
ORDER BY visit_time, id;
查询某个患者最近一次就诊也一样。
这种写法:
SELECT *
FROM outpatient_visit
WHERE patient_no = 'TESTP000001'
LIMIT 1;
不能表达“最近一次”。
应该明确排序:
SELECT *
FROM outpatient_visit
WHERE patient_no = 'TESTP000001'
ORDER BY visit_time DESC, id DESC
LIMIT 1;
还有一个容易忽略的细节。
即使写了:
ORDER BY visit_time
如果多条记录的 visit_time 完全相同,它们之间的相对顺序仍然没有保证。
官方文档建议继续增加排序字段,直到排序条件能够稳定区分记录。
所以这里又加了 id:
ORDER BY visit_time, id
这个问题最麻烦的地方是 SQL 不会报错。
页面能打开,数据也能显示。
只有数据量上来、Region 分布发生变化或者执行计划变化以后,业务人员才可能发现:
候诊顺序怎么变了?
迁移测试时碰到这种 SQL,不能因为执行成功就算兼容通过。
五、诊断名称用 GROUP_CONCAT 拼接,也要明确顺序
医院报表和接口 SQL 里经常会碰到字符串拼接。
比如一次就诊存在多个诊断:
CREATE TABLE diagnosis_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
visit_no VARCHAR(32) NOT NULL,
diagnosis_seq INT NOT NULL,
diagnosis_code VARCHAR(32) NOT NULL,
diagnosis_name VARCHAR(100) NOT NULL,
KEY idx_visit_no (visit_no)
);
插入模拟数据:
INSERT INTO diagnosis_record
(visit_no, diagnosis_seq, diagnosis_code, diagnosis_name)
VALUES
('T202608220001', 1, 'TEST-D01', '测试诊断A'),
('T202608220001', 2, 'TEST-D02', '测试诊断B'),
('T202608220001', 3, 'TEST-D03', '测试诊断C');
如果报表这样写:
SELECT
visit_no,
GROUP_CONCAT(diagnosis_name SEPARATOR ',') AS diagnosis_list
FROM diagnosis_record
GROUP BY visit_no;
拼出来的字符串不应该依赖当前观察到的顺序。
TiDB 官方开发文档专门拿 GROUP_CONCAT() 举过例子。因为数据可能从存储层并行读取,没有 ORDER BY 时,拼接顺序可能发生变化。
医院这类数据通常本身就有主诊断、次诊断或者录入顺序,因此 SQL 应该写成:
SELECT
visit_no,
GROUP_CONCAT(
diagnosis_name
ORDER BY diagnosis_seq
SEPARATOR ','
) AS diagnosis_list
FROM diagnosis_record
GROUP BY visit_no;
这样数据库执行计划怎么变化,诊断顺序都有明确依据。
六、药房库存的 SELECT FOR UPDATE,不能只验证语法
医院系统里并发比较敏感的地方很多,药品库存是比较容易拿来做测试的一个场景。
建表:
CREATE TABLE drug_stock (
drug_id BIGINT PRIMARY KEY,
drug_code VARCHAR(32) NOT NULL,
batch_no VARCHAR(32) NOT NULL,
stock_qty INT NOT NULL,
update_time DATETIME NOT NULL,
UNIQUE KEY uk_drug_batch (drug_code, batch_no)
);
插入模拟数据:
INSERT INTO drug_stock VALUES
(1001, 'TESTDRUG001', 'B20260801', 1000, NOW()),
(1005, 'TESTDRUG005', 'B20260802', 800, NOW()),
(1010, 'TESTDRUG010', 'B20260803', 500, NOW());
TiDB 3.0.8 以后新建集群默认使用悲观事务模式。
用两个会话测试同一批次库存。
会话一
BEGIN PESSIMISTIC;
SELECT *
FROM drug_stock
WHERE drug_id = 1005
FOR UPDATE;
先不提交。
会话二
BEGIN PESSIMISTIC;
UPDATE drug_stock
SET stock_qty = stock_qty - 1,
update_time = NOW()
WHERE drug_id = 1005;
第二个会话需要等待第一个事务释放悲观锁。
会话一:
COMMIT;
随后第二个事务才能继续。
这一部分和 MySQL InnoDB 的使用习惯比较接近。
普通 SELECT 是快照读,不会因为这一行存在悲观锁就被堵住;UPDATE、DELETE、INSERT 和 SELECT ... FOR UPDATE 这类当前读需要处理相应的锁。等锁时间由 innodb_lock_wait_timeout 控制,TiDB 默认值为 50 秒,超时返回兼容 MySQL 的 1205。死锁检测到以后,会返回兼容 MySQL 的 1213。
七、SELECT FOR UPDATE 如果没有显式事务,行为要特别确认
有些程序 SQL 看上去是:
SELECT *
FROM drug_stock
WHERE drug_id = 1005
FOR UPDATE;
然后程序再做一些判断,最后执行:
UPDATE drug_stock
SET stock_qty = stock_qty - 1
WHERE drug_id = 1005;
光看 SQL 很像已经加锁。
问题在于应用到底有没有把这两条 SQL 放进同一个显式事务。
TiDB 官方文档明确说明:
自动提交事务中的 SELECT FOR UPDATE 不会等待悲观锁。
所以迁移检查不能只在代码库里搜:
FOR UPDATE
看到有这个关键字就认为并发控制没有问题。
还要确认 JDBC、ORM 或应用框架实际执行出来的是不是这种结构:
BEGIN;
SELECT ...
FOR UPDATE;
UPDATE ...;
COMMIT;
收费扣费、库存扣减、号源占用这类业务,如果依赖数据库锁保证先后关系,这个事务边界一定要从实际程序里确认。
八、TiDB 没有 MySQL InnoDB 那种 Gap Lock
继续使用库存表。
现在有:
1001
1005
1010
三个 drug_id。
会话一执行:
BEGIN PESSIMISTIC;
SELECT *
FROM drug_stock
WHERE drug_id BETWEEN 1001 AND 1010
FOR UPDATE;
保持事务不提交。
另一个会话插入:
BEGIN PESSIMISTIC;
INSERT INTO drug_stock
(drug_id, drug_code, batch_no, stock_qty, update_time)
VALUES
(1006, 'TESTDRUG006', 'B20260804', 600, NOW());
1006 明明位于 1001~1010 这个范围里,但 TiDB 不会像 MySQL InnoDB 的 Gap Lock 那样,因为前一个范围 FOR UPDATE 就阻止这个新 Key 插入。
如果另一个会话改的是已经存在的:
UPDATE drug_stock
SET stock_qty = stock_qty - 1
WHERE drug_id = 1005;
仍然会等待,因为 1005 这条记录确实已经被锁。
这不是推测,TiDB 7.5 悲观事务文档给出的官方例子就是:
SELECT *
FROM t1
WHERE id BETWEEN 1 AND 10
FOR UPDATE;
另外一个事务插入 id=6,MySQL 会因为 Gap Lock 阻塞,TiDB 不会;修改已经存在的 id=5,MySQL 和 TiDB 都会等待。
医院应用里如果存在下面这种逻辑:
先把一个号码范围 SELECT FOR UPDATE
然后认为其他会话无法往这个范围插入新记录
迁移到 TiDB 后就需要重新设计。
SQL 本身可以正常执行,真正不同的是锁的范围。
九、悲观锁还有一个故障场景,正常并发测试看不出来
TiDB 默认开启 pipelined pessimistic locking。
它的作用是降低悲观锁写入 TiKV 带来的延迟:TiKV 判断可以加锁以后,可以先通知 TiDB 继续处理,悲观锁本身再异步通过 Raft 写入。
正常情况下这样能够减少锁操作的延迟。
但官方文档也明确写了一个边界:如果此时发生网络隔离或者 TiKV 节点故障,异步悲观锁可能写入失败,极端情况下无法阻止另一个事务修改相同数据。
官方给出的建议是,如果业务逻辑依赖加锁或者等锁机制,或者希望在集群异常情况下尽量保证事务提交成功,可以关闭 pipelined locking:
[pessimistic-txn]
pipelined = false
TiDB 4.0.9 以后也可以动态修改:
SET CONFIG tikv pessimistic-txn.pipelined='false';
这里不能简单理解成:
医院系统必须关闭 pipelined
这样下结论同样不严谨。
是否关闭需要结合业务并发量、事务设计、性能测试和故障场景一起评估。
但如果收费、库存或者其他关键流程明确依赖数据库悲观锁来保证业务正确性,这个配置不能没人知道。只测试“两个正常会话会不会互相等锁”,验证是不完整的。
十、BEGIN 取得快照的时间和 MySQL 不一样
事务还有一个不容易注意到的差异。
TiDB 执行:
BEGIN;
或者:
START TRANSACTION;
时就会取得当前数据库快照。
MySQL 普通 BEGIN / START TRANSACTION 则是在事务开始后的第一次普通一致性读时取得快照。
TiDB 官方文档因此说明,TiDB 的:
BEGIN;
和:
START TRANSACTION;
在快照行为上更接近 MySQL 的:
START TRANSACTION WITH CONSISTENT SNAPSHOT;
这类差异对普通的单条挂号、收费 CRUD 通常没什么感觉。
需要留意的是事务开始和第一次查询之间隔了比较长时间的程序。
比如一个批处理:
开启事务
程序先做其他计算
过几秒再查询数据库
如果程序恰好依赖这几秒内其他会话刚提交的数据,就需要把 MySQL 和 TiDB 的实际结果放在测试环境里对照。
这类兼容性问题从 DDL 和 SQL 文本里看不出来,只能结合真实事务调用顺序检查。
十一、Trigger、存储过程、Event 不能等迁移以后再发现
医院系统运行时间长以后,数据库里经常不只有表和索引。
接口、审计、报表或者历史程序有时会把部分逻辑写进数据库。
源 MySQL 先查一下:
SELECT
ROUTINE_SCHEMA,
ROUTINE_NAME,
ROUTINE_TYPE
FROM information_schema.ROUTINES
WHERE ROUTINE_SCHEMA NOT IN (
'mysql',
'information_schema',
'performance_schema',
'sys'
);
再查 Trigger:
SELECT
TRIGGER_SCHEMA,
TRIGGER_NAME,
EVENT_MANIPULATION,
EVENT_OBJECT_TABLE
FROM information_schema.TRIGGERS;
Event:
SELECT
EVENT_SCHEMA,
EVENT_NAME,
STATUS
FROM information_schema.EVENTS;
TiDB 7.5 官方兼容性文档明确列出的不支持功能包括:
- Stored Procedure 和 Stored Function
- Trigger
- Event
- User Defined Function
- FULLTEXT
- SPATIAL / GIS 数据类型、函数和索引
- XA SQL 语法
这类对象如果存在,就得先弄清楚用途。
比如 Trigger 是不是在收费记录写入后同步接口表,Event 是不是每天晚上生成结算数据,存储过程是不是还承担某个老系统的业务逻辑。
这些东西不处理,单纯把表和数据搬到 TiDB,应用表面上可能能登录,但后台接口可能已经不工作了。
所以兼容性检查里,数据库对象比“客户端连没连上”重要得多。
十二、字符集不能只看 utf8mb4,还要看 Collation
TiDB 7.5 默认字符集是:
utf8mb4
默认排序规则是:
utf8mb4_bin
MySQL 8.0 默认排序规则则是:
utf8mb4_0900_ai_ci
医院数据库里中文字段很多,不过这类差异不只影响患者姓名。
业务编码也值得检查。
例如:
LIS001
lis001
在二进制排序规则下:
SET NAMES utf8mb4 COLLATE utf8mb4_bin;
SELECT 'LIS001' = 'lis001';
结果是:
0
换成大小写不敏感的规则:
SET NAMES utf8mb4 COLLATE utf8mb4_general_ci;
SELECT 'LIS001' = 'lis001';
结果变成:
1
TiDB 官方字符集文档用 A 和 a 给出的测试就是这个行为。
医院接口里有很多类似字段:
系统编码
药品编码
检验项目编码
检查项目编码
字典编码
设备编码
如果源 MySQL 使用大小写不敏感排序规则,目标 TiDB 却按照 utf8mb4_bin 新建表,原来:
WHERE system_code = 'LIS001'
能够匹配的数据,迁移以后未必还是相同结果。
因此源库要把数据库、表、字段的 Collation 一起导出来检查,不能只看到两边都是 utf8mb4 就结束。
十三、库名和表名的大小写也不能忽略
还有一个容易和字符集混在一起的问题:
lower_case_table_names
TiDB 7.5 只支持:
2
Linux 上 MySQL 默认一般是:
0
源 MySQL 可以先查:
SHOW VARIABLES LIKE 'lower_case_table_names';
再找一下是否存在只靠大小写区分的对象。
比如源库里同时出现:
Patient_Info
patient_info
在 Linux MySQL 的某些配置下可以作为不同表存在,但 TiDB 的名称比较行为不同,这类对象迁移时会发生冲突。
DM 官方最佳实践也专门提醒了上游 MySQL 大小写敏感而 TiDB 默认不区分大小写的问题。
老医院系统里还有一种情况比较常见:
程序里写:
SELECT * FROM PATIENT_INFO;
实际数据库表叫:
patient_info
原环境能不能工作和操作系统、MySQL 配置都有关系。
换数据库以前最好通过实际 SQL 或流量回放确认,不要靠开发人员口头说“表名应该都是小写”。
十四、把源 MySQL 的这些信息先查出来
做到这里以后,应用适配其实已经不像最开始想的那么简单。
不过前期摸底并不复杂。
源 MySQL 上可以先把这些信息查出来:
-- 数据库版本
SELECT VERSION();
-- SQL MODE
SELECT @@GLOBAL.sql_mode,
@@SESSION.sql_mode;
-- 字符集、排序规则、表名大小写
SHOW VARIABLES
WHERE Variable_name IN (
'character_set_server',
'collation_server',
'lower_case_table_names',
'explicit_defaults_for_timestamp'
);
-- 使用 AUTO_INCREMENT 的字段
SELECT
TABLE_SCHEMA,
TABLE_NAME,
COLUMN_NAME,
COLUMN_TYPE
FROM information_schema.COLUMNS
WHERE EXTRA LIKE '%auto_increment%'
AND TABLE_SCHEMA NOT IN (
'mysql',
'information_schema',
'performance_schema',
'sys'
)
ORDER BY TABLE_SCHEMA, TABLE_NAME;
-- 存储过程和函数
SELECT
ROUTINE_SCHEMA,
ROUTINE_NAME,
ROUTINE_TYPE
FROM information_schema.ROUTINES
WHERE ROUTINE_SCHEMA NOT IN (
'mysql',
'information_schema',
'performance_schema',
'sys'
);
-- Trigger
SELECT
TRIGGER_SCHEMA,
TRIGGER_NAME,
EVENT_MANIPULATION,
EVENT_OBJECT_TABLE
FROM information_schema.TRIGGERS;
-- Event
SELECT
EVENT_SCHEMA,
EVENT_NAME,
STATUS
FROM information_schema.EVENTS;
这些结果能先把明显的对象问题暴露出来。
但对象检查只是静态检查。
医院应用真正使用的 SQL,最好还是从实际业务流量、慢日志或者测试日志里收集,再放到 TiDB 测试环境执行。
社区里已有迁移案例采用过收集生产 SQL 后在 TiDB 回放的方式来检查兼容性和性能,这个思路比人工挑几十条 SQL 有代表性。
生产医院系统当然不能为了测试随意修改线上参数或抓取包含患者隐私的数据。SQL 收集、脱敏、回放都要按医院的信息安全要求执行。
十五、医院业务验证不能只交给 DBA 看 SQL
数据库兼容性检查做完,只能说明数据库这一侧发现的问题处理得差不多。
医院系统正式切换前,业务回归还是不能省。
挂号要看患者建档、挂号、退号和候诊顺序。
收费要看计价、收费、退费、医保结算以及异常回滚。
药房不光看库存能不能减,还要验证发药、退药、批次、库存不足和并发发药。
医嘱需要关注开立、停止、作废、执行状态以及不同系统之间的同步。
LIS、PACS、EMR 之间还有大量接口,数据库迁移以后不能只在库里查到数据就认为接口正常。
尤其涉及患者安全的流程,数据库测试环境里几个 INSERT、UPDATE 和 SELECT FOR UPDATE 只能说明某种数据库行为,不能替代应用验收。
正式切换前,数据一致性、性能、并发、接口、故障切换和回退方案都得单独验证。这一点比选哪个参数更重要。
结语
这次把 MySQL 和 TiDB 的几个差异放到医院业务场景里以后,感觉比单纯看兼容性列表容易理解很多。有些问题很直观。
TiDB 不支持 Trigger、存储过程,检查源库对象就能发现。有些问题没那么明显。SQL 能执行,结果也能出来,但应用原来默认的行为已经变了。
门诊候诊 SQL 没有 ORDER BY,数据库不会替应用保证顺序;AUTO_INCREMENT 可以生成唯一主键,却不能拿来代表收费流水或者就诊先后;SELECT FOR UPDATE 语法一样,Gap Lock 和 MySQL 又不一样;程序没有显式事务时,看到 FOR UPDATE 也不能马上认定锁逻辑没问题。这些才是迁移时比较麻烦的地方。
医院系统上线时间长、接口多,很多业务逻辑不是重新看一遍代码就能完全摸清楚。数据库里有什么对象,线上实际执行什么 SQL,事务是怎么提交的,都要拿真实结果说话。
TiDB 对 MySQL 的兼容度已经很高,但“兼容度高”和“某套医院应用可以不做验证直接切换”显然不是一个意思。
对 DBA 来说,测试的目的也不是证明 TiDB 能不能执行几条 MySQL SQL,而是把原系统依赖的数据库行为找出来,确认换到 TiDB 后结果没有变。
后面的 MySQL 到 TiDB 数据迁移测试,也会按照这个思路继续做:先把源端对象和业务数据准备好,再用 DM 做全量和增量同步,最后对表结构、数据量、增量追平情况以及业务数据进行核对。