一次普通的库存扣减,在并发请求下可能悄悄变成覆盖更新。本文从 MySQL 事务隔离与 JPA 更新机制入手,比较乐观锁和原子 SQL 两种方案,并说明重试、批量更新及持久化上下文中的常见陷阱。

问题背景

库存扣减是数据库开发中很典型的一类操作。单看业务代码,它通常只有三步:查询商品、判断库存、扣减库存。

@Transactional
public void deduct(Long productId, int amount) {
    Product product = productRepository.findById(productId)
            .orElseThrow(() -> new IllegalArgumentException("商品不存在"));

    if (product.getStock() < amount) {
        throw new IllegalStateException("库存不足");
    }

    product.setStock(product.getStock() - amount);
}

在单线程测试中,这段代码没有问题。但假设库存为 10,两个事务同时购买 1 件:

  1. 事务 A 查询到库存 10;
  2. 事务 B 也查询到库存 10;
  3. A 将库存更新为 9;
  4. B 根据之前读到的值,也将库存更新为 9。

两次购买成功,数据库却只减少了 1 件。这就是典型的丢失更新。

很多人认为给方法加上 @Transactional,或者使用 MySQL 默认的可重复读隔离级别,就能避免这个问题。实际上,事务保证的是一组操作的原子性,并不自动理解“库存不能被旧值覆盖”这样的业务约束。

为什么事务没有阻止覆盖

JPA 修改实体时,最终可能生成类似下面的 SQL:

update product
set stock = 9
where id = 1;

InnoDB 执行 UPDATE 时会获取排他锁,因此两个更新不会在同一瞬间修改同一行。后到的事务会等待先到的事务释放锁,但等待结束后,它仍然可以执行 stock = 9

问题不在于写操作没有加锁,而在于 SQL 携带的是应用内存中计算出的旧结果,并且 WHERE 条件没有验证这条记录在读取之后是否已被别人修改。

普通的快照读也不会因为处于可重复读事务中,就自动转换为悲观锁定读。隔离级别、行锁和业务并发控制是三个需要分别理解的概念。

方案一:使用 JPA 乐观锁

对于读取后还要修改多个字段、并且并发冲突不算频繁的业务,可以使用 JPA 的 @Version

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Version;

@Entity
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    private int stock;

    @Version
    private long version;

    protected Product() {
    }

    public Product(String name, int stock) {
        this.name = name;
        this.stock = stock;
    }

    public void deduct(int amount) {
        if (amount <= 0) {
            throw new IllegalArgumentException("扣减数量必须大于 0");
        }
        if (stock < amount) {
            throw new IllegalStateException("库存不足");
        }
        stock -= amount;
    }

    public Long getId() {
        return id;
    }

    public int getStock() {
        return stock;
    }
}

在 Spring Boot 3 和 Jakarta Persistence 3 环境中,注解包名是 jakarta.persistence;较早的 Spring Boot 2 项目通常使用 javax.persistence

Repository 不需要编写特殊更新语句:

import org.springframework.data.jpa.repository.JpaRepository;

public interface ProductRepository extends JpaRepository<Product, Long> {
}

事务服务如下:

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class InventoryService {

    private final ProductRepository productRepository;

    public InventoryService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @Transactional
    public void deduct(Long productId, int amount) {
        Product product = productRepository.findById(productId)
                .orElseThrow(() -> new IllegalArgumentException("商品不存在"));
        product.deduct(amount);
    }
}

有了 @Version 后,JPA 更新时会加入版本条件,并在成功后递增版本号,其语义类似:

update product
set stock = ?, version = version + 1
where id = ? and version = ?;

如果事务 A 已经将版本从 0 更新为 1,事务 B 再携带版本 0 更新时,受影响行数就是 0。JPA 会将它识别为乐观锁冲突,Spring 通常会转换为 ObjectOptimisticLockingFailureException 一类异常。这样,旧数据不会无声地覆盖新数据。

乐观锁并不等于请求一定成功。冲突发生后,应根据业务选择返回“请重试”,或者重新开启一个事务再次读取和扣减。不能在原事务已经提交失败后,继续复用其中的实体状态。

方案二:把判断和扣减合并成原子 SQL

如果业务只是扣减库存,并不需要先将完整实体加载到内存,那么条件更新通常更直接:

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface ProductRepository extends JpaRepository<Product, Long> {

    @Modifying(clearAutomatically = true, flushAutomatically = true)
    @Query("""
            update Product p
               set p.stock = p.stock - :amount
             where p.id = :productId
               and p.stock >= :amount
            """)
    int deductStock(@Param("productId") Long productId,
                    @Param("amount") int amount);
}

服务层通过受影响行数判断结果:

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class AtomicInventoryService {

    private final ProductRepository productRepository;

    public AtomicInventoryService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @Transactional
    public void deduct(Long productId, int amount) {
        if (amount <= 0) {
            throw new IllegalArgumentException("扣减数量必须大于 0");
        }

        int updatedRows = productRepository.deductStock(productId, amount);
        if (updatedRows == 0) {
            throw new IllegalStateException("商品不存在或库存不足");
        }
    }
}

这里的关键不是“SQL 执行得快”,而是库存校验和扣减发生在同一条语句中。并发事务会在数据库中串行修改目标行,后执行的语句会基于当时已经提交的库存检查 stock >= amount,不会使用 Java 对象中的旧库存。

如果需要区分“商品不存在”和“库存不足”,可以额外查询,但不要为了区分提示而退回到先查再无条件更新的写法。也可以根据业务设计统一的扣减失败结果。

两种方案如何选择

乐观锁适合一次业务修改实体的多个属性,并且这些修改依赖读取时状态的场景。它能够发现整个实体在读取后是否发生变化,但高冲突时可能产生较多失败和重试。

原子条件更新更适合计数器、余额变更和库存增减等操作。它减少了一次查询,也缩短了持锁时间。不过它属于 JPQL 批量更新,不会按照普通实体更新流程逐个触发脏检查,@Version 字段也不会自动递增。

因此,不要一边在同一个持久化上下文中持有已经加载的 Product,一边执行批量更新后继续相信该实体中的库存值。示例中的 clearAutomatically = true 会在更新后清理持久化上下文,避免后续误用陈旧对象,但也意味着尚未同步的实体状态需要谨慎处理。

常见坑

在同一个类中调用事务方法

this.deduct(...) 形式的内部调用通常不会经过 Spring 事务代理。重试逻辑如果要让每次尝试都使用新事务,应放在另一个 Spring Bean 中,或者使用明确的事务模板,不能只给内部方法增加 @Transactional

捕获异常后仍然提交事务

乐观锁异常经常在 flush 或事务提交阶段才出现。不要在事务方法内部捕获异常后假装成功返回。即使捕获,也应继续抛出,让事务正确回滚。

synchronized 保护数据库数据

synchronized 只能协调当前 JVM 中的线程。应用部署多个实例后,不同实例之间仍会并发更新。数据库约束、条件更新或分布式协调机制才覆盖真正的数据边界。

无限制地自动重试

库存竞争激烈时,无限重试会放大数据库压力。重试应限制次数,必要时加入短暂且有上限的退避,并保证下单请求具有幂等标识,避免网络重试造成重复扣减。

实践建议

首先把不变量放进 SQL 条件,例如库存不能小于零、余额不能透支。其次让事务尽量短,不要在持有数据库锁期间调用远程接口或执行耗时计算。最后为扣减失败、乐观锁冲突和数据库死锁分别记录指标,它们代表的原因并不相同,不应统一归类成“系统异常”。

对于普通库存扣减,我通常优先采用带条件的原子更新;当一次修改涉及多个字段和复杂状态流转时,再使用 @Version 保护实体一致性。悲观锁并非不能用,但应在明确评估竞争程度、锁等待和事务长度后选择,而不是把 SELECT ... FOR UPDATE 当成默认答案。

总结

@Transactional 解决的是事务边界,不会自动解决业务层面的丢失更新。MySQL 的行锁能让写操作有序执行,却无法判断后一个写入值是否来自过期快照。

面对并发修改,要么用版本号让旧更新失败,要么把校验和变更合并到一条原子 SQL 中。真正可靠的实现,不是“先查询时看起来正确”,而是在最终写入数据库的那一刻,仍然验证业务约束成立。

最后修改:2026 年 08 月 09 日
如果觉得我的文章对你有用,请随意赞赏