人人都会AI编程

15.4 长事务的危害与识别处理

更新时间:2026-07-11

事务是保证数据一致性的利器,但如果一个事务从开启到提交拖了太久,这把利器就会反过来伤到自己。在实际生产环境中,长事务是导致性能抖动、锁等待甚至数据库不可用的常见元凶之一。理解它的危害并掌握排查方法,是每个后端开发者的必修课。

15.4.1 什么是长事务

长事务并没有一个绝对的时长定义,通常是指执行时间远超业务正常水平、长时间未提交或未回滚的事务。典型特征包括:

  • 在一个事务中执行了大量 SQL 操作,整体耗时达到秒级甚至分钟级。
  • 事务开启后,中间夹杂了非数据库操作,例如调用外部 HTTP 接口、处理文件、等待用户输入等,导致事务长期挂起。
  • 事务开启后因为程序异常或逻辑遗漏,既没有提交也没有回滚,变成“僵尸事务”,一直持有资源。

一个残酷的现实是:很多长事务不是故意为之,而是开发者在代码中不小心把耗时操作包进了事务范围,或者异常处理没有正确关闭事务,最终导致数据库层面的“慢性自杀”。

15.4.2 长事务的四大危害

1. 锁资源长时间占用,阻塞其他业务

InnoDB 的行锁是在事务结束时才释放,而不是语句执行完就释放。这意味着只要事务不提交,它持有的所有行锁都会一直存在。如果这个事务恰好修改了某些高频访问的行(比如热点库存、热门用户的记录),其他需要操作这些行的事务就会被阻塞,形成锁等待链,严重时引发大面积超时。

更隐蔽的情况是间隙锁的滞留。在可重复读隔离级别下,为了防止幻读,UPDATEDELETE 等语句不仅锁住目标行,还会施加间隙锁。一个长事务可能在不经意间锁住了许多本来没数据的“空白区域”,导致其他事务的插入操作被阻塞,而这种现象很难从业务日志中直观发现。

2. Undo Log 无法清理,导致回滚段膨胀

MVCC 机制下,事务需要看到自己开始时的一致性快照。这就意味着,即使长事务没有修改数据,只要它一直处于活跃状态,它开始时刻之前产生的所有 Undo Log 都不能被清理——因为后续的读事务可能需要用这些 Undo 版本构建可见性。

结果是:Undo 表空间持续膨胀,占用大量磁盘空间。在极端情况下,甚至可能耗尽磁盘容量,导致整个数据库拒绝写入。MySQL 8.0 虽然提供了自动截断 Undo 表空间的能力,但仍然需要等待不再有事务引用旧版本时才能生效。

3. 引起主从复制延迟

修改类事务在主库提交后会产生 Binlog,从库通过回放这些 Binlog 来同步数据。主库上的长事务提交时,会瞬间产生大量的 Binlog 写入,从库回放这些日志需要时间。如果长事务频繁出现,从库延迟就会逐步累积,导致读写分离架构下读到过时数据,严重干扰业务。

此外,某些 DDL 操作(如加列、改索引)在从库应用 Binlog 的方式可能与主库不同,如果主库上长时间运行的事务干扰了 DDL 的调度,从库的复制线程可能被迫等待,进一步放大延迟。

4. 主库故障恢复时间变长

当主库突然崩溃后重启,InnoDB 需要根据 Redo Log 和 Undo Log 进行崩溃恢复。如果崩溃前存在大量长时间未提交的事务,恢复过程就要回滚这些事务的操作,扫描和推测 Undo 日志的时间会显著增加,导致数据库恢复耗时延长,影响服务可用性。

15.4.3 如何识别长事务

MySQL 提供了多种方式查看当前运行中的事务信息,最常用的两个核心表是:

  • information_schema.innodb_trx:记录 InnoDB 引擎当前活跃事务的详细信息。
  • performance_schema.events_transactions_current:记录事务的当前状态、开启时间等(需开启 performance_schema)。

快速排查长事务的 SQL 示例:

-- 查看当前所有活跃事务的持续时间(秒)和相关语句
SELECT 
    trx_id,
    trx_state,
    trx_started,
    TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS trx_duration_seconds,
    trx_mysql_thread_id,
    trx_tables_locked,
    trx_rows_locked,
    trx_query
FROM 
    information_schema.innodb_trx
ORDER BY 
    trx_started;

重点关注:

  • trx_duration_seconds 较大的事务,比如超过 30 秒。
  • trx_stateRUNNINGtrx_query 为空的事务,这往往是代码中事务未提交而数据库连接空闲的“僵尸事务”。
  • trx_rows_locked 数值较大的事务,可能正在锁定大量行。

监控需要结合连接信息定位来源:

-- 关联查询,找出长事务对应的客户端 IP、用户和当前 SQL
SELECT 
    t.trx_id,
    t.trx_started,
    TIMESTAMPDIFF(SECOND, t.trx_started, NOW()) AS duration_sec,
    p.ID AS conn_id,
    p.USER,
    p.HOST,
    p.DB,
    p.COMMAND,
    p.TIME AS conn_time_sec,
    t.trx_query
FROM 
    information_schema.innodb_trx t
JOIN 
    information_schema.processlist p 
    ON t.trx_mysql_thread_id = p.ID
ORDER BY 
    t.trx_started;

这个查询可以帮你迅速定位到是哪个应用服务器(HOST)、哪个数据库(DB)上的哪个连接在持有长事务,甚至可以抓到当前正在执行的 SQL。对于排查线上问题是第一手资料。

15.4.4 预防与处理措施

1. 设置事务超时自动终止

MySQL 8.0 提供了 innodb_lock_wait_timeout(默认 50 秒),用于控制等待行锁的超时时间,但不能主动终止一个跑得太久但没等待锁的长事务。更直接的参数是 MySQL 5.7.8 引入的 max_execution_time(针对 SELECT 语句):

SET SESSION max_execution_time = 10000; -- SELECT 超过 10 秒自动终止

对于写入类事务,可以在应用端使用连接池的超时配置,或在 MySQL 端使用资源组来限制执行时间,但现在最有效的方式依然是应用层自己控制事务粒度。

从 MySQL 8.0.18 起,增加了 XA 事务的超时参数 innodb_xa_prepare_timeout,但对普通事务没有全局超时。因此,最佳实践是代码层面设置事务超时控制,例如在 Spring 框架中配置 @Transactional(timeout = 30)

2. 及时终止异常长事务

如果已经出现长事务,并且判断它属于异常状态(比如应用已经崩溃但连接未释放),可以直接 KILL 掉相应的连接:

-- 根据上面查到的连接 ID 终止连接
KILL 1234;

或者在确保是事务问题时,可以终止事务但保留连接:

KILL QUERY 1234;   -- 先终止正在执行的 SQL
-- 如果事务仍未提交,再 KILL 整个连接
KILL 1234;

注意:KILL 会回滚事务,对业务数据没有影响,但需要与应用代码协调,确保业务能正确处理连接断开的异常。

3. 从代码层面控制事务粒度

预防长事务,最有效的手段不在数据库配置,而在代码逻辑。几个硬性原则:

  • 不在事务中执行外部调用:HTTP 请求、RPC 调用、Redis 查询等全部移到事务外部。数据库事务是内部资源的原子操作,不能与外部不确定性操作捆绑。
  • 将大的事务拆小:假设你需要处理 10000 条记录,可以每 1000 条一个小事务,避免一次锁定大量行。务必将 @Transactional 放在 Service 层方法而不是整个 Controller,确保调用链路中的逻辑不会无故扩大事务范围。
  • 避免人工交互:绝对不要在事务中等待用户输入、发送邮件等异步操作。如果需要,程序可以先提交事务,再执行外部操作,通过补偿机制处理失败。
4. 监控与告警

将长事务纳入日常监控体系,设置阈值告警(例如事务运行超过 10 秒即时通知 DBA 或开发)。可以利用 Prometheus + mysqld_exporter 或脚本定时查询 information_schema.innodb_trx,配合 Grafana 看板直观展示。

一个简单的监控脚本逻辑:定期查询 innodb_trx,如果发现持续时间超过阈值的记录,就自动记录日志、发送报警,甚至根据白名单自动 KILL(需谨慎,防止误杀重要批处理作业)。

15.4.5 真实案例与教训

案例一:Excel 导入引发的生产事故

某后台系统提供“批量导入订单”功能,开发者在导入循环中开启了事务,每处理一行数据就进行一次外部校验,整个导入过程持续 5 分钟。在这 5 分钟内,事务一直未提交,锁住了订单表的许多行,导致其他用户的正常下单操作全部被阻塞,系统出现大面积超时。修复方法:将导入逻辑改为每 100 条记录一次事务,且外部校验移出事务范围。

案例二:定时任务的“幽灵事务”

一个凌晨执行的定时清理任务,开启了事务后,中间因为代码 bug 抛出异常,但 catch 块中既没有记录日志也没有回滚,连接被抛出后归还连接池,事务却还挂着。数据库中出现了一个运行时间超过 3 小时的空闲事务,导致 Undo Log 疯狂膨胀,最终磁盘告警。排查时发现 trx_query 为空,trx_stateRUNNING,通过 HOST 定位到应用服务器后快速 KILL 掉。

这些真实的问题反复提醒我们:长事务不是偶然的,是设计上对事务边界不清晰造成的必然结果。 养成“尽快提交、最小粒度、不混逻辑”的习惯,比事后频繁排查更能保障系统的顺畅运行。