0
3
3
3
博客/.../

TiDB 7.5 医院接口高频轮询 SQL 的慢查询排查与索引优化记录

 拍脑袋小助手  发表于  2026-09-17
原创测试

这篇接着看医院接口程序里的高频轮询 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。

0
3
3
3

版权声明:本文为 TiDB 社区用户原创文章,遵循 CC BY-NC-SA 4.0 版权协议,转载请附上原文出处链接和本声明。

评论
暂无评论