博客记录一次MySQL锁等待超时问题排查与解决

在项目日常迭代中,突然收到线上告警,核心业务接口出现异常,查看日志后发现是MySQL锁等待超时问题,特此记录整个排查、分析与解决过程,也希望能给遇到同类问题的小伙伴提供参考。

一、问题现象:明确报错信息

线上接口报错,核心错误信息如下:

【Lock wait timeout exceeded; try restarting transaction】

触发报错的SQL语句:【UPDATE table_name SET num = num + ? WHERE buyer_user_id = ?】

从报错信息来看,很直观的是MySQL锁等待超时——当一个事务持有某条数据的锁且未提交时,另一个事务尝试更新同一条数据,无法获取锁,等待时间超过MySQL默认的锁等待阈值(默认50秒),最终抛出该异常。

二、理论分析:锁等待超时的核心逻辑

先明确一个核心逻辑:MySQL InnoDB引擎下,更新操作(UPDATE)会对匹配的行加行级排他锁(X锁)。当多个事务同时操作同一条数据时,会出现锁竞争:

  1. 事务A执行UPDATE语句,对buyer_user_id = XXX的数据加行锁,开始执行后续逻辑,未提交事务;

  2. 事务B紧接着执行相同条件的UPDATE语句,尝试获取该数据的行锁,但此时锁被事务A持有;

  3. 事务B进入锁等待队列,等待事务A释放锁;

  4. 若事务A持有锁的时间过长,超过MySQL的innodb_lock_wait_timeout设置(默认50秒),事务B就会抛出「Lock wait timeout exceeded」报错。

简单来说,就是「前面的UPDATE还没提交,新的UPDATE又要操作同一条锁数据,新事务拿不到锁,执行超时」。

三、问题排查:从日志定位根因

好在项目日志有完整打印SQL语句和执行链路,结合日志排查,逐步锁定问题核心:

1. 日志检索,定位异常SQL

根据报错时间,往前检索日志,找到相同的UPDATE语句(UPDATE table_name SET num = num + ? WHERE buyer_user_id = ?),发现有一条该SQL执行后,未出现提交或回滚的日志记录——说明该事务未正常结束,一直持有锁。

2. 进一步定位:事务嵌套导致锁冲突

结合代码执行链路,发现了关键问题:

  • a. 锁冲突的核心:两条UPDATE语句更新的是同一条数据(buyer_user_id相同),锁定的数据有重叠;

  • b. 事务未提交的原因:外层方法开启了事务,包含该UPDATE操作;子方法又手动开启了新的事务,同时执行了相同条件的UPDATE操作;

  • c. 致命逻辑:执行链路陷入循环等待——子方法执行完后,外层事务才能提交;而外层事务的UPDATE执行完后,子方法的UPDATE才能获取锁执行,形成「事务与锁嵌套」的死循环,最终导致锁等待超时。

举个简单的执行链路示例,更易理解:

// 外层方法(开启事务)
@Transactional
public void outerMethod() {
    // 执行UPDATE操作,加锁,未提交
    updateTable(buyerUserId);
    // 调用子方法(开启新事务)
    innerMethod(buyerUserId);
    // 外层事务提交(需等待子方法执行完成)
}

// 子方法(开启新事务)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void innerMethod(Long buyerUserId) {
    // 执行相同条件的UPDATE,尝试获取锁(被外层事务持有)
    updateTable(buyerUserId);
}

这种情况下,外层事务的UPDATE持有锁,等待子方法执行完成才能提交;子方法的UPDATE需要获取锁,却被外层事务持有,一直等待,最终子方法的事务触发锁等待超时。

四、核心解决方案:解决事务嵌套导致的锁冲突

针对排查出的「事务与锁嵌套」问题,核心解决方案只有一个:子方法沿用外层事务,使用同一个事务,避免多事务嵌套

具体实现(基于Spring事务传播机制):

将子方法的事务传播机制改为默认的「PROPAGATION_REQUIRED」(无需手动设置,Spring默认就是该机制),即子方法不开启新事务,而是加入外层事务,共用同一个事务。

// 外层方法(开启事务)
@Transactional
public void outerMethod() {
    updateTable(buyerUserId);
    // 调用子方法(沿用外层事务)
    innerMethod(buyerUserId);
    // 外层事务提交,所有操作统一提交,锁一次性释放
}

// 子方法(沿用外层事务,无需开启新事务)
// 移除 @Transactional(propagation = Propagation.REQUIRES_NEW),或使用默认传播机制
public void innerMethod(Long buyerUserId) {
    updateTable(buyerUserId);
}

修改后,外层事务和子方法的UPDATE操作共用一个事务,先执行外层的UPDATE,再执行子方法的UPDATE(同一事务内,锁不会冲突),最后统一提交事务,释放锁,彻底解决锁等待超时问题。

五、其他优化点:进一步避免锁等待超时(含适用场景+补充方案)

解决核心问题后,为了避免后续再出现类似问题,结合业务场景,补充了以下优化点,每个优化点均说明适用场景和可替代方案,方便根据实际业务选择。

优化点1:缩小事务代码范围

适用场景:事务内包含非核心操作(如日志写入、第三方接口调用、无关查询等),导致事务执行时间过长,锁持有时间久,易引发锁等待。

具体操作:事务内仅保留核心的数据库操作(如UPDATE、INSERT、DELETE),将日志写入、第三方接口调用、无关查询等耗时操作,移到事务外部执行。

补充方案:若某些耗时操作必须在事务内(如更新后需调用第三方接口同步数据),可采用「异步执行」方式,将耗时操作交给线程池异步处理,缩短事务执行时间。

优化点2:事务方法改为先后顺序,避免嵌套

适用场景:多个事务方法存在依赖关系,且均操作同一条数据,易出现嵌套导致的锁冲突;或多事务方法执行顺序混乱,引发锁竞争。

具体操作:梳理事务方法的执行逻辑,取消不必要的事务嵌套,改为线性执行顺序——先执行完一个事务的所有操作,提交后再执行另一个事务,避免同时持有锁。

补充方案:若业务必须嵌套,除了沿用外层事务,还可采用「分布式锁」(如Redis锁),对同一buyer_user_id的操作进行串行化,避免锁冲突。

优化点3:为UPDATE、事务设置超时时间

适用场景:非核心业务,允许偶尔更新失败,且能通过重试机制弥补;希望避免长时间锁等待导致的系统阻塞。

具体操作

  • 事务超时:在@Transactional注解中设置timeout属性,如@Transactional(timeout = 10),表示事务最长执行10秒,超时则回滚,释放锁;

  • UPDATE超时:通过MySQL参数设置,如set global innodb_lock_wait_timeout = 10,缩短锁等待时间(默认50秒)。

弊端:缩短超时时间后,可能出现事务回滚,导致数据不一致(如更新了部分数据后超时回滚);

补充方案:结合「重试机制」,超时后将任务放入Redis重试队列,间隔一定时间重试,同时做好数据一致性校验(如更新前查询数据状态),避免数据错乱。

优化点4:缩小UPDATE锁定的数据范围

适用场景:UPDATE语句的WHERE条件范围过大,导致锁定过多数据(甚至升级为表锁),引发大面积锁冲突;或WHERE条件未命中索引,导致全表扫描+表锁。

具体操作

  • 优化WHERE条件:确保UPDATE语句的WHERE条件精准,仅锁定需要更新的数据(如本次问题中的buyer_user_id,确保只锁定单个用户的数据);

  • 添加索引:给UPDATE语句的WHERE条件字段(如buyer_user_id)添加唯一索引或普通索引,避免MySQL全表扫描,确保锁是行锁而非表锁。

补充方案:若需批量更新数据,避免一次性更新大量数据,改为分批更新(如每次更新100条),每批更新后提交事务,释放锁,减少锁持有时间和冲突概率。

六、总结

本次MySQL锁等待超时的核心原因,是「事务嵌套导致的锁冲突」——外层事务与子方法新事务同时操作同一条数据,形成循环等待,最终触发超时。

解决这类问题的关键的是:避免不必要的事务嵌套,确保操作同一条数据的事务串行执行,同时缩短锁持有时间、缩小锁范围

另外,日志打印的重要性不可忽视,本次能快速定位问题,全靠日志完整记录了SQL语句和执行链路。后续开发中,也需注意事务的使用规范,避免因事务滥用导致锁相关问题,同时做好优化,提升系统稳定性。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐