ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

电商系统超卖问题解决方案与实战优化

电商系统超卖问题解决方案与实战优化

1. 超卖问题的本质与危害

第一次接触"超卖"这个词是在2016年做电商促销系统时。凌晨2点,运营同事突然打来电话:"商品库存显示-1000多,客户已经付款但没货可发了!"这就是典型的超卖场景——系统显示的库存数量与实际可售库存不一致,导致卖出超过实际库存的商品。

超卖问题本质上是个并发控制问题。当多个用户同时下单购买同一商品时,系统如果没有做好并发控制,就会出现库存扣减错误。比如:

  • 商品A当前库存100件
  • 用户1和用户2同时下单各买1件
  • 两个请求几乎同时到达服务器
  • 两个线程都读取到库存为100
  • 都执行100-1=99
  • 最终库存被设置为99(实际应该98)

这种问题在以下场景尤为突出:

  • 秒杀活动(大量用户抢购少量商品)
  • 限时折扣(热门商品集中下单)
  • 库存较少的商品(更容易被多线程同时操作)

超卖带来的直接后果包括:

  1. 客户投诉:付了款却被告知没货
  2. 商誉损失:客户对平台失去信任
  3. 赔偿成本:可能需要补偿客户
  4. 运营混乱:需要人工核对处理异常订单

注意:超卖问题在测试环境很难发现,因为测试时并发量通常不够大。这就是为什么很多系统上线前测试正常,一到促销就出问题。

2. 常见解决方案对比分析

2.1 数据库乐观锁方案

这是最基础的解决方案,通过在更新时校验库存版本号或时间戳:

UPDATE products SET stock = stock - 1, version = version + 1 WHERE id = 123 AND version = 5 AND stock > 0

优点:

  • 实现简单,只需要修改SQL
  • 对系统改动小

缺点:

  • 高并发下大量请求会失败(返回影响行数为0)
  • 用户体验差(显示有货但下单失败)
  • 需要配合重试机制

适用场景:

  • 并发量不大的普通商品
  • 对用户体验要求不高的后台系统

2.2 数据库悲观锁方案

通过SELECT...FOR UPDATE锁定记录:

BEGIN; SELECT * FROM products WHERE id = 123 FOR UPDATE; -- 检查库存 UPDATE products SET stock = stock - 1 WHERE id = 123; COMMIT;

优点:

  • 保证强一致性
  • 不会出现超卖

缺点:

  • 性能差(锁竞争严重)
  • 可能导致死锁
  • 不适用于高并发场景

实战经验:我们曾在QPS 500+的场景使用悲观锁,数据库连接很快被耗尽,系统直接崩溃。

2.3 Redis原子操作方案

利用Redis的原子性操作:

local stock = tonumber(redis.call('GET', KEYS[1])) if stock > 0 then redis.call('DECR', KEYS[1]) return 1 else return 0 end

优点:

  • 性能极高(Redis单线程模型)
  • 实现相对简单

缺点:

  • 需要维护Redis和DB的数据一致性
  • 需要考虑Redis宕机情况
  • 需要处理库存回滚问题

2.4 分布式锁方案

使用Redis或Zookeeper实现分布式锁:

// 伪代码 try { boolean locked = redisLock.lock("product_123", 3, TimeUnit.SECONDS); if(locked) { // 扣减库存 } } finally { redisLock.unlock("product_123"); }

优点:

  • 适用于分布式系统
  • 相对灵活

缺点:

  • 实现复杂
  • 锁超时时间难以确定
  • 存在锁失效风险

2.5 消息队列方案

将订单请求先放入队列,异步处理:

用户 -> 下单请求 -> 消息队列 -> 库存服务(单线程消费)

优点:

  • 削峰填谷,保护后端系统
  • 保证顺序处理

缺点:

  • 实时性差
  • 系统架构复杂
  • 需要处理消息堆积问题

3. 电商系统实战解决方案

3.1 分层防御架构设计

经过多次大促的教训,我们最终采用了分层防御的方案:

  1. 前端层:

    • 本地计数限制(防止用户疯狂点击)
    • 随机延迟提交(分散请求峰值)
    • 活动预热(提前加载页面资源)
  2. 接入层:

    • 限流(Nginx限流模块)
    • 排队(滑动窗口算法)
    • 验证码(分散用户请求)
  3. 服务层:

    • Redis预扣减(原子操作)
    • 分布式锁(Redisson)
    • 库存分段(将库存拆分为多个段)
  4. 数据层:

    • 最终一致性(异步同步到DB)
    • 库存流水表(便于对账)
    • 定期库存校准(防止长期不一致)

3.2 Redis+Lua脚本实现

这是我们线上使用的核心方案:

-- KEYS[1]: 商品库存key -- KEYS[2]: 商品售罄标记key -- ARGV[1]: 购买数量 local stock = tonumber(redis.call('GET', KEYS[1])) if stock <= 0 then -- 已经售罄 return 0 elseif stock >= tonumber(ARGV[1]) then -- 扣减库存 redis.call('DECRBY', KEYS[1], ARGV[1]) -- 检查是否刚好售罄 local newStock = tonumber(redis.call('GET', KEYS[1])) if newStock <= 0 then redis.call('SET', KEYS[2], '1', 'EX', 86400) end return 1 else -- 库存不足 return 0 end

关键设计点:

  1. 使用DECRBY保证原子性
  2. 售罄标记避免无效查询
  3. 设置过期时间防止内存泄漏

3.3 库存分段技术

对于特别热门的商品(如iPhone首发),我们采用库存分段:

  1. 将1000件库存分为10个段,每段100件
  2. 每个段有独立的Redis key
  3. 用户请求随机路由到某个段
  4. 段内使用上述Lua脚本控制

优势:

  • 分散热点key压力
  • 提高并发处理能力
  • 降低单个key的竞争

3.4 异步库存同步方案

Redis与MySQL的库存同步设计:

1. 下单成功 -> 写入订单表(状态"待支付") 2. -> 发送MQ消息(库存扣减) 3. 库存服务消费消息 -> 真实扣减DB库存 4. 支付成功 -> 更新订单状态 5. 支付超时 -> 反向补回库存

异常处理机制:

  • 定时任务核对Redis和DB库存差异
  • 人工干预接口处理异常情况
  • 库存操作流水表记录所有变更

4. 踩坑经验与优化建议

4.1 必须避免的典型错误

  1. 先查后改:

    // 错误示范! int stock = getStockFromDB(); if(stock > 0) { updateStock(stock - 1); }

    这在并发下必然超卖

  2. 过度依赖缓存:

    • 忘记设置缓存过期时间
    • 缓存穿透导致DB压力
    • 缓存和DB不一致
  3. 忽略网络延迟:

    • 分布式锁的时钟漂移
    • 锁超时时间设置不合理
    • 没有考虑GC停顿影响

4.2 性能优化技巧

  1. 库存预热:

    • 活动前将库存加载到Redis
    • 使用Pipeline批量初始化
    • 适当设置过期时间(避免长期不一致)
  2. 本地缓存:

    // 使用Guava Cache LoadingCache<String, Integer> localCache = CacheBuilder.newBuilder() .expireAfterWrite(100, TimeUnit.MILLISECONDS) .build(...);

    减少Redis访问压力

  3. 热点Key处理:

    • 添加随机后缀分散热点
    • 使用Redis Cluster分片
    • 考虑使用内存版Redis(如阿里云Tair)

4.3 监控与告警

必须建立的监控指标:

  1. 库存健康度:

    • Redis与DB库存差异
    • 库存变更流水异常
    • 负库存出现次数
  2. 系统压力:

    • Redis QPS/CPU使用率
    • DB连接池使用率
    • 消息队列堆积量
  3. 业务指标:

    • 下单失败率
    • 订单取消率
    • 库存售罄时间

建议告警阈值:

  • Redis与DB库存差异>5% → 警告
  • 下单失败率>0.1% → 紧急告警
  • 负库存出现 → 立即电话告警

5. 不同场景下的方案选型

5.1 普通商品销售

推荐方案:Redis原子操作 + 异步同步

实现要点:

  1. 读操作直接走Redis
  2. 写操作使用Lua脚本
  3. 异步消息同步到DB
  4. 定时任务核对数据

5.2 秒杀场景

推荐方案:库存分段 + 本地缓存 + 队列削峰

额外措施:

  1. 前置验证(用户资格检查)
  2. 令牌桶限流
  3. 页面静态化
  4. 订单过期自动释放

5.3 预售模式

特殊考虑:

  1. 虚拟库存概念
  2. 超卖可能是业务需求
  3. 需要明确预售规则
  4. 特别注意超时未支付处理

5.4 多仓库库存

复杂点:

  1. 需要维护总库存和分仓库存
  2. 要考虑仓库之间的调拨
  3. 就近发货的库存分配
  4. 需要更复杂的事务控制

解决方案:

  1. 使用分布式事务(如Seata)
  2. 采用最终一致性模式
  3. 设计合理的超时和重试机制

在实际项目中,我们通常会根据业务特点混合使用多种方案。比如在618大促时,对普通商品使用Redis原子操作,对秒杀商品采用库存分段+队列的方案,同时配合完善的前端限流和监控告警系统。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进