理解事务传播与 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/1t2/3t2n/4 时,接口能够正常执行并返回预期结果。但当我们调用 t1n/2 接口时,系统出现了死锁,接口无法返回

问题分析

要理解为什么 t1n/2 会死锁,我们需要分析以下几个关键点:

1. InnoDB 的锁机制

MySQL InnoDB 存储引擎使用行级锁,当执行 INSERTUPDATE 等修改操作时,InnoDB 会给相关的记录加上排他锁(X锁),以确保该行在事务提交之前不会被其他事务修改

2. 事务传播特性 Propagation.REQUIRES_NEW

在 Spring 中,事务传播机制决定了方法在调用时,是否会使用当前事务还是开启新事务,Propagation.REQUIRES_NEW 会强制开启一个新的事务,并挂起当前事务,这意味着 t1n/2 中的 dao.u1n(2) 执行时,会开启一个新的事务,与之前的 dao.i1(2) 插入操作处于两个独立的事务中

3. 死锁的具体原因

我们来看接口 t1n/2 的具体执行流程:

  1. Step 1:调用 dao.i1(2),插入一条 col1=2 的记录,这时数据库会对 col1=2 这条记录加上排他锁(X锁),以确保其他事务无法对这条记录进行修改或插入

  2. Step 2:接下来调用 dao.u1n(2)。由于 Propagation.REQUIRES_NEW,此操作将开启一个新的事务。而 u1n(2) 的更新语句 update test set t = '1' where col1 = #{col1} 试图修改 col1=2 的记录

  3. Step 3:新事务试图对 col1=2 记录加排他锁(X锁),但由于这条记录已经在前一个事务中被锁定,新的事务只能等待锁释放。然而,前一个事务并没有提交或回滚,于是产生了死锁

4. 为什么其他接口不死锁?

解决方案

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.设计上避免并发锁冲突

如果必须使用多个事务,考虑通过业务逻辑设计避免多个事务对同一资源进行锁定