线上业务突然抛出Lock wait timeout exceeded异常、接口超时、甚至事务被强制回滚,90%以上的场景都源于MySQL锁等待与死锁问题。很多开发者面对问题时无从下手,要么只会重启服务,要么找不到根因导致问题反复出现。
一、InnoDB锁体系核心前置认知
想要排查锁问题,必须先搞懂InnoDB锁的底层逻辑,90%的锁问题排查失败,都源于对锁类型、兼容性、生效规则的认知错误。
1.1 锁的核心维度划分
1.1.1 按兼容性划分的基础锁类型
这是锁机制的核心基础,兼容性矩阵决定了锁是否会发生阻塞,所有规则100%遵循MySQL 8.0官方规范:
| 锁类型 | 共享锁(S) | 排他锁(X) | 意向共享锁(IS) | 意向排他锁(IX) |
| 共享锁(S) | 兼容 | 互斥 | 兼容 | 互斥 |
| 排他锁(X) | 互斥 | 互斥 | 互斥 | 互斥 |
| 意向共享锁(IS) | 兼容 | 互斥 | 兼容 | 兼容 |
| 意向排他锁(IX) | 互斥 | 互斥 | 兼容 | 兼容 |
- 共享锁(S锁) :读锁,多个事务可同时持有同一行的S锁,用于
select ... lock in share mode,持有S锁的事务只能读不能修改该行。 - 排他锁(X锁) :写锁,一个事务持有某行的X锁后,其他事务不能持有该行的任何锁,
UPDATE/DELETE/INSERT语句会自动加X锁,select ... for update手动加X锁。 - 意向锁(IS/IX) :表级锁,用于快速判断表内是否有行锁,避免全表扫描检查行锁,事务加行锁前必须先加对应的意向锁,意向锁之间互相兼容,仅与表级的S/X锁互斥。
1.1.2 行级锁的细分类型(RR隔离级别,MySQL 8.0默认)
这是死锁问题的核心重灾区,必须精准理解每一种锁的生效规则:
- 记录锁(Record Lock) :仅锁住索引中的某一行具体记录,只在
唯一索引+精准匹配的场景下生效,比如where id=1(id为主键),仅锁住id=1的行,对其他行完全无影响。 - 间隙锁(Gap Lock) :锁住索引记录之间的间隙,仅用于防止幻读,核心规则:间隙锁之间完全兼容,仅与插入意向锁互斥。比如表中id有1、3、5,间隙分为
(-∞,1)、(1,3)、(3,5)、(5,+∞),执行select * from t where id=2 for update会锁住(1,3)间隙,禁止任何插入该区间的操作,但其他事务可以同时持有该间隙的间隙锁。 - 临键锁(Next-Key Lock) :InnoDB RR级别下默认的行锁算法,由「记录锁+该记录左边的间隙锁」组成,左开右闭区间,比如上述id的临键锁区间为
(-∞,1]、(1,3]、(3,5]、(5,+∞]。仅当查询条件为唯一索引+精准匹配时,临键锁会退化为记录锁,其他场景均为临键锁。 - 插入意向锁(Insert Intention Lock) :一种特殊的间隙锁,
INSERT语句插入前会先申请该锁,它与间隙锁互斥,与其他插入意向锁兼容,是间隙锁场景下死锁的核心诱因。
1.2 锁等待与死锁的核心区别
很多开发者会将两者混为一谈,实际上二者的触发逻辑、处理机制完全不同:
二、锁等待的根因拆解与全链路排查方法论
2.1 锁等待的高频根因
- 长事务未提交占用行锁:事务执行完更新语句后未及时提交/回滚,长期持有行锁,导致后续所有操作该行的事务全部阻塞,是线上最常见的锁等待诱因。
- 索引失效导致行锁升级为全表锁:更新/删除语句的查询条件没有索引,或索引失效,InnoDB无法精准定位行,会走全表扫描并给所有行加临键锁,相当于锁全表,任何更新操作都会被阻塞。
- 热点行并发更新:秒杀、库存扣减等场景,大量并发请求同时更新同一行记录,导致后续事务排队等待锁释放,出现大面积锁等待超时。
- 手动加锁范围过大:滥用
select ... for update,查询条件范围过大或无索引,导致锁住大量无关行,阻塞其他业务操作。
2.2 锁等待的标准化排查步骤
MySQL 8.0.13之后,锁信息从information_schema.INNODB_LOCKS迁移至performance_schema.data_locks,以下SQL均适配MySQL 8.0最新规范。
步骤1:定位阻塞的线程与SQL
执行以下命令,找到处于锁等待状态的线程:
show processlist;
重点关注State列值为Waiting for row lock/Waiting for table metadata lock的线程,记录Id(MySQL线程ID)、Time(阻塞时长)、Info(正在执行的SQL)。
步骤2:查询锁等待的关联关系
执行以下SQL,直接定位「等待锁的事务」与「持有锁的事务」的对应关系:
SELECT
r.trx_id waiting_trx_id,
r.trx_mysql_thread_id waiting_thread_id,
r.trx_query waiting_sql,
b.trx_id blocking_trx_id,
b.trx_mysql_thread_id blocking_thread_id,
b.trx_query blocking_sql,
b.trx_started blocking_trx_start_time
FROM
performance_schema.data_lock_waits w
INNER JOIN information_schema.innodb_trx b ON w.blocking_engine_transaction_id = b.trx_id
INNER JOIN information_schema.innodb_trx r ON w.requesting_engine_transaction_id = r.trx_id;
步骤3:查看锁的详细信息
执行以下SQL,查看当前数据库中所有持有的锁详情,包括锁类型、锁所在的表、索引、具体记录:
SELECT
engine_transaction_id trx_id,
object_schema db_name,
object_name table_name,
index_name,
lock_type,
lock_mode,
lock_data,
lock_status
FROM
performance_schema.data_locks;
步骤4:根因定位与应急处理
- 若阻塞源是未提交的长事务,可通过
kill 阻塞线程ID临时释放锁,恢复业务; - 若为索引失效导致的全表锁,立即给查询条件添加合适的索引,确保更新语句走精准索引;
- 若为热点行更新,优化业务逻辑,采用分布式锁、队列削峰等方式控制并发度。
三、死锁的形成条件与高频场景复现
3.1 死锁形成的4个必要条件
死锁的触发必须同时满足以下4个条件,只要打破其中任意一个,就能彻底避免死锁:
- 互斥条件:锁只能被一个事务持有,其他事务无法同时持有同一把锁;
- 占有且等待:事务已持有至少一把锁,又申请新的锁,且新锁被其他事务持有时,不释放已持有的锁;
- 不可抢占:事务已持有的锁,只能由自身提交/回滚释放,无法被其他事务强制抢占;
- 循环等待:多个事务形成头尾相接的等待闭环,互相等待对方持有的锁。
3.2 线上高频死锁场景与完整复现
以下所有场景均基于MySQL 8.0默认RR隔离级别,SQL可直接执行复现。
场景1:交叉更新行导致的死锁(最经典、最高发)
表结构与初始化数据
CREATE TABLE `t_user` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`user_name` varchar(64) NOT NULL COMMENT '用户名',
`age` int NOT NULL COMMENT '年龄',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_name` (`user_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
INSERT INTO `t_user` (`id`, `user_name`, `age`) VALUES (1, '张三', 20), (2, '李四', 25);
死锁复现步骤(按时间顺序执行)
| 时间 | 事务A | 事务B |
| T1 | BEGIN; | BEGIN; |
| T2 | UPDATE t_user SET age=21 WHERE id=1; | UPDATE t_user SET age=26 WHERE id=2; |
| T3 | UPDATE t_user SET age=22 WHERE id=2; | |
| T4 | 阻塞,等待id=2的行X锁 | UPDATE t_user SET age=27 WHERE id=1; |
| T5 | 死锁触发,事务被回滚 |
根因分析:事务A持有id=1的X锁,申请id=2的X锁;事务B持有id=2的X锁,申请id=1的X锁,形成循环等待闭环,4个死锁条件全部满足,触发死锁。
场景2:间隙锁+插入意向锁导致的死锁(90%线上隐藏死锁的诱因)
表结构复用上述t_user表,当前数据id为1、2,存在间隙(2,+∞)
死锁复现步骤(按时间顺序执行)
| 时间 | 事务A | 事务B |
| T1 | BEGIN; | BEGIN; |
| T2 | SELECT * FROM t_user WHERE id=3 FOR UPDATE; | SELECT * FROM t_user WHERE id=4 FOR UPDATE; |
| T3 | 持有(2,+∞)的间隙锁 | 持有(2,+∞)的间隙锁(间隙锁之间兼容,无阻塞) |
| T4 | INSERT INTO t_user (id, user_name, age) VALUES (3, '王五', 30); | |
| T5 | 阻塞,申请插入意向锁,被事务B的间隙锁阻塞 | INSERT INTO t_user (id, user_name, age) VALUES (4, '赵六', 35); |
| T6 | 阻塞,申请插入意向锁,被事务A的间隙锁阻塞,死锁触发 |
根因分析:两个事务同时持有同一间隙的间隙锁,又同时申请插入意向锁,而插入意向锁与间隙锁互斥,形成循环等待,触发死锁。该场景是线上最高发的隐藏死锁,很多开发者因不了解间隙锁的兼容规则,无法定位根因。
场景3:唯一键冲突导致的死锁
表结构复用上述t_user表,user_name为唯一索引
死锁复现步骤(按时间顺序执行)
| 时间 | 事务A | 事务B | 事务C |
| T1 | BEGIN; | BEGIN; | BEGIN; |
| T2 | INSERT INTO t_user (user_name, age) VALUES ('test', 18); | ||
| T3 | INSERT INTO t_user (user_name, age) VALUES ('test', 18); | INSERT INTO t_user (user_name, age) VALUES ('test', 18); | |
| T4 | 唯一键冲突,阻塞,申请S锁 | 唯一键冲突,阻塞,申请S锁 | |
| T5 | ROLLBACK; | ||
| T6 | 事务A释放锁,B和C同时拿到S锁 | 持有S锁,申请X锁完成插入 | 持有S锁,申请X锁完成插入 |
| T7 | 阻塞,等待C的S锁释放 | 阻塞,等待B的S锁释放,死锁触发 |
根因分析:唯一键冲突时,InnoDB不会直接报错,而是会给冲突的记录申请S锁;事务A回滚后,B和C同时持有S锁,又同时申请X锁,X锁与S锁互斥,形成循环等待,触发死锁。
四、show engine innodb status 死锁日志完整解读与精准定位
show engine innodb status是MySQL排查死锁的终极工具,它会记录最近一次死锁的完整信息,包含参与事务的SQL、持有的锁、等待的锁、循环等待链条等核心信息,只要读懂这份日志,就能100%定位死锁源头。
4.1 死锁日志完整示例
以下是上述场景1交叉更新触发的完整死锁日志,来自MySQL 8.0环境:
------------------------
LATEST DETECTED DEADLOCK
------------------------
2026-04-08 10:00:00 0x7f8a1b2c3700
*** (1) TRANSACTION:
TRANSACTION 42107, ACTIVE 10 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 12, OS thread handle 140230523201280, query id 245 localhost root updating
UPDATE t_user SET age=22 WHERE id=2
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 26 page no 4 n bits 72 index PRIMARY of table `test`.`t_user` trx id 42107 lock_mode X locks rec but not gap waiting
Record lock, heap no 3 PHYSICAL RECORD: n_fields 5; compact format; info bits 0
0: len 8; hex 8000000000000002; asc ;;
1: len 6; hex 00000000a47b; asc {;;
2: len 7; hex 81000001100110; asc ;;
3: len 4; hex 8000001a; asc ;;
4: len 2; hex e69d8e; asc ;;
*** (2) TRANSACTION:
TRANSACTION 42108, ACTIVE 5 sec starting index read
mysql tables in use 1, locked 1
2 lock struct(s), heap size 1136, 2 row lock(s)
MySQL thread id 13, OS thread handle 140230522930944, query id 246 localhost root updating
UPDATE t_user SET age=27 WHERE id=1
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 26 page no 4 n bits 72 index PRIMARY of table `test`.`t_user` trx id 42108 lock_mode X locks rec but not gap
Record lock, heap no 3 PHYSICAL RECORD: n_fields 5; compact format; info bits 0
0: len 8; hex 8000000000000002; asc ;;
1: len 6; hex 00000000a47b; asc {;;
2: len 7; hex 81000001100110; asc ;;
3: len 4; hex 8000001a; asc ;;
4: len 2; hex e69d8e; asc ;;
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 26 page no 4 n bits 72 index PRIMARY of table `test`.`t_user` trx id 42108 lock_mode X locks rec but not gap waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 5; compact format; info bits 0
0: len 8; hex 8000000000000001; asc ;;
1: len 6; hex 00000000a47a; asc z;;
2: len 7; hex 820000010f0120; asc ;;
3: len 4; hex 80000014; asc ;;
4: len 3; hex e5bca0e4b889; asc ;;
*** WE ROLL BACK TRANSACTION (1)
4.2 死锁日志逐行精准解读
1. 死锁头部信息
2026-04-08 10:00:00 0x7f8a1b2c3700
- 第一部分:死锁发生的精确时间;
- 第二部分:触发死锁的InnoDB OS线程ID,可用于关联MySQL错误日志。
2. 第一个参与死锁的事务(事务1)
*** (1) TRANSACTION:
TRANSACTION 42107, ACTIVE 10 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 12, OS thread handle 140230523201280, query id 245 localhost root updating
UPDATE t_user SET age=22 WHERE id=2
TRANSACTION 42107:事务的唯一ID,可用于关联事务详情表;ACTIVE 10 sec:事务已活跃10秒,锁持有时间过长是死锁的重要诱因;LOCK WAIT:当前事务处于锁等待状态;MySQL thread id 12:MySQL线程ID,可通过kill 12终止该线程;- 最后一行:事务当前正在执行的、被阻塞的SQL语句,可直接定位到业务代码位置。
3. 事务1等待的锁信息(核心定位点)
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 26 page no 4 n bits 72 index PRIMARY of table `test`.`t_user` trx id 42107 lock_mode X locks rec but not gap waiting
Record lock, heap no 3 PHYSICAL RECORD: n_fields 5; compact format; info bits 0
0: len 8; hex 8000000000000002; asc ;;
RECORD LOCKS:锁类型为记录锁;index PRIMARY:锁加在主键索引上,可直接定位到锁对应的索引;table test.t_user:锁对应的库名和表名;lock_mode X locks rec but not gap waiting:锁模式为排他锁(X),仅锁记录不锁间隙,当前处于等待状态;0: len 8; hex 8000000000000002:主键索引的字段值,十六进制转换为十进制为2,即事务1正在等待id=2的主键记录的X锁。
4. 第二个参与死锁的事务(事务2)
结构与事务1完全一致,核心信息为:事务ID为42108,MySQL线程ID为13,正在执行的SQL为UPDATE t_user SET age=27 WHERE id=1。
5. 事务2持有的锁信息(核心关联点)
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 26 page no 4 n bits 72 index PRIMARY of table `test`.`t_user` trx id 42108 lock_mode X locks rec but not gap
Record lock, heap no 3 PHYSICAL RECORD: n_fields 5; compact format; info bits 0
0: len 8; hex 8000000000000002; asc ;;
该部分明确显示:事务2持有test.t_user表主键索引上id=2的记录的X锁,正好是事务1正在等待的锁。
6. 事务2等待的锁信息
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 26 page no 4 n bits 72 index PRIMARY of table `test`.`t_user` trx id 42108 lock_mode X locks rec but not gap waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 5; compact format; info bits 0
0: len 8; hex 8000000000000001; asc ;;
该部分明确显示:事务2正在等待test.t_user表主键索引上id=1的记录的X锁,而该锁正好被事务1持有。
7. 死锁处理结果
*** WE ROLL BACK TRANSACTION (1)
InnoDB死锁检测线程会计算两个事务的回滚代价,选择代价最小的事务进行回滚,此处回滚了仅持有1个行锁的事务1。
4.3 锁模式关键字段解读
日志中锁模式的关键字段直接决定了死锁的类型,必须精准理解:
| 字段 | 含义 |
| lock_mode X | 排他锁 |
| lock_mode S | 共享锁 |
| locks rec but not gap | 仅记录锁,无间隙锁 |
| gap | 仅间隙锁,不锁记录 |
| locks gap before rec | 临键锁(记录锁+左边间隙锁) |
| insert intention | 插入意向锁 |
| waiting | 正在等待该锁 |
五、死锁全链路实战排查与修复
以下通过Java业务代码复现死锁,再通过上述方法定位根因,最终给出修复方案,形成完整的排查闭环。
5.1 项目环境与核心代码
项目基于JDK 17、Spring Boot 3.2.5、MyBatis-Plus 3.5.7开发。
pom.xml核心依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
<relativePath/>
</parent>
<groupId>com.jam.demo</groupId>
<artifactId>mysql-lock-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>mysql-lock-demo</name>
<description>MySQL锁与死锁实战Demo</description>
<properties>
<java.version>17</java.version>
<mybatis-plus.version>3.5.7</mybatis-plus.version>
<fastjson2.version>2.0.52</fastjson2.version>
<guava.version>33.1.0-jre</guava.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.5.0</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.30</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>${fastjson2.version}</version>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>${guava.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
实体类User
package com.jam.demo.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
/**
* 用户实体类
* @author ken
*/
@Data
@TableName("t_user")
@Schema(description = "用户实体")
public class User implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@TableId(type = IdType.AUTO)
@Schema(description = "主键ID", example = "1")
private Long id;
@Schema(description = "用户名", example = "张三")
private String userName;
@Schema(description = "年龄", example = "20")
private Integer age;
}
Mapper接口UserMapper
package com.jam.demo.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.jam.demo.entity.User;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
/**
* 用户Mapper接口
* @author ken
*/
public interface UserMapper extends BaseMapper<User> {
/**
* 根据ID更新用户年龄
* @param id 用户ID
* @param age 新年龄
* @return 影响行数
*/
@Update("UPDATE t_user SET age = #{age} WHERE id = #{id}")
int updateAgeById(@Param("id") Long id, @Param("age") Integer age);
}
服务实现类UserServiceImpl
package com.jam.demo.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.jam.demo.entity.User;
import com.jam.demo.mapper.UserMapper;
import com.jam.demo.service.UserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.DefaultTransactionDefinition;
import org.springframework.util.ObjectUtils;
import jakarta.annotation.Resource;
/**
* 用户服务实现类
* @author ken
*/
@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
@Resource
private PlatformTransactionManager transactionManager;
@Resource
private UserMapper userMapper;
@Override
public void crossUpdateFirst(Long id1, Long id2, Integer age1, Integer age2) {
if (ObjectUtils.isEmpty(id1) || ObjectUtils.isEmpty(id2)) {
throw new IllegalArgumentException("用户ID不能为空");
}
DefaultTransactionDefinition def = new DefaultTransactionDefinition();
def.setPropagationBehavior(DefaultTransactionDefinition.PROPAGATION_REQUIRED);
TransactionStatus status = transactionManager.getTransaction(def);
try {
log.info("事务A:开始更新id={}的用户年龄", id1);
userMapper.updateAgeById(id1, age1);
Thread.sleep(1000);
log.info("事务A:开始更新id={}的用户年龄", id2);
userMapper.updateAgeById(id2, age2);
transactionManager.commit(status);
log.info("事务A:执行完成,事务提交");
} catch (InterruptedException e) {
transactionManager.rollback(status);
Thread.currentThread().interrupt();
log.error("事务A:线程中断,事务回滚", e);
throw new RuntimeException("更新失败,线程中断", e);
} catch (Exception e) {
transactionManager.rollback(status);
log.error("事务A:更新异常,事务回滚", e);
throw new RuntimeException("更新失败", e);
}
}
@Override
public void crossUpdateSecond(Long id1, Long id2, Integer age1, Integer age2) {
if (ObjectUtils.isEmpty(id1) || ObjectUtils.isEmpty(id2)) {
throw new IllegalArgumentException("用户ID不能为空");
}
DefaultTransactionDefinition def = new DefaultTransactionDefinition();
def.setPropagationBehavior(DefaultTransactionDefinition.PROPAGATION_REQUIRED);
TransactionStatus status = transactionManager.getTransaction(def);
try {
log.info("事务B:开始更新id={}的用户年龄", id2);
userMapper.updateAgeById(id2, age2);
Thread.sleep(1000);
log.info("事务B:开始更新id={}的用户年龄", id1);
userMapper.updateAgeById(id1, age1);
transactionManager.commit(status);
log.info("事务B:执行完成,事务提交");
} catch (InterruptedException e) {
transactionManager.rollback(status);
Thread.currentThread().interrupt();
log.error("事务B:线程中断,事务回滚", e);
throw new RuntimeException("更新失败,线程中断", e);
} catch (Exception e) {
transactionManager.rollback(status);
log.error("事务B:更新异常,事务回滚", e);
throw new RuntimeException("更新失败", e);
}
}
}
控制器UserController
package com.jam.demo.controller;
import com.jam.demo.service.UserService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import jakarta.annotation.Resource;
import java.util.concurrent.CountDownLatch;
/**
* 用户控制器
* @author ken
*/
@Slf4j
@RestController
@RequestMapping("/user")
@Tag(name = "用户管理", description = "用户相关操作接口")
public class UserController {
@Resource
private UserService userService;
@PostMapping("/deadlock/simulate")
@Operation(summary = "模拟交叉更新死锁场景", description = "并发执行两个交叉更新的事务,触发死锁")
public String simulateDeadlock(
@Parameter(description = "第一个用户ID", example = "1") @RequestParam Long id1,
@Parameter(description = "第二个用户ID", example = "2") @RequestParam Long id2,
@Parameter(description = "第一个用户新年龄", example = "21") @RequestParam Integer age1,
@Parameter(description = "第二个用户新年龄", example = "26") @RequestParam Integer age2
) {
CountDownLatch countDownLatch = new CountDownLatch(2);
new Thread(() -> {
try {
userService.crossUpdateFirst(id1, id2, age1, age2 + 1);
} catch (Exception e) {
log.error("线程1执行异常", e);
} finally {
countDownLatch.countDown();
}
}, "deadlock-thread-1").start();
new Thread(() -> {
try {
userService.crossUpdateSecond(id1, id2, age1 + 1, age2);
} catch (Exception e) {
log.error("线程2执行异常", e);
} finally {
countDownLatch.countDown();
}
}, "deadlock-thread-2").start();
try {
countDownLatch.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return "模拟中断";
}
return "死锁模拟完成,请查看MySQL死锁日志";
}
}
application.yml配置
spring:
datasource:
url: jdbc:mysql://localhost:3306/test?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root
password: your_password
driver-class-name: com.mysql.cj.jdbc.Driver
application:
name: mysql-lock-demo
mybatis-plus:
mapper-locations: classpath*:/mapper/**/*.xml
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
springdoc:
swagger-ui:
path: /swagger-ui.html
enabled: true
api-docs:
enabled: true
path: /v3/api-docs
server:
port: 8080
5.2 死锁复现与排查
- 在MySQL中创建
t_user表并插入初始数据; - 启动Spring Boot项目,访问
http://localhost:8080/swagger-ui.html,调用/user/deadlock/simulate接口,传入参数id1=1、id2=2、age1=21、age2=26; - 接口执行完成后,在MySQL中执行
show engine innodb status,查看死锁日志; - 通过日志解读,定位到死锁根因为两个事务交叉更新id=1和id=2的记录,形成循环等待。
5.3 死锁修复方案
核心修复逻辑:统一资源访问顺序,打破循环等待条件修改crossUpdateSecond方法,将更新顺序调整为与crossUpdateFirst一致,均按照id从小到大的顺序更新:
@Override
public void crossUpdateSecond(Long id1, Long id2, Integer age1, Integer age2) {
if (ObjectUtils.isEmpty(id1) || ObjectUtils.isEmpty(id2)) {
throw new IllegalArgumentException("用户ID不能为空");
}
DefaultTransactionDefinition def = new DefaultTransactionDefinition();
def.setPropagationBehavior(DefaultTransactionDefinition.PROPAGATION_REQUIRED);
TransactionStatus status = transactionManager.getTransaction(def);
try {
// 统一更新顺序:先更新id较小的记录,再更新id较大的记录
Long firstId = Math.min(id1, id2);
Long secondId = Math.max(id1, id2);
Integer firstAge = firstId.equals(id1) ? age1 : age2;
Integer secondAge = secondId.equals(id2) ? age2 : age1;
log.info("事务B:开始更新id={}的用户年龄", firstId);
userMapper.updateAgeById(firstId, firstAge);
Thread.sleep(1000);
log.info("事务B:开始更新id={}的用户年龄", secondId);
userMapper.updateAgeById(secondId, secondAge);
transactionManager.commit(status);
log.info("事务B:执行完成,事务提交");
} catch (InterruptedException e) {
transactionManager.rollback(status);
Thread.currentThread().interrupt();
log.error("事务B:线程中断,事务回滚", e);
throw new RuntimeException("更新失败,线程中断", e);
} catch (Exception e) {
transactionManager.rollback(status);
log.error("事务B:更新异常,事务回滚", e);
throw new RuntimeException("更新失败", e);
}
}
修改后,两个事务均先更新id较小的记录,再更新id较大的记录,不会形成交叉等待,循环等待条件被打破,死锁彻底解决。
六、锁等待与死锁的根治最佳实践
6.1 SQL与索引优化
- 所有更新、删除语句必须通过
explain检查执行计划,确保走精准索引,避免全表扫描导致的全表锁; - 尽量使用精准匹配查询,避免大范围的范围查询,减少间隙锁的生效范围;
- 业务允许的情况下,将隔离级别调整为**读已提交(RC)**,RC级别下无间隙锁,仅存在记录锁,可消除90%的间隙锁导致的死锁,同时半一致性读可大幅减少锁等待;
- 避免并发插入相同的唯一键值,减少唯一键冲突导致的S锁申请。
6.2 业务代码优化
- 所有并发更新场景,必须统一资源的访问顺序,比如按主键从小到大、按业务编码固定顺序更新,打破循环等待条件;
- 严格控制事务粒度,事务内的SQL数量尽量最少,避免在事务内执行RPC调用、IO操作等耗时逻辑,减少锁的持有时间;
- 避免滥用
select ... for update手动加锁,若必须使用,确保查询条件走精准索引,且锁定范围最小; - 优先使用编程式事务,避免声明式事务传播行为不当导致的大事务。
6.3 数据库配置优化
- 确保
innodb_deadlock_detect=ON,开启死锁检测,默认开启; - 开启
innodb_print_all_deadlocks=ON,将所有死锁日志打印到MySQL错误日志,方便事后排查; - 高并发场景下,适当调小
innodb_lock_wait_timeout,避免长时间的业务阻塞; - 超高并发场景下,若死锁检测导致CPU占用过高,可通过分布式锁、队列削峰等方式控制并发度,避免直接关闭死锁检测。
总结
MySQL锁等待与死锁的本质,是InnoDB锁机制下并发事务对资源的竞争形成的阻塞与循环等待。想要彻底解决这类问题,核心是先搞懂InnoDB锁的底层逻辑与生效规则,再通过show engine innodb status精准定位死锁的循环等待链条,最终通过统一资源访问顺序、优化索引、控制事务粒度等方式,打破死锁的必要条件,从根因上解决问题。