一、业务背景:数据产品的 "定时任务之痛"
我们团队负责一款 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'
:
- T1 执行唯一性检查,加 S 型 Next-Key Lock 锁间隙
('2025-05-15-0', +∞)
; - T2 同时执行唯一性检查,同样尝试加 S 型 Next-Key Lock 锁同一间隙;
- InnoDB 中 S 型 Next-Key Lock 在间隙锁场景下互斥(防止幻读),T1 和 T2 互相阻塞;
- 此时 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 锁机制与业务场景的碰撞。通过调整隔离级别、缩短事务、添加重试等组合拳,可有效解决此类问题。关键是要理解数据库底层锁机制,结合业务场景做针对性优化。