GCash 支付 Webhook 重试机制怎么设计?避免丢单的接收、幂等与补偿方案(2026)
我在马尼拉带一个做本地生活服务的技术团队,GCash 是我们订单量最大的收款方式。上线头两个月,客服每周都会收到同一类投诉:用户说钱已经扣了,订单却还停在"待支付"。排查下来,通道那边交易是成功的,问题全在我们自己:支付通知到的时候服务正在发版、数据库连接池满了、或者处理到一半进程被重启,通知就这么丢了。关于回调的基本接法和验签,GCash 商户回调怎么做已经讲过;这篇文章只谈一件事:把 Webhook 的"重试"当成一个完整的机制来设计,发送方怎么重试、接收方怎么配合、重试耗尽之后怎么兜底,让丢单从"每周都有"变成"出了也能自动补回来"。
丢单到底丢在哪一段
一笔 GCash 支付从用户点确认到商户订单变成"已支付",通知至少要经过四段:通道或网关生成通知、通过公网投递到商户入口、商户应用接收并验签、业务逻辑更新订单并触发发货。丢单可能发生在任何一段,但性质完全不同。前两段的失败,发送方自己知道(连接超时、对方返回错误),所以会重试;后两段的失败最危险,因为商户入口可能已经返回了成功,发送方认为送达了,不会再发,而商户内部其实没处理完。
我们复盘过自己的丢单记录,绝大多数属于后一种:接口在"业务处理完成之前"就应答了成功,或者在"处理到一半"时超时,发送方重试过来又被错误的去重逻辑挡掉。所以重试机制的设计重心不在"让对方多发几次",而在接收方的三件事:什么时候应答、重复通知怎么处理、对方不再发了怎么办。
发送方的重试规则:先读文档,别靠猜
无论是直连通道还是通过聚合网关接 GCash,通知的发送方都有一套自己的重试规则:判定失败的条件、重试次数、间隔如何递增、总共持续多久、耗尽之后是否还能手动重推。这些参数各家不同,也会调整,务必以对接文档和商务确认为准,不要照搬别处看到的数字。对接时我会把下面几个问题逐条问清楚并写进内部文档:
- 什么样的应答算成功?只看 HTTP 状态码,还是要求响应体里有约定的字符串或字段?
- 应答超时阈值是多少?超过即判失败并进入重试。
- 重试的间隔策略是固定间隔还是递增间隔?最长持续多久?
- 重试耗尽后,后台能否手动重推?有没有主动查询接口可以补状态?
- 通知是否保证顺序?同一笔订单会不会收到多种状态的通知?
- 通知的来源地址是否固定,能否做白名单?
最容易踩的是第一条。有的网关只认状态码,有的要求响应体精确等于某个约定值,多一个换行都算失败,结果就是对方不停重试,而你以为自己早就处理成功了。参数和签名规则可以对照GCash API 对接文档速查。
接收方的核心原则:先落盘,再应答,后处理
接收端的设计我们改过三版。第一版是同步处理:收到通知后验签、更新订单、扣库存、发消息,全部做完才应答。业务链路一长,应答就容易超时,发送方判定失败重试,重试过来的请求又和第一次的处理并发,状态乱成一团。第二版走向另一个极端:收到就立刻应答成功,然后丢进内存队列异步处理,进程一重启,队列里的通知全没了,而发送方不会再发。
第三版才是稳定的做法,原则只有一句:先把原始通知持久化,再应答成功,最后异步处理业务。应答成功的含义被重新定义为"我已经可靠地收下了",而不是"我已经处理完了"。只要落盘成功,后面业务处理失败多少次都可以由我们自己重试;落盘失败就返回错误,让发送方重试。三种做法的对比如下:
| 接收方式 | 应答时机 | 进程崩溃时 | 下游变慢时 | 评价 |
|---|---|---|---|---|
| 同步处理后应答 | 业务全部完成后 | 未应答,发送方会重试,但可能留下半完成状态 | 应答超时,触发大量重试并发 | 链路短时可用,量大后不稳定 |
| 立即应答加内存队列 | 收到即应答 | 队列内通知丢失,发送方不再重发 | 不受影响 | 丢单的主要来源,不建议 |
| 落盘后应答加异步处理 | 原始通知写入持久化存储后 | 已落盘的通知重启后继续处理 | 不受影响,积压可观测 | 推荐做法 |
下面是这一版接收端的简化实现(Node.js),注意验签必须基于原始请求体,落盘用唯一键防重复:
const express = require('express');
const crypto = require('crypto');
const app = express();
// 保留原始报文用于验签,不要用解析后再序列化的结果
app.use('/notify/gcash', express.raw({ type: '*/*' }));
app.post('/notify/gcash', async (req, res) => {
const raw = req.body.toString('utf8');
const sign = req.get('X-Signature') || '';
const expect = crypto.createHmac('sha256', process.env.NOTIFY_SECRET)
.update(raw).digest('hex');
const ok = sign.length === expect.length &&
crypto.timingSafeEqual(Buffer.from(sign), Buffer.from(expect));
if (!ok) return res.status(401).send('INVALID_SIGN');
const body = JSON.parse(raw);
try {
// notify_inbox 对 (gateway_ref, status) 建唯一索引,重复通知直接忽略
await db.query(
`INSERT INTO notify_inbox (gateway_ref, merchant_ref, status, raw_body, state)
VALUES ($1, $2, $3, $4, 'PENDING')
ON CONFLICT (gateway_ref, status) DO NOTHING`,
[body.gateway_ref, body.merchant_ref, body.status, raw]
);
} catch (e) {
// 落盘失败才返回错误,让发送方重试
return res.status(500).send('RETRY');
}
// 落盘成功即应答,业务处理交给后台 worker
res.status(200).send('SUCCESS');
});
幂等:重复通知一定会来
有重试就有重复。网络抖动时对方没收到我们的应答、对方的重试任务和手动重推撞在一起、我们自己的 worker 处理到一半崩溃后重新领取,这些都会让同一条通知被处理不止一次。幂等不是优化项,而是重试机制成立的前提。我们用了三层保护:
- 入口去重:收件表的唯一索引挡掉完全相同的通知,同一流水号加同一状态只留一条。
- 状态机约束:订单状态只允许单向流转。更新时把当前状态写进条件里,已经是"已支付"的订单再收到成功通知,更新影响行数为零,直接跳过后续动作;已经终态的订单收到更早阶段的状态通知也不回退。
- 副作用去重:发货、发券、加余额这类不可重复的动作,用订单号作为业务幂等键单独记录,和订单状态更新放在同一个数据库事务里,或者用事务消息保证只触发一次。
还要处理金额与订单不符的情况:通知里的金额、币种、商户号要和本地订单逐项比对,不一致的不能按成功处理,要进人工复核队列。高并发场景下的锁与限流设计,可以参考支付网关 API 限流与并发处理。
自己的重试:后台 worker 怎么退避
通知落盘之后,业务处理的可靠性就掌握在自己手里了。后台 worker 从收件表领取待处理记录,处理成功标记完成,失败则按退避策略安排下一次。几个要点:
- 区分可重试与不可重试:数据库超时、下游服务不可用属于可重试;订单不存在、金额不符、验签后字段非法属于不可重试,应直接转人工,不要空转。
- 间隔递增并加随机抖动:下游故障时,固定间隔的重试会形成整齐的冲击波,递增间隔加抖动能把压力摊开。
- 设置上限并进入死信:超过最大尝试次数的记录转入死信状态并告警,由值班人员处理,而不是静默放弃。
- 领取要加锁:多个 worker 并发时用行锁或带租约的领取方式,避免同一条记录被同时处理。
// worker:领取并处理待处理通知
async function drainInbox() {
const { rows } = await db.query(
`UPDATE notify_inbox SET state = 'PROCESSING', locked_at = now()
WHERE id IN (
SELECT id FROM notify_inbox
WHERE state = 'PENDING' AND next_run_at <= now()
ORDER BY id LIMIT 50 FOR UPDATE SKIP LOCKED)
RETURNING *`
);
for (const n of rows) {
try {
await applyPayment(n); // 内部按状态机做幂等更新
await mark(n.id, 'DONE');
} catch (e) {
const attempt = n.attempts + 1;
if (!e.retryable || attempt >= MAX_ATTEMPTS) {
await mark(n.id, 'DEAD'); // 死信并告警
continue;
}
// 递增间隔加随机抖动,单位为秒
const base = Math.min(MAX_DELAY, BASE_DELAY * Math.pow(2, attempt));
const delay = base + Math.floor(Math.random() * base / 2);
await reschedule(n.id, attempt, delay);
}
}
}
重试耗尽之后:主动查询与对账兜底
即使接收端做到了上面所有事,仍然有通知根本到不了的情况:域名解析出问题、证书过期、防火墙规则误伤、对方的重试窗口刚好落在我们的故障期里。所以完整的机制必须有不依赖通知的第二条、第三条路径。
| 补偿手段 | 触发方式 | 覆盖的问题 | 局限 |
|---|---|---|---|
| 主动查询补单 | 定时扫描超过阈值仍处于待支付的订单,调用订单查询接口 | 通知完全未到达、被入口拦截 | 受查询接口频率限制,需控制扫描节奏 |
| 后台手动重推 | 在网关商户后台对指定订单重新推送通知 | 个别订单的定向修复 | 人工操作,不适合批量 |
| 日终对账 | 拉取通道交易报表与本地订单逐笔核对 | 以上手段全部遗漏的单边账 | 发现时间最晚,用于兜底而非实时修复 |
主动查询的结果要走和通知完全相同的处理入口:把查询到的终态构造成一条内部通知写进收件表,由同一个 worker 处理。这样幂等、状态机、副作用去重只需要实现一次,通知和查询谁先到都不会出问题。查询请求示例如下:
curl -X POST https://api.example-gateway.com/v1/order/query \
-H "Content-Type: application/json" \
-d '{"merchant_id":"M_XXXXX","merchant_ref":"ORDER_20261014_0001","nonce":"b7f3c1","sign":"..."}'
日终对账是最后一道防线,怎么搭可以看菲律宾支付对账系统怎么搭建。
补偿机制有一个容易被忽略的前提:查询接口和交易报表必须是可信的。承接你资金清算与结算的主体,应当是持有菲律宾央行颁发的 BSP OPS 或 EMI 资质的机构,这类机构有义务提供规范的订单查询、通知重推和结算记录。如果一家服务商既说不清通知的重试规则,也拿不出可核对的交易报表,那么无论接收端写得多严谨,你都只是在对一份无法验证的数据做重试。
监控:让丢单在用户投诉之前被发现
机制搭好之后要靠指标来证明它在工作。我们在看板上固定放了这几项:收件表待处理积压量及最老一条的等待时长;通知从支付完成到落盘的延迟分布;验签失败次数;死信数量;主动查询补回的订单数;日终对账发现的单边账数量。后两项尤其有价值:补单数突然升高,通常说明通知链路出了问题,比如证书、白名单或对方变更了来源地址;对账还能发现单边账,说明查询补单的扫描范围有漏洞。通知延迟本身怎么排查,和支付成功率的关系,可以结合支付成功率怎么提升和GCash 支付对接常见错误码一起看。
发版流程也要配合:接收服务做滚动发布并等待在途请求处理完再退出;入口层在应用不可用时返回错误而不是成功,把重试的机会留给发送方;变更回调地址、证书、签名密钥前,先在沙箱验证,再安排在低峰期切换。
常见问题
GCash 支付通知没收到,对方还会再发吗?
发送方在未收到约定的成功应答时会按自己的规则重试,但次数和持续时间有限,具体以对接文档为准。不要把业务的可靠性寄托在对方的重试上,主动查询和对账必须同时具备。
应答成功之后发现业务处理失败了怎么办?
这正是"先落盘再应答"要解决的问题。原始通知已经持久化,后台 worker 会按退避策略自行重试;多次失败后进入死信并告警,由人工处理。不需要也不应该指望发送方再推一次。
同一笔订单收到两次成功通知,会不会重复发货?
只要订单状态更新带状态条件,并且发货等动作有独立的业务幂等键,就不会。第二次通知在入口去重或状态机这一层就会被识别为已处理。
先收到失败通知,后来又收到成功通知,以哪个为准?
以通道的终态为准,并用主动查询确认。状态机里要明确哪些状态是终态,哪些可以被后续通知覆盖;对已经执行了关单或释放库存的订单,后到的成功通知要进人工复核,决定补发还是退款。
主动查询多久扫一次合适?
没有通用答案,取决于业务对延迟的容忍度和查询接口的频率限制。常见做法是对刚创建不久的待支付订单扫得密一些,时间越久间隔越长,超过订单有效期后做最后一次查询再关单。
回调地址需要做来源白名单吗?
能做就做,但白名单只是辅助,验签才是判断通知真伪的依据。另外要留意对方变更来源地址的通知,白名单没及时更新是通知集体丢失的常见原因。
用了聚合网关,这些还需要自己做吗?
需要。网关解决的是通道侧的统一和发送侧的重试,落盘、幂等、后台处理、主动查询和对账都发生在商户自己的系统里,谁也替代不了。
写在最后
Webhook 重试不是一个开关,而是一条从发送方到接收方再到补偿手段的完整链路:弄清对方的重试规则,接收端先落盘再应答,用唯一键和状态机保证幂等,后台 worker 带退避地自行重试并保留死信,再用主动查询和日终对账兜住通知到不了的情况,最后用指标盯住每一段。我们把这套做完之后,"扣了钱没到账"的投诉基本只剩下用户看错订单的情况。如果你正在排查 GCash 通知丢失,或者想对一下接收端的表结构和退避参数,欢迎通过 Telegram 联系@LouisSMARTPAY,或邮件联系[email protected]。