Redis分布式锁防超卖方案解析:从需求到落地的技术选型
Redis分布式锁防超卖方案解析:从需求到落地的技术选型
适合谁看:正在处理库存并发问题的后端开发者,如果只关注业务功能可以跳过代码部分直接看思路。
一位做日淘代购的创业者向我求助:618大促期间,3件同款限量球鞋同时被下单,但仓库实际只剩2件。客户收到超卖通知后直接炸群退款。这种场景在代购系统中并不罕见——自营仓储与1688代发仓的库存同步存在天然延迟,而多个用户在同一秒内的下单请求会制造经典的竞态条件。
超卖问题的根源往往不是代码写错了,而是并发访问的时序问题。Taocarts在设计采购引擎时选择了Redis分布式锁作为核心解法,这篇文章解析从需求分析到落地的完整技术选型过程。
代购系统的库存模型比普通电商更复杂。Taocarts支持自营仓和1688代发仓两套库存体系:当用户下单时,系统需要同时检查本地仓库存和1688的可采购数量,任何一个环节的延迟都可能导致超卖。
典型的事故链条是这样的:用户A和用户B同时抢购限量款→系统查询库存显示剩余1件→两个请求都通过了库存校验→用户A的订单锁定库存→用户B的订单在提交时才发现库存已被占用→触发超卖告警。
这种竞态条件在单进程单线程环境下很少发生,但代购系统通常部署在多节点容器环境中,PHP-FPM的多进程模型让每个请求可能落在不同的worker处理。传统的关系型数据库行锁可以解决单库场景,但当查询和写入之间存在时间窗口时,数据库层面的一致性无法覆盖整个业务链路。
数据库悲观锁(SELECT FOR UPDATE)是最直接的方案,但有三个明显的局限性。首先是性能开销:每次库存操作都需要锁定整行,在高峰期可能导致大量请求排队等待。其次是跨库场景受限——当库存分布在多个数据库实例或者需要调用1688开放平台接口时,数据库锁无能为力。第三是死锁风险:多个事务相互等待对方释放锁时,系统可能陷入不可用状态。
乐观锁(版本号机制)解决了部分问题:通过对比版本号判断是否被其他请求修改过。但乐观锁在高并发下会导致大量重试,接口调用延迟会显著影响用户体验。
Redis分布式锁在一致性和性能之间取得了更好的平衡。Taocarts的采购引擎采用了Redisson封装的分布式锁能力,结合PHP环境下的predis客户端实现。
分布式锁的本质是在多个进程/节点之间抢占同一个"令牌",只有抢到令牌的请求才能继续执行库存操作。Redis的SETNX(SET if Not eXists)指令天然适合这个场景,但光有SETNX不够——还需要考虑锁的自动过期和正确释放。
class StockLock {
private $redis;
private $lockKey;
private $expireMs;
public function __construct(Redis $redis, string $lockKey, int $expireMs = 3000) {
$this->redis = $redis;
$this->lockKey = $lockKey;
$this->expireMs = $expireMs;
}
public function acquire(): string|false {
$token = bin2hex(random_bytes(16));
$startTime = microtime(true);
// SET key value NX PX milliseconds
$acquired = $this->redis->set(
$this->lockKey,
$token,
'NX',
'PX',
$this->expireMs
);
if ($acquired) {
return $token;
}
// 竞争激烈时短暂等待后重试
if (microtime(true) - $startTime < 0.1) {
usleep(5000);
return $this->acquire();
}
return false;
}
public function release(string $token): bool {
// Lua脚本保证原子性检查和删除
$script = <<<LUA
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
LUA;
return (bool)$this->redis->eval($script, 1, $this->lockKey, $token);
}
}
这段代码有三个关键设计点。首先是唯一token:每个锁持有者都有唯一的标识,防止误删他人持有的锁。其次是PX参数设置过期时间,即使进程崩溃也能自动释放,避免死锁。第三是Lua脚本实现原子性释放——检查和删除必须在一个原子操作中完成,否则可能出现检查时锁还存在,删除时已被他人获取的窗口期。
在Taocarts的订单提交流程中,锁的作用域是SKU维度:
public function submitOrder(string $userId, string $skuId, int $quantity): OrderResult {
$lockKey = "stock:lock:{$skuId}";
$lock = new StockLock($this->redis, $lockKey);
$token = $lock->acquire();
if (!$token) {
throw new ServiceUnavailableException('系统繁忙,请稍后重试');
}
try {
// 查询当前库存(可能来自多个仓库)
$stockInfo = $this->stockService->getAvailableStock($skuId);
if ($stockInfo['total'] < $quantity) {
return OrderResult::insufficientStock($stockInfo['total']);
}
// 扣减库存
$this->stockService->reserveStock($skuId, $quantity);
// 创建订单
$order = $this->orderService->create([
'user_id' => $userId,
'sku_id' => $skuId,
'quantity' => $quantity,
'source' => $stockInfo['source']
]);
return OrderResult::success($order);
} finally {
$lock->release($token);
}
}
try-finally确保无论业务成功还是抛异常,锁都会被释放。这是分布式编程中最容易遗漏的错误点——一旦忘记释放,系统会出现大量不可用的锁。
分布式锁不是银弹。首先要承认它的局限性:Redis主从架构下可能存在数据丢失(从节点异步复制),对CAP理论而言这是选择可用性而非强一致性的权衡。对于超卖这种需要强一致的场景,Taocarts建议用户在高可用场景下启用Redis Cluster并开启wait指令增强一致性。
其次是锁粒度的选择。锁太粗(全局锁)会严重降低并发性能,锁太细(按批次、按SKU)则需要精确的键设计。Taocarts采用SKU维度的锁,在大多数代购场景下能覆盖实际需求,但像球鞋这类热门单品仍然可能成为瓶颈——可以考虑在SKU锁内部再做批次号的分段。
第三个边界条件是超时设置。如果锁的过期时间短于业务处理时间,锁会自动释放但业务还在执行,此时另一个请求可能获取到锁并修改同一份数据。Taocarts的方案是将过期时间设置得足够宽松(默认3秒),同时在业务层加入幂等性校验作为兜底。
第四个容易踩坑的配置细节是Redis的 hz 参数(默认值为10)。它决定了后台定期扫描并清理过期键的频率。在高并发抢购时,锁虽然达到了TTL理论上已过期,但Redis并不会立刻将其物理删除,而是存在几十到上百毫秒的扫描延迟。如果此时客户端采用固定极短间隔(如本文示例的5ms)不断重试抢锁,可能会误判为旧锁仍存在,导致大量请求在无效等待后直接抛出超时异常。实际落地时,建议将重试策略改为指数退避,或适当调高 hz 值,以对齐业务对锁释放时效的预期。
引入Redis分布式锁后,超卖问题从"频发事故"变成"偶发告警"。配合库存预警机制,当某SKU库存低于5件时会自动触发补货提醒和前端限购提示。锁的获取成功率也进入了监控告警范围——当成功率低于95%时,系统会发出扩容或优化建议。
对于日订单量超过500单的代购站点,仅靠单机Redis锁已经足够应对。但如果业务规模继续增长,Taocarts支持平滑切换到Redisson的看门狗机制(自动续期)或者引入Lua脚本实现更复杂的库存分配策略。
分布式锁解决的是并发控制问题,但根源上需要库存数据的准确性作为基础。Redis的库存快照与数据库的最终一致性同步、1688代发仓的实时库存拉取,这些环节的延迟控制同样重要。技术方案从来不是孤立的,系统设计需要把每个环节的可靠性都考虑到位。只有把锁的可靠性与多仓数据流彻底打通,开篇那位创业者遇到的“限量球鞋剩2件却卖出3单、炸群退款”的失控场景,才能真正成为历史。