实战记录:Java 高并发插入 MySQL 唯一索引表引发死锁的排查与解决

#新星杯·14天创作挑战营·第11期#

一、业务背景:数据产品的 "定时任务之痛"

我们团队负责一款 ToB 数据产品,核心逻辑是将实时计算平台(如 ODPS)的分析结果同步到 MySQL,供前端 BI 工具快速查询。其中有一张核心表tkn_tb_cinema_show_data(影院排片数据表),表结构如下(关键部分):

sql

CREATE TABLE tkn_tb_cinema_show_data (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    cinema_id BIGINT COMMENT '影院ID',
    show_id BIGINT COMMENT '影片ID',
    now_date VARCHAR(32) COMMENT '统计日期',
    UNIQUE KEY uid_cinema_show_date (cinema_id, show_id, now_date) -- 联合唯一索引
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

问题爆发:定时任务的 "死亡循环"

每天凌晨的定时同步任务是数据链路的核心,但最近运维同学反馈:当任务并发量提升至 200 + 线程时,插入成功率从 99% 骤降至 60%,日志中大量出现:

plaintext

com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: 
Deadlock found when trying to get lock; try restarting transaction

DBA 拉取SHOW ENGINE INNODB STATUS死锁日志,关键信息如下:

plaintext

*** (1) TRANSACTION:
TRANSACTION 73278630816, ACTIVE 1 sec inserting
LOCK WAIT 622 lock struct(s), heap size 79400, 743 row lock(s)
LOCKED RECORD indices: uid_cinema_show_date
*** (2) TRANSACTION:
TRANSACTION 73278630826, ACTIVE 0 sec inserting
WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 42 page no 5 n bits 72 index uid_cinema_show_date of table `tkn`.`tkn_tb_cinema_show_data` 
trx id 73278630826 lock_mode X locks gap before rec insert intention waiting

现象矛盾点:明明插入的是不同now_date(统计日期)的记录,为何会出现锁冲突?

二、问题复现:用 Java 代码模拟死锁场景

为定位问题,我们用 Java+Spring Boot 模拟线上环境:

java

// 模拟定时任务的并发插入服务
@Service
public class CinemaDataService {
    @Autowired
    private JdbcTemplate jdbcTemplate;

    // 原始插入逻辑(问题代码)
    @Transactional(isolation = Isolation.REPEATABLE_READ) // 默认可重复读隔离级别
    public void batchInsert(List<CinemaShowData> dataList) {
        String sql = "INSERT INTO tkn_tb_cinema_show_data " +
            "(cinema_id, show_id, now_date) VALUES (?,?,?)";
        dataList.forEach(data -> 
            jdbcTemplate.update(sql, 
                data.getCinemaId(), 
                data.getShowId(), 
                data.getNowDate())
        );
    }
}

// 压测代码(模拟200线程并发)
@Test
void testDeadLock() throws InterruptedException {
    ExecutorService executor = Executors.newFixedThreadPool(200);
    // 构造2000条不同now_date的记录(cinema_id=1001, show_id=2001)
    List<CinemaShowData> dataList = IntStream.range(0, 2000)
        .mapToObj(i -> new CinemaShowData(1001L, 2001L, "2025-05-15-" + i))
        .collect(Collectors.toList());
    
    // 拆分200线程并发插入
    for (int i = 0; i < 200; i++) {
        executor.submit(() -> batchInsert(dataList.subList(i*10, (i+1)*10)));
    }
    executor.shutdown();
    executor.awaitTermination(10, TimeUnit.MINUTES);
}

压测结果:10 分钟内抛出 127 次死锁异常,与线上现象完全一致。

三、原理深挖:InnoDB 锁机制的 "隐形冲突"

通过分析 InnoDB 锁机制(参考《MySQL 技术内幕:InnoDB 存储引擎》),问题根源在于唯一索引的插入意向锁(Insert Intention Lock)与 Next-Key Lock 的冲突

1. 关键锁机制回顾

  • 插入意向锁(X IK):插入新记录前,InnoDB 会在目标间隙(Gap)加插入意向锁,表示 "我要插入这里",与其他插入意向锁兼容(不同间隙)。
  • Next-Key Lock(S NK):唯一索引插入时,InnoDB 会先执行唯一性检查(防止重复),此时会加 S 型 Next-Key Lock(记录锁 + 间隙锁),锁范围为(上一记录, 当前记录]

2. 死锁场景推演

假设唯一索引(cinema_id=1001, show_id=2001)已有记录now_date='2025-05-15-0',两个并发事务 T1、T2 分别插入now_date='2025-05-15-1'now_date='2025-05-15-2'

  1. T1 执行唯一性检查,加 S 型 Next-Key Lock 锁间隙('2025-05-15-0', +∞)
  2. T2 同时执行唯一性检查,同样尝试加 S 型 Next-Key Lock 锁同一间隙;
  3. InnoDB 中 S 型 Next-Key Lock 在间隙锁场景下互斥(防止幻读),T1 和 T2 互相阻塞;
  4. 此时 T1 和 T2 都等待对方释放锁以获取插入意向锁,形成循环等待,触发死锁。

四、Java 层面的终极解决方案

针对上述原理,我们从数据库配置、Java 代码、业务逻辑三端协同优化,最终将死锁率降至 0.01% 以下。

方案 1:调整事务隔离级别(核心优化)

将事务隔离级别从默认的REPEATABLE READ改为READ COMMITTED(读已提交),Java 中通过@Transactional注解配置:

java

// 优化后:降低隔离级别减少锁范围
@Transactional(isolation = Isolation.READ_COMMITTED) 
public void batchInsert(List<CinemaShowData> dataList) {
    // 插入逻辑不变
}

原理READ COMMITTED下,InnoDB 仅保留记录锁(释放间隙锁),且唯一性检查仅加记录锁(非 Next-Key Lock),大幅减少锁冲突。

方案 2:拆分批量插入(缩短事务生命周期)

将单次插入 2000 条改为每 100 条提交一次,避免长事务长时间持锁:

java

// 优化后:拆分批量插入
public void batchInsert(List<CinemaShowData> dataList) {
    int batchSize = 100;
    for (int i = 0; i < dataList.size(); i += batchSize) {
        List<CinemaShowData> batch = dataList.subList(i, Math.min(i + batchSize, dataList.size()));
        doInsert(batch); // 单独事务提交
    }
}

@Transactional(isolation = Isolation.READ_COMMITTED)
private void doInsert(List<CinemaShowData> batch) {
    String sql = "INSERT INTO tkn_tb_cinema_show_data (cinema_id, show_id, now_date) VALUES (?,?,?)";
    jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
        @Override
        public void setValues(PreparedStatement ps, int i) throws SQLException {
            CinemaShowData data = batch.get(i);
            ps.setLong(1, data.getCinemaId());
            ps.setLong(2, data.getShowId());
            ps.setString(3, data.getNowDate());
        }
        @Override
        public int getBatchSize() {
            return batch.size();
        }
    });
}

方案 3:添加死锁重试逻辑(兜底保障)

通过 Spring Retry 实现死锁自动重试(需引入spring-retry依赖):

java

// 依赖配置(pom.xml)
<dependency>
    <groupId>org.springframework.retry</groupId>
    <artifactId>spring-retry</artifactId>
</dependency>

// 启用重试注解
@Service
@EnableRetry
public class CinemaDataService {
    @Retryable(value = MySQLTransactionRollbackException.class, maxAttempts = 3, backoff = @Backoff(delay = 100))
    @Transactional(isolation = Isolation.READ_COMMITTED)
    public void batchInsert(List<CinemaShowData> dataList) {
        // 插入逻辑
    }

    @Recover
    public void recover(MySQLTransactionRollbackException e, List<CinemaShowData> dataList) {
        log.error("最终重试失败,数据:{}", dataList, e);
        // 可选:将失败数据写入补偿队列
    }
}

方案 4:按索引顺序排序插入(减少锁竞争)

插入前按唯一索引(cinema_id, show_id, now_date)排序,避免随机插入导致多事务竞争同一间隙:

java

// 插入前排序(Java 8 Stream)
List<CinemaShowData> sortedData = dataList.stream()
    .sorted(Comparator.comparingLong(CinemaShowData::getCinemaId)
        .thenComparingLong(CinemaShowData::getShowId)
        .thenComparing(CinemaShowData::getNowDate))
    .collect(Collectors.toList());

五、效果验证:从 "死亡循环" 到 "丝滑插入"

优化后压测(200 线程并发插入 2000 条数据):

  • 死锁次数:0 次(原 127 次 / 10 分钟);
  • 插入耗时:从 82s 降至 15s(因事务提交次数增加,需平衡批量大小);
  • 业务成功率:从 60% 提升至 99.99%。

总结

高并发插入唯一索引表的死锁问题,本质是 InnoDB 锁机制与业务场景的碰撞。通过调整隔离级别、缩短事务、添加重试等组合拳,可有效解决此类问题。关键是要理解数据库底层锁机制,结合业务场景做针对性优化。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

混进IT圈

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值