ThinkPHP 8 支付回调幂等实战:从重复订单到最终一致的完整方案

2026-10-04 0 958

去年年底,有个客户半夜给我们值班群发消息,说他们一个用户投诉,下了 1 单,收到了 2 件货。仓库同事核对后发现确实是同一笔订单的两次发货记录,两次发货时间相差 47 秒。支付回调日志里,同一个微信支付的通知 ID 出现了 3 次。

这就是典型的支付回调不幂等导致的问题。收到消息的时候我在想,这套回调逻辑我们写过不下 20 次了,怎么会栽在这里。翻开代码一看,怎么说呢,也不是没做防重,是做了个半吊子防重 —— 先查订单状态,如果已经是「已支付」就直接返回,否则就走发货流程。看起来没问题对吧?坏就坏在那 47 秒上:第一次回调进来,查订单状态还是「待支付」,开始处理;处理到一半,第二次回调进来了,查订单状态还是「待支付」,也开始处理。两条请求交错跑,最后都走了发货分支。

这篇文章就把这次事故的完整复盘、最终落地方案、以及后来做的压测验证全部写出来。如果你的项目也有支付、退款、订阅这类回调接口,这篇应该能帮你少熬几个通宵。

先厘清问题的本质

支付回调不像普通请求,它是「外部系统主动推给你」的。你没法控制对方推多少次、什么顺序推、什么时候推。微信、支付宝、Stripe 这些网关,为了确保通知送达,普遍采用的是「至少一次」的投递语义 —— 也就是同一条通知可能推送多次。

「至少一次」意味着两件事:

  • 你的接口一定会收到重复请求,躲是躲不掉的。
  • 业务侧必须自己承担「去重」的责任,否则就会重复处理。

重复处理在小场景下最多多几毛钱,在大场景下就是仓库多发一批货、账目对不上、客服接到投诉电话。这不是可选项,是必修课。

先说说那些常见的错误做法

这些年我看过的回调代码没有一百也有八十份,最常见的错误做法就那么几种,几乎每次都有人踩。

第一种:查订单状态判断。就是我上面栽的那个坑。看似简单有效,实则完全不具备并发安全性。两个请求同时读到「待支付」,都会继续往下跑。数据库是共享的,但两次读是有时间差的,这个时间差足够让第二次读也读到旧值。

第二种:用「先更新订单状态」做乐观锁。这个方案比第一种好,但很多人的写法有问题,比如:

Db::name('order')
    ->where('order_no', $orderNo)
    ->where('status', 'pending')
    ->update(['status' => 'paid']);

// 后面直接走发货
$this->ship($order);

问题在于没有检查 update 的返回值。如果这次 update 影响了 0 行,说明订单已经不是 pending 状态,那就不该继续走下去。很多人写完就忘了加判断。

第三种:只锁订单号,不管网关通知 ID。微信、支付宝的回调里都有一个通知 ID 或者交易号,同一笔订单可能产生多条不同的通知(比如支付成功、支付失败、退款等),你用订单号做唯一键就挡住了所有后续通知。

第四种:只用文件锁或者本地缓存。单机环境可能看着没问题,一上多台机器就彻底失效。这个问题特别阴,因为测试环境(通常单机)完全复现不了,只有生产环境多实例才会暴露。

第五种:Redis 锁加锁没加对。常见的是 SETNX 加锁成功,处理失败后没删锁,锁一直挂着,回调全部被拒。或者删锁的时候没判断是不是自己加的锁,把别人的锁删了。

一个完整的幂等方案要满足什么

根据我自己的实践经验,一个可靠的支付回调幂等方案至少需要满足以下几点:

  1. 并发安全。多个回调同时到达时,只有一个能进入业务处理。
  2. 跨进程生效。多台机器、多个 worker 都能共享同一份去重依据。
  3. 失败可重试。业务处理失败时,必须允许后续的重复回调能拿到重试机会,而不是被永久拒绝。
  4. 业务可追溯。每一笔回调的处理过程要能查到,出问题的时候能定位。
  5. 网关通知粒度。区分「同一通知被重推」和「不同通知针对同一订单」这两种情况。

这五条想清楚了,方案其实就不复杂了。

整体设计:四层防线

我最终落地的方案分了四层,从外到内依次是:

层次 作用 失效场景
接入层验签 拦掉伪造请求,保证只处理来自网关的合法通知 验签算法被绕过的极端情况
Redis 分布式锁 同一通知 ID 同一时刻只允许一个处理线程 Redis 宕机
幂等表唯一键 数据库层面的最终兜底,防止重复插入 无(数据库是最终一致点)
订单状态机 业务层的状态跃迁检查 无(业务规则的守护)

为什么要四层?因为每一层都有它明确的职责,不要指望一层守全部。Redis 不稳定的时候,数据库唯一键兜住;数据库死锁重试的时候,状态机兜住。分层设计不是教条,是经验 —— 事故就是这么一次一次攒出来的。

接入层:验签和基础参数校验

第一层比较简单,主要是防止伪造请求。我用的是一个 ThinkPHP 的中间件来处理,这样各个回调控制器可以共享逻辑。

<?php
namespace appcommonmiddleware;

use thinkRequest;
use thinkResponse;

class NotifyVerifier
{
    public function handle(Request $request, Closure $next, string $channel = 'wxpay')
    {
        $raw = $request->getContent();

        if (empty($raw)) {
            return json(['code' => 'FAIL', 'message' => 'empty body'], 400);
        }

        $headers = $this->extractSignHeaders($request, $channel);
        if (!$this->verify($channel, $raw, $headers)) {
            // 记日志,但不要返回太多细节给外部
            trace("notify verify failed: {$channel}", 'error');
            return json(['code' => 'FAIL', 'message' => 'invalid signature'], 401);
        }

        return $next($request);
    }

    private function extractSignHeaders(Request $request, string $channel): array
    {
        return match ($channel) {
            'wxpay' => [
                'timestamp' => $request->header('Wechatpay-Timestamp', ''),
                'nonce'     => $request->header('Wechatpay-Nonce', ''),
                'signature' => $request->header('Wechatpay-Signature', ''),
                'serial'    => $request->header('Wechatpay-Serial', ''),
            ],
            'alipay' => [
                'sign'      => $request->post('sign', ''),
                'sign_type' => $request->post('sign_type', ''),
            ],
            default => [],
        };
    }

    private function verify(string $channel, string $raw, array $headers): bool
    {
        // 具体实现按网关文档来,这里省略
        return true;
    }
}

中间件注册在路由组上:

Route::group('notify', function () {
    Route::post('wxpay', 'Notify/wxpay');
    Route::post('alipay', 'Notify/alipay');
})->middleware([
    NotifyVerifier::class . ':wxpay',
]);

Redis 锁:只锁通知 ID,不锁订单号

第二层是最容易被写错的。首先明确一点:我们的锁应该加在「网关通知 ID」上,而不是订单号上。

原因是同一笔订单可能会收到多种通知:支付成功通知、退款通知、退款结果通知。这些通知的性质不同,不该被同一把锁互相阻塞。但同一条通知如果被网关重推了三次,那三次就应该只处理一次。

所以我用的 key 是 notify:lock:{channel}:{notify_id}。

写锁的工具方法:

<?php
namespace appcommonservice;

use thinkfacadeCache;

class NotifyLock
{
    /** 锁的默认过期时间(秒),比业务最长处理时间留出足够余量 */
    private const LOCK_TTL = 30;

    /**
     * 尝试获取锁
     * @return string|null 成功返回 token,失败返回 null
     */
    public static function acquire(string $channel, string $notifyId): ?string
    {
        $key = self::key($channel, $notifyId);
        $token = bin2hex(random_bytes(16));

        // Redis 原生 SET NX EX,原子操作
        $ok = Cache::store('redis')->handler()->set(
            $key,
            $token,
            ['nx', 'ex' => self::LOCK_TTL]
        );

        return $ok ? $token : null;
    }

    public static function release(string $channel, string $notifyId, string $token): void
    {
        $key = self::key($channel, $notifyId);
        $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, $token], 1);
    }

    private static function key(string $channel, string $notifyId): string
    {
        return "notify:lock:{$channel}:{$notifyId}";
    }
}

几个细节说明一下。

为什么用 token?加锁时生成一个随机 token 存到 value 里,释放时比对。这能防止一种极端情况:A 加锁后处理超时,锁自动过期,B 加了锁,A 处理完去释放锁,把 B 的锁删了。加上 token 比对就不会有这个问题。

为什么用 Lua 脚本释放?因为 GET 和 DEL 是两个操作,中间有窗口期。Lua 脚本在 Redis 里是原子执行的,安全。

为什么锁 TTL 是 30 秒?这是经验值。支付回调的处理逻辑正常情况下不超过 3 秒,留 10 倍余量。如果你有更长的处理逻辑,得根据自己的业务调整,但也不能太短,短了会出现「锁已经过期但业务还没跑完」的问题,反而有害。

幂等表:数据库层面的最终防线

Redis 可能挂、可能有脏数据、可能被清空,所以必须有一个数据库层面的兜底。我建了一张独立的回调记录表:

CREATE TABLE `payment_notify_log` (
  `id` bigint unsigned NOT NULL AUTO_INCREMENT,
  `channel` varchar(16) NOT NULL COMMENT '网关标识 wxpay/alipay/stripe',
  `notify_id` varchar(128) NOT NULL COMMENT '网关通知ID',
  `order_no` varchar(64) NOT NULL,
  `payload` mediumtext COMMENT '原始报文,便于排查',
  `status` tinyint NOT NULL DEFAULT 0 COMMENT '0处理中 1成功 2失败',
  `error_msg` varchar(512) DEFAULT NULL,
  `created_at` int NOT NULL,
  `updated_at` int NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_channel_notify` (`channel`, `notify_id`),
  KEY `idx_order_no` (`order_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

关键就在那个唯一键 uk_channel_notify 上。任何一条回调进来,先尝试 insert 到这张表,插入成功说明这是首次处理,插入失败(触发唯一键冲突)说明已经处理过。

注意这里的 status 字段设计:0 处理中、1 成功、2 失败。这样即使 Redis 完全失效,数据库层也能挡住重复处理。

状态机:业务层的最后一道关

最后一层是订单状态机。就算前面所有的防线都失效了,状态机也应该能拦住不合法的状态跃迁。

<?php
namespace appcommonservice;

class OrderStateMachine
{
    /** 状态跃迁白名单 */
    private const TRANSITIONS = [
        'pending'  => ['paid', 'cancelled'],
        'paid'     => ['shipped', 'refunding'],
        'shipped'  => ['received'],
        'received' => ['refunding', 'completed'],
        'refunding' => ['refunded', 'paid'],
        'refunded' => [],
        'completed' => [],
        'cancelled' => [],
    ];

    public static function canTransit(string $from, string $to): bool
    {
        return in_array($to, self::TRANSITIONS[$from] ?? [], true);
    }

    /**
     * 事务安全的状态跃迁,返回 true 表示本次跃迁是自己完成的
     */
    public static function transit(int $orderId, string $from, string $to): bool
    {
        if (self::canTransit($from, $to) === false) {
            return false;
        }

        $affected = Db::name('order')
            ->where('id', $orderId)
            ->where('status', $from)
            ->update([
                'status'     => $to,
                'updated_at' => time(),
            ]);

        // 影响行数 0 说明被别人抢先了,或者状态已经不对
        return $affected === 1;
    }
}

这里的 transit 方法用了「条件更新」的技巧:UPDATE ... WHERE id = ? AND status = ?。如果返回 0 行,说明状态在这期间被别人改过了,本次不应该继续处理业务。

这是并发场景下状态切换的经典做法,比「先查再改」安全得多。

把四层防线串起来:回调处理器

现在把这几层串到一个完整的处理器里。这是一个抽象类,微信、支付宝、Stripe 之类的网关可以继承它,只实现各自特有的部分。

<?php
namespace appcommonservicenotify;

use appcommonserviceNotifyLock;
use thinkfacadeDb;
use thinkfacadeLog;
use Throwable;

abstract class AbstractNotifyHandler
{
    /**
     * 子类实现:从原始报文里提取通知 ID
     */
    abstract protected function extractNotifyId(array $payload): string;

    /**
     * 子类实现:从原始报文里提取订单号
     */
    abstract protected function extractOrderNo(array $payload): string;

    /**
     * 子类实现:业务处理逻辑
     */
    abstract protected function doBusiness(string $orderNo, array $payload): void;

    /**
     * 子类实现:返回当前网关标识
     */
    abstract protected function channel(): string;

    /**
     * 主入口
     */
    public function handle(array $payload): array
    {
        $channel  = $this->channel();
        $notifyId = $this->extractNotifyId($payload);
        $orderNo  = $this->extractOrderNo($payload);

        if (empty($notifyId)) {
            return ['code' => 'FAIL', 'message' => 'missing notify id'];
        }

        // 第二层:Redis 锁
        $token = NotifyLock::acquire($channel, $notifyId);
        if ($token === null) {
            // 有另一个线程正在处理同一通知,返回 SUCCESS 让网关别再重试
            // 注意这里不是并发错误,是正常流程
            return ['code' => 'SUCCESS', 'message' => 'processing'];
        }

        try {
            return $this->process($channel, $notifyId, $orderNo, $payload);
        } finally {
            NotifyLock::release($channel, $notifyId, $token);
        }
    }

    private function process(string $channel, string $notifyId, string $orderNo, array $payload): array
    {
        // 第三层:幂等表
        $logId = $this->tryInsertLog($channel, $notifyId, $orderNo, $payload);

        if ($logId === null) {
            // 已经处理过,看看上次是什么结果
            $existing = Db::name('payment_notify_log')
                ->where('channel', $channel)
                ->where('notify_id', $notifyId)
                ->find();

            if ($existing && $existing['status'] === 1) {
                return ['code' => 'SUCCESS', 'message' => 'already processed'];
            }

            if ($existing && $existing['status'] === 2) {
                // 上次失败了,允许本次重试
                $logId = $existing['id'];
                Db::name('payment_notify_log')
                    ->where('id', $logId)
                    ->update(['status' => 0, 'updated_at' => time()]);
            } else {
                // 状态是 0,说明前一个线程正在处理
                return ['code' => 'SUCCESS', 'message' => 'processing'];
            }
        }

        try {
            Db::startTrans();
            // 第四层:业务处理(内部会用到状态机)
            $this->doBusiness($orderNo, $payload);

            Db::name('payment_notify_log')
                ->where('id', $logId)
                ->update(['status' => 1, 'updated_at' => time()]);

            Db::commit();
            return ['code' => 'SUCCESS', 'message' => 'ok'];

        } catch (Throwable $e) {
            Db::rollback();

            Log::error('notify business failed', [
                'channel'   => $channel,
                'notify_id' => $notifyId,
                'order_no'  => $orderNo,
                'error'     => $e->getMessage(),
            ]);

            Db::name('payment_notify_log')
                ->where('id', $logId)
                ->update([
                    'status'     => 2,
                    'error_msg'  => substr($e->getMessage(), 0, 500),
                    'updated_at' => time(),
                ]);

            // 返回 FAIL,让网关重推
            return ['code' => 'FAIL', 'message' => 'retry'];
        }
    }

    private function tryInsertLog(string $channel, string $notifyId, string $orderNo, array $payload): ?int
    {
        $now = time();
        try {
            $id = Db::name('payment_notify_log')->insertGetId([
                'channel'    => $channel,
                'notify_id'  => $notifyId,
                'order_no'   => $orderNo,
                'payload'    => json_encode($payload, JSON_UNESCAPED_UNICODE),
                'status'     => 0,
                'created_at' => $now,
                'updated_at' => $now,
            ]);
            return $id ?: null;
        } catch (Throwable $e) {
            // 唯一键冲突,说明已经处理过
            if (str_contains($e->getMessage(), 'Duplicate entry')) {
                return null;
            }
            throw $e;
        }
    }
}

这段代码里有几个设计决策值得展开说。

为什么 Redis 拿不到锁时返回 SUCCESS?因为这不是「处理失败」,网关那边看的是「你有没有收到通知」。既然另一个线程正在处理,那这次就直接告诉网关签收成功,不要让它再重推。返回失败反而会加剧重推风暴。

为什么幂等表状态是 0 的时候也返回 SUCCESS?同理。前一个线程还没处理完,不要重推,等它处理完就好。如果它处理失败了,状态会变成 2,后续网关重推时就会走到重试分支。

为什么业务失败后要返回 FAIL?因为想让网关重推。此时状态已经更新成 2,下次重推会重新尝试处理。这是「至少一次」语义的正确用法 —— 让对方重试,直到你成功为止。

为什么 payload 要存全?事故排查的时候有用。上面那次事故,我们就是靠比对三次 payload 的字段差异,才定位到是网关把「成功」和「成功+已发货」两条不同事件都推过来了。

微信支付的具体实现

抽象类写完了,具体的网关处理就很薄:

<?php
namespace appcommonservicenotify;

class WechatNotifyHandler extends AbstractNotifyHandler
{
    protected function channel(): string
    {
        return 'wxpay';
    }

    protected function extractNotifyId(array $payload): string
    {
        // 微信支付 v3 的通知 ID 字段
        return (string)($payload['id'] ?? '');
    }

    protected function extractOrderNo(array $payload): string
    {
        return (string)($payload['resource']['ciphertext']['out_trade_no'] ?? '');
    }

    protected function doBusiness(string $orderNo, array $payload): void
    {
        $tradeState = $payload['resource']['ciphertext']['trade_state'] ?? '';

        // 只有支付成功才处理
        if ($tradeState !== 'SUCCESS') {
            return;
        }

        $order = Db::name('order')->where('order_no', $orderNo)->find();
        if (!$order) {
            throw new RuntimeException("order not found: {$orderNo}");
        }

        // 第四层:状态机
        if (!OrderStateMachine::transit($order['id'], 'pending', 'paid')) {
            // 状态已经不是 pending 了,说明别人处理过了
            return;
        }

        // 记录支付流水
        Db::name('payment_record')->insert([
            'order_no'     => $orderNo,
            'channel'      => 'wxpay',
            'trade_no'     => $payload['resource']['ciphertext']['transaction_id'] ?? '',
            'amount'       => $payload['resource']['ciphertext']['amount']['total'] ?? 0,
            'created_at'   => time(),
        ]);

        // 触发后续业务:发货、通知等
        event('OrderPaid', ['order_id' => $order['id']]);
    }
}

控制器里的调用就一行:

<?php
namespace appcontroller;

use appcommonservicenotifyWechatNotifyHandler;
use thinkRequest;

class Notify
{
    public function wxpay(Request $request)
    {
        $payload = json_decode($request->getContent(), true) ?: [];

        // 微信支付 v3 需要解密 resource.ciphertext
        $payload = app(WechatDecryptor::class)->decrypt($payload);

        $handler = new WechatNotifyHandler();
        return json($handler->handle($payload));
    }
}

压测验证

上线前我做了几轮压测,用的是 Apache Bench 加一个本地 mock 网关。测试的关键不是吞吐量,而是幂等性 —— 同一个通知 ID 并发打 1000 次,看数据库里是不是只有一条记录,业务是不是只处理了一次。

命令大致长这样:

ab -n 1000 -c 50 -p payload.json -T application/json 
   -H "Wechatpay-Signature: xxx" 
   http://localhost/notify/wxpay

验证脚本:

SELECT COUNT(*) FROM payment_notify_log WHERE notify_id = 'TEST_NOTIFY_001';
-- 期望结果:1

SELECT COUNT(*) FROM payment_record WHERE trade_no = 'TEST_TRADE_001';
-- 期望结果:1

SELECT status FROM `order` WHERE order_no = 'TEST_ORDER_001';
-- 期望结果:paid

三轮测试下来都没问题。同时观察 Redis 锁的命中日志,能清楚地看到:1000 个请求里,只有 1 个进入了业务处理,其余都是被 Redis 锁或者幂等表挡回去的。

几个额外的坑

坑一:网关重推的时间窗口。微信支付的重推策略是「15 秒、15 秒、30 秒、3 分钟、10 分钟、20 分钟、30 分钟、30 分钟、30 分钟、60 分钟、3 小时、3 小时、3 小时、6 小时、6 小时」这样递增的。这意味着如果你锁 TTL 是 30 秒,但业务处理时间刚好卡在 25 秒,那么有的重推会落在锁还没释放的时候被拒,有的落在锁释放后被处理。这本身没问题,但要注意锁 TTL 和业务超时时间要协调。

坑二:业务日志的噪音。幂等挡回去的请求不要记 error 日志,否则一次网关重推就是十几条告警。我的做法是用 info 日志记录 duplicated notify skipped,只在失败重试时记 error。

坑三:订单号从回调报文里取错位置。微信支付 v3 的报文,订单号在 resource.ciphertext 里,需要解密之后才能拿到。如果你在解密前提取,拿到的会是空字符串。这个坑我见过不止一次。

坑四:事务里调外部服务。业务处理里嵌套了一次 HTTP 请求去通知 ERP 系统,那这个事务的时间会变得不可控,进而锁 TTL 也可能不够用。正确做法是事务提交后再触发事件,让队列去处理外部调用。

坑五:Redis 和数据库不在同一事务里。如果 Redis 加锁成功,但业务抛异常回滚,别忘了在 finally 里释放 Redis 锁。即便释放失败(比如 Redis 挂了),锁也会在 TTL 后自动过期。

写在最后

支付回调的幂等不是什么新话题,但真正做到位需要承认一件事:没有一层防线是足够可靠的。Redis 会挂,数据库会死锁,网络会抖动,网关会重推。四层防线不是为了炫技,是真的每一层都可能在某个时刻失效。

写完之后回看整个方案,核心其实就三句话:通知 ID 做去重键,状态机做业务护栏,失败允许重试成功兑现承诺。这三句话想明白了,代码怎么写都是次要的。

这篇事故复盘写成文章的时候,那个客户已经稳定运行了半年多,之前那种重复发货的投诉再没出现过。希望这套方案对你也有用。

ThinkPHP 8 支付回调幂等实战:从重复订单到最终一致的完整方案
收藏 (0) 打赏

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

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

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

淘吗网 thinkphp ThinkPHP 8 支付回调幂等实战:从重复订单到最终一致的完整方案 https://www.taomawang.com/server/thinkphp/2861.html

常见问题

相关文章

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

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