在医院HIS收费程序中,通常是先锁定收费单,再等待支付平台返回,如果外部调用变慢,数据库事务迟迟没有提交,其他处理线程更新同一张收费单时就可能等锁。业务端看到的可能是收费页面一直等待、支付回调处理缓慢,或重复点击后没有返回。
这次测试模拟一笔门诊收费串起排查过程:从事务和连接找到持锁会话,再核对收费主表和支付流水。
一、准备收费表和三个连接
测试库沿用 his_lab。A、B 是两个独立的业务连接,C 用于查询事务、锁和最终数据,不能用同一连接轮流代替。
| 会话 | 用途 | 权限 |
|---|---|---|
| A | 锁定收费单,保留未提交事务 | 测试表的读写权限 |
| B | 更新同一笔收费,观察锁等待 | 测试表的读写权限 |
| C | 查询事务、连接和锁信息 | 测试表查询权限及 PROCESS 权限 |
DATA_LOCK_WAITS 和 DEADLOCKS 要求 PROCESS 权限。缺少该权限时,TIDB_TRX 也只能查看当前用户的事务,可能漏掉其他账号持有的锁。
以下初始化操作使用有建库、建表权限的连接执行,供首次准备环境使用:
SET SESSION autocommit = 1;
CREATE DATABASE IF NOT EXISTS his_lab;
CREATE TABLE his_lab.outpatient_charge (
id BIGINT NOT NULL AUTO_INCREMENT,
charge_no VARCHAR(32) NOT NULL COMMENT '收费流水号',
visit_no VARCHAR(32) NOT NULL COMMENT '门诊就诊号',
patient_no VARCHAR(32) NOT NULL COMMENT '患者内部编号',
total_amount DECIMAL(12,2) NOT NULL,
paid_amount DECIMAL(12,2) NOT NULL DEFAULT 0,
charge_status VARCHAR(16) NOT NULL COMMENT 'UNPAID/PAYING/PAID/VOID',
payment_no VARCHAR(40) DEFAULT NULL,
cashier_code VARCHAR(16) DEFAULT NULL,
created_at DATETIME(3) NOT NULL,
updated_at DATETIME(3) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_charge_no (charge_no),
KEY idx_visit_no (visit_no),
UNIQUE KEY uk_payment_no (payment_no)
);
CREATE TABLE his_lab.outpatient_payment (
id BIGINT NOT NULL AUTO_INCREMENT,
payment_no VARCHAR(40) NOT NULL,
charge_no VARCHAR(32) NOT NULL,
pay_channel VARCHAR(16) NOT NULL,
pay_amount DECIMAL(12,2) NOT NULL,
pay_status VARCHAR(16) NOT NULL COMMENT 'PROCESSING/SUCCESS/FAILED',
created_at DATETIME(3) NOT NULL,
updated_at DATETIME(3) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_payment_no (payment_no),
KEY idx_charge_no (charge_no)
);
收费主表保存收费状态,支付表保存支付流水。 payment_no 是业务侧全局唯一的支付流水号,一张收费单采用单次支付。
准备一笔 15 元的待收费记录:
INSERT INTO his_lab.outpatient_charge
(
charge_no,
visit_no,
patient_no,
total_amount,
paid_amount,
charge_status,
created_at,
updated_at
)
VALUES
(
'SF202609190001872',
'MZ20260919084236',
'P202609190318',
15.00,
0,
'UNPAID',
NOW(3),
NOW(3)
);
这条记录在自动提交状态下写入,后续 A、B 的回滚不会撤销它。会话 C 也使用独立的新连接,保持 autocommit = 1,不在显式事务中查询最终结果,避免读到旧快照。
二、确认事务模式和超时参数
在 A、B 中分别查询当前会话参数:
SELECT
@@SESSION.tidb_txn_mode AS txn_mode,
@@SESSION.innodb_lock_wait_timeout AS lock_wait_timeout,
@@SESSION.tidb_idle_transaction_timeout AS idle_trx_timeout;
TiDB 8.5 的默认值如下,实际配置以查询结果为准:
| 参数 | 默认值 | 含义 |
|---|---|---|
tidb_txn_mode |
pessimistic |
默认事务模式 |
innodb_lock_wait_timeout |
50 | 悲观锁等待超时,单位为秒 |
tidb_idle_transaction_timeout |
0 | 不启用空闲事务会话超时 |
新建集群从 v3.0.8 起默认使用悲观事务模式,旧集群升级不会自动改变原来的事务模式。本次测试使用 BEGIN PESSIMISTIC,它的优先级高于 tidb_txn_mode。
tidb_idle_transaction_timeout 从 v7.6.0 引入,判断的是事务中没有正在处理的请求、正在等待客户端下一条请求的空闲时间。正在执行 SQL 或等锁,不属于这种空闲状态。
三、会话 A 保留一笔未提交的收费事务
在 A 中选择默认数据库,记录连接 ID,再开启事务。本轮先在这个测试连接中关闭空闲事务超时,避免观察期间 A 被提前断开;第十七节单独说明该参数。
USE his_lab;
SELECT CONNECTION_ID() AS session_id;
SET SESSION tidb_idle_transaction_timeout = 0;
BEGIN PESSIMISTIC;
SELECT
charge_no,
visit_no,
total_amount,
paid_amount,
charge_status
FROM his_lab.outpatient_charge
WHERE charge_no = 'SF202609190001872'
FOR UPDATE;
SELECT ... FOR UPDATE 使用当前读,读取最新已提交的数据并加锁。确认状态为 UNPAID 后,将收费单改为处理中:
UPDATE his_lab.outpatient_charge
SET charge_status = 'PAYING',
cashier_code = 'CASH01',
updated_at = NOW(3)
WHERE charge_no = 'SF202609190001872';
在同一个事务中写入支付请求记录:
INSERT INTO his_lab.outpatient_payment
(
payment_no,
charge_no,
pay_channel,
pay_amount,
pay_status,
created_at,
updated_at
)
VALUES
(
'ZF202609190009731',
'SF202609190001872',
'WECHAT',
15.00,
'PROCESSING',
NOW(3),
NOW(3)
);
到这里保留 A 连接,不执行 COMMIT 或 ROLLBACK,也不继续发送请求。这对应程序持有收费记录的锁、等待外部接口返回的情况。A 对收费状态的修改和新增支付流水都尚未提交。
其他事务修改同一条收费记录时会等锁;普通快照读可以读取之前已提交的版本,不能因为查询仍能返回数据就认定没有锁。
四、从 CLUSTER_TIDB_TRX 找到事务
TIDB_TRX 显示当前 TiDB Server 的用户事务;CLUSTER_TIDB_TRX 汇总各 TiDB Server 的事务,多一个 INSTANCE 字段。以下排查 SQL 均在会话 C 中执行。
SELECT
INSTANCE,
ID AS trx_id,
START_TIME,
TIMESTAMPDIFF(
SECOND,
START_TIME,
NOW()
) AS trx_seconds,
STATE,
WAITING_START_TIME,
SESSION_ID,
USER,
DB,
MEM_BUFFER_KEYS,
MEM_BUFFER_BYTES,
CURRENT_SQL_DIGEST_TEXT
FROM information_schema.CLUSTER_TIDB_TRX
WHERE DB = 'his_lab'
ORDER BY START_TIME;
ID 是事务的 start_ts,SESSION_ID 是连接 ID,二者不能混用。START_TIME 是事务开始时间,trx_seconds 据此计算事务已持续的秒数。MEM_BUFFER_KEYS 和 MEM_BUFFER_BYTES 分别是事务内存缓冲区的键数与键值总字节数,不等同于业务行数和连接总内存。
这里的 DB 是连接当前的默认数据库,不是事务访问过的数据库清单。前面 A 执行了 USE his_lab,才能按这个条件定位。应用只使用 his_lab.表名、没有选择默认库时,该筛选可能漏掉事务,应结合连接 ID、账号或 RELATED_TABLE_IDS 检查。
A 停止发送请求后,预期可以看到:
| 字段 | 预期状态 |
|---|---|
SESSION_ID |
与 A 的 CONNECTION_ID() 一致 |
DB |
his_lab |
STATE |
Idle |
CURRENT_SQL_DIGEST_TEXT |
当前没有执行中的 SQL,通常为 NULL |
trx_seconds |
随事务持续而增加 |
Idle 表示事务正在等待客户端输入,事务仍然打开,之前取得的锁仍可能保留。只看连接处于空闲状态,容易漏掉这种事务。
五、关联应用连接
把事务和连接按实例、Session ID 关联:
SELECT
t.INSTANCE,
t.ID AS trx_id,
t.START_TIME,
TIMESTAMPDIFF(
SECOND,
t.START_TIME,
NOW()
) AS trx_seconds,
t.STATE AS trx_state,
t.SESSION_ID,
t.USER,
p.HOST,
p.COMMAND,
p.TIME AS process_time,
LEFT(p.INFO, 200) AS current_sql
FROM information_schema.CLUSTER_TIDB_TRX t
LEFT JOIN information_schema.CLUSTER_PROCESSLIST p
ON p.INSTANCE = t.INSTANCE
AND p.ID = t.SESSION_ID
WHERE t.DB = 'his_lab'
ORDER BY t.START_TIME;
用 HOST 对应应用连接来源,再结合账号和连接 ID 定位程序。p.TIME 的单位也是秒,但反映当前命令或状态的持续时间,不能替代从事务开始时间计算的 trx_seconds。
六、回看事务执行过的 SQL
ALL_SQL_DIGESTS 保存事务执行过的 SQL Digest,最多记录前 50 条语句。需要回看 A 做过什么时,再对指定事务调用 TIDB_DECODE_SQL_DIGESTS()。
在 C 中,将下一行的 NULL 替换为第四节查到的目标事务 ID:
SET @trx_id = NULL;
SELECT
INSTANCE,
ID AS trx_id,
SESSION_ID,
TIDB_DECODE_SQL_DIGESTS(
ALL_SQL_DIGESTS
) AS trx_sqls
FROM information_schema.CLUSTER_TIDB_TRX
WHERE ID = @trx_id;
本例中需要对应到锁定收费单的 SELECT ... FOR UPDATE、修改收费状态的 UPDATE,以及写入支付流水的 INSERT。解码结果是归一化 SQL,业务参数会被替换,不能依靠它恢复完整收费流水号。
解码依赖 Statement Summary 中保存的 SQL 信息,未找到对应语句时可能返回 NULL。函数开销较高,先定位事务,再解析该事务的摘要,不对全部事务逐行调用。
七、会话 B 更新同一笔收费
A 保持未提交状态。在 B 中执行:
USE his_lab;
SELECT CONNECTION_ID() AS session_id;
SET SESSION tidb_idle_transaction_timeout = 0;
SET SESSION innodb_lock_wait_timeout = 15;
BEGIN PESSIMISTIC;
UPDATE his_lab.outpatient_charge
SET payment_no = 'ZF202609190009731',
paid_amount = 15.00,
charge_status = 'PAID',
updated_at = NOW(3)
WHERE charge_no = 'SF202609190001872';
这条 UPDATE 用于构造支付回调更新收费单时的冲突,没有包含支付流水落账的完整业务逻辑。B 修改的记录被 A 持有,预期进入悲观锁等待;持续拿不到锁时,返回错误码 1205。
15 秒只用于缩短本例的等待时间。第八至第十节的查询应事先在 C 中准备好,在 B 等待期间按需执行;如果 B 已超时,等待关系可能已经消失。需要重新观察时,先在 B 执行 ROLLBACK,再重新执行本节的 BEGIN PESSIMISTIC 和 UPDATE,A 保持不动。
锁等待超时会回滚失败的语句,不代表整个显式事务已经结束。本文在第十三节统一结束 A、B 两个事务。
八、查看 LockWaiting 状态
在 C 中查询:
SELECT
INSTANCE,
ID AS trx_id,
START_TIME,
STATE,
WAITING_START_TIME,
SESSION_ID,
CURRENT_SQL_DIGEST_TEXT
FROM information_schema.CLUSTER_TIDB_TRX
WHERE DB = 'his_lab'
ORDER BY START_TIME;
B 正在加悲观锁时,STATE 可以显示为 LockWaiting,WAITING_START_TIME 是该状态下等待开始的时间。但 TiDB 在悲观加锁操作开始时就会进入这个状态,即使没有被其他事务阻塞,也可能短暂出现。
本例确实存在 A 持锁、B 等锁,仍要通过 DATA_LOCK_WAITS 确认对应关系,不能只凭 LockWaiting 下结论。
九、查看谁在等待谁
DATA_LOCK_WAITS 收集整个集群各 TiKV 节点当前的等锁信息。TiDB 8.5 文档说明,它包含悲观事务等锁和乐观事务被阻塞的信息,不是只面向悲观事务。
SELECT
TRX_ID,
CURRENT_HOLDING_TRX_ID,
SQL_DIGEST_TEXT,
KEY_INFO
FROM information_schema.DATA_LOCK_WAITS;
本例等待关系应对应如下:
| 字段 | 对应内容 |
|---|---|
TRX_ID |
B 的事务 ID,即 B 的 start_ts |
CURRENT_HOLDING_TRX_ID |
A 的事务 ID,即 A 的 start_ts |
SQL_DIGEST_TEXT |
B 正在等待的归一化 UPDATE,能够查到摘要时显示 |
KEY_INFO |
被等待的 Key 对应的表、行或索引信息 |
KEY_INFO 是 JSON 格式的文本。本例应能关联到 his_lab.outpatient_charge;具体锁在记录键还是索引键上,要看返回的 handle_value、index_name、index_values 等信息,不能事先写死。不同 Key 类型显示的字段也不同。
乐观事务被阻塞时,SQL_DIGEST 和 SQL_DIGEST_TEXT 当前为 NULL,可以按事务 ID 关联 CLUSTER_TIDB_TRX,再查看该事务的 SQL 摘要。
十、关联等待事务与持锁事务
SELECT
w.TRX_ID AS waiting_trx_id,
wt.INSTANCE AS waiting_instance,
wt.SESSION_ID AS waiting_session_id,
wt.USER AS waiting_user,
wt.START_TIME AS waiting_trx_start,
wt.STATE AS waiting_state,
w.CURRENT_HOLDING_TRX_ID AS holding_trx_id,
ht.INSTANCE AS holding_instance,
ht.SESSION_ID AS holding_session_id,
ht.USER AS holding_user,
ht.START_TIME AS holding_trx_start,
ht.STATE AS holding_state,
w.SQL_DIGEST_TEXT AS waiting_sql,
w.KEY_INFO
FROM information_schema.DATA_LOCK_WAITS w
LEFT JOIN information_schema.CLUSTER_TIDB_TRX wt
ON wt.ID = w.TRX_ID
LEFT JOIN information_schema.CLUSTER_TIDB_TRX ht
ON ht.ID = w.CURRENT_HOLDING_TRX_ID;
用这条查询把等待关系对应到 A、B 的连接 ID,再结合第五节的 HOST 找到应用连接来源。关联使用事务 ID,不能用 Session ID 去匹配 TRX_ID。
这些系统表不是同一时刻的一致性快照。采集过程中事务结束、连接断开,或持锁事务已经不在当前 TiDB 用户事务列表中,都可能使 LEFT JOIN 后的部分字段为空。此时保留锁表中的事务 ID 和 Key 信息继续核对,不能把空值直接解释为没有阻塞者。
十一、按需查询 DATA_LOCK_WAITS
DATA_LOCK_WAITS 每次查询都需要从所有 TiKV 节点收集信息,添加 WHERE 条件也不能避免这一步。集群较大、负载较高时,高频查询可能造成性能抖动,各 TiKV 返回的数据也不保证属于同一时刻。
日常检查先用 CLUSTER_TIDB_TRX 找持续时间较长或状态异常的事务,需要确认锁关系时再查 DATA_LOCK_WAITS。本例第九节和第十节按排查需要选用,不必反复执行相同的全局锁信息采集。
十二、检查死锁历史
本例只有 B 等待 A,A 没有再等待 B,不构成循环等待。怀疑发生过死锁时,可以查询:
SELECT
INSTANCE,
DEADLOCK_ID,
OCCUR_TIME,
RETRYABLE,
TRY_LOCK_TRX_ID,
CURRENT_SQL_DIGEST_TEXT,
KEY_INFO,
TRX_HOLDING_LOCK
FROM information_schema.CLUSTER_DEADLOCKS
WHERE OCCUR_TIME >= NOW() - INTERVAL 1 HOUR
ORDER BY OCCUR_TIME DESC;
DEADLOCKS 保存单个 TiDB Server 最近的死锁事件,CLUSTER_DEADLOCKS 汇总各节点。默认每个节点保留最近 10 次事件,通过 pessimistic-txn.deadlock-history-capacity 调整;一次死锁可以对应多行,不能把行数当成事件数。跨节点查看时还要带上 INSTANCE,DEADLOCK_ID 不保证全局唯一。
默认不收集可重试的死锁,可通过 pessimistic-txn.deadlock-history-collect-retryable 控制。记录容量有限且不持久化,查询为空不能证明过去没有发生过死锁。
这次单向等待不应新增由 A、B 构成的死锁事件;集群里已有其他死锁记录时,整张表仍可能有数据。锁等待超时 1205 与死锁错误 1213 也要分开判断。
十三、结束 A、B 两个事务
完成观察后,在 A 中执行:
ROLLBACK;
如果 B 的 UPDATE 仍在等待,它获得锁后可以继续完成,但修改仍在 B 的显式事务中,尚未提交。如果 B 已经返回 1205,失败语句不会在 A 释放锁后自行重试。
本例只验证锁等待,不完成收费入账。等 B 的语句返回成功或超时后,都在 B 中执行:
ROLLBACK;
不能只回滚 A,再把 B 留在未提交状态。也不要在这个验证流程中提交 B:它只更新收费主表,没有完成支付流水的相应处理。事务回滚和断开连接时的回滚行为,可对照 TiDB 事务概览。
现场处理长事务时,先对应收费流水、支付流水和支付平台结果,再确定是否终止连接。TiDB 8.5 默认开启 enable-global-kill,启用时可以跨 TiDB Server 终止连接;如果关闭了该配置,不能沿用这一前提。KILL QUERY 只终止当前语句,不能代替结束一个空闲的持锁事务;终止整个连接使用 KILL CONNECTION,参数是连接 ID。
数据库事务回滚不会撤销外部支付平台已经完成的支付,这部分需要按业务流水另行核对。
十四、核对回滚后的业务数据
A、B 都回滚后,在自动提交的 C 连接中查询收费记录:
SELECT
charge_no,
visit_no,
total_amount,
paid_amount,
charge_status,
payment_no,
cashier_code,
updated_at
FROM his_lab.outpatient_charge
WHERE charge_no = 'SF202609190001872';
再查支付流水:
SELECT
payment_no,
charge_no,
pay_channel,
pay_amount,
pay_status,
created_at,
updated_at
FROM his_lab.outpatient_payment
WHERE charge_no = 'SF202609190001872'
ORDER BY created_at;
没有其他连接修改这笔数据时,预期恢复到初始化状态:
| 对象 | 预期结果 |
|---|---|
| 收费主记录 | 仍有一条,初始化时已经提交 |
total_amount |
15.00 |
paid_amount |
0.00 |
charge_status |
UNPAID |
收费主表的 payment_no |
NULL |
cashier_code |
NULL |
支付流水 ZF202609190009731 |
不存在 |
A 回滚撤销的是收费主表的 PAYING、收费员及更新时间修改,以及支付表中新增的整条记录。收费主表的 payment_no 和 paid_amount 由 B 的 UPDATE 修改;B 如果曾获得锁并执行成功,这部分也由 B 的回滚撤销。
十五、同时核对收费金额和支付流水
状态字段之外,把收费金额与成功支付流水一起查:
SELECT
c.charge_no,
c.visit_no,
c.total_amount,
c.paid_amount,
c.charge_status,
COUNT(
CASE
WHEN p.pay_status = 'SUCCESS'
THEN 1
END
) AS success_pay_count,
COALESCE(
SUM(
CASE
WHEN p.pay_status = 'SUCCESS'
THEN p.pay_amount
ELSE 0
END
),
0
) AS success_pay_amount
FROM his_lab.outpatient_charge c
LEFT JOIN his_lab.outpatient_payment p
ON p.charge_no = c.charge_no
WHERE c.charge_no = 'SF202609190001872'
GROUP BY
c.charge_no,
c.visit_no,
c.total_amount,
c.paid_amount,
c.charge_status;
按前面两个事务都回滚的流程,成功支付笔数和金额应为 0、0.00。如果另一次核对中出现下面的数据,就需要查明收费状态与支付流水为什么没有对应上:
| 字段 | 不一致状态示例 |
|---|---|
charge_status |
UNPAID |
paid_amount |
0.00 |
success_pay_count |
1 |
success_pay_amount |
15.00 |
这不是前面双回滚流程的预期结果,也不能据此再次发起扣款。应对应支付平台流水,确认是否需要补记或冲正;这条 SQL 只能核对数据库中已记录的数据,不能证明外部平台的实际支付状态。
十六、支付流水的唯一约束
支付表用 UNIQUE KEY uk_payment_no (payment_no) 保证支付流水号唯一,收费主表用 UNIQUE KEY uk_charge_no (charge_no) 保证收费流水号唯一。收费主表的 payment_no 则用于关联本例的单次支付。
相同支付流水号重复写入时,唯一约束可以阻止重复记录最终提交,但不能阻止应用用不同流水号再次发起扣款,也不能替代完整的业务幂等处理。唯一约束检查与事务提交的关系,可参阅 TiDB 事务与约束检查。
按收费单检查多条成功支付记录:
SELECT
charge_no,
COUNT(*) AS success_count,
SUM(pay_amount) AS success_amount
FROM his_lab.outpatient_payment
WHERE pay_status = 'SUCCESS'
GROUP BY charge_no
HAVING COUNT(*) > 1;
本例采用单次支付,多条 SUCCESS 记录需要核对。分次支付、组合支付业务允许一张收费单对应多条支付记录,不能直接按这个查询结果认定重复收费。
十七、设置空闲事务保护
前面的锁等待验证结束后,可以在单独的测试连接中设置会话级保护:
SET SESSION tidb_idle_transaction_timeout = 300;
该连接进入事务后,如果没有正在处理的请求,持续等待客户端新请求超过 300 秒,TiDB 会终止会话,未提交事务随连接关闭回滚。正在等锁的 B 有执行中的请求,应由锁等待超时等机制处理,不能靠这个参数限制。
需要修改新建连接的默认值时,全局设置写法为:
SET GLOBAL tidb_idle_transaction_timeout = 300;
全局值修改后,已有连接的会话值不会自动变成 300。连接池里的旧连接尤其需要留意,可以在相应连接中核对:
SELECT
@@SESSION.tidb_idle_transaction_timeout AS session_timeout,
@@GLOBAL.tidb_idle_transaction_timeout AS global_timeout;
设置前要确认正常事务可能空闲多久,以及应用能否处理连接被关闭。把外部支付调用放在持锁事务中等待,仍需从程序的事务边界处理,超时参数只能限制持续影响的时间。
十八、长事务日志阈值
tidb_expensive_txn_time_threshold 从 v7.2.0 引入,TiDB 8.5 默认是 600 秒。事务持续时间超过阈值、仍未提交或回滚时,TiDB 会记录 expensive transaction 日志。这个参数用于记录日志,不会自动终止事务。
它虽然是 GLOBAL 变量,但只作用于当前连接的 TiDB 实例,不持久化到整个集群。多 TiDB Server 环境下,不能只检查一个节点就认为所有节点设置相同。收费已经出现等待时,直接检查事务和锁关系,不要等日志阈值触发再处理。
十九、长事务与 GC
活跃事务可能阻碍 GC Safe Point 推进。tidb_gc_max_wait_time 控制这种阻碍允许持续的最长时间,TiDB 8.5 默认是 86400 秒;它不负责终止数据库连接。
这个值也不表示悲观事务可以持锁 24 小时。TiDB 的 performance.max-txn-ttl 默认是 3600000 毫秒,即 1 小时,用于限制事务持锁时长。超过后,锁可能被其他事务清理,原事务不能再假定可以正常提交。
结束验证时,需要先核对 A、B 的事务已经结束,原来的锁等待关系不再出现,再确认收费单恢复为 UNPAID、支付流水不存在。实际收费处理中,锁等待消失后仍要核对支付平台结果,不能把数据库超时直接当成扣款失败。