bigywater.tech
分布式事务 · 29

长事务:超时与悬挂的处理

分布式事务里到处是“事务开着不结束”的隐患:全局事务超时、悬挂、客户端崩溃。长事务在数据库本身会造成什么?undo 积压、锁等待、DDL 排队、死元组清不掉。这篇用真实的 MySQL 和 PostgreSQL 把这些后果逐个量出来,并给出监控和自动处理的办法。

13.46 ms vs 0.04 ms
长事务的快照读与新会话读同一行(这一行被更新了 6 万次)的耗时
1.72 s
一个长事务 + 排队的 ALTER 把后来本只要零点几毫秒的普通 SELECT 卡住的时间
25000 → 0
PostgreSQL 里 idle in transaction 会话在时 VACUUM 清不掉的死元组数 → 会话断开后

undo 积压与快照读变慢

实测 · 本机执行输出
  A:START TRANSACTION WITH CONSISTENT SNAPSHOT,读到 v = 0;此刻 History list length = 38
  更新次数 → History list length:15000 → 188,  30000 → 338,  45000 → 488,  60000 → 638
  (History list length 数的是还没清理的 undo 日志单位——每个提交的事务一个——不是行版本的个数;这里每 100 次更新提交一次,所以每 15000 次更新涨 150 左右。真正按着不放的是 6 万个行版本)
  A 的快照读同一行:读到 0(快照里的旧值),单次耗时中位数 13.46 ms;新会话读同一行:读到 60000,单次耗时中位数 0.04 ms —— A 要顺着 6 万个版本的 undo 链往回找到它快照里的那一版
  A 提交之后,purge 线程把 undo 清掉:History list length 回到 1,用了 49.1 s
  A 提交后再读同一行(新快照):单次耗时中位数 0.05 ms

长事务的快照要保留旧版本,别人的更新让 undo 链越来越长,它自己的读要沿链回溯;提交后 purge 才能清理。

行锁与超时

实测 · 本机执行输出
  B 先改了 id=2(成功),再改被 A 锁住的 id=1:等了 1005 ms 后报错 1205 Lock wait timeout exceeded; try restarting transac
  超时之后 B 这个事务还开着吗:innodb_trx 里有 1 条属于它的记录;它自己读 id=2 = 200(第一条 UPDATE 还在事务里,没有被回滚)—— 默认只回滚超时的那一条语句,不回滚整个事务
  如果应用只是捕获了异常、没有 ROLLBACK 就继续往下走并最终 COMMIT:这一半的修改就被提交了,库里 id=2 = 200 (本该和 id=1 的修改一起成功或一起失败)

长事务持有的行锁让别人等到超时;MySQL 默认只回滚超时的那条语句,事务本身还开着,应用必须显式 ROLLBACK。

元数据锁排队

实测 · 本机执行输出
  1.5 秒时 processlist 里在等元数据锁的会话:[('线程 256', 'Waiting for table metadata lock'), ('线程 257', 'Waiting for table metadata lock')]
  A 提交(事务结束)之后:ALTER 用了 2.03 s 才完成(其中 2 秒是在等 A),后来的普通 SELECT 用了 1.72 s(它本身是 0.1 ms 的查询,却在 ALTER 后面排了队)

长事务让 ALTER TABLE 等待,而等待中的 DDL 又让后来的普通查询排在它后面。

PostgreSQL 的闲置事务

实测 · 本机执行输出
  会话 X:BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT ...(之后闲着);别的会话把 churn 表(5000 行)整体 UPDATE 了 5 次,然后 VACUUM:
    tuples: 0 removed, 30000 remain, 25000 are dead but not yet removable
    removable cutoff: 732, which was 5 XIDs old when operation ended
  pg_stat_activity 里 X 的状态: [('idle in transaction', True)]
  会话 X 被服务器断开之后再用它:AdminShutdown
  X 断开之后再 VACUUM:
    tuples: 25000 removed, 5000 remain, 0 are dead but not yet removable
    removable cutoff: 737, which was 0 XIDs old when operation ended
  设置了 idle_in_transaction_session_timeout = 700ms 的会话:事务里闲了 1.2 秒后再用它,得到 IdleInTransactionSessionTimeout: terminating connection due to idle-in-transaction timeout

idle in transaction 按住快照,VACUUM 清不掉死元组;idle_in_transaction_session_timeout 让服务器自己断开它。

监控与处理

实测 · 本机执行输出
  20 个事务:15 个提交成功,5 个被 watchdog 中断;watchdog 的记录(线程号、事务年龄 s、已修改行数):[(280, 1.59, 1), (276, 1.59, 1), (272, 1.59, 1), (265, 1.59, 1), (266, 1.59, 1)]
  库里 w 表有 15 行(都是提交成功的 15 个;被杀的事务,修改随连接断开一起回滚),全部是 fast 的:15 行
  只按“开了多久”杀事务会误伤合法的慢事务(比如批处理),所以 watchdog 的阈值、白名单和告警都要按业务定;杀之前最好先告警

用 innodb_trx 找出超过阈值的事务,watchdog 杀掉;阈值与白名单按业务定。

分步演示

长事务
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / MySQL 26.7.0 与 PostgreSQL 16.15(Homebrew,每个实验自己 initdb、自己起实例,端口 9150 以上,只监听 127.0.0.1)/ Python 3.14.8 + PyMySQL 1.2.3 + psycopg 3.3.6 / Redis 8.10.2 / Kafka 4.3.1 / RabbitMQ 4.3.6 / etcd 3.7.2(按各篇注明用到哪些);不连任何外部服务,不碰本机已有的数据库实例;故障用 kill -9 进程、让连接断开或在代码里的指定位置抛异常来制造;计时用 time.perf_counter,数字每次运行会有小幅抖动。本篇用一个 MySQL 26.7.0 实例(InnoDB,默认 REPEATABLE-READ;第二节会话里把 innodb_lock_wait_timeout 调成 1 秒)和一个 PostgreSQL 16.15 实例(autovacuum 关闭,手动 VACUUM VERBOSE);第四节里 X 被断开用的是 pg_terminate_backend,真正由 idle_in_transaction_session_timeout 触发的断开在后一个小实验里单独验证;watchdog 是我写的一个线程,每 0.2 秒查一次 innodb_trx
    • 没有验证:innodb_rollback_on_timeout=ON(需要启动参数)时整个事务回滚的行为;MySQL 的 purge 速度参数(innodb_purge_threads 等)对 49.1 s 的影响;在线 DDL(ALGORITHM=INPLACE / INSTANT)对元数据锁排队的缓解;PostgreSQL 的 hot_standby_feedback 与复制槽对 VACUUM 的影响;分布式事务里“全局事务超时”与本地长事务的关系(第 22 篇);History list length 的数字取自 SHOW ENGINE INNODB STATUS,不同版本口径可能不同
    京ICP备2026067502号-1京公网安备11010502063487号