去年年底,有个客户半夜给我们值班群发消息,说他们一个用户投诉,下了 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 加锁成功,处理失败后没删锁,锁一直挂着,回调全部被拒。或者删锁的时候没判断是不是自己加的锁,把别人的锁删了。
一个完整的幂等方案要满足什么
根据我自己的实践经验,一个可靠的支付回调幂等方案至少需要满足以下几点:
- 并发安全。多个回调同时到达时,只有一个能进入业务处理。
- 跨进程生效。多台机器、多个 worker 都能共享同一份去重依据。
- 失败可重试。业务处理失败时,必须允许后续的重复回调能拿到重试机会,而不是被永久拒绝。
- 业务可追溯。每一笔回调的处理过程要能查到,出问题的时候能定位。
- 网关通知粒度。区分「同一通知被重推」和「不同通知针对同一订单」这两种情况。
这五条想清楚了,方案其实就不复杂了。
整体设计:四层防线
我最终落地的方案分了四层,从外到内依次是:
| 层次 | 作用 | 失效场景 |
|---|---|---|
| 接入层验签 | 拦掉伪造请求,保证只处理来自网关的合法通知 | 验签算法被绕过的极端情况 |
| 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 做去重键,状态机做业务护栏,失败允许重试成功兑现承诺。这三句话想明白了,代码怎么写都是次要的。
这篇事故复盘写成文章的时候,那个客户已经稳定运行了半年多,之前那种重复发货的投诉再没出现过。希望这套方案对你也有用。

