这篇接着看医院接口程序里的高频轮询 SQL。HIS 往 LIS 发检验申请、往集成平台写接口消息,或者 EMR 查询待处理文书、第三方平台轮询未发送数据,都可能每隔几秒查一次待处理记录。
这类查询通常按目标系统和处理状态筛选,再按生成时间取一批消息。如果接口表积累了几年的历史数据,大部分记录已经处理成功,待发送的可能只有几十条,甚至一条都没有,但程序还在不断查询。单次多扫描一些数据,在多个接口程序持续轮询时,就可能给 TiKV 和 TiDB 带来持续负担。下面在测试环境里根据 TiDB 7.5 官方文档整理模拟排查流程,测试版本为 v7.5.7。
一、环境与数据库入口
数据库入口沿用前面的 HAProxy 双机和 Keepalived VIP 配置,示例环境如下。业务查询使用 hip_app;建库、建表、诊断和 DDL 操作使用具备相应权限的测试管理账号。
| 项目 | 示例配置 |
|---|---|
| TiDB | v7.5.7 |
| TiDB Server | 2 个 |
| PD | 3 个 |
| TiKV | 3 个 |
| 数据库入口 | 192.168.56.115:3390 |
| 测试数据库 | hip_perf_lab |
| 场景 | 医院集成平台接口消息轮询 |
客户端通过 VIP 登录:
mysql -h 192.168.56.115 \
-P 3390 \
-u hip_app \
-p
先确认当前连接:
SELECT VERSION(),
@@hostname,
CONNECTION_ID();
VIP 后面的新连接可能落到不同 TiDB Server。会用 CLUSTER_SLOW_QUERY 和 CLUSTER_STATEMENTS_SUMMARY 查整个集群的数据,避免漏掉另一台节点上的记录。CLUSTER_SLOW_QUERY 比单节点的 SLOW_QUERY 多一个 INSTANCE 字段,用来区分记录来自哪台 TiDB Server。
二、接口消息表
用一张医院集成平台的接口消息表做示例。处理状态按表内注释约定,报文正文保存在 payload 中。
CREATE DATABASE hip_perf_lab
DEFAULT CHARACTER SET utf8mb4
COLLATE utf8mb4_bin;
USE hip_perf_lab;
CREATE TABLE hip_interface_message (
id BIGINT NOT NULL AUTO_INCREMENT,
message_no VARCHAR(40) NOT NULL COMMENT '接口消息唯一号',
visit_no VARCHAR(32) DEFAULT NULL COMMENT '模拟就诊号',
source_system VARCHAR(16) NOT NULL COMMENT '来源系统',
target_system VARCHAR(16) NOT NULL COMMENT '目标系统',
message_type VARCHAR(24) NOT NULL COMMENT '消息类型',
process_status TINYINT NOT NULL DEFAULT 0 COMMENT '0待发送 1处理中 2成功 9失败',
retry_count SMALLINT NOT NULL DEFAULT 0 COMMENT '重试次数',
created_at DATETIME(3) NOT NULL COMMENT '消息生成时间',
next_retry_at DATETIME(3) DEFAULT NULL COMMENT '下次重试时间',
processed_at DATETIME(3) DEFAULT NULL COMMENT '处理完成时间',
payload MEDIUMTEXT DEFAULT NULL COMMENT '模拟接口报文',
PRIMARY KEY (id),
UNIQUE KEY uk_message_no (message_no),
KEY idx_visit_no (visit_no),
KEY idx_created_at (created_at)
);
医院接口表里保存 HL7、XML、JSON 或其他报文正文很常见,这类字段通常比较大。如果轮询阶段只需要确定待处理消息,可以先查询编号等字段,准备发送时再读取正文。是否拆成两次查询,要看程序拿到这批消息以后怎么处理。
除主键外,表上有 uk_message_no、idx_visit_no 和 idx_created_at 三个二级索引,暂时没有包含“目标系统 + 状态”的联合索引。先用这个结构查看原轮询 SQL 的访问路径。
三、看接口数据的分布
判断这条 SQL,只有表的总行数还不够。LIS 消息占多少、成功记录和待发送记录各占多少,以及数据集中在哪段时间,都会影响扫描量和执行计划。先检查这些分布,避免只用一批均匀生成的数据判断索引效果。本文没有附测试数据集,执行前需要另行准备符合所讨论分布的模拟数据,不能用空表上的执行计划判断优化效果。
先统计总行数和时间范围,再按目标系统、状态分组。前两条查询会统计整表,大表需要安排合适的执行窗口。
SELECT
COUNT(*) AS total_rows,
MIN(created_at) AS first_time,
MAX(created_at) AS last_time
FROM hip_interface_message;
SELECT
target_system,
process_status,
COUNT(*) AS row_count
FROM hip_interface_message
GROUP BY target_system, process_status
ORDER BY target_system, process_status;
下面这条查看当天零点以来的数据,CURRENT_DATE() 按当前会话时区确定日期,并非最近 24 小时:
SELECT
target_system,
process_status,
COUNT(*) AS row_count
FROM hip_interface_message
WHERE created_at >= CURRENT_DATE()
GROUP BY target_system, process_status
ORDER BY target_system, process_status;
四、LIS 接口的轮询 SQL
模拟 LIS 接口程序每次取 100 条待发送消息。SQL 中的 2026-09-10 00:00:00 是示例时间下界,使用时要与准备的测试数据对应。
SELECT
id,
message_no,
visit_no,
message_type,
created_at
FROM hip_interface_message
WHERE target_system = 'LIS'
AND process_status = 0
AND created_at >= '2026-09-10 00:00:00'
ORDER BY created_at, id
LIMIT 100;
这条 SQL 查询从指定时间起生成、目标系统为 LIS、状态为待发送的消息。如果用于实际接口,还要确认这个时间起点是否会排除更早的待发送消息;不能仅为减少扫描量就把条件改成当天。
id 放到第二排序字段,是为了在多条消息的 created_at 相同时仍然有确定顺序。这和前面 MySQL 兼容性文章提到的排序问题一样:只写 ORDER BY created_at,相同时间记录之间的顺序并没有完整确定。
五、从集群慢查询查起
TiDB 默认开启慢查询日志,默认阈值为 300 毫秒。超过阈值的 SQL 会写入慢日志,默认文件名为 tidb-slow.log,也可以通过 INFORMATION_SCHEMA.SLOW_QUERY 查询。现场如果调整过日志开关或阈值,要以实际配置为准。
这里有两个 TiDB Server,直接查集群视图,并把时间限制在最近 30 分钟:
SELECT
INSTANCE,
TIME,
QUERY_TIME,
PROCESS_TIME,
WAIT_TIME,
REQUEST_COUNT,
TOTAL_KEYS,
PROCESS_KEYS,
DIGEST,
`QUERY`
FROM information_schema.CLUSTER_SLOW_QUERY
WHERE DB = 'hip_perf_lab'
AND TIME >= NOW() - INTERVAL 30 MINUTE
AND `QUERY` LIKE '%hip_interface_message%'
ORDER BY QUERY_TIME DESC
LIMIT 20;
除了 QUERY_TIME,还要看 PROCESS_KEYS、TOTAL_KEYS 和 REQUEST_COUNT。PROCESS_KEYS 是 Coprocessor 处理的 Key 数,不包含 MVCC 旧版本;TOTAL_KEYS 统计扫描的 Key,包含旧版本。REQUEST_COUNT 是这条语句发送的 Coprocessor 请求数。查询只返回几十条数据,处理的 Key 却很多时,需要继续看执行计划。
慢日志里的时间字段以秒为单位。PROCESS_TIME 是 SQL 在 TiKV 的处理时间总和,由于请求可以并发,它可能大于 QUERY_TIME,不能直接当作客户端等待的时长。
六、用 Statement Summary 看高频请求
单次只用几十毫秒的查询,可能进不了慢日志。但多个接口节点每隔两秒执行一次,累计耗时仍可能很高,需要接着查 Statement Summary。TiDB 7.5 默认开启这个功能,按 SQL Digest、Plan Digest 等信息聚合统计;同一条 SQL 使用不同计划时,会分成不同记录。
CLUSTER_STATEMENTS_SUMMARY 返回各 TiDB Server 的统计记录,INSTANCE 用来区分节点。下面把统计窗口和累计延迟一起取出来:
SELECT
INSTANCE,
SUMMARY_BEGIN_TIME,
SUMMARY_END_TIME,
DIGEST,
PLAN_DIGEST,
EXEC_COUNT,
ROUND(SUM_LATENCY / 1000000000, 3) AS sum_latency_s,
ROUND(AVG_LATENCY / 1000000, 2) AS avg_latency_ms,
ROUND(MAX_LATENCY / 1000000, 2) AS max_latency_ms,
INDEX_NAMES,
QUERY_SAMPLE_TEXT
FROM information_schema.CLUSTER_STATEMENTS_SUMMARY
WHERE SCHEMA_NAME = 'hip_perf_lab'
AND TABLE_NAMES LIKE '%hip_interface_message%'
ORDER BY EXEC_COUNT DESC;
延迟字段以纳秒为单位,除以 1000000 转成毫秒;这里的 sum_latency_s 则换算成秒。它累计的是 SQL 执行延迟,不能当作 CPU 使用时间。可以先从 QUERY_SAMPLE_TEXT 确认是哪条轮询 SQL,再结合执行次数和累计延迟决定排查优先级。
当前表默认按 1800 秒,也就是 30 分钟刷新,EXEC_COUNT 不能直接当成当天总次数。对比前后数据时要一起看 SUMMARY_BEGIN_TIME、SUMMARY_END_TIME 和 INSTANCE,完整历史窗口可以查 CLUSTER_STATEMENTS_SUMMARY_HISTORY;如果当前窗口才开始,执行次数少并不代表业务请求减少。
七、查看执行计划和实际扫描量
先用 EXPLAIN 看优化器准备采用的计划,它不会执行下面这条查询:
EXPLAIN FORMAT = 'brief'
SELECT
id,
message_no,
visit_no,
message_type,
created_at
FROM hip_interface_message
WHERE target_system = 'LIS'
AND process_status = 0
AND created_at >= '2026-09-10 00:00:00'
ORDER BY created_at, id
LIMIT 100;
计划里的 access object 可以看到表和索引,operator info 可以看到访问范围等信息,estRows 是估算输出行数。如果出现 TableFullScan,说明这条访问路径没有通过索引条件缩小扫描范围。即使使用了 idx_created_at,也可能需要读取指定时间范围内的大量消息,再判断目标系统和处理状态,不能只凭索引名字判断效果。
确认可以承受这条查询的扫描开销后,在测试环境执行:
EXPLAIN ANALYZE
SELECT
id,
message_no,
visit_no,
message_type,
created_at
FROM hip_interface_message
WHERE target_system = 'LIS'
AND process_status = 0
AND created_at >= '2026-09-10 00:00:00'
ORDER BY created_at, id
LIMIT 100;
EXPLAIN ANALYZE 会真正执行 SQL,增加 actRows、execution info、memory 和 disk 等运行信息。需要逐个算子对比 estRows 和 actRows,但 actRows 表示算子输出行数,不等于底层扫描量。顶层只输出 100 行时,需要往下看扫描算子的 scan_detail,包括 total_process_keys、total_keys,并结合慢日志核对。
对 UPDATE、DELETE、INSERT 使用 EXPLAIN ANALYZE,数据修改也会发生。即使是 SELECT,在生产环境执行前仍要考虑扫描压力,不能当成普通 EXPLAIN 使用。
八、检查统计信息
扫描量大时,先查统计信息,再判断是否缺索引。优化器依赖统计信息选择访问路径,先看表的行数和修改量:
SHOW STATS_META
WHERE db_name = 'hip_perf_lab'
AND table_name = 'hip_interface_message';
再看统计信息健康度:
SHOW STATS_HEALTHY
WHERE db_name = 'hip_perf_lab'
AND table_name = 'hip_interface_message';
SHOW STATS_META 包含 row_count 和 modify_count。健康度按修改量与表行数的比例粗略计算,只能作为参考,不能理解成估算准确率。刚做过大批量导入、表数据变化很大,或者同一算子的 estRows 与 actRows 差距明显时,可以考虑重新收集统计信息。
ANALYZE TABLE hip_interface_message;
TiDB v7.5 文档将这种方式称为全量收集,过程中仍有采样机制。官方也提醒,ANALYZE TABLE 的执行时间可能明显长于 MySQL。生产环境执行前得先确认表规模、当前负载和自动 Analyze 状态,再安排执行窗口,不在门诊高峰直接补跑。
九、按轮询条件增加复合索引
假设统计信息没有明显问题,原来的 idx_created_at(created_at) 仍然需要读取较多消息,再按目标系统和状态过滤。可以在测试环境增加下面这个复合索引,验证能否缩小访问范围:
ALTER TABLE hip_interface_message
ADD INDEX idx_poll_target_status_time
(
target_system,
process_status,
created_at,
id
);
target_system 和 process_status 都是等值条件,放在前面;created_at 对应时间范围和第一排序字段,后面的 id 对应相同时间下的第二排序字段。这个顺序与本例的过滤、排序条件一致,是否被优化器选中仍要看执行计划。官方的索引选择文档也将访问条件、回表成本和能否满足排序作为判断因素。
这里没有把 message_no、visit_no 和 message_type 都放进索引,所以它不是覆盖索引,读取这些列仍需要回表。轮询 SQL 没有返回 payload,也不需要为了这条查询把报文正文加进索引。接口表持续有 INSERT、UPDATE,还要观察新增索引带来的维护开销。
十、建索引期间观察 DDL 任务
TiDB 的 ADD INDEX 是在线 DDL,建索引期间允许表继续读写。但大表的历史数据扫描和索引回填仍会占用集群资源,需要观察业务延迟与资源使用,不能简单把“在线”理解成没有影响。
加索引的会话还在等待时,另开管理连接查看任务:
ADMIN SHOW DDL JOBS;
确认库名、表名和任务类型后,找到本次 ADD INDEX 的 JOB_ID。TiDB 7.5 支持暂停、恢复 DDL 任务,如果执行期间业务压力明显增加,可以暂停这个任务。下面的 <job_id> 要替换为查到的实际任务编号。
ADMIN PAUSE DDL JOBS <job_id>;
查看命令返回的 RESULT,并再次用 ADMIN SHOW DDL JOBS 确认任务状态。暂停后,原来执行 DDL 的会话不会立即返回,仍可能显示为执行中。等负载合适后,再恢复同一个任务:
ADMIN RESUME DDL JOBS <job_id>;
恢复后继续检查任务状态,直到建索引完成。任务如果已经结束,暂停命令可能返回找不到任务,不能据此认定暂停成功。
十一、对比索引前后的执行计划
建索引完成后,先确认索引存在:
SHOW INDEX
FROM hip_interface_message;
使用相同查询条件重新查看计划:
EXPLAIN FORMAT = 'brief'
SELECT
id,
message_no,
visit_no,
message_type,
created_at
FROM hip_interface_message
WHERE target_system = 'LIS'
AND process_status = 0
AND created_at >= '2026-09-10 00:00:00'
ORDER BY created_at, id
LIMIT 100;
确认是否使用了 idx_poll_target_status_time,访问范围是否包含 LIS、待发送状态和指定时间下界,以及计划是否利用索引顺序处理 ORDER BY created_at, id。然后在相同数据条件下再次执行前面的 EXPLAIN ANALYZE,对比扫描算子的 scan_detail 和执行耗时。
慢日志里的 PROCESS_KEYS、TOTAL_KEYS、REQUEST_COUNT 也可以辅助比较,但如果查询已经低于慢日志阈值,就可能没有对应记录。新增索引能否减少扫描、减少多少,都要以实际输出为准,不能只凭计划中出现了新索引名就判断优化成功。
十二、观察实际轮询中的执行情况
测试查询使用了新索引以后,我还会查看接口程序实际执行的 SQL。运行一段时间后,再取 Statement Summary:
SELECT
INSTANCE,
SUMMARY_BEGIN_TIME,
SUMMARY_END_TIME,
DIGEST,
PLAN_DIGEST,
EXEC_COUNT,
ROUND(SUM_LATENCY / 1000000000, 3) AS sum_latency_s,
ROUND(AVG_LATENCY / 1000000, 2) AS avg_latency_ms,
ROUND(MAX_LATENCY / 1000000, 2) AS max_latency_ms,
INDEX_NAMES,
QUERY_SAMPLE_TEXT
FROM information_schema.CLUSTER_STATEMENTS_SUMMARY
WHERE SCHEMA_NAME = 'hip_perf_lab'
AND TABLE_NAMES LIKE '%hip_interface_message%'
ORDER BY EXEC_COUNT DESC;
这里按同一个 DIGEST 对比各节点的 PLAN_DIGEST、INDEX_NAMES 和延迟,避免把接口表上的其他查询混进来。平均延迟下降后,最大延迟是否仍有异常、执行次数是否符合轮询频率,也要继续看。
前后比较要选择长度相同、负载可比的统计窗口,已经结束的窗口到 CLUSTER_STATEMENTS_SUMMARY_HISTORY 中查询。当前表会刷新,默认内存统计还会在节点重启后丢失;要观察完整业务周期,需要及时保存这些记录。不能拿一次当前窗口查询代替全天情况。
十三、保留旧时间索引,继续观察其他 SQL
现在表上同时有 idx_created_at(created_at) 和 idx_poll_target_status_time(target_system, process_status, created_at, id)。虽然都包含 created_at,不能直接删除旧索引,因为接口运维还可能按时间查询所有目标系统的消息:
SELECT
message_no,
source_system,
target_system,
process_status,
created_at
FROM hip_interface_message
WHERE created_at >= '2026-09-10 08:00:00'
AND created_at < '2026-09-10 09:00:00'
ORDER BY created_at;
这条 SQL 没有目标系统和状态条件,与 LIS 待发送轮询的访问方式不同。新索引包含时间列,并不代表它能同样高效地替代原来的时间索引;旧索引是否删除,要等观察完整业务周期和其他 SQL 后再决定。
十四、轮询是否需要同时读取报文
如果接口程序使用 SELECT *,payload 也会被取出来。单条 HL7/XML 报文可能有几 KB,甚至更大;轮询阶段如果只需要确定待处理消息,这部分传输就没有必要。下面另列一组不带时间下界的轮询示例。
SELECT *
FROM hip_interface_message
WHERE target_system = 'LIS'
AND process_status = 0
ORDER BY created_at
LIMIT 100;
只查询编号、消息类型和就诊号等字段,写成下面这样。同一时间的记录仍用 id 确定顺序:
SELECT
id,
message_no,
visit_no,
message_type,
created_at
FROM hip_interface_message
WHERE target_system = 'LIS'
AND process_status = 0
ORDER BY created_at, id
LIMIT 100;
真正准备发送某条消息时,再按主键读取正文。这里的 ? 是应用预处理语句的参数占位符,需绑定实际消息 ID,不能原样粘到 mysql 客户端执行:
SELECT
message_no,
payload
FROM hip_interface_message
WHERE id = ?;
这两步适合先查询待办、再按需读取正文的处理方式。如果每次取出的 100 条消息都要立即发送,拆开读取会增加数据库请求,未必更省资源。按接口程序的实际处理方式决定是否拆分。
十五、核对消息范围和处理状态
新增索引本身不改变 SQL 语义,确认查询条件没有被顺手改掉,尤其是时间范围。下面按 LIS 的待发送状态查询,不加时间下界,用来检查是否有更早的积压消息:
SELECT
target_system,
process_status,
COUNT(*) AS row_count
FROM hip_interface_message
WHERE target_system = 'LIS'
AND process_status = 0
GROUP BY target_system, process_status;
查看当前最早的一批待发送消息:
SELECT
id,
message_no,
visit_no,
message_type,
retry_count,
created_at
FROM hip_interface_message
WHERE target_system = 'LIS'
AND process_status = 0
ORDER BY created_at, id
LIMIT 100;
失败记录也单独检查:
SELECT
message_no,
visit_no,
message_type,
retry_count,
created_at,
next_retry_at
FROM hip_interface_message
WHERE target_system = 'LIS'
AND process_status = 9
ORDER BY created_at, id
LIMIT 100;
对比查询结果时,测试环境要使用相同的数据状态;如果接口程序还在不断修改处理状态,两次查询结果不同,不能直接归因于索引变化。这几条 SQL 只能检查待发送和失败记录,不能证明消息没有漏发或重复发送,还需要结合原有接口的发送记录和接收回执核对。ORDER BY created_at, id 确定的是查询返回顺序,也不代表多个消费者会按这个顺序完成发送。
十六、返回 0 行时也要看扫描量
接口轮询可能大部分时间都没有待处理消息,但返回 0 行不等于没有扫描。缺少合适索引时,数据库可能检查很多历史记录,最后才确认没有满足条件的消息。频率高了,这部分开销也会持续存在。
平时排查 Oracle 的接口轮询 SQL,也得把返回行数和实际访问量分开看。在 TiDB 里,仍要结合 PROCESS_KEYS、TOTAL_KEYS、执行计划和执行次数判断,不能只看到客户端显示“没有数据”就排除这条 SQL。