导读:本期聚焦于小伙伴创作的《在使用可重复读隔离级别下,如何避免使用事务注解导致的商品超卖问题》,敬请观看详情,探索知识的价值。以下视频、文章将为您系统阐述其核心内容与价值。如果您觉得《在使用可重复读隔离级别下,如何避免使用事务注解导致的商品超卖问题》有用,将其分享出去将是对创作者最好的鼓励。

可重复读是MySQL InnoDB引擎的默认事务隔离级别,开启事务后,事务内多次查询同一数据会得到相同的结果,这个特性在多数业务场景下能避免脏读、不可重复读问题,但在商品库存扣减这类高并发写场景中,如果不当使用@Transactional注解,很容易出现商品超卖的情况。

超卖问题的产生原因

我们先看一段常见的库存扣减代码,假设使用Spring的@Transactional注解管理事务,数据库隔离级别为可重复读:

@Service
public class ProductService {
    @Autowired
    private ProductMapper productMapper;

    @Transactional(rollbackFor = Exception.class)
    public void reduceStock(Long productId, Integer buyNum) {
        // 1. 查询当前库存
        Product product = productMapper.selectById(productId);
        if (product.getStock() < buyNum) {
            throw new RuntimeException("库存不足");
        }
        // 2. 扣减库存
        product.setStock(product.getStock() - buyNum);
        productMapper.updateById(product);
    }
}

这段代码的执行流程在并发场景下会出现问题:假设商品初始库存为1,两个请求同时进入事务,事务开启后都执行第一步查询,可重复读隔离级别下,两个事务查询到的库存都是1,都判断库存充足,接着执行扣减操作,最终库存会变成-1,出现超卖。

核心原因是可重复读隔离级别下,普通查询是快照读,不会加锁,多个事务可以同时读到相同的库存值,事务提交时才会判断数据是否被修改,默认的更新操作如果没有命中唯一索引锁,也不会阻塞其他事务的查询。

解决方案一:使用数据库行锁的当前读

可重复读隔离级别下,使用SELECT ... FOR UPDATE语句查询数据会触发当前读,并且会对查询到的行加排他锁,其他事务要修改这行数据或者也用SELECT ... FOR UPDATE查询时会被阻塞,直到当前事务提交。

修改后的代码如下:

@Service
public class ProductService {
    @Autowired
    private ProductMapper productMapper;

    @Transactional(rollbackFor = Exception.class)
    public void reduceStock(Long productId, Integer buyNum) {
        // 使用FOR UPDATE加行锁,注意查询条件要命中索引,否则会锁表
        Product product = productMapper.selectByIdForUpdate(productId);
        if (product.getStock() < buyNum) {
            throw new RuntimeException("库存不足");
        }
        product.setStock(product.getStock() - buyNum);
        productMapper.updateById(product);
    }
}

对应的Mapper接口方法:

@Select("SELECT * FROM product WHERE id = #{productId} FOR UPDATE")
Product selectByIdForUpdate(Long productId);

这种方式的优点是依赖数据库本身的锁机制,可靠性高,不需要额外引入中间件。缺点是如果查询没有命中索引,SELECT ... FOR UPDATE会锁全表,影响性能;同时长事务会长时间持有锁,降低并发度。

解决方案二:更新时带库存条件判断

我们可以在更新库存的SQL中直接带上库存充足的判断条件,利用数据库更新的原子性来避免超卖,不需要提前查询库存。

Service层代码修改为:

@Service
public class ProductService {
    @Autowired
    private ProductMapper productMapper;

    @Transactional(rollbackFor = Exception.class)
    public void reduceStock(Long productId, Integer buyNum) {
        // 更新时判断库存是否大于等于购买数量,返回影响的行数
        int rows = productMapper.reduceStockWithCondition(productId, buyNum);
        if (rows == 0) {
            throw new RuntimeException("库存不足");
        }
    }
}

Mapper接口方法:

@Update("UPDATE product SET stock = stock - #{buyNum} WHERE id = #{productId} AND stock >= #{buyNum}")
int reduceStockWithCondition(@Param("productId") Long productId, @Param("buyNum") Integer buyNum);

这种方式的原理是数据库更新操作是原子的,多个事务同时执行这条更新SQL时,只有第一个事务能更新成功,后续事务因为库存已经不足,更新影响的行数为0,就能判断库存不足。这种方式不需要加行锁,性能比第一种方案更好,也不需要担心索引问题。

解决方案三:引入分布式锁

如果是分布式部署的系统,多个服务实例同时操作同一商品的库存,前两种基于数据库锁的方案依然有效,但也可以引入分布式锁来保证同一时间只有一个请求能处理同一商品的库存扣减。

以Redis实现分布式锁为例,代码逻辑如下:

@Service
public class ProductService {
    @Autowired
    private ProductMapper productMapper;
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    // 锁的过期时间,根据业务执行时间调整
    private static final long LOCK_EXPIRE_TIME = 3000;

    public void reduceStock(Long productId, Integer buyNum) {
        String lockKey = "product_stock_lock:" + productId;
        String requestId = UUID.randomUUID().toString();
        try {
            // 尝试获取分布式锁
            Boolean lockSuccess = redisTemplate.opsForValue().setIfAbsent(lockKey, requestId, LOCK_EXPIRE_TIME, TimeUnit.MILLISECONDS);
            if (!lockSuccess) {
                throw new RuntimeException("当前请求过多,请稍后再试");
            }
            // 获取锁之后再执行库存扣减逻辑,这里可以用之前的任意一种库存扣减方式
            reduceStockInner(productId, buyNum);
        } finally {
            // 释放锁,需要判断是当前请求加的锁,避免误删其他请求的锁
            String currentValue = redisTemplate.opsForValue().get(lockKey);
            if (requestId.equals(currentValue)) {
                redisTemplate.delete(lockKey);
            }
        }
    }

    @Transactional(rollbackFor = Exception.class)
    public void reduceStockInner(Long productId, Integer buyNum) {
        Product product = productMapper.selectById(productId);
        if (product.getStock() < buyNum) {
            throw new RuntimeException("库存不足");
        }
        product.setStock(product.getStock() - buyNum);
        productMapper.updateById(product);
    }
}

这种方式的优点是可以跨服务实例保证库存扣减的互斥性,也能减少数据库锁的持有时间。缺点是需要引入Redis中间件,同时要处理好锁的过期、误删等问题,实现复杂度更高。

不同方案的选型建议

我们可以根据业务场景选择合适的方案:

  • 如果是单体应用,并发量不高,优先选择更新时带库存条件判断的方案,实现简单,性能也足够。
  • 如果并发量较高,且需要保证查询和更新的强一致性,可以选择SELECT ... FOR UPDATE行锁的方案,注意查询条件要命中索引,避免长事务。
  • 如果是分布式部署的高并发场景,可以选择分布式锁+更新条件判断的组合方案,既保证跨实例的互斥,也减少数据库的压力。

无论选择哪种方案,都要注意@Transactional注解的事务范围,尽量缩小事务的粒度,避免长时间持有锁或者占用数据库连接,进一步提升系统的并发能力。

可重复读@Transactional商品超卖事务隔离级别修改时间:2026-07-02 22:21:58

免责声明:​ 已尽一切努力确保本网站所含信息的准确性。网站内容多为原创整理与精心编撰,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们处理。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。