可重复读是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