1. 超卖问题的本质与危害
第一次接触"超卖"这个词是在2016年做电商促销系统时。凌晨2点,运营同事突然打来电话:"商品库存显示-1000多,客户已经付款但没货可发了!"这就是典型的超卖场景——系统显示的库存数量与实际可售库存不一致,导致卖出超过实际库存的商品。
超卖问题本质上是个并发控制问题。当多个用户同时下单购买同一商品时,系统如果没有做好并发控制,就会出现库存扣减错误。比如:
- 商品A当前库存100件
- 用户1和用户2同时下单各买1件
- 两个请求几乎同时到达服务器
- 两个线程都读取到库存为100
- 都执行100-1=99
- 最终库存被设置为99(实际应该98)
这种问题在以下场景尤为突出:
- 秒杀活动(大量用户抢购少量商品)
- 限时折扣(热门商品集中下单)
- 库存较少的商品(更容易被多线程同时操作)
超卖带来的直接后果包括:
- 客户投诉:付了款却被告知没货
- 商誉损失:客户对平台失去信任
- 赔偿成本:可能需要补偿客户
- 运营混乱:需要人工核对处理异常订单
注意:超卖问题在测试环境很难发现,因为测试时并发量通常不够大。这就是为什么很多系统上线前测试正常,一到促销就出问题。
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 分层防御架构设计
经过多次大促的教训,我们最终采用了分层防御的方案:
前端层:
- 本地计数限制(防止用户疯狂点击)
- 随机延迟提交(分散请求峰值)
- 活动预热(提前加载页面资源)
接入层:
- 限流(Nginx限流模块)
- 排队(滑动窗口算法)
- 验证码(分散用户请求)
服务层:
- Redis预扣减(原子操作)
- 分布式锁(Redisson)
- 库存分段(将库存拆分为多个段)
数据层:
- 最终一致性(异步同步到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关键设计点:
- 使用DECRBY保证原子性
- 售罄标记避免无效查询
- 设置过期时间防止内存泄漏
3.3 库存分段技术
对于特别热门的商品(如iPhone首发),我们采用库存分段:
- 将1000件库存分为10个段,每段100件
- 每个段有独立的Redis key
- 用户请求随机路由到某个段
- 段内使用上述Lua脚本控制
优势:
- 分散热点key压力
- 提高并发处理能力
- 降低单个key的竞争
3.4 异步库存同步方案
Redis与MySQL的库存同步设计:
1. 下单成功 -> 写入订单表(状态"待支付") 2. -> 发送MQ消息(库存扣减) 3. 库存服务消费消息 -> 真实扣减DB库存 4. 支付成功 -> 更新订单状态 5. 支付超时 -> 反向补回库存异常处理机制:
- 定时任务核对Redis和DB库存差异
- 人工干预接口处理异常情况
- 库存操作流水表记录所有变更
4. 踩坑经验与优化建议
4.1 必须避免的典型错误
先查后改:
// 错误示范! int stock = getStockFromDB(); if(stock > 0) { updateStock(stock - 1); }这在并发下必然超卖
过度依赖缓存:
- 忘记设置缓存过期时间
- 缓存穿透导致DB压力
- 缓存和DB不一致
忽略网络延迟:
- 分布式锁的时钟漂移
- 锁超时时间设置不合理
- 没有考虑GC停顿影响
4.2 性能优化技巧
库存预热:
- 活动前将库存加载到Redis
- 使用Pipeline批量初始化
- 适当设置过期时间(避免长期不一致)
本地缓存:
// 使用Guava Cache LoadingCache<String, Integer> localCache = CacheBuilder.newBuilder() .expireAfterWrite(100, TimeUnit.MILLISECONDS) .build(...);减少Redis访问压力
热点Key处理:
- 添加随机后缀分散热点
- 使用Redis Cluster分片
- 考虑使用内存版Redis(如阿里云Tair)
4.3 监控与告警
必须建立的监控指标:
库存健康度:
- Redis与DB库存差异
- 库存变更流水异常
- 负库存出现次数
系统压力:
- Redis QPS/CPU使用率
- DB连接池使用率
- 消息队列堆积量
业务指标:
- 下单失败率
- 订单取消率
- 库存售罄时间
建议告警阈值:
- Redis与DB库存差异>5% → 警告
- 下单失败率>0.1% → 紧急告警
- 负库存出现 → 立即电话告警
5. 不同场景下的方案选型
5.1 普通商品销售
推荐方案:Redis原子操作 + 异步同步
实现要点:
- 读操作直接走Redis
- 写操作使用Lua脚本
- 异步消息同步到DB
- 定时任务核对数据
5.2 秒杀场景
推荐方案:库存分段 + 本地缓存 + 队列削峰
额外措施:
- 前置验证(用户资格检查)
- 令牌桶限流
- 页面静态化
- 订单过期自动释放
5.3 预售模式
特殊考虑:
- 虚拟库存概念
- 超卖可能是业务需求
- 需要明确预售规则
- 特别注意超时未支付处理
5.4 多仓库库存
复杂点:
- 需要维护总库存和分仓库存
- 要考虑仓库之间的调拨
- 就近发货的库存分配
- 需要更复杂的事务控制
解决方案:
- 使用分布式事务(如Seata)
- 采用最终一致性模式
- 设计合理的超时和重试机制
在实际项目中,我们通常会根据业务特点混合使用多种方案。比如在618大促时,对普通商品使用Redis原子操作,对秒杀商品采用库存分段+队列的方案,同时配合完善的前端限流和监控告警系统。