分类:架构与高并发 · 作者:管理员 · 发布于 2026-10-26 · 阅读 0 · 标签:防超卖 / Redis / Lua / 库存一致性 / 秒杀

一、超卖是怎么发生的

电商秒杀、限时抢购里最常见的事故就是超卖:库存明明只剩 1 件,却成交了 3 单。根因几乎都是同一个——先查后改的非原子操作:

// ❌ 典型错误:先查出库存,再判断扣减
int stock = stockMapper.selectStock(skuId);
if (stock > 0) {
    orderService.create(skuId);        // 并发下两个请求都判断通过
    stockMapper.deduct(skuId);         // 各自扣 1,库存变负数
}

两个请求同时读到 stock=1,都满足 if 条件,都去下单,最后库存变成 -1。要解决超卖,核心不是"加锁"或"加缓存",而是让"判断库存充足 + 扣减"这两个动作原子化,且扣减结果可被下游可靠感知。下面按演进路线给出三层方案。

二、第一层:数据库条件更新(最可靠的地基)

面向 MySQL/PostgreSQL,最简单正确的做法是把判断写进 UPDATE 的条件里,让数据库行锁保证原子性:

UPDATE t_stock
SET stock = stock - 1,
    version = version + 1
WHERE sku_id = #{skuId}
  AND stock >= 1;   -- 条件更新:库存足够才扣
// MyBatis 返回受影响行数:1=扣减成功,0=库存不足
@Update("UPDATE t_stock SET stock = stock - 1 WHERE sku_id = #{skuId} AND stock >= 1")
int deductStock(@Param("skuId") Long skuId);

public boolean tryDeduct(Long skuId) {
    return stockMapper.deductStock(skuId) > 0;
}

要点:用影响行数判断成败,而不是先 SELECT 再 UPDATE;扣减与订单创建放在同一个本地事务里,扣减成功但下单失败要整体回滚,防止"库存扣了、单没成"。这个方案在单库 QPS 几千以内完全够用,也是后面所有方案的兜底。

三、第二层:Redis Lua 脚本原子扣减(抗住秒杀峰值)

当单点数据库扛不住热点 SKU 的集中读写,把库存预加载到 Redis,用 Lua 脚本把"判断 + 扣减"在 Redis 单线程内原子完成:

-- 扣减库存脚本:返回扣减后剩余,负数表示库存不足
local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
local count = tonumber(ARGV[1])
if stock < count then
    return -1
end
redis.call('DECRBY', KEYS[1], count)
return stock - count
private static final DefaultRedisScript<Long> DEDUCT_SCRIPT = new DefaultRedisScript<>();
static {
    DEDUCT_SCRIPT.setScriptText("... 上面的 Lua ...");
    DEDUCT_SCRIPT.setResultType(Long.class);
}

public long deduct(Long skuId, int count) {
    return redisTemplate.execute(DEDUCT_SCRIPT,
            Collections.singletonList("stock:" + skuId), String.valueOf(count));
}

Redis 是单线程模型,Lua 脚本执行期间不会被其他命令打断,因此判断与扣减天然原子。返回 -1 直接拒绝;返回非负值才放行下单。生产级还要配套:

  • 库存预热:活动开始前把 DB 库存同步到 Redis,脚本里 GET 为 nil 时返回 -1 兜底;
  • 异步落库:Redis 扣减成功后,把扣减事件发 MQ,由消费端最终同步回数据库,保证缓存与 DB 最终一致;
  • 回补幂等:订单超时未支付/取消时回补库存,回补动作要带业务幂等键(如 orderId),防止同一事件重复回补。

四、第三层:本地库存表 + MQ 的最终一致性回写

Redis 只是前置闸门,真正的账本是数据库。扣减事件异步回写时,一个常见坑是消费重复投递导致的重复扣减。解决思路是"扣减流水表 + 唯一约束":

CREATE TABLE t_stock_deduct_log (
    id           BIGINT PRIMARY KEY,
    sku_id       BIGINT NOT NULL,
    order_id     BIGINT NOT NULL,
    count        INT NOT NULL,
    -- 用订单+SKU 唯一约束做消费幂等
    CONSTRAINT uk_order_sku UNIQUE (order_id, sku_id)
);
@Transactional
public void onDeductMessage(DeductMessage msg) {
    // 先插流水,唯一约束冲突说明已消费过
    int inserted = deductLogMapper.tryInsert(msg);
    if (inserted == 0) return;
    stockMapper.deductStock(msg.getSkuId());
    // 更新订单库存扣减状态,标记完成
    orderMapper.markStockDeducted(msg.getOrderId());
}

配合顺序:先插唯一流水、再扣库存、最后更新订单状态,三者同一事务。消息消费失败就靠 MQ 重试,重试也不会重复扣,因为唯一约束挡住了;彻底失败则进死信队列人工对账。

五、踩坑记录

  1. SELECT FOR UPDATE 度过大:把整张表锁住或事务里查完再等用户操作,秒杀场景直接打爆连接池。事务要短,锁粒度要小,等待用户操作绝不能放在事务里。
  2. Redis 扣减成功、DB 回写失败,二者不一致:不能只靠 MQ,还要加定时对账任务,周期比对 Redis 剩余量 + 已成功流水数与 DB 库存,差异自动补偿。
  3. Lua 脚本里用 KEYS[1] 拼接 key:集群模式下 key 必须通过 KEYS 数组传入并落在同一 slot,redis.call('GET', KEYS[1]) 没问题,但 redis.call('GET', 'stock:' .. ARGV[1]) 在集群下会报 CROSSSLOT 或路由错误。
  4. 扣减成功但下单失败的补偿遗漏:Redis 已扣、数据库未扣,此时用户看到"下单失败但库存少了"。一定要在订单创建失败分支里显式回补 Redis 库存(无脑 INCRBY,但需防并发双回补,用 Lua 或带版本号)。
  5. 把数据库当计数器滥用:高频 UPDATE 同一条 SKU 行,行锁竞争会让 RT 飚升。热点 SKU 一定要走 Redis 前置,DB 只做最终记账。

六、最佳实践小结

  • 先问量级:单库几千 QPS 内,数据库条件更新就是最优解,别为炫技上缓存;
  • 高并发秒杀:Redis Lua 前置原子扣减 + MQ 异步落库 + 唯一流水幂等 + 定时对账,四件套缺一不可;
  • 所有扣减/回补动作必须原子或幂等,失败路径要显式补偿;
  • 监控三指标:Redis 拒绝率(-1 占比)、DB 落库延迟、对账差异数,任何一项异常立即告警。