理解事务传播与 MySQL 锁机制如何相互作用导致死锁
问题背景
新建 SpringBoot 测试项目,使用 MySQL 5.7,引擎为 InnoDB,事务隔离级别为 Read Committed
我们有一张简单的测试表 test
,表结构如下:
create table test
(
col1 bigint default 0 not null,
col2 bigint default 0 not null,
t varchar(50) null,
constraint col1_uk
unique (col1)
);
其中 col1
是唯一约束列(unique
),col2
则没有这种限制
接着定义一个 MyBatis Mapper 接口:
@Mapper
public interface MyDAO {
@Insert("insert into test (col1) values (#{col1})")
int i1(@Param("col1") long col1);
@Insert("insert into test (col2) values (#{col2})")
int i2(@Param("col2") long col2);
@Transactional(rollbackFor = Exception.class)
@Update("update test set t = '1' where col1 = #{col1}")
int u1(@Param("col1") long col1);
@Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRES_NEW)
@Update("update test set t = '1' where col1 = #{col1}")
int u1n(@Param("col1") long col1);
@Update("update test set t = '2' where col1 = #{col2}")
int u2(@Param("col2") long col2);
@Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRES_NEW)
@Update("update test set t = '2' where col1 = #{col2}")
int u2n(@Param("col2") long col2);
}
同时有一个 MyController
来调用这些方法:
@RestController
@RequestMapping("test")
@RequiredArgsConstructor
public class MyController {
private final MyDAO dao;
@GetMapping("t1/{i}")
@Transactional(rollbackFor = Exception.class)
public Object t1(@PathVariable long i) {
dao.i1(i);
dao.u1(i);
return 1;
}
@GetMapping("t1n/{i}")
@Transactional(rollbackFor = Exception.class)
public Object t1n(@PathVariable long i) {
dao.i1(i);
dao.u1n(i);
return 1;
}
@GetMapping("t2/{i}")
@Transactional(rollbackFor = Exception.class)
public Object t2(@PathVariable long i) {
dao.i2(i);
dao.u2(i);
return 1;
}
@GetMapping("t2n/{i}")
@Transactional(rollbackFor = Exception.class)
public Object t2n(@PathVariable long i) {
dao.i2(i);
dao.u2n(i);
return 1;
}
}
这个 Controller 中分别提供了四个接口,其中关键的 t1n/{i}
接口在执行时出现了死锁问题
现象描述
调用接口 t1/1
、t2/3
、t2n/4
时,接口能够正常执行并返回预期结果。但当我们调用 t1n/2
接口时,系统出现了死锁,接口无法返回
问题分析
要理解为什么 t1n/2
会死锁,我们需要分析以下几个关键点:
1. InnoDB 的锁机制
MySQL InnoDB 存储引擎使用行级锁,当执行 INSERT
、UPDATE
等修改操作时,InnoDB 会给相关的记录加上排他锁(X锁),以确保该行在事务提交之前不会被其他事务修改
- 插入操作
insert
:在插入时,InnoDB 会对插入的行加锁,如果表中有唯一索引(如col1
),那么唯一性约束也会导致相应的行被锁定 - 更新操作
update
:当执行UPDATE
时,MySQL 会首先在更新条件的记录上加上排他锁(X锁)
2. 事务传播特性 Propagation.REQUIRES_NEW
在 Spring 中,事务传播机制决定了方法在调用时,是否会使用当前事务还是开启新事务,Propagation.REQUIRES_NEW
会强制开启一个新的事务,并挂起当前事务,这意味着 t1n/2
中的 dao.u1n(2)
执行时,会开启一个新的事务,与之前的 dao.i1(2)
插入操作处于两个独立的事务中
3. 死锁的具体原因
我们来看接口 t1n/2
的具体执行流程:
-
Step 1:调用
dao.i1(2)
,插入一条col1=2
的记录,这时数据库会对col1=2
这条记录加上排他锁(X锁),以确保其他事务无法对这条记录进行修改或插入 -
Step 2:接下来调用
dao.u1n(2)
。由于Propagation.REQUIRES_NEW
,此操作将开启一个新的事务。而u1n(2)
的更新语句update test set t = '1' where col1 = #{col1}
试图修改col1=2
的记录 -
Step 3:新事务试图对
col1=2
记录加排他锁(X锁),但由于这条记录已经在前一个事务中被锁定,新的事务只能等待锁释放。然而,前一个事务并没有提交或回滚,于是产生了死锁
4. 为什么其他接口不死锁?
t1/1
:这里的dao.i1(1)
和dao.u1(1)
都在同一个事务中执行,u1(1)
不会因锁被前一个事务占用而等待t2/3
和t2n/4
:这两个接口操作col2
列,col2
列没有唯一性约束,因此即使是插入和更新不同的值,也不会发生锁冲突
解决方案
1. 修改事务传播属性
最简单的解决办法是将 dao.u1n(2)
方法的事务传播属性从 REQUIRES_NEW
改为默认的 REQUIRED
。这样所有操作都将在同一个事务中执行,不会因为事务隔离而导致死锁
@Transactional(rollbackFor = Exception.class)
@Update("update test set t = '1' where col1 = #{col1}")
int u1n(@Param("col1") long col1);
2.设计上避免并发锁冲突
如果必须使用多个事务,考虑通过业务逻辑设计避免多个事务对同一资源进行锁定