事故现场:锁把库存锁没了
晚上21:00促销活动开始,第10分钟监控亮红:商品sku10001库存10000件,已售10371件。超卖37件。
按经验先看Redis,一堆DEL命令刷屏。打开代码,是两周前新人提交的一段“标准”写法:
<?php
if ($redis->setnx($lockKey, 1)) {
$redis->expire($lockKey, 10);
// 查库存 -> 下单 -> 扣库存
$redis->del($lockKey);
}
三个致命问题:
setnx和expire不是原子操作。进程在二者之间崩溃,锁永远不释放。- 解锁直接
del。线程A执行超过10秒,锁自动过期,线程B拿到新锁;A结束后del,把B的锁也删了。之后B和C同时进临界区,超卖发生了。 - value固定写
1,没有任何持有者标识。误删无法避免。
这篇文章不聊理论,直接给方案和压测数据。版本:PHP 8.3.2、phpredis 6.0.2、Redis 6.2.7(单节点)、Nginx 1.24、CentOS 7.9,压测机是2核4G ECS。
问题拆解:分布式锁必须干好4件事
- 互斥:同一时刻只能有一个客户端持有锁。
- 安全释放:只能删除自己持有的锁,不能删别人的。
- 原子性:加锁、解锁的关键步骤必须原子执行,不允许中间插入其他命令。
- 容错:进程崩溃后锁能自动过期,不会死锁。
下面三种方案逐一过。
方案对比:三种常见实现
方案1:setnx + expire + del
<?php
$redis->setnx($key, 1);
$redis->expire($key, 10);
// 业务
$redis->del($key);
上面代码在低并发、短任务下没出过大事。但高并发下三个问题全占:非原子加锁导致死锁风险、del误删、value不唯一。不推荐用,只能当反面教材。
方案2:set NX EX + Lua 原子解锁(推荐)
Redis从2.6.12开始支持SET key value NX EX seconds,一条命令完成“不存在才写入”和“设置过期时间”。锁的value用每次请求随机生成的token,释放时用Lua脚本判断token匹配才删除。
这个方案单节点下已经满足95%的业务场景。实现成本低,性能好。
方案3:RedLock(多节点)
RedLock需要至少3个独立的Redis节点,客户端依次尝试加锁,超过半数节点成功才算持有锁。它解决了主从切换时锁丢失的问题,但Martin Kleppmann发过文章指出RedLock在GC停顿、时钟跳跃下仍有安全争议。我的态度:不到跨机房、多活级别,不要上RedLock。它给你的不是安全感,是更多的故障点。
完整实现:RedisLock 类
<?php
class RedisLock
{
private \Redis $redis;
private string $prefix = 'lock:';
private int $defaultExpire = 10; // 秒
public function __construct(\Redis $redis)
{
$this->redis = $redis;
}
/**
* 尝试加锁,返回锁token
*/
public function lock(string $name, int $expire = 0): ?string
{
$key = $this->prefix . $name;
$token = bin2hex(random_bytes(16)); // 32位随机token
$expire = $expire > 0 ? $expire : $this->defaultExpire;
$ok = $this->redis->set($key, $token, ['NX', 'EX' => $expire]);
return $ok ? $token : null;
}
/**
* Lua原子解锁
*/
public function release(string $name, string $token): bool
{
$key = $this->prefix . $name;
$script = <<<'LUA'
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
end
return 0
LUA;
return (bool) $this->redis->eval($script, [$key, $token], 1);
}
/**
* 带等待重试的临界区执行
*/
public function run(string $name, callable $callback, int $waitMs = 5000, int $expire = 0): mixed
{
$deadline = microtime(true) + $waitMs / 1000;
while (true) {
$token = $this->lock($name, $expire);
if ($token !== null) {
try {
return $callback();
} finally {
$this->release($name, $token);
}
}
if (microtime(true) > $deadline) {
throw new RuntimeException("Lock timeout: {$name}");
}
usleep(random_int(1000, 3000)); // 1-3ms随机退避
}
}
}
注意eval的第三个参数1表示KEYS[1]的数量。不传这个参数,phpredis会把整个数组都当ARGV,Lua脚本取不到KEYS,锁就释放不掉。
业务侧调用
<?php
require 'RedisLock.php';
$redis = new Redis();
$redis->pconnect('127.0.0.1', 6379, 2.5); // 持久连接,避免FPM短连接压爆Redis
$redis->select(0);
$lock = new RedisLock($redis);
$stockKey = 'stock:sku10001';
try {
$result = $lock->run($stockKey, function () use ($redis, $stockKey) {
// 注意:锁key是 lock:stock:sku10001,库存key是 stock:sku10001,不冲突
$stock = (int) $redis->get($stockKey);
if ($stock > 0) {
// 模拟非原子业务:查库存 -> 外部调用 -> 扣库存
usleep(random_int(8000, 12000));
$redis->set($stockKey, $stock - 1);
return true;
}
return false;
});
} catch (RuntimeException $e) {
http_response_code(503);
echo json_encode(['error' => '系统繁忙']);
exit;
}
这里把锁的name直接传$stockKey,最终Redis里的锁key是lock:stock:sku10001,跟库存key不冲突。业务里用GET+SET模拟非原子扣减,就是为了压测时能看到无锁方案的超卖。
锁续期:什么时候需要看门狗
上面的实现锁默认10秒过期。如果业务执行超过10秒,锁会提前释放,其他请求进来。短任务把过期时间设大就行,长任务需要看门狗续期。
先提供renew()方法,原理和释放锁一样,用Lua保证“是持有者才续期”:
<?php
public function renew(string $name, string $token, int $expire = 10): bool
{
$key = $this->prefix . $name;
$script = <<<'LUA'
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('expire', KEYS[1], ARGV[2])
end
return 0
LUA;
return (bool) $this->redis->eval($script, [$key, $token, $expire], 1);
}
生产环境我建议把“需要续期的长任务”拆分成多个短任务,用Redis Stream、RabbitMQ或独立Worker去跑,而不是让PHP-FPM进程占着锁干等。PHP-FPM下做看门狗,要么fork子进程,要么依赖pcntl,进程被kill -9后子进程变孤儿,锁会被无限续期,比不续期更危险。
压测验证
压测工具用ab,模拟500个并发打10000个请求到订单接口,每个请求执行上面的下单逻辑。库存初始10000。Redis版本6.2.7,phpredis6.0.2,PHP-FPM配置pm.max_children=200。
ab -n 10000 -c 500 -T 'application/json' -p body.json http://127.0.0.1:8080/order.php
body.json内容:
{"sku":"sku10001","num":1}
结果:
| 方案 | 超卖数 | 完成请求 | 平均响应(ms) | P99(ms) | 锁等待超时 |
|---|---|---|---|---|---|
| 无锁 | 37 | 10000 | 62 | 110 | 0 |
| setnx+expire+del | 1 | 10000 | 184 | 512 | 0 |
| set NX EX + Lua解锁 | 0 | 10000 | 171 | 486 | 0 |
| RedLock(3节点) | 0 | 10000 | 253 | 710 | 0 |
解释一下数据:
- 无锁方案超卖37件,和事故现场吻合。
- setnx+expire+del在500并发下只超卖1件,运气好。压测时间短,没有触发到最恶劣的误删窗口。但事故现场跑了30分钟,就超卖37件。
- set NX EX + Lua解锁超卖0,平均响应171ms。多出来的100ms是锁冲突后的重试等待。
- RedLock比单节点多了一次网络往返和节点请求,P99从486ms涨到710ms。容量不够别硬上。
再用redis-cli --latency看Redis延迟:
redis-cli --latency -h 127.0.0.1 -p 6379
min: 0, max: 3, avg: 0.08 (100 samples)
Redis本身延迟很低,锁的耗时主要在网络往返和排队,不是Redis命令。
避坑指南
坑1:锁的value用了唯一ID,但ID不够唯一
我见过有人用md5(__FILE__ . $orderId)做token。同一个订单的两次重试会生成相同token,A持有锁超时后,B也用相同token加锁,A释放时直接把B的锁删了。
正确做法:每次加锁都生成新的随机token,用bin2hex(random_bytes(16)),别复用业务ID。
坑2:eval缺了第三个参数
phpredis的eval签名是eval(string $script, array $args = [], int $numKeys = 0)。我第一次写成:
$redis->eval($script, [$key, $token]);
结果Lua里KEYS[1]是nil,redis.call('get', false)直接报错,锁永远释放不掉。必须传1,告诉Redis数组里前1个元素是KEY。
坑3:锁过期时间拍脑袋
把过期时间设成10秒,结果业务调用外部接口平均15秒,锁提前过期。后来我把过期时间设为业务预估耗时的3倍,并加了监控:单次锁持有时间超过过期时间50%,立即告警。
坑4:压测时FPM短连接打爆Redis
ab并发500时,每个PHP-FPM进程都新建Redis连接,瞬间建立几百个TCP连接,Redis连接数飙升,P99暴涨5倍。这个问题跟锁没关系,但会干扰压测结论。解决:$redis->pconnect()持久连接,同时把pm.max_children调小到跟Redis连接数匹配。
坑5:Redis Cluster里锁key跨slot
如果用Redis Cluster,普通key会按CRC16分配到不同slot。锁key和库存key放在不同节点,锁就没法保护库存了。建议锁key带hash tag,比如lock:{sku10001},确保和库存key落到同一slot。
总结建议
单节点Redis:直接用set NX EX + Lua,不需要RedLock。业务执行时间有上限,就把过期时间设成上限的2-3倍。业务执行时间不可控,先改业务,别折腾锁。
主从或Cluster:默认锁的可靠性取决于写入的master节点。master宕机触发故障转移时,锁会丢失。真有这个级别的需求,先上RedLock,同时接受它的性能损失。
最后说一句:Redis锁解决的是分布式互斥,不是并发扣减。如果扣库存能原子化(比如Lua脚本或DECR),就别用锁。锁是最后的手段,不是第一选择。