Spring Boot中如何实现钱包金额增减方法的原子性?
看起来你遇到了典型的并发场景下的丢失更新问题——多个请求同时读写同一个钱包余额,导致最终结果不符合预期。我来帮你分析下为什么之前的方案没生效,以及该怎么解决。
为什么你的现有方案没解决问题?
1. @Transactional(SERIALIZABLE) 不生效的核心原因
SERIALIZABLE是最高隔离级别,但它并不能直接解决先读再改的丢失更新问题——除非你的查询操作本身就加锁。默认情况下,即使在SERIALIZABLE事务中,普通的SELECT语句(比如你的findWalletsByCustId)只会加共享锁,多个事务可以同时读取同一行。当两个事务都读取到初始余额后,各自修改并提交,第二个事务的更新会直接覆盖第一个的,最终导致余额错误。
另外还要注意:Spring的@Transactional在Controller层使用时,依赖Spring代理生效,如果你的Controller是final类或者没有被Spring正确扫描代理,事务逻辑可能根本没执行。
2. 悲观锁的使用方式错误
你当前的代码是先查询Wallet对象,再调用em.lock(wallet, PESSIMISTIC_WRITE),这时候查询阶段没有加锁,两个并发事务已经各自读取到了旧的余额值。即使后续加了写锁,也只是锁定了内存中的对象,但事务已经基于旧值完成了计算,最终还是会出现覆盖更新。
3. synchronized + @Transactional 的冲突
synchronized保证了同一时间只有一个线程执行方法,但Spring的事务是通过代理实现的:事务的开启和提交是在代理层完成的,也就是说,synchronized释放锁的时机早于事务提交的时机。当第一个线程释放锁后,第二个线程开始执行方法,此时第一个线程的事务可能还没提交,第二个线程读取到的还是数据库中的旧值,自然会计算出错。
正确的解决方案
方案1:查询时直接加悲观锁(推荐用于需要业务逻辑依赖钱包状态的场景)
要解决丢失更新,必须在查询阶段就加写锁,让后续的并发查询等待锁释放。具体步骤:
- 在
WalletRepository中添加带悲观锁的查询方法:
import org.springframework.data.jpa.repository.Lock; import javax.persistence.LockModeType; import java.util.Optional; public interface WalletRepository extends JpaRepository<Wallet, Long> { // 保留原有方法... @Lock(LockModeType.PESSIMISTIC_WRITE) Optional<Wallet> findByCustId(Long custId); }
- 修改Controller中的方法,使用这个带锁的查询:
@GetMapping("/addAmount") @ResponseBody @Transactional public void addAmount(@RequestParam Long custId, @RequestParam Long amount){ repository.findByCustId(custId).ifPresent(wallet -> { wallet.setBalance(wallet.getBalance() + amount); repository.save(wallet); }); } @GetMapping("/deductAmount") @ResponseBody @Transactional public boolean deductAmount(@RequestParam Long custId, @RequestParam Long amount){ return repository.findByCustId(custId) .map(wallet -> { if(wallet.getBalance() < amount) return false; wallet.setBalance(wallet.getBalance() - amount); repository.save(wallet); return true; }) .orElse(false); }
这样,第一个事务查询Wallet时会加写锁,第二个事务的查询会被阻塞,直到第一个事务提交释放锁,从而保证每次读取的都是最新的余额。
方案2:使用数据库原子更新(最高效,推荐优先使用)
避免先读再改的模式,直接用JPQL执行原子性的更新操作,让数据库层面保证原子性:
- 在
WalletRepository中添加原子更新方法:
import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; public interface WalletRepository extends JpaRepository<Wallet, Long> { // 保留原有方法... @Modifying @Query("UPDATE Wallet w SET w.balance = w.balance + :amount WHERE w.custId = :custId") int addAmount(@Param("custId") Long custId, @Param("amount") Long amount); @Modifying @Query("UPDATE Wallet w SET w.balance = w.balance - :amount WHERE w.custId = :custId AND w.balance >= :amount") int deductAmount(@Param("custId") Long custId, @Param("amount") Long amount); }
- 修改Controller方法:
@GetMapping("/addAmount") @ResponseBody @Transactional public void addAmount(@RequestParam Long custId, @RequestParam Long amount){ repository.addAmount(custId, amount); } @GetMapping("/deductAmount") @ResponseBody @Transactional public boolean deductAmount(@RequestParam Long custId, @RequestParam Long amount){ int updatedRows = repository.deductAmount(custId, amount); return updatedRows > 0; }
这种方式不需要查询对象,直接在数据库中完成加减操作,天然具备原子性,性能也更好(减少了一次查询操作)。返回的updatedRows表示受影响的行数:如果是0,说明用户不存在或余额不足。
方案3:使用乐观锁(适合并发冲突较少的场景)
如果你的系统并发冲突频率不高,可以使用乐观锁,通过版本号来检测冲突:
- 修改Wallet实体类,添加版本字段:
import javax.persistence.*; @Entity public class Wallet { // 原有字段... @Column(nullable = false) private Long balance; @Version private Integer version; // 版本号,用于乐观锁 // getter和setter方法... }
- 正常使用查询和更新操作,当出现并发冲突时,Spring Data JPA会抛出
OptimisticLockingFailureException,你可以捕获异常并重试:
@GetMapping("/addAmount") @ResponseBody @Transactional public void addAmount(@RequestParam Long custId, @RequestParam Long amount){ int retryCount = 3; while(retryCount-- > 0){ try{ Wallet wallet = repository.findByCustId(custId).orElseThrow(() -> new RuntimeException("Wallet not found")); wallet.setBalance(wallet.getBalance() + amount); repository.save(wallet); break; }catch(OptimisticLockingFailureException e){ // 并发冲突,重试操作 } } }
乐观锁不需要加锁,性能更高,但需要处理重试逻辑,适合冲突较少的场景。
总结
你的核心问题是先读再改导致的丢失更新,@Transactional本身没问题,但需要配合正确的锁机制或原子更新操作。推荐优先使用方案2(数据库原子更新),因为它最简单高效;如果业务逻辑必须依赖查询后的钱包状态,再使用方案1(查询时加悲观锁)。
内容的提问来源于stack exchange,提问作者Gokul

