凌晨两点半,手机告警群突然弹出消息,生产环境的订单接口开始陆续报错。打开日志一看,清一色的MySQLTransactionRollbackException: Lock wait timeout exceeded; try restarting transaction,接口调用方那边已经开始积压重试。这个报错在做 Java + MySQL 的项目里太经典了,几乎每一个稍微有点并发量的系统都会遇到一次。它的字面意思很简单——当前事务等待获取行锁超时了,InnoDB 直接把这个事务回滚掉。但真正头疼的是,定位到“谁在占着锁不释放”这个环节,往往比解决报错本身花的时间还多。这篇博文就把我这次完整排查的过程记录下来,从日志分析到系统表查询,从根因定位到代码改造,希望能给同样被这个报错折磨过的朋友一条清晰的排查路径。
这个报错的本质是锁等待超时,不是死锁,也不是数据库挂了。它适合所有用 MySQL 作为业务数据库、使用 Spring 事务管理或者自己手写事务的开发者阅读,尤其是那些经常在夜里被告警叫醒的兄弟们。搞清楚它背后的原理和定位方法,你不仅能在下次遇到时迅速救火,还能顺手把事务设计里那些埋着的雷排掉。
1. 线上报错:从日志定位到异常全貌
1.1 异常日志的完整模样
先还原一下我当时日志里的真实报错,方便大家对号入座。如果你是 Spring Boot + MyBatis 的项目,大概率看到的是这样一段堆栈:
org.springframework.dao.CannotAcquireLockException: ### Error updating database. Cause: com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Lock wait timeout exceeded; try restarting transaction ### The error may exist in com/xxx/mapper/OrderMapper.java (best guess) ### The error occurred while setting parameters ### SQL: UPDATE t_order SET status = ? WHERE order_no = ? ### Cause: com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Lock wait timeout exceeded; try restarting transaction两个关键信息:底层异常类是MySQLTransactionRollbackException,Spring 把它包装成了CannotAcquireLockException;SQL 是一条很常规的UPDATE。很多人第一次看到这个报错会以为是 SQL 语法问题,或者怀疑数据库连接池不够用,其实都不对。
这条报错的含义非常具体:当前事务在执行 UPDATE 时,需要获取目标行上的排他锁(X锁),但等了好久都没等到,超过了 InnoDB 的锁等待超时阈值(默认 50 秒),于是 InnoDB 把当前事务直接回滚,并抛出异常。注意,抛异常的时候是“先回滚再抛”,不是“等锁失败就只报个错”那么轻描淡写——事务里之前做的所有写操作都会被撤销。
这里有个特别容易混淆的知识点:是不是一执行 UPDATE 就会立刻拿锁?不一定。如果 UPDATE 语句走了索引,通常只会把命中的行锁住;但如果没走索引,InnoDB 会扫描聚簇索引,把扫描过程中碰到的行都加上锁,锁范围会急剧扩大,后面的事务排队等锁的时间自然就上去了。
1.2 为什么业务代码会抛这个异常
从调用链上看,MyBatis 执行 UPDATE 时是正常提交 SQL 给 MySQL 的,MySQL 那边等锁超时后返回错误码1205(ER_LOCK_WAIT_TIMEOUT),JDBC 驱动把它翻译成MySQLTransactionRollbackException,Spring 的异常转换机制又包装成CannotAcquireLockException。对业务代码来说,感知到的就是一次事务回滚。
但是这里有一个“延迟暴露”的特点,特别坑人。你的业务方法里可能早就执行了好几条 SQL,锁等待超时发生在最后一条 UPDATE 上,异常抛出后整个事务回滚,但前面几条 SQL 对数据库的修改已经真实发生过了。如果中间有人把事务配置成了REQUIRES_NEW或者嵌套了独立事务,那部分修改就不会被回滚掉,数据会处于一种“半成功半失败”的中间状态。所以排查这类问题的时候,不能只盯着报错那一条 SQL 看,要把整个事务方法里的所有写操作都列出来。
还有一个点需要说清楚:这个异常和死锁是两码事。死锁报错是Deadlock found when trying to get lock; try restarting transaction,那是两个事务互相持有对方想要的锁,InnoDB 检测到后会主动牺牲其中一个事务。而Lock wait timeout是纯粹的“等待超时”,另一个事务持有锁的时间太长,或者压根就是没提交也没回滚,把锁攥在手里不放。
2. 排查锁等待的三板斧
2.1 第一板斧:查事务表 innodb_trx
定位这种问题,最直接的手段就是登录 MySQL 实例,查information_schema里的事务视图。我当时第一件事就是跑下面这条 SQL:
SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query, trx_rows_locked, trx_rows_modified FROM information_schema.INNODB_TRX ORDER BY trx_started;重点看三样东西:
trx_state:正在跑的事务如果处于RUNNING状态,且trx_started时间非常早,基本可以断定是“长事务”嫌疑人。trx_query:事务当前正在执行的 SQL,注意这个字段经常是 NULL,因为事务可能在空闲状态(比如等外部调用返回),但锁并没有释放。trx_mysql_thread_id:对应的 MySQL 连接线程 ID,这个后面 kill 会话要用。
在我那次排查里,查出来的结果很典型:有一个事务trx_started已经是 8 分钟之前,trx_state=RUNNING,trx_query=NULL。也就是说,有个事务在 8 分钟前就开启了,期间执行过写操作,之后就一直保持空闲,锁一直没释放。而报错的那个业务事务,正好需要更新同一行数据,于是排队等到超时。
看到这里,问题的方向已经清晰了一大半:锁的源头是一个长时间未提交的空闲事务。但仅仅知道“有长事务”还不够,还得继续追:这个长事务是哪个应用的哪个连接?它到底锁了哪些行?
2.2 第二板斧:锁定谁在等谁
查到疑似长事务后,下一步就是看锁的等待关系。MySQL 5.7 及之前版本,用INNODB_LOCK_WAITS和INNODB_LOCKS两张表串起来查;8.0 版本这两张表改成了performance_schema.data_lock_waits和performance_schema.data_locks。我当时的环境是 MySQL 5.7,用下面这条联表 SQL 直接定位锁等待链路:
SELECT r.trx_id AS wait_trx_id, r.trx_mysql_thread_id AS wait_thread_id, r.trx_query AS wait_query, b.trx_id AS block_trx_id, b.trx_mysql_thread_id AS block_thread_id, b.trx_query AS block_query FROM information_schema.INNODB_LOCK_WAITS w JOIN information_schema.INNODB_TRX r ON w.requesting_trx_id = r.trx_id JOIN information_schema.INNODB_TRX b ON w.blocking_trx_id = b.trx_id;这条 SQL 输出的结果里,wait_trx_id是正在等锁的事务,block_trx_id是持有锁不放的事务。我当时查出来的结果是:报错的订单 UPDATE 在等一个早已开启的事务,而且那个事务的连接线程 ID 对应的还是测试环境的 IP——后来一查才知道,是有人直接用 Navicat 手动查了一遍数据,没提交也没回滚,连接就一直挂着,行锁就这么被锁了一晚上。
这里有个关键细节:block_query很多时候是 NULL,因为阻塞者可能处于空闲状态,当前并没有在执行任何 SQL。你不能因为trx_query是 NULL 就认为它没有持有锁,锁是在执行 UPDATE/DELETE/INSERT 时获得的,之后哪怕事务空闲,锁也会一直持有到事务结束。
2.3 第三板斧:看 InnoDB 状态详情
如果系统表里的信息还不够,或者你想看到更完整的锁信息,就得祭出 InnoDB 自带的诊断命令:
SHOW ENGINE INNODB STATUS\G输出结果里重点看TRANSACTIONS段落,里面会列出当前活跃事务、锁等待信息,甚至能打印出等锁事务具体在等哪一行记录的锁。格式类似这样:
---TRANSACTION 323223, ACTIVE 8 sec starting index read mysql tables in use 1, locked 1 LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s) MySQL thread id 12345, OS thread handle 140123456789, query id 98765 ... UPDATE t_order SET status = 'PAID' WHERE order_no = '20250101001' ------- TRX HAS BEEN WAITING 8 SEC FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 100 page no 8 n bits 72 index PRIMARY of table `test`.`t_order` trx id 323223 lock_mode X locks rec but not gap waiting这段信息非常直白:事务323223在等表t_order主键索引上某一行记录的排他锁,等锁的来源 SQL 就是那条 UPDATE。不过说实话,SHOW ENGINE INNODB STATUS输出内容比较长且杂,生产环境我只把它当辅助手段,主要定位工具还是系统表联表查询,信息更精确、更好解析。
3. 深挖根因:事务为什么会“卡”住
3.1 事务边界过长是头号元凶
锁等待的本质是两个事务访问了同一行数据,且其中一个长期持有锁。而“长期持有锁”最常见的原因就是事务边界太长。我见过太多代码把事务开在 Controller 层或者 Service 层的外层方法上,一个事务里面包含了多次 RPC 调用、远程接口等待、甚至还有慢查询。
比如下面这种反面教材:
@Override @Transactional(rollbackFor = Exception.class) public void processOrder(OrderDTO dto) { // 1. 更新订单状态 orderMapper.updateStatus(dto.getOrderNo(), "PAYING"); // 2. 调用支付网关,可能耗时 2~3 秒,甚至更久 String payResult = payClient.pay(dto); // 3. 再写一条流水 payRecordMapper.insert(dto.getOrderNo(), payResult); }这个事务从第 1 步开始就持有了t_order对应行的 X 锁,第 2 步调支付网关如果网络抖动或者对方超时重试,这行锁就被一直攥着。高并发场景下,其他线程在更新同一行订单时只能排队,等到第 50 秒超时阈值一过,直接抛Lock wait timeout exceeded。
3.2 慢 SQL 把锁攥得太久
另一个常见原因是事务内部的 SQL 本身特别慢。注意这里的“慢”不一定是全表扫描那种肉眼可见的慢,也可能是一条很简单的 UPDATE 因为索引失效或者数据量暴增,从几毫秒退化到几秒钟。事务持有锁的时间跟 SQL 执行时间几乎成正比,一条 UPDATE 跑了 5 秒,意味着后面所有想更新同一行的事务至少要多等 5 秒。
排查慢 SQL 的手段大家都熟:开启慢查询日志,或者直接查performance_schema.events_statements_summary_by_digest这类统计表。我当时顺便看了一眼t_order表的索引情况,发现order_no字段虽然建了唯一索引,但是状态更新的 WHERE 条件里还有个额外的status = 'PAYING'条件,而status字段没有索引。MySQL 在优化器选择执行计划的时候,可能会选择先通过order_no唯一索引找到记录,再回表过滤status,这倒是没多大问题;但如果哪天优化器抽风走错索引,锁范围就可能扩大。
3.3 索引失效导致锁定范围失控
这是锁等待问题里最隐蔽也最危险的场景。InnoDB 的行锁是基于索引实现的,如果 UPDATE 语句的 WHERE 条件无法命中索引,InnoDB 会对聚簇索引(主键索引)做全表扫描,并且对扫描过程中访问到的每一行加锁,而不是只锁住最终需要更新的那几行。
举个例子:
UPDATE t_order SET status = 'CANCELLED' WHERE buyer_phone = '13800138000';如果buyer_phone上没有索引,执行这行 SQL 的时候,InnoDB 会扫描整张表,把扫描路径上的所有行都加上 X 锁,直到找到匹配的行。扫描结束前,整张表上符合条件的记录(甚至更多)全被锁住了。另一个事务想更新一条完全不相关订单的状态,也会阻塞在这个全表锁上。这种问题用EXPLAIN一看一个准:
EXPLAIN SELECT * FROM t_order WHERE buyer_phone = '13800138000';如果type是ALL或者key是 NULL,恭喜你,这就是锁爆炸的根源。所以遇到锁等待,别急着调超时参数,先检查更新条件字段的索引情况。
3.4 隐式提交与外部调用干扰
还有一个很多人没注意到的坑:事务方法里如果调用了会自动提交连接的代码,或者执行了 DDL 语句,事务边界会被悄无声息地打断。比如直接用JdbcTemplate执行了一些操作,而连接是从同一个DataSource获取的,默认autocommit=true,那这段操作会独立提交,但事务上下文感知不到,锁的行为就会变得很奇怪。
外部调用的问题更恶心。有一次我排查一个锁等待,发现阻塞事务的 SQL 是SELECT ... FOR UPDATE,但对应线程卡在了一个很正常的查询上。后来翻代码才看到,事务里调了一个 HTTP 接口,那个接口内部查询了同一张表,而且用了SELECT FOR UPDATE,两个锁在同一事务里叠加,把等锁时间拉得更长。在事务里做网络调用是大忌,这句话我已经说腻了,但还是不断有人踩。
4. 从解围到根治:实操方案详解
4.1 临时解围:如何安全处理阻塞会话
线上已经出故障了,第一要务是恢复服务。最直接的办法是杀掉阻塞事务对应的 MySQL 会话,释放锁。根据前面INNODB_TRX查出来的trx_mysql_thread_id,执行:
-- 先确认一下这个线程对应的连接信息,避免误杀 SELECT * FROM information_schema.PROCESSLIST WHERE ID = 12345; -- 确认无误后杀掉 KILL 12345;杀会话之前一定要确认两件事:一是这个连接是不是应用连接池里的核心连接,杀了之后连接池会自动重连,业务影响有限;二是这个事务里有没有重要业务操作正在执行。我当时排查看下来,阻塞的是一个 Navicat 手动查询连接,属于运维操作的漏网之鱼,杀掉完全没风险。如果阻塞事务是应用自身的长事务,那就要评估一下能不能杀、杀了之后业务方能不能自愈。
这里有个非常实用的细节:KILL 一个事务连接,InnoDB 会立刻回滚这个事务,锁随之释放。但如果是 Debezium 这类 CDC 工具在读 binlog,或者有从库在同步,回滚操作也可能产生大量 binlog 事件,要注意观察主从延迟。
4.2 参数调整:innodb_lock_wait_timeout 怎么设
innodb_lock_wait_timeout这个参数控制了事务等待行锁的超时时间,默认 50 秒。很多人遇到超时第一步就把它调大,比如调成 120 秒,这其实是治标不治本的思维——等锁时间变长了,接口响应时间也跟着变长,用户侧的体验更差,而且事务长时间挂起还会拖垮连接池。
我个人的建议是:默认值 50 秒对于大多数业务系统太长,对于需要快速失败的接口又太短。更合理的思路是根据业务接口的 SLA 来设置。比如你要求接口在 3 秒内返回,那锁等待时间超过 1 秒就该让事务快速失败并抛出异常,而不是傻等 50 秒。通过配置中心动态调整或者连接参数指定都可以:
SET GLOBAL innodb_lock_wait_timeout = 5; SET SESSION innodb_lock_wait_timeout = 5;注意这个参数是动态的,可以同时设置全局和会话级别。调小之后,应用端一定要做好异常捕获和重试逻辑,否则快速失败和成功请求的比例会变得很难看。
4.3 代码改造:事务与锁的正确姿势
解决完眼前的故障,根因还得靠代码层面解决。我总结了一套事务与锁的最佳实践,基本上可以覆盖大部分场景:
事务范围要最小化。只把真正需要原子性保证的写操作放在事务里,查询、RPC、外部接口调用全部移出事务。假如必须在一个方法里完成状态更新和后续动作,可以把事务拆成两个独立事务,第一个事务只做 UPDATE,第二个事务处理后续逻辑,中间用消息队列或本地消息表来保证最终一致性。
public void processOrder(OrderDTO dto) { // 不在事务里 PayResult payResult = payClient.pay(dto); // 事务里只做最短的写操作 orderService.updateStatus(dto.getOrderNo(), payResult.getStatus()); // 后续可以再发消息,异步处理 mqSender.send(PayResultEvent.of(dto.getOrderNo())); }业务上要设计好锁的获取顺序。如果多个事务会更新多张表的同一批记录,尽量让他们按相同顺序加锁,从源头规避死锁风险,同时也能让锁等待的时间更可预期。
写操作必须走索引。在 UPDATE/DELETE 的 WHERE 条件涉及的所有过滤字段上建立合适的索引,不仅能让 SQL 变快,还能把锁的粒度精确控制在目标行上。用EXPLAIN检查执行计划,避免出现type=ALL或者key=NULL。
增加锁等待监控和告警。把information_schema.INNODB_TRX的长事务查询做成定时任务,发现超过阈值的长事务就告警;把锁等待时间也纳入监控。这样再遇到类似问题,可以在用户感知之前就发现苗头。
5. 避坑指南:这些问题我也都遇到过
5.1 死锁与锁等待的区别
很多人把死锁和锁等待混为一谈,排查方向完全跑偏。死锁(Deadlock found when trying to get lock)是多个事务互相持有对方需要的锁,形成循环等待,InnoDB 的死锁检测机制会立刻发现并回滚其中一个事务,通常不需要等 50 秒。而锁等待是单纯的一边倒:一个事务在持有锁,另一个事务在排队等它,没有循环。
排查死锁要开innodb_print_all_deadlocks参数,把死锁信息打印到 error log 里:
SET GLOBAL innodb_print_all_deadlocks = ON;排查锁等待则重点看INNODB_TRX和INNODB_LOCK_WAITS。两者虽然都和锁有关,但定位逻辑完全不同,别搞混。
5.2 无法定位到具体 SQL 怎么办
有时候INNODB_TRX.trx_query是 NULL,INNODB_LOCK_WAITS也看不到等待关系,这时候别急,按下面顺序排查:
- 打开
performance_schema的语句事件采集,查events_statements_current表,可以看到每个连接当前正在执行的 SQL。 - 开启慢查询日志,把阈值调到 1 秒,观察报错时段是否有慢 SQL 在拖后腿。
- 查 binlog 或者应用侧日志,反推报错时哪个事务在执行哪个方法。
如果performance_schema没开,有些 MySQL 版本需要重启实例才能生效,生产环境要注意维护窗口。这也是我建议平时就把performance_schema=ON保持开启的原因,关键时刻信息量差太多了。
5.3 别忽略连接池的影响
这个问题很容易被忽略:连接池的大小配置跟锁等待有直接关系。假如应用连接池最大连接数是 20,某个时刻有 15 个连接都在等同一行锁,剩下 5 个连接即使拿到了锁也处理不了新请求,因为线程都阻塞在等锁上了。表面上看是锁等待问题,实际上是连接池被等锁的线程占满。
遇到这种情况,单纯 kill 会话或者调超时参数都不够,得从根上控制并发度。比如对热点行更新做限流,或者引入分布式锁把同一个订单的更新操作串行化,减少同时等锁的线程数量。我当时就把订单支付接口改成了基于 Redis 的分布式锁,同一个订单号同时只能有一个请求在跑,锁等待的问题明显改善。
5.4 监控不是摆设,要真用起来
最后说一句大实话:很多锁等待问题之所以变成事故,是因为监控报警没做到位,或者做了但没人响应。我在这次排查结束后,专门把长事务监控加到了运维告警平台,规则很简单——执行时间超过 5 秒的事务自动告警,超过 30 秒直接推送到钉钉群。另外也把innodb_lock_wait_timeout调到了 5 秒,让事务快速失败,配合应用端的重试机制,用户基本无感知。
监控方案的 SQL 也不复杂,放到定时任务里跑就完事:
SELECT trx_mysql_thread_id, trx_started, trx_query FROM information_schema.INNODB_TRX WHERE trx_started < NOW() - INTERVAL 5 SECOND;查到结果就发告警,哪怕不是锁等待,一个事务空转 5 秒也本身就值得警惕。线上问题处理完之后,把过程沉淀成文档、把监控补上,这步才是让团队从“救火队员”变成“预防大师”的关键。
这次排查下来,我最大的体会有两点。第一,Lock wait timeout exceeded这个报错其实是 InnoDB 在帮你提前暴露问题,它的出现意味着数据库的并发控制机制已经守住了底线,真正需要反思的是应用层的事务设计。第二,排查锁问题不要一上来就调参数、杀进程,先看事务表、再看锁等待关系、最后查执行计划,一套连招打下来,90% 的问题都能在十分钟内定位。把这个排查思路沉淀成你自己的 SOP,下次再遇到这个报错,你就能心平气和地打开终端,而不是半夜对着屏幕叹气了。