ThinkPHP 8 分布式锁与接口幂等:一次重复下单事故后的完整重构记录

2026-10-29 0 840

去年年底有个做社区团购的朋友找我,说他们系统一到每周三的限定商品开抢就出问题:后台订单表里同一个用户同一个商品出现两三笔订单,库存被扣了双份,财务对账的时候对不上。他们用的是 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 分布式锁,真正保护临界资源

中间件为什么还不够

中间件解决的是「同一个幂等键重复发」,但有两种情况它管不了:

  1. 用户用了两个不同的幂等键,但抢的是同一件商品的最后一件库存。这是两个合法请求,只是库存只有一个。
  2. 抢购期间同一秒有几百个不同用户同时下单,库存扣减本身是个读改写,没有锁就会超卖。

所以锁的粒度不是「请求」,而是「商品」。

锁的实现

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') 配好,别让不同业务的锁撞到一起。

ThinkPHP 8 分布式锁与接口幂等:一次重复下单事故后的完整重构记录
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 thinkphp ThinkPHP 8 分布式锁与接口幂等:一次重复下单事故后的完整重构记录 https://www.taomawang.com/server/thinkphp/2979.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务