TAOCARTS 知识

双十一代购站点超卖事故:一个 Redis 分布式锁引发的连锁反应-腾讯云开发者社区-腾讯云

2026-06-26 系统功能介绍

凌晨两点,监控告警突然炸响。某代购站点双十一活动刚开始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 海外华人代购代运系统。欢迎交流探讨。