Java库存扣减案例如何防超卖

wen java案例 28

本文目录导读:

Java库存扣减案例如何防超卖

  1. 方案一:数据库乐观锁(最常用、性价比高)
  2. 方案二:数据库悲观锁(SELECT ... FOR UPDATE
  3. 方案三:Redis 分布式锁(适合跨服务跨节点)
  4. 方案四:Redis Lua 脚本(最优选:原子性 + 高性能)
  5. 方案五:消息队列 + 最终一致性(适合秒杀/异步处理)
  6. 面试加分项:如何选择?

在Java库存扣减场景中,防超卖的核心是保证扣减操作的原子性(要么全部成功,要么全部失败,且中间状态不可见)。

这里整理了几种从简单到复杂、从单体到分布式的方案,并附带了核心代码示例和适用场景。

数据库乐观锁(最常用、性价比高)

这是单体应用或简单分布式场景的首选,核心思想是利用数据库的行锁和版本号,在更新时校验库存是否足够。

原理:在库存表中增加一个 version 字段,更新时,where 条件中加上 version = old_version,如果版本号不匹配(被其他线程改了),则更新失败,需要重试。

库存表结构(简化)

CREATE TABLE `product_stock` (
  `id` bigint NOT NULL,
  `product_id` varchar(32) NOT NULL COMMENT '商品ID',
  `stock` int NOT NULL DEFAULT '0' COMMENT '当前库存',
  `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号',
  PRIMARY KEY (`id`),
  UNIQUE KEY `idx_product_id` (`product_id`)
) ENGINE=InnoDB;

核心Java代码

// Service层
@Service
public class StockService {
    @Autowired
    private StockMapper stockMapper;
    /**
     * 使用乐观锁扣减库存
     * @param productId 商品ID
     * @param quantity 扣减数量
     * @return true 扣减成功, false 库存不足或并发冲突
     */
    public boolean deductStock(String productId, int quantity) {
        int retryCount = 3; // 重试次数
        while (retryCount-- > 0) {
            // 1. 查询当前库存和版本号
            Stock stock = stockMapper.selectByProductId(productId);
            if (stock == null || stock.getStock() < quantity) {
                return false; // 库存不足
            }
            // 2. 尝试更新(关键SQL:where version = oldVersion)
            int rows = stockMapper.deductStockByVersion(
                productId, 
                quantity, 
                stock.getVersion()
            );
            if (rows > 0) {
                return true; // 扣减成功
            }
            // rows = 0 说明版本号冲突(被其他线程改了),重试
        }
        return false; // 重试耗尽,返回失败
    }
}

Mapper XML

<update id="deductStockByVersion">
    UPDATE product_stock 
    SET stock = stock - #{quantity}, 
        version = version + 1 
    WHERE product_id = #{productId} 
      AND version = #{version} 
      AND stock >= #{quantity}  <!-- 防止负数库存 -->
</update>

优点:无锁等待,性能好;通过 stock >= #{quantity} 防止库存变负。 缺点:高并发下重试可能较多,不适合极端高并发(如秒杀瞬间百万QPS)。


数据库悲观锁(SELECT ... FOR UPDATE

通过数据库行锁强制串行化操作,适合并发不高、但要求绝对严格的场景。

原理:在事务内,先 SELECT stock FOR UPDATE 锁定该行,其他事务必须等待,然后判断库存并更新。

核心代码

@Transactional
public boolean deductStockWithLock(String productId, int quantity) {
    // 1. 加锁查询(此时该行被锁定,其他线程等待)
    //    (必须加上 "FOR UPDATE",且事务不能提前关闭)
    Stock stock = stockMapper.selectByProductIdWithLock(productId);
    // (等价 SQL: SELECT * FROM product_stock WHERE product_id = ? FOR UPDATE)
    if (stock == null || stock.getStock() < quantity) {
        throw new RuntimeException("库存不足");
    }
    // 2. 更新库存(因为已经锁定,更新一定成功)
    int rows = stockMapper.deductStock(productId, quantity);
    // (等价 SQL: UPDATE product_stock SET stock = stock - #{quantity} 
    //  WHERE product_id = #{productId} AND stock >= #{quantity})
    return rows > 0;
}

注意:FOR UPDATE 必须放在事务中;并且如果索引使用不当可能锁表。

优点:绝对安全,无ABA问题。 缺点:性能差,容易死锁(尤其多个行锁顺序不一致时);会阻塞请求,适合低并发。


Redis 分布式锁(适合跨服务跨节点)

当应用多实例、需要跨 JVM 同步时,可用 Redis 实现分布式锁,结合本地缓存提高性能。

原理:获取一个互斥锁(product:lock:123),获取锁的线程才允许扣减库存,扣减完后释放锁。

核心代码(使用 Redisson 框架)

@Autowired
private RedissonClient redissonClient;
public boolean deductStockWithRedisLock(String productId, int quantity) {
    // 1. 获取分布式锁(key: product:stock:lock:123)
    RLock lock = redissonClient.getLock("product:stock:lock:" + productId);
    try {
        // 2. 尝试加锁,最多等待10秒,锁自动释放30秒(避免死锁)
        if (lock.tryLock(10, 30, TimeUnit.SECONDS)) {
            // 3. 查库存(从 Redis 或 DB 查)
            Integer stock = getStockFromRedis(productId);
            if (stock < quantity) {
                return false;
            }
            // 4. 更新库存(同时更新 Redis 和 DB)
            updateStock(productId, quantity);
            return true;
        } else {
            // 没获取到锁,说明其他线程在处理该商品,可以重试或返回失败
            return false;
        }
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        return false;
    } finally {
        // 5. 释放锁(必须放在 finally)
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
}

优点:跨进程、多节点通用,比数据库悲观锁性能好。 缺点:Redis 本身不是强一致性,极端情况(主节点宕机、主从切换)可能出问题;实现复杂。


Redis Lua 脚本(最优选:原子性 + 高性能)

如果库存预存在 Redis 中,Lua 脚本可以保证扣减操作的原子性,且无需网络往返。

原理:Redis 执行 Lua 脚本时是单线程、原子的,脚本内检查库存并扣减,然后返回结果。

核心代码

@Autowired
private StringRedisTemplate redisTemplate;
// Lua 脚本:检查库存并扣减
// KEYS[1] = 商品库存键
// ARGV[1] = 扣减数量
private static final String DEDUCT_SCRIPT = 
    "local stock = redis.call('get', KEYS[1]) " +
    "if (not stock or tonumber(stock) < tonumber(ARGV[1])) then " +
    "    return -1 " +  // 库存不足
    "end " +
    "redis.call('decrby', KEYS[1], ARGV[1]) " +
    "return redis.call('get', KEYS[1])"; // 返回剩余库存
public boolean deductStockByLua(String productId, int quantity) {
    // 构建脚本参数
    RedisScript<Long> script = new DefaultRedisScript<>(DEDUCT_SCRIPT, Long.class);
    List<String> keys = Collections.singletonList("product:stock:" + productId);
    // 执行 Lua 脚本(原子操作)
    Long result = redisTemplate.execute(script, keys, String.valueOf(quantity));
    if (result == null || result == -1L) {
        return false; // 库存不足
    }
    // 注意:这里只更新了 Redis,DB 需要异步同步(用消息队列)
    // 或者先扣 DB(用数据库锁),再用 Redis 做高性能缓存层
    return true;
}

优点:极高并发、高吞吐、原子性,适合秒杀等场景。 缺点:只能保证 Redis 端的原子性,如果业务需要与 DB 强一致,需额外处理异步落库。


消息队列 + 最终一致性(适合秒杀/异步处理)

将请求去重、排队,然后异步扣减库存,牺牲强一致性换取高并发吞吐。

流程

  1. 前端请求到订单系统,创建订单(状态“待支付”)。
  2. 订单消息发送到消息队列(如 RocketMQ),并持久化。
  3. 库存服务消费消息,使用数据库乐观锁扣减库存。
  4. 如果库存不足,回滚订单。

优点:削峰填谷,不直接冲击 DB,极端并发下系统更稳定。 缺点:存在最终一致时间窗口;实现复杂。


面试加分项:如何选择?

场景 推荐方案 理由
单体应用,并发中等(<500 QPS) 数据库乐观锁 简单、可靠、成本低
对一致性要求极高(如银行转账) 数据库悲观锁(FOR UPDATE 绝对串行,无隐患
分布式多节点,并发高(>1000 QPS) Redis Lua 脚本 + 消息队列 性能与一致性的最佳平衡
秒杀场景,流量巨大且允许少量失败 Redis Lua 脚本(异步落库) 扛住洪峰,核心是牺牲强一致性换吞吐

最后提醒:无论使用哪一种方案,核心SQL一定要加 WHERE stock >= #{quantity} 条件,这是防超卖的最后一道防线,防止因为代码 BUG 导致库存变负数。

抱歉,评论功能暂时关闭!