理解事务传播与 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.设计上避免并发锁冲突
如果必须使用多个事务,考虑通过业务逻辑设计避免多个事务对同一资源进行锁定
