ThinkPHP 8 接口幂等实战:一次重复下单,改了三处代码

2026-09-21 0 327

先说背景。项目上线两周后,运营在群里发了一张截图:同一个用户,同一个商品,一分钟内产生了三笔订单,状态都是「待支付」,金额完全一样。用户主动联系客服说误触了,能不能退掉两笔。

去查日志,三笔请求的时间戳分别是 10:23:41.80210:23:41.91510:23:42.103。前后差不到 300 毫秒。典型的前端按钮没锁住,用户手指点在屏幕上没松开,浏览器把 click 事件派发了三次。

这件事不复杂,但要彻底解决,需要在三个层面同时动手。这篇文章把整个改造过程完整拆开讲。

为什么「前端禁用按钮」不是答案

第一反应往往是加一句 button.disabled = true。这招能解决 90% 的场景,但剩下来的 10% 才是真正麻烦的。

想想这些情况怎么处理:

  • 用户在提交过程中按了刷新,浏览器会重放上一次的 POST 请求;
  • 网络抖动,客户端 SDK 自动重试,重试打到了另一台服务器;
  • 用户在手机 App 里操作,客户端和服务端是两个团队,你不能保证对方一定加了防重逻辑;
  • 有人拿抓包工具直接把请求重放十遍。

前端的限制都是「善意约束」,绕过它的成本几乎为零。要真解决问题,服务端必须自己扛住这件事。

三层防线,各管一段

做幂等不是加一道锁就完事。真实的系统里,我一般会布三层:

  • 第一层:Token 校验。请求携带一个一次性 Token,用完即失效。它解决的是「同一个客户端连续重复提交」。
  • 第二层:Redis 分布式锁按业务维度(比如用户 ID + 商品 ID)加锁。它解决的是「并发请求同时打进来」。
  • 第三层:数据库唯一索引。库存流水、订单号这类关键字段上建唯一索引。它解决的是「锁失效、服务重启、时钟漂移」这类极端情况下的兜底。

三层不是互相替代,而是各自管不同时间段的事情。只有第一层,跨服务的重试拦不住;只有第二层,锁超时释放了照样出问题;只有第三层,数据库压力会上去,而且报错体验差。

下面把三层依次写出来。

第一层:用中间件校验一次性 Token

先做一个生成 Token 的接口,前端在进入下单页时先调一次:

namespace appapicontroller;

use thinkfacadeCache;
use thinkResponse;

class Token
{
    public function issue(): Response
    {
        $token = bin2hex(random_bytes(16));
        // 60 秒内有效,一次使用
        Cache::store('redis')->set('idem:token:' . $token, 1, 60);

        return json(['token' => $token]);
    }
}

然后是中间件本体:

namespace appmiddleware;

use Closure;
use thinkfacadeCache;
use thinkRequest;
use thinkResponse;

class Idempotent
{
    public function handle(Request $request, Closure $next): Response
    {
        // 只对会修改数据的请求做校验
        if (!$request->isPost() && !$request->isPut() && !$request->isDelete()) {
            return $next($request);
        }

        $token = $request->header('X-Idempotent-Token');
        if (empty($token)) {
            return json(['code' => 40001, 'msg' => '缺少幂等 Token'], 400);
        }

        $key = 'idem:token:' . $token;

        // Redis 的 DEL 返回被删除的键数量,是 1 说明本次是首次使用
        $consumed = Cache::store('redis')->delete($key);
        if (!$consumed) {
            return json(['code' => 40002, 'msg' => '请求已处理,请勿重复提交'], 400);
        }

        try {
            return $next($request);
        } catch (Throwable $e) {
            // 业务处理失败,把 Token 放回去让客户端可以重试
            Cache::store('redis')->set($key, 1, 60);
            throw $e;
        }
    }
}

这里有几个设计决定需要说明。

为什么用 DEL 而不是 GET + DEL?GET 再 DEL 是两步操作,两步之间有时间窗口,两个并发请求可能都能通过 GET 检查。用 DEL 的返回值判断是原子的,只有一个调用者会拿到 1。这是最容易写错的地方。

为什么失败要把 Token 放回去?如果不放回去,用户第一次提交因为某个业务校验没过(比如库存不足),第二次提交时 Token 已经消耗掉了,会收到「请勿重复提交」。用户一脸懵:我明明第一次就失败了,为什么不让再来一次。这里的取舍是:同一个 Token 只保证「成功处理」之前只能进一次,业务失败允许重试。

为什么 60 秒?太短,用户还没反应过来就过期了;太长,缓存占用上去了。60 秒是个常用值,具体还是看业务处理时长。

第二层:Redis 分布式锁

Token 解决的是同一个客户端重复发请求,但如果两个不同客户端(用户手上有 App 和网页同时在操作)恰好同时点提交,Token 挡不住。这时候需要在业务维度加锁:

namespace appservice;

use thinkfacadeCache;

class DistributedLock
{
    /**
     * @return string|null 拿到锁返回标识,拿不到返回 null
     */
    public static function acquire(string $key, int $ttl = 10): ?string
    {
        $owner = bin2hex(random_bytes(8));
        $redis = Cache::store('redis')->handler();

        $ok = $redis->set($key, $owner, ['NX', 'EX' => $ttl]);
        return $ok ? $owner : null;
    }

    public static function release(string $key, string $owner): void
    {
        $redis = Cache::store('redis')->handler();
        // 用 Lua 脚本保证「比对 + 删除」的原子性
        $script = <<<LUA
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end
LUA;
        $redis->eval($script, [$key, $owner], 1);
    }
}

下单处的调用:

public function createOrder(Request $request)
{
    $userId = $request->userId;
    $goodsId = $request->post('goods_id');
    $lockKey = sprintf('idem:order:%d:%d', $userId, $goodsId);

    $owner = DistributedLock::acquire($lockKey, 10);
    if ($owner === null) {
        return json(['code' => 40003, 'msg' => '操作进行中,请稍候'], 429);
    }

    try {
        // 先查一次,避免重复创建
        $exists = Order::where('user_id', $userId)
            ->where('goods_id', $goodsId)
            ->where('status', '<>', 'cancelled')
            ->find();
        if ($exists) {
            return json(['code' => 0, 'data' => ['order_no' => $exists->order_no]]);
        }

        $order = $this->doCreateOrder($userId, $goodsId);
        return json(['code' => 0, 'data' => ['order_no' => $order->order_no]]);
    } finally {
        DistributedLock::release($lockKey, $owner);
    }
}

这块代码有两个和幂等直接相关的地方。

释放锁为什么用 Lua 脚本?如果不比对 owner 就直接 DEL,会出现这一幕:请求 A 拿到锁,处理时超时(超过 10 秒),锁自动过期;请求 B 拿到锁开始执行;此时 A 处理完了,直接 DEL 把 B 的锁删了;请求 C 又能拿到锁。整个锁的互斥就失效了。Lua 脚本在 Redis 端原子执行,只有 owner 对得上才会删。

为什么锁内还要再查一次订单?因为「锁保护的是当前进程」,不能依赖锁本身保证业务无重。锁内查询是应用层幂等,就算锁意外失效,这个查询依然能把重复请求拦下来。这就是所谓的「双保险」。

锁超时时间的设置是个平衡:设短了业务还没处理完锁就自动释放,会导致并发问题;设长了万一持有者崩溃,其他请求会一直等。10 秒对于下单这种操作是够的,如果里面有调三方接口,那得重新评估。

第三层:数据库唯一索引兜底

前面两层都是应用层的,如果服务重启、Redis 集群故障,或者你有个同学写了个脚本直接往数据库插数据,应用层的防线就都不起作用了。真正能在数据库这一层保证唯一性的,只有唯一索引。

ALTER TABLE `order`
  ADD UNIQUE INDEX `uk_user_goods_active` (`user_id`, `goods_id`, `status`);

等等,这个索引设计有个问题:状态变成 cancelled 之后,用户应该能重新下单,但唯一索引不允许。所以这种「部分唯一」的需求,得换一种写法。

常见做法是给表加一个 active_flag 字段,用 1 表示有效、NULL 表示已失效:

ALTER TABLE `order`
  ADD COLUMN `active_flag` TINYINT DEFAULT 1 COMMENT '1=有效 NULL=已失效',
  ADD UNIQUE INDEX `uk_user_goods_active` (`user_id`, `goods_id`, `active_flag`);

MySQL 的唯一索引对 NULL 是容忍的——多行同一组 (user_id, goods_id) 只要 active_flag 都是 NULL,就不会冲突。取消订单时把 active_flag 置为 NULL,新订单又能创建了。

业务代码里捕获唯一索引冲突:

use thinkexceptionDbException;

try {
    $order = Order::create([
        'user_id' => $userId,
        'goods_id' => $goodsId,
        'active_flag' => 1,
        // ...其他字段
    ]);
    return $order;
} catch (DbException $e) {
    // 1062 是 MySQL 唯一索引冲突的错误码
    if (str_contains($e->getMessage(), '1062')) {
        $existing = Order::where('user_id', $userId)
            ->where('goods_id', $goodsId)
            ->where('active_flag', 1)
            ->find();
        return $existing;
    }
    throw $e;
}

捕获到 1062 之后不要直接抛「重复提交」,而是查出已有的订单返回给用户。用户拿到的是同一个订单号,这就是幂等——同样的请求执行多次,结果一致。

把三层都接上:完整流程

把中间件注册到路由上:

// route/app.php
Route::post('api/order', 'api/Order/create')
    ->middleware([
        appmiddlewareIdempotent::class,
    ]);

客户端调用时序:

  1. 进入下单页,调 GET /api/token 拿 Token;
  2. 提交时携带 Token 到 POST /api/order
  3. Idempotent 中间件消费 Token,首次请求放行;
  4. Order 控制器获取分布式锁;
  5. 锁内查重,无重复则创建订单;
  6. 创建时若触发唯一索引冲突,捕获后返回已有订单;
  7. finally 释放锁;
  8. 返回订单信息。

用户重复点击引发的三个请求,会分别在第二层和第三层被拦下来:第一个正常处理,第二个拿不到锁返回 429,第三个消费 Token 失败返回 40002。用户看到的是同一个订单号加一句「操作进行中」,体验是连贯的。

五个容易写错的地方

一、Token 生成时没有设置过期时间。没用过的 Token 会一直留在 Redis 里,时间长了就是内存泄漏。一定要设 TTL。

二、锁的 TTL 和业务处理时间不匹配。调三方支付接口可能要十几秒,锁设 10 秒就自动释放了。要么加锁续期(看门狗),要么把锁的 TTL 设得比业务最长处理时间宽裕一些。

三、捕获数据库异常时把错误码搞混了。ThinkPHP 8 抛出的 DbException 里嵌套了 PDOException,取真正的 SQL 错误码要通过 $e->getPrevious()->getCode(),直接 getCode() 拿到的是框架的错误码。上面示例用字符串匹配只是一种简写,生产环境建议用更严谨的方式。

四、唯一索引冲突处理里又漏了条件。查已有订单时只按 user_id + goods_id 查,没带 active_flag = 1,可能翻出来一条已取消的历史订单返回给用户。这种 bug 排查起来很费劲,因为看起来数据没问题。

五、把幂等和限流搞混了。幂等是「同样的请求只执行一次」,限流是「单位时间内最多执行 N 次」。前者保护业务数据,后者保护系统稳定性。两者都需要,但放在不同的中间件里做。

什么时候这套方案太重了

三层防线不是所有接口都值得铺。如果是「发送短信验证码」这种 60 秒内只能调一次的场景,一层 Redis 限流就够了;如果是「修改用户昵称」这种天然可重入的操作,连幂等都不用做——把昵称改成「张三」,执行十遍结果都一样。

真正值得上三层的是这几类:涉及资金的操作、库存扣减、优惠券领取、积分兑换、以及那些「重复一次就会引发客诉」的动作。判断标准很朴素:如果我今晚忘了加防护,明天会不会被运营或者客服找上门。

最后补一句关于测试。这些幂等逻辑写好之后,光看代码很难判断对不对。我会写一小段并发压测脚本,用 Apache Bench 或者 k6 起 50 个并发往同一个接口发同样的请求,看数据库里到底有几个订单。跑通一次,后面睡得踏实一点。

ThinkPHP 8 接口幂等实战:一次重复下单,改了三处代码
收藏 (0) 打赏

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

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

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

淘吗网 thinkphp ThinkPHP 8 接口幂等实战:一次重复下单,改了三处代码 https://www.taomawang.com/server/thinkphp/2795.html

常见问题

相关文章

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

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