Spring Boot多实例与MySQL并发问题解决方案咨询
我将Spring Boot应用部署在两个实例上,使用MySQL作为数据库。当发送两条携带相同message的并发请求时,出现以下问题:
- 实例1执行如下逻辑:
Item item = itemRepository.findByMessage(message); if (item != null) { item.setDate(date); } else { Item newItem = new Item(); newItem.setMessage(message); // message是唯一标识 newItem.setDate(date); try { itemRepository.save(newItem); } catch (DataIntegrityViolationException e) { if (isDuplicateEntryException(e)) { itemUpdateService.updateItemIfFailure(message, date); } } }
- 实例2同时执行相同请求,由于未检测到实例1插入的条目,也尝试插入,导致唯一键约束冲突(Duplicate Entry异常)。
- 为处理该情况,我在独立事务方法中实现了更新操作,使用
@Transactional(propagation = Propagation.REQUIRES_NEW):
@Transactional(propagation = Propagation.REQUIRES_NEW) public long updateItemIfFailure(Date date, String message) { Item item = itemRepository.findByMessage(message); item.setDate(date); itemRepository.save(item); }
但这会引发锁等待超时错误:
Lock wait timeout exceeded; try restarting transaction
问题:如何避免两个实例间的并发问题,在不触发唯一键冲突或锁等待超时的前提下,正确处理条目的插入或更新?
方案1:用MySQL原子操作INSERT ... ON DUPLICATE KEY UPDATE(推荐)
这是最直接高效的方案,把插入/更新的逻辑交给数据库原子处理,彻底避免应用层的竞态问题。
前提是message字段已经在MySQL表中设置了唯一索引(你已经满足这个条件)。在Repository中定义原生SQL方法:
public interface ItemRepository extends JpaRepository<Item, Long> { @Modifying @Transactional @Query(value = "INSERT INTO item (message, date) VALUES (:message, :date) ON DUPLICATE KEY UPDATE date = :date", nativeQuery = true) void insertOrUpdate(@Param("message") String message, @Param("date") Date date); }
然后业务逻辑直接简化成调用这个方法就行,不用再写查询判断和异常捕获:
itemRepository.insertOrUpdate(message, date);
MySQL会自动处理:如果该message的记录不存在就插入,存在则更新date字段,全程原子性,不会有冲突。
方案2:使用悲观锁(SELECT ... FOR UPDATE)
如果不想写原生SQL,可以在查询时加排他锁,确保同一时间只有一个事务能操作目标记录。
修改Repository的查询方法,加上悲观锁注解:
public interface ItemRepository extends JpaRepository<Item, Long> { @Lock(LockModeType.PESSIMISTIC_WRITE) Optional<Item> findByMessage(String message); }
然后把业务逻辑放在同一个事务里执行:
@Transactional public void handleItem(String message, Date date) { Optional<Item> itemOpt = itemRepository.findByMessage(message); if (itemOpt.isPresent()) { Item item = itemOpt.get(); item.setDate(date); itemRepository.save(item); } else { Item newItem = new Item(); newItem.setMessage(message); newItem.setDate(date); itemRepository.save(newItem); } }
PESSIMISTIC_WRITE会在查询时给对应行加排他锁,其他事务必须等锁释放才能操作,避免了并发插入的问题。注意必须保证整个逻辑在同一个事务中,否则锁会提前释放。
方案3:应用层加分布式锁
如果需要更灵活的控制,或者数据库锁满足不了场景,可以用分布式锁(比如Redis),确保同一时间只有一个实例能处理特定message的请求。
这里用Redisson做示例:
@Autowired private RedissonClient redissonClient; @Autowired private ItemRepository itemRepository; public void handleItem(String message, Date date) { // 用message作为锁的唯一标识 RLock lock = redissonClient.getLock("item_lock_" + message); try { // 尝试获取锁,最多等5秒,持有锁10秒 if (lock.tryLock(5, 10, TimeUnit.SECONDS)) { // 执行原来的查询-插入/更新逻辑 Optional<Item> itemOpt = itemRepository.findByMessage(message); if (itemOpt.isPresent()) { Item item = itemOpt.get(); item.setDate(date); itemRepository.save(item); } else { Item newItem = new Item(); newItem.setMessage(message); newItem.setDate(date); itemRepository.save(newItem); } } else { // 获取锁失败,可根据业务需求重试或返回提示 throw new RuntimeException("当前请求繁忙,请稍后再试"); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException("请求被中断"); } finally { // 确保锁被释放 if (lock.isHeldByCurrentThread()) { lock.unlock(); } } }
这种方式完全隔离了并发请求,但需要额外引入分布式锁组件,适合复杂的多实例并发场景。
为什么原来的方案会出问题?
你用REQUIRES_NEW开启新事务的方式,会导致实例1插入数据未提交时,实例2触发Duplicate异常后进入新事务,尝试查询更新,但此时实例1的事务还握着该行的锁,实例2的新事务会一直等锁,超过MySQL默认的锁等待超时时间(50秒)就会抛出锁等待超时错误。而上面的方案要么用数据库原子操作,要么用更合理的锁策略,都能避免这个问题。
内容的提问来源于stack exchange,提问作者Rodik

