Spring Boot/JPA+MySQL异步Upsert操作如何避免死锁?
嘿,针对你遇到的Spring Boot/JPA + MySQL upsert死锁问题,结合你提到的异步并发调用、大量请求涌入的场景,我整理了几个实用的解决思路,你可以一步步排查优化:
你这种场景下的死锁,大概率是因为并发upsert时不同事务获取行锁的顺序不一致,再加上单个请求重复触发upsert、事务持有锁时间过长,多重因素叠加导致的。比如两个事务分别先锁行A再锁行B,另一个反向操作,就很容易形成交叉等待的死锁。
1. 统一行锁获取顺序(最关键)
不管是单个请求的多次调用,还是不同请求的并发操作,都要保证操作同一批数据时,按固定顺序锁定行。比如按主键ID的升序处理,这样所有事务都会先锁ID小的行,再锁ID大的,从根源上避免交叉等待锁的情况。
举个批量upsert的例子:
// 先对要操作的用户ID排序 List<Long> sortedUserIds = userIds.stream().sorted().collect(Collectors.toList()); for (Long userId : sortedUserIds) { // 执行upsert逻辑 userScoreRepository.upsertScore(userId, addScore); }
2. 用MySQL原生原子upsert替代JPA默认逻辑
Spring JPA的save()方法本质是先查询再插入/更新,中间存在时间间隙,容易引发锁冲突。直接用MySQL原生的INSERT ... ON DUPLICATE KEY UPDATE语句,原子性更强,能大幅减少锁竞争的窗口。
你可以在Repository层定义原生SQL查询:
@Repository public interface UserScoreRepository extends JpaRepository<UserScore, Long> { @Modifying @Query(value = "INSERT INTO user_score(user_id, score, update_time) " + "VALUES (:userId, :score, NOW()) " + "ON DUPLICATE KEY UPDATE score = score + :score, update_time = NOW()", nativeQuery = true) void upsertScore(@Param("userId") Long userId, @Param("score") Integer score); }
3. 控制单个请求的重复调用(幂等性)
单个请求并发多次调用upsert完全是冗余操作,还会加剧锁冲突。你可以在服务层加幂等控制,比如用Redis分布式锁(分布式场景)或本地锁(单实例场景),同一个请求ID只允许执行一次upsert。
示例(Redis幂等实现):
@Service public class UserScoreService { @Autowired private StringRedisTemplate redisTemplate; @Autowired private UserScoreRepository userScoreRepository; public void upsertUserScore(String requestId, Long userId, Integer score) { // 用请求ID作为锁键,设置过期时间防止死锁 Boolean lockAcquired = redisTemplate.opsForValue() .setIfAbsent(requestId, "locked", 5, TimeUnit.MINUTES); if (lockAcquired != null && lockAcquired) { try { userScoreRepository.upsertScore(userId, score); } finally { redisTemplate.delete(requestId); } } else { log.info("请求{}已处理过用户{}的upsert操作,跳过重复执行", requestId, userId); } } }
4. 缩短事务持有时间
尽量让事务只包裹必要的数据库操作,避免在事务里做远程调用、复杂计算等耗时操作。如果服务层方法里有大量非DB逻辑,建议把DB操作抽出来单独加事务:
@Service public class UserScoreService { @Autowired private UserScoreRepository userScoreRepository; // 非事务方法:处理业务校验、日志等逻辑 public void handleScoreUpdate(Long userId, Integer score) { doParameterCheck(userId, score); doLogRecord(userId); // 调用独立的事务方法执行upsert doUpsertInTransaction(userId, score); } @Transactional(propagation = Propagation.REQUIRES_NEW) public void doUpsertInTransaction(Long userId, Integer score) { userScoreRepository.upsertScore(userId, score); } }
这样事务只在执行DB操作时存在,锁持有时间大幅缩短,冲突概率自然降低。
5. 合理调整事务隔离级别
MySQL默认的REPEATABLE READ隔离级别会产生间隙锁,容易加剧死锁。如果你的业务能接受不可重复读的情况,可以降低到READ COMMITTED,缩小间隙锁范围:
在Spring Boot配置文件中添加:
spring.jpa.properties.hibernate.connection.isolation=2 # 2对应READ COMMITTED,4为默认的REPEATABLE READ
注意:调整前要确认业务逻辑不受影响,比如金额计算类场景需谨慎。
6. 增加死锁重试机制
即使做了以上优化,极端场景下仍可能出现死锁。你可以捕获MySQL的死锁异常(SQLState为40001),添加重试逻辑:
public void upsertWithRetry(Long userId, Integer score) { int retryCount = 3; while (retryCount > 0) { try { userScoreRepository.upsertScore(userId, score); return; } catch (DataAccessException e) { if (e.getRootCause() instanceof SQLException) { SQLException sqlEx = (SQLException) e.getRootCause(); if ("40001".equals(sqlEx.getSQLState())) { retryCount--; log.warn("发生死锁,剩余重试次数:{}", retryCount); // 重试前短暂休眠,避免立即冲突 Thread.sleep(100); } else { throw e; } } else { throw e; } } } throw new RuntimeException("多次重试后仍因死锁失败"); }
内容的提问来源于stack exchange,提问作者Gremash

