秒杀库存扣减场景很多人踩坑,我来手把手带你过一遍。别管多少种实现方案,能跑通、防超卖、可运维才是硬道理。
## 学完能做什么
学完这篇,你将掌握 Redis 分布式锁 的完整实现逻辑,能独立搭建一套支持并发安全、带唯一标识释放的库存扣减原型。前置条件:本地已安装 PHP 8.0+、Redis 6.0+ 并启动服务,已开启 `phpredis` 扩展。假设你熟悉基础 PHP 语法与 Redis 常用命令,环境确认完毕后直接进入第 1 步。
## Step by Step
### Step 1:基础依赖与连通性验证(预计耗时 3 分钟)
**前置条件**:Redis 服务端 6379 端口已开启,PHP CLI 可调用。
打开终端执行扩展检查:
```bash
php -m | grep redis
```
**预期输出**:终端直接返回 `redis`。若为空,需通过 `pecl install redis` 编译安装或在 `php.ini` 中取消 `extension=redis.so` 的注释。接着验证连通性,执行:
```bash
php -r "echo (new Redis())->connect('127.0.0.1', 6379) ? 'OK' : 'FAIL';"
```
返回 `OK` 即可继续。
### Step 2:封装原子加锁与安全释放(预计耗时 8 分钟)
**前置条件**:新建 `DistributedLock.php`,准备面向对象结构。
分布式锁的核心矛盾在于“网络抖动导致锁过期”与“误删他人锁”。直接 `DEL` 是危险操作,必须引入客户端唯一 Token 与 Lua 脚本。写入以下完整类定义:
```php
<?php
class DistributedLock
{
private $redis;
private $lockName;
private $token;
private $expireMs;
public function __construct(Redis $redis, string $lockKey, int $expireMs = 3000)
{
$this->redis = $redis;
$this->lockName = 'dist_lock:' . $lockKey;
$this->token = bin2hex(random_bytes(16)); // 当前请求唯一标识
$this->expireMs = $expireMs;
}
// 尝试抢占锁,成功返回 true,已被占用或超时返回 false
public function acquire(): bool
{
$options = ['nx', 'px' => $this->expireMs];
return (bool)$this->redis->set($this->lockName, $this->token, $options);
}
// 仅当值与当前 token 一致时才删除,Lua 保证原子性
public function release(): bool
{
$lua = <<<'LUA'
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0
LUA;
$result = $this->redis->eval($lua, [$this->lockName, $this->token], 1);
return (int)$result === 1;
}
// 暴露 Token 便于调试或监控打点
public function getToken(): string
{
return $this->token;
}
}
?>
```
### Step 3:串联库存扣减业务流(预计耗时 5 分钟)
**前置条件**:Redis 中预设库存数据,终端执行 `redis-cli SET item:stock:sku_886 10`。
新建 `run_demo.php` 调用锁类,模拟高并发下的单次请求处理:
```php
<?php
require_once 'DistributedLock.php';
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$sku = 'sku_886';
$lock = new DistributedLock($redis, $sku, 5000);
// 抢占锁
if ($lock->acquire()) {
try {
$currentStock = (int)$redis->get("item:stock:{$sku}");
if ($currentStock > 0) {
$redis->decr("item:stock:{$sku}");
echo "扣减成功 | 剩余库存: " . ($currentStock - 1) . PHP_EOL;
} else {
echo "已售罄,拦截请求" . PHP_EOL;
}
} finally {
// 无论业务成功或异常,必须尝试释放
$lock->release();
}
} else {
echo "并发过高,请稍后重试" . PHP_EOL;
}
?>
```
执行命令:`php run_demo.php`
**预期输出**:首次运行显示 `扣减成功 | 剩余库存: 9`。使用 `ab -n 100 -c 10 http://localhost/run_demo.php` 压测或开多个终端并行执行 `php run_demo.php`,库存严格递减至 0 后停止,终端不会出现负数或重复扣减提示。
## 代码详解
很多实现方案把加锁和释放拆开,但在真实网络里,业务逻辑执行时间若超过锁的 TTL,锁会自动失效,后续请求就能拿到新锁。此时前一个业务还没跑完,后一个已经开始操作共享变量,数据一致性直接崩溃。
上述代码用 `nx` 选项确保只有键不存在时才能写入,配合 `px` 毫秒级过期策略,防止服务端异常挂起导致死锁。`bin2hex(random_bytes(16))` 生成的 Token 绑定当前请求周期,释放阶段不走原生的 `del`,而是把 `GET` 比对与 `DEL` 操作打包进 Lua 脚本。Redis 执行 Lua 期间是单线程阻塞模型,其他客户端的请求会被排队,彻底切断并发释放时的竞态条件。
在我们自研的 taocarts 订单流转项目中,早期库存同步全靠数据库行锁硬扛,峰值期慢查询堆积,后来把热点商品库存切换至这套 Redis 分布式锁 模式后,接口 P99 延迟压到 20ms 内,线上再未触发超卖告警。
`finally` 块是兜底设计。PHP 在遇到未捕获异常或显式 `return` 时,`finally` 依然会执行。这保证了即使扣减逻辑中途抛出 PDOException 或网络超时,锁资源也能被安全回收,不会残留脏数据阻塞后续请求。
## 小作业 + 延伸
修改 `DistributedLock.php` 的 `$expireMs` 为 `100`,再次连续运行三次 `run_demo.php`,观察输出是否出现库存跳变或报错。这个实验能直观暴露“业务耗时 > 锁存活时间”的经典缺陷。
下一步可研究 Redisson 框架的 WatchDog 看门狗续期机制。建议对比单节点锁与 Redlock 多节点算法在分区容错下的差异,结合实际业务对一致性与可用性的容忍度做技术选型。实际生产环境务必配合 Prometheus 指标采集锁等待队列长度与持有时间,数据达标后再决定是否引入异步队列削峰。