去年年底有个做社区团购的朋友找我,说他们系统一到每周三的限定商品开抢就出问题:后台订单表里同一个用户同一个商品出现两三笔订单,库存被扣了双份,财务对账的时候对不上。他们用的是 ThinkPHP 8,前置 Nginx 两台,Redis 一主一从,MySQL 8 主从。
我让他先把那天的访问日志和慢查询拉出来看。结果很典型:一个手机号在 4 秒内发起了 5 次 POST /api/order/create,其中 3 次返回了 200。中间件日志里能看到前端做了「点击后置灰」的处理,但用户是在小程序里,网络一卡他就手动下拉刷新重来了一次,加上 iOS 端有个自动重试逻辑,请求就这么叠上去了。
问题不在「前端该不该防重」,而在于后端从头到尾没有任何一层认为「这可能是一个重复请求」。这篇文章就把我们后来做的那套东西完整讲一遍:两层拦截——中间件挡重复请求,Redis 锁保护临界资源,再叠加事务和库存条件更新。代码贴着 ThinkPHP 8 写,直接能改成自己的。
先把依赖和环境确认一遍
项目基线是 PHP 8.2 + ThinkPHP 8.1,装两个 composer 包(Redis 缓存驱动和 phpredis 扩展),如果你的机器还没有:
php -v
# PHP 8.2.x
composer require topthink/think-cache
composer require topthink/think-multi-app # 如果你是单应用模式可以不装
php -m | grep redis
# redis (要看到这一行,没有就装 phpredis 扩展)
config/cache.php 里配一个 redis 驱动,别用默认的 file:
return [
'default' => 'redis',
'stores' => [
'redis' => [
'type' => 'redis',
'host' => env('redis.host', '127.0.0.1'),
'port' => env('redis.port', 6379),
'password' => env('redis.password', ''),
'select' => 0,
'prefix' => 'app:',
'timeout' => 3,
'persistent' => false,
],
],
];
这里有个小提醒:如果你后面打算把项目跑在 Swoole 或者 Workerman 这种常驻内存模式下,persistent 一定要保持 false,否则多进程抢同一条长连接,会出现那种「A 请求读到 B 请求的锁令牌」的诡异现象,后面踩坑记录里我会细说。
第一层拦截:用中间件把重复请求挡在业务之外
幂等键到底怎么定
这一步如果键设计错了,后面写得再花哨也没用。我们踩过的两个坑:
- 只用
user_id + 路由做键。结果是用户连续下两个不同商品也会被拦,误杀。 - 用
user_id + 路由 + 请求体做键。看着严谨,但请求体里带有时间戳字段,每次都不一样,等于没拦。
最后定下来的规则是这样的:优先读客户端传的 Idempotency-Key 请求头,没有的话再降级到「路由 + 登录用户 + 请求体 md5」。前端在下单按钮点击时生成一个 UUID 塞进请求头,重试时复用同一个值,这样既能防网络重试,也不会误伤正常的第二笔下单。
中间件代码
新建 app/middleware/Idempotent.php:
<?php
declare(strict_types=1);
namespace appmiddleware;
use appcommonlockRedisLock;
use Closure;
use thinkRequest;
use thinkResponse;
class Idempotent
{
/**
* @param string $ttl 同一幂等键的互斥窗口(秒)
*/
public function handle(Request $request, Closure $next, string $ttl = '10'): Response
{
// GET 这类幂等请求不需要处理
if (in_array($request->method(), ['GET', 'HEAD', 'OPTIONS'], true)) {
return $next($request);
}
$key = $this->buildKey($request);
$lock = new RedisLock();
$token = $lock->acquire('idempotent:' . $key, (int) $ttl, 0);
if ($token === null) {
return json([
'code' => 429,
'msg' => '请求处理中,请勿重复提交',
'data' => null,
]);
}
try {
$response = $next($request);
} finally {
$lock->release('idempotent:' . $key, $token);
}
return $response;
}
private function buildKey(Request $request): string
{
$header = (string) $request->header('idempotency-key', '');
$path = $request->pathinfo();
if ($header !== '') {
return $path . ':' . md5($header);
}
$user = $request->user ?? null;
$uid = is_array($user) ? ($user['id'] ?? 0) : 0;
$payload = md5($request->getContent() ?: '');
return $path . ':' . $uid . ':' . $payload;
}
}
几个细节值得说一下。acquire() 的第三个参数我传了 0,意思是「拿不到锁立刻返回,不等待」——这是幂等场景和普通并发锁最大的区别。并发锁希望排队等一等,幂等希望马上告诉调用方「这单已经在处理了」。
另外 $request->user 这个属性不是框架自带的,是我们在 JWT 中间件里手动挂上去的:
// app/middleware/JwtAuth.php 里
$request->user = $payload; // Request 类有 __set,会存进 param
所以中间件的执行顺序必须是 JWT 在前、幂等在后。路由注册时:
// route/app.php
use thinkfacadeRoute;
Route::group('api/v1', function () {
Route::post('order/create', 'Order/create')
->middleware(appmiddlewareIdempotent::class, '10');
}) ->middleware(appmiddlewareJwtAuth::class);
框架是按注册顺序从外到内执行的,JWT 注册在 group 上,会先于路由内的 Idempotent 跑,这个顺序正好。
第二层:Redis 分布式锁,真正保护临界资源
中间件为什么还不够
中间件解决的是「同一个幂等键重复发」,但有两种情况它管不了:
- 用户用了两个不同的幂等键,但抢的是同一件商品的最后一件库存。这是两个合法请求,只是库存只有一个。
- 抢购期间同一秒有几百个不同用户同时下单,库存扣减本身是个读改写,没有锁就会超卖。
所以锁的粒度不是「请求」,而是「商品」。
锁的实现
app/common/lock/RedisLock.php:
<?php
declare(strict_types=1);
namespace appcommonlock;
use thinkfacadeCache;
class RedisLock
{
private Redis $redis;
private string $prefix;
public function __construct()
{
/** @var Redis $handler */
$handler = Cache::store('redis')->handler();
$this->redis = $handler;
$this->prefix = (string) config('lock.prefix', 'lock:');
}
/**
* 尝试获取锁,成功返回令牌,失败返回 null
*/
public function acquire(string $key, int $ttl = 10, float $wait = 3): ?string
{
$token = bin2hex(random_bytes(16));
$deadline = microtime(true) + $wait;
do {
$ok = $this->redis->set(
$this->prefix . $key,
$token,
['NX', 'PX' => $ttl * 1000]
);
if ($ok) {
return $token;
}
if ($wait <= 0) {
return null;
}
usleep(random_int(20000, 60000));
} while (microtime(true) < $deadline);
return null;
}
/**
* 释放锁,只删除自己持有的那把
*/
public function release(string $key, string $token): bool
{
$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,
[$this->prefix . $key, $token],
1
);
}
}
这段代码里最容易被忽略的是 release 必须用 Lua。很多人图省事写成:
if ($this->redis->get($key) === $token) {
$this->redis->del($key); // 经典错误
}
这个写法在理论上就是错的:GET 返回之后、DEL 执行之前,你的锁可能刚好到期被别人拿走了,这时候 DEL 删掉的是别人的锁。窗口期很短,但抢购量一上来,一晚上能撞上七八次,表现出来就是「莫名其妙有两个人同时进了临界区」。
Lua 脚本在 Redis 里是原子执行的,判断和删除之间不会被打断,这是唯一严谨的做法。
令牌为什么要用随机串
有人喜欢用 uniqid() 或者 md5(微秒时间戳) 当令牌。理论上同一微秒两台机器能生成一样的值,虽然概率极低,但既然 random_bytes(16) 又便宜又安全,没必要省这一行。
第三层:业务层的锁 + 事务 + 条件更新
锁拿在控制器里就完事了?不是。我们把它下沉到 Service,让「加锁、开事务、校验、扣减、提交、释放」变成一个完整的动作。
app/service/OrderService.php:
<?php
declare(strict_types=1);
namespace appservice;
use appcommonlockRedisLock;
use appexceptionBusinessException;
use appmodelGoods;
use appmodelOrder;
use thinkfacadeDb;
class OrderService
{
public function create(int $userId, int $goodsId): array
{
$lock = new RedisLock();
$token = $lock->acquire('goods:' . $goodsId, 5, 2);
if ($token === null) {
throw new BusinessException('当前商品下单人数较多,请稍后再试', 423);
}
try {
Db::startTrans();
try {
// 悲观锁兜底,防多实例下 Redis 抖动导致锁失效
$goods = Goods::where('id', $goodsId)->lock(true)->find();
if (!$goods || $goods->status !== 1) {
throw new BusinessException('商品已下架', 404);
}
// 条件更新:stock >= 1 才算成功,影响行数为 0 说明被抢完了
$affected = Db::name('goods')
->where('id', $goodsId)
->where('stock', '>=', 1)
->dec('stock', 1)
->update();
if ($affected === 0) {
throw new BusinessException('库存不足', 400);
}
$order = Order::create([
'order_no' => $this->genOrderNo(),
'user_id' => $userId,
'goods_id' => $goodsId,
'amount' => $goods->price,
'status' => Order::STATUS_UNPAID,
]);
Db::commit();
} catch (Throwable $e) {
Db::rollback();
throw $e;
}
} finally {
$lock->release('goods:' . $goodsId, $token);
}
// 事务提交之后再投递事件,避免消费者拿到还没落库的订单
event('OrderCreated', ['order' => $order]);
return [
'order_no' => $order->order_no,
'amount' => $order->amount,
];
}
private function genOrderNo(): string
{
return date('YmdHis') . str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
}
}
这里有三点是我在不同项目里反复确认过的:
第一,lock(true) 必须在事务里才生效。 它本质是 SELECT ... FOR UPDATE,如果不在事务里,MySQL 语句结束就释放了,一点用没有。所以顺序必须是 startTrans 之后再查。
第二,扣库存用条件更新而不是先查后改。 where('stock', '>=', 1)->dec('stock', 1) 翻译成 SQL 是 UPDATE goods SET stock = stock - 1 WHERE id = ? AND stock >= 1,它本身就是原子的,就算 Redis 锁因为网络抖动没拿到,这一层也不会扣成负数。这是最后的底线。
第三,事件在事务提交之后投递。 我见过把 event() 写在 Db::commit() 之前导致消费者查到订单为空的写法,虽然 TP 的事件默认是同步的,但如果你接了 think-queue 就一定会出问题。
怎么验证这套东西真的管用
不要靠肉眼看代码,直接上压测。开一台机器跑 ab:
ab -n 500 -c 50
-T 'application/json'
-H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIs...'
-H 'Idempotency-Key: fixed-key-for-test'
-p order.json
http://127.0.0.1:8000/api/v1/order/create
预期结果很明确:
| 指标 | 改造前 | 改造后 |
|---|---|---|
| 成功创建订单数 | 几十笔 | 1 笔 |
| 429 响应数 | 0 | 约 499 笔 |
| 商品库存最终值 | -38 | 原值 – 1 |
| 数据库死锁数 | 偶发 | 0 |
然后把 Idempotency-Key 换成随机值再跑一遍,这时候应该看到的是:成功创建订单数等于初始库存数,多出来的请求全部返回 400 库存不足。这一步才是真正验证锁和条件更新起作用的地方。
踩过的坑,按严重程度排一下
1. Swoole 常驻内存下的 Redis 连接复用。 项目从 php-fpm 迁到 Swoole 之后,persistent => true 的配置没改,结果协程之间共用同一个连接,出现了 A 请求释放掉了 B 请求的锁。解决办法是把 Cache::store('redis')->handler() 换成了项目自己维护的连接池,每个协程独立取连接。这个坑排查了两天,现象是「锁偶尔失效,但压测复现不出来」。
2. 锁的 TTL 小于业务执行时间。 最初 acquire 的 ttl 给了 3 秒,结果某次数据库慢查询把临界区撑到了 5 秒,锁提前过期,另一个请求进来了,前一个请求执行完 release 时因为 Lua 校验失败什么都没删——好在这层被条件更新兜住了,没造成超卖,但日志里开始出现大量 release failed。后来统一把 TTL 调到业务 P99 的两倍以上,并且加了「释放失败打点告警」。真要彻底解决得上看门狗续期,不过我们这个场景 5 秒够用,就没做。
3. Redis 集群下多 key 的 Lua 会报错。 项目换了 Redis Cluster 之后,原本一个脚本里同时操作锁 key 和业务 key 的写法直接报 CROSSSLOT。要么拆成两次调用,要么在 key 里加 {} 哈希标签让它们落到同一个槽。我们选了后者,key 写成 lock:{goods:101} 这种形式。
4. 幂等窗口给太长。 一开始 TTL 设了 300 秒,用户下单失败想立刻重来,结果被自己的幂等键挡住了 5 分钟,投诉了一堆。后来改成 10 秒——足够覆盖网络重试,又不会挡住正常重试。
最后几句
这套方案其实没有什么新技术,就是三层叠加:中间件管「同一个请求别来两遍」,分布式锁管「同一件商品别同时扣」,数据库条件更新管「万一前两层都失效了别扣成负数」。每层各管各的事,谁出问题都能被下一层兜住。
如果你现在的项目还停留在「前端点完置灰」这一步,建议先去看一眼订单表的 order_no 有没有唯一索引——加个唯一索引不解决并发问题,但至少能让脏数据在落库那一刻就暴露出来,而不是等到财务对账的时候。这一步的投入产出比,比你现在去研究分布式锁高得多。
代码里用到的 BusinessException 和全局异常处理,是另一个话题了,后面有机会单独写。上面这些代码都是可以直接抄进项目改的,唯一要注意的是把 config('lock.prefix') 配好,别让不同业务的锁撞到一起。

