You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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:查询时直接加悲观锁(推荐用于需要业务逻辑依赖钱包状态的场景)

要解决丢失更新,必须在查询阶段就加写锁,让后续的并发查询等待锁释放。具体步骤:

  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);
}
  1. 修改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执行原子性的更新操作,让数据库层面保证原子性:

  1. 在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);
}
  1. 修改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:使用乐观锁(适合并发冲突较少的场景)

如果你的系统并发冲突频率不高,可以使用乐观锁,通过版本号来检测冲突:

  1. 修改Wallet实体类,添加版本字段:
import javax.persistence.*;

@Entity
public class Wallet {
    // 原有字段...
    
    @Column(nullable = false)
    private Long balance;
    
    @Version
    private Integer version; // 版本号,用于乐观锁
    
    // getter和setter方法...
}
  1. 正常使用查询和更新操作,当出现并发冲突时,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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.04.29 13:42:38