Spring Boot RESTful API中Hibernate引发的竞态条件问题求助
看起来你遇到了典型的并发读写不一致问题——多个请求同时操作同一对用户的余额和交易记录,导致读取旧值后执行插入/更新,破坏了业务逻辑的原子性。我来帮你拆解问题根源,然后给出几个靠谱的解决方案:
为什么之前的方案无效?
synchronized块:它是JVM进程级别的锁,如果你的应用是多实例部署(比如集群),不同实例的请求会直接绕开锁;就算是单实例,若事务提交在synchronized块之外,锁释放后其他线程可能在事务提交前读到旧数据,依然会有问题。- 常规事务隔离级别:比如
REPEATABLE READ(MySQL默认)会读取事务内的数据快照,无法保证读取到数据库最新的行数据;而且单纯靠隔离级别无法跨表保证操作的串行性。
可行解决方案
1. 数据库悲观锁(最直接的单/多实例通用方案)
核心思路:在读取BalanceEntity时,给对应的行加排他锁,其他事务必须等待锁释放才能读取该行,确保后续的更新/插入操作基于最新值执行。
修改你的balanceDAO.getByBothUsersId方法,通过Hibernate添加悲观写锁:
public BalanceEntity getByBothUsersId(Integer payerId, Integer payeeId) { Session session = entityManager.unwrap(Session.class); return session.createQuery( "from BalanceEntity where payerId = :payerId and payeeId = :payeeId", BalanceEntity.class) .setParameter("payerId", payerId) .setParameter("payeeId", payeeId) .setLockMode(LockMode.PESSIMISTIC_WRITE) // 添加排他锁 .uniqueResult(); }
同时确保你的服务层方法在同一个事务内完成所有操作(你已经加了类级别的@Transactional,保持默认的REQUIRED传播行为即可)。锁会在事务提交/回滚时自动释放,保证整个读-改-插流程的原子性。
2. 乐观锁(适合并发量不高的场景)
如果你的并发请求不是特别频繁,可以用乐观锁来避免悲观锁的性能开销。给BalanceEntity添加版本字段,每次更新时校验版本号:
第一步,给实体类加@Version注解:
@Entity public class BalanceEntity { // 其他字段... @Version private Integer version; // 版本号,Hibernate自动维护递增 // getter、setter }
第二步,在updatePendingAmount方法中,Hibernate会自动校验版本号:如果当前线程读取的版本号和数据库中的版本号不一致,会抛出OptimisticLockingFailureException,这时候你需要捕获异常并重试整个操作。可以用Spring的RetryTemplate来实现重试逻辑:
@Autowired private RetryTemplate retryTemplate; public ResponseDTO insert(RequestParams request) { return retryTemplate.execute(context -> { // 原来的业务逻辑:读余额、更新、插入交易 // ... }); }
3. 分布式锁(多实例部署必选)
如果你的应用是多实例集群部署,synchronized和数据库行锁可能不足以完全避免跨实例的并发问题,这时候需要用分布式锁来锁定用户对(payerId+payeeId),保证同一对用户的操作全局串行执行。
比如用Redis的Redisson实现分布式锁:
@Autowired private RedissonClient redissonClient; public ResponseDTO insert(RequestParams request) { String lockKey = String.format("balance-lock:%d:%d", request.getPayerId(), request.getPayeeId()); RLock lock = redissonClient.getLock(lockKey); try { // 尝试获取锁,5秒等待超时,30秒持有超时防止死锁 if (lock.tryLock(5, 30, TimeUnit.SECONDS)) { // 在锁内执行完整的业务逻辑(读余额、更新、插入交易) // ... } else { throw new RuntimeException("当前操作繁忙,请稍后重试"); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException("操作被中断"); } finally { // 确保锁被释放 if (lock.isHeldByCurrentThread()) { lock.unlock(); } } }
注意:分布式锁要和数据库事务配合使用,最好是先获取锁,再开启事务,避免锁释放后事务还未提交导致其他线程读到旧数据。
额外优化建议
- 确保
transactionDao.insert和balanceDAO.update操作都在同一个事务内,避免部分提交的情况。 - 避免在事务内执行耗时操作(比如远程调用),防止锁持有时间过长影响性能。
- 针对高频操作的用户对,可以考虑在业务层做本地缓存+分布式锁的组合优化,但要注意缓存一致性问题。
内容的提问来源于stack exchange,提问作者Mark

