双十一代购站点超卖事故:一个 Redis 分布式锁引发的连锁反应-腾讯云开发者社区-腾讯云
凌晨两点,监控告警突然炸响。某代购站点双十一活动刚开始40分钟,后台订单系统已显示三款热门球鞋库存归零,但前台用户仍然可以正常下单支付。等运营手动关掉商品链接时,超卖订单已经产生了近百单。更棘手的是,这些订单中有一部分已经通过1688自动代采系统向供应商下了采购单——退单成本、汇率损失、客户投诉,一夜之间全部压上来。
排查过程:库存扣减的竞态条件
排查从订单日志开始。系统采用 Redis 缓存商品库存,每次用户下单时先读缓存再扣减。日志显示,同一款库存为2件的商品,在200毫秒内收到了3个下单请求。三个请求都从 Redis 读到库存为2,各自扣减1后写回1,最终数据库里该商品的已售数量只增加了1,但实际生成了3个订单。
问题出在库存扣减操作不是原子的。Redis 的 `GET` 和 `DECR` 是两个独立命令,高并发下多个请求同时读到相同的初始值,各自做本地计算后写回,就产生了典型的竞态条件。这是分布式环境下数据一致性的经典陷阱——表面上是库存不准,深层却是分布式环境下数据一致性问题。
```php
// 问题代码:非原子操作导致竞态
$stock = $redis->get("product:1001:stock"); // 两个请求同时读到2
if ($stock > 0) {
$redis->decr("product:1001:stock"); // 各自减1,但都以为库存充足
$orderId = createOrder($userId, $productId);
}
```
根因分析:Redis 分布式锁的粒度失配
排查到这一步,第一反应是加锁。团队很快在库存扣减操作外包了一层 Redis 分布式锁,用 `SET NX EX` 实现互斥。压测时单机200并发通过,但上线后问题依然存在——只是从超卖变成了订单排队拥堵。
进一步分析发现,锁的粒度太粗了。整个库存扣减方法被一把锁保护,意味着同一时间只有一个请求能操作任何商品的库存。一个用户下单 A 商品时,B 商品的订单也只能排队等着。TPS 被限制在单线程水平,双十一流量下订单处理队列迅速堆积,用户端表现为下单后长时间无响应,部分用户重复点击导致更多请求涌入。
```php
// 粗粒度锁:所有商品共用一把锁
$lockKey = "inventory:lock"; // 全局锁
if ($redis->set($lockKey, 1, ["NX", "EX" => 3])) {
// 处理所有商品的库存扣减
releaseLock($lockKey);
}
```
更隐蔽的问题是,Redis 分布式锁无法解决上游数据滞后问题。代购系统的库存数据来源有两个:自营仓和1688代发仓。1688的商品库存通过定时任务同步,延迟在10-40分钟不等。锁只能保证本地扣减不冲突,但无法阻止多个订单基于同一个过期的1688库存数据同时下单。双十一那晚,恰好有一款商品的1688库存回调延迟了约40分钟,导致系统基于"库存充足"的旧数据放行了大量订单。
修复方案:细粒度锁 + 库存预占机制
修复方案从两个维度展开。第一,将锁粒度从全局锁拆分为商品级锁,每个商品独立加锁,互不阻塞。第二,引入库存预占机制,将下单和实际扣库存分离,用异步队列保证最终一致性。
```php
// 细粒度锁:按商品ID加锁,互不干扰
$lockKey = "inventory:lock:{$productId}";
$locked = $redis->set($lockKey, 1, ["NX", "EX" => 3]);
if (!$locked) {
// 返回"稍后重试"提示,避免用户重复点击
return response()->json(['code' => 429, 'message' => '订单处理中,请勿重复提交']);
}
try {
$stock = $redis->get("product:{$productId}:stock");
if ($stock <= 0) throw new \Exception("库存不足");
$order = createOrderPreOccupied($userId, $productId); // 预占库存
$redis->decr("product:{$productId}:stock");
dispatch(new ConfirmOrderJob($order->id)); // 异步确认
} finally {
$redis->del($lockKey);
}
```
库存预占机制的核心是下单时只锁定库存,不立即向1688下单。异步 Job 在5分钟后检查1688实时库存,如果库存充足则继续采购,不足则自动取消订单并退款。这给1688库存同步留出了缓冲时间,也避免了因上游数据滞后导致的批量超卖。
```php
// 异步确认Job:检查1688实时库存
class ConfirmOrderJob {
public function handle() {
$order = Order::find($this->orderId);
$realStock = sync1688Stock($order->product_id); // 实时查询
if ($realStock < $order->quantity) {
$order->cancel("1688库存不足");
refundUser($order);
return;
}
$order->confirm();
dispatch(new PurchaseJob($order)); // 向1688下单
}
}
```
实际部署时,代购系统的库存模块需要同时考虑自营仓和1688代发仓两个数据源。自营仓库存实时准确,可以直接用 Redis 分布式锁防超卖;1688代发仓库存有延迟,必须用预占机制兜底。两者混合使用时,锁的过期时间需要根据同步频率动态调整——同步间隔10分钟,锁的 TTL 就设为12分钟,留出余量。
这套方案上线后,超卖事故发生率降到几乎可以忽略的水平。后续又在监控层面增加了库存水位告警:当某商品库存低于5件时自动通知运营人工核验,作为最后一道防线。
技术方案的价值在于降低门槛
回头看这次事故,问题表面上是库存不准,深层却是分布式环境下数据一致性的系统性问题。Redis 分布式锁本身不是银弹,它的粒度、过期时间、与上游系统的配合,每个环节都可能成为瓶颈。好的方案不是让系统不出错,而是出错时能快速恢复、损失可控。这套细粒度锁加库存预占的组合,让代购站点在后续的大促中再没出现过类似的超卖事故,也证明了技术应该降低门槛,让普通人也能稳定运营跨境生意。
---
搞了十年后端架构,做过韩国跨境商城(多平台商品搜索、中韩双语、韩币结算)和 betteryoyo 海外华人代购代运系统。欢迎交流探讨。