跳到正文

同一条支付通知,为什么会到两次

2026年9月15日 2 分钟读完 Paymos Tech Paymos Tech
四个一模一样的投递信封,只有第一个盖了已处理的戳——Paymos webhook 幂等插图

要点速览

支付 webhook 的幂等是接收端的事,因为网络投递最多只能承诺至少一次。Paymos 的 一个投递周期是 11 次尝试、约 16 小时,之后失败事件还能手动重放。按 X-Webhook-Id 里那个 evt_ 标识去重,按 data 里的资源标识记订单:前者每个端点各有一个,后者 从头到尾只有一个。

一条 invoice.paid 到了两次。发第二件货的是订单系统,不是 webhook。

能让第二次到达不出事的地方只有接收端,因为网络投递只承诺得了至少一次。发送方在超时之内没等到回复,它分不清是请求丢了还是回复丢了,于是再问一遍。Paymos 这边一共问 11 遍,摊在大约 16 小时里。

每次投递自带一个 evt_… 标识,放在 X-Webhook-Id 里;data 里那个账单标识是另一回事,它从头到尾不变。按前者去重,按后者记订单,重复投递的代价就只剩一次索引查询。

投递契约本身写在文档里。下面讲的是它为什么长这样,以及它对你这边的代码提了什么要求。

发送方为什么保证不了「只到一次」?

网络上没有谁保证得了。两方只能通过一条会丢消息的信道对话,就没办法对彼此的状态达成确定,这是 1975 年就证明过的结论,不是预算问题。三年后 Jim Gray 给它起了现在这个名字,两军悖论。

盯住一次投递看。发送方打开请求,写完 body,等着。10 秒的单次尝试超时到了,socket 上什么都没有。接收端根本没看见?还是它验了签、给订单入了账,回复在回来的路上丢了?从发送这一侧看,这两种情况是同一种沉默。

剩下的选择只有两条,其中一条对钱不诚实。只发一次,付清的账单有时候永远到不了订单系统。再发一次,处理器有时候对同一笔付款跑两遍,而这一种恰好是接收端能靠工程手段防住的。

至少一次不是加密货币的特殊做法

Google 把至少一次写成 Pub/Sub 所有订阅类型的默认行为,并且直接要求订阅方做到幂等。Stripe 的 webhook 页说端点偶尔会收到同一个事件不止一次,答案是把处理过的 ID 记下来。同样的约束,同样的结论。

产品之间不一样的是重复的代价。重复一次埋点,图表歪一点。重复一次 invoice.paid,第二件货发出去了,或者第二枚授权码生成了,而订单系统毫不知情,因为每一次单独看都是对的。

RFC 9110 对这个词本身讲得很准:一个方法是幂等的,指的是多次相同请求的预期效果和一次相同。幂等讲的是接收端拿事件做了什么,不是发送方往线上放了什么。请求头能带一把钥匙;能让两次效果相同的只有接收端。

事务性发件箱补不上这个洞

它补不上重复,而这本来就不归它管。状态变更和消息必须一起出门,可是没有事务能同时罩住数据库和网络。先写行,发送之前崩了,消息丢了。先发送,写之前崩了,你宣布了一件没发生的事。这就是双写,事务性发件箱是标准答案:消息进同一个数据库,和它描述的那个事实待在同一笔事务里,再由搬运进程把它送上网络。

保证要读细一点,它比名声窄。事务提交了消息才存在,事务没提交消息就不存在。Richardson 把另一半列在这个模式的问题里:搬运进程可以发出一条消息,在记下「已发送」之前死掉,重启之后再发一次。

这笔交换值得说明白。消失的消息在任何地方都不留痕迹;重复的消息带着它第一次那个 ID 到达,而 ID 能建索引。某个发送方跑不跑发件箱,从外面看不出来。落到你端点上的只是它的行为。

去重该认哪个 ID?

一次投递里走着两个身份,选错哪一个,正是这套设计要防住的失败。

X-Webhook-Id 和 body 里的 event_id 是同一个值,evt_… 开头,它命名的是这次投递,也就是某个端点手里这一次状态变化的副本。这次投递的每一次重试都带着它,手动重放也保留它。

data 里的资源标识是 inv_…wdr_…pcd_…,它命名的是钱落在哪个东西上。订阅了这个事件的每个端点看到的都是同一个,这个资源之后经过的每一个状态也还是同一个。信封的完整结构在报文参考里。

两种错法。开了两个端点、又把订单表的键建在 event_id 上的商户,一笔付款看到两个不同的 ID,订单记两遍。把投递去重的键建在账单 ID 上的商户,同一张账单的第二次投递就挡掉了;那第二次正是跟在 invoice.confirming 后面的 invoice.paid,于是订单根本没有履约。

Stripe 从另一头得到了同一个划分:记事件 ID 抓重复,两个不同的事件对象描述同一件事的时候,用 data.object 里的对象 ID 配上事件类型。

为什么先写记录,再干活?

先写记录是一条崩溃恢复规则,它只在很窄的窗口里起作用。先履约订单、再记事件的处理器,两步之间留着缝。进程正好死在这条缝里,货发出去了,系统里没有任何记录知道它,而下一次投递会再发一遍。

把处理器翻过来。验签、插投递记录、回复,剩下的交给后台按你自己定的节奏做。

create table webhook_delivery (
    event_id     text primary key,          -- evt_…,这一次投递
    resource_id  text        not null,      -- inv_… / wdr_… / pcd_…,钱落在哪
    event_type   text        not null,
    body         jsonb       not null,
    received_at  timestamptz not null default now(),
    processed_at timestamptz
);

insert into webhook_delivery (event_id, resource_id, event_type, body)
values ($1, $2, $3, $4)
on conflict (event_id) do nothing
returning event_id;

返回零行,说明这次投递已经在册:回 2xx 收工,没有什么要判断的了。返回一行,活是你的,而你回的那个 2xx 是一张收到字节的回执,不是一句「已经发货」。

这一刀也让处理器留在 10 秒超时以内。会挂住的第三方调用、偶尔卡壳的授权码生成,都该待在一条自带重试的队列后面。

重放为什么不会重复发货?

重放是运营对失败事件做的动作,它保留这次投递自己的 ID。你表里已经有的东西重放过来,在门口就拦下了,代价是一次索引查询。

另一种情况更有意思。整个故障期间都在拒收的端点,表是空的,修好之后每一个重放过来的事件对它都是新的。而这些事件背后的业务状态,可能早就对了:客服看了控制台,把订单标成已付;或者夜里的对账任务先一步发现了它。

去重表看不见这些。它只知道自己读过哪些投递。这种情况的保护写在状态流转上:已经记为已付的账单不会再记一次,而这个判断要和它保护的那次写入待在同一笔事务里。

一个事件会回来多久?

11 次尝试,间隔从 1 分钟长到 8 小时,最后一次落在第一次之后大约 16 小时。退避时间表一级一级列了出来。

把它读成关于你自己停机时间的两个事实。一次发版不会有人察觉,因为前面几级只隔几分钟,事件在谁打开控制台之前就回来了。跨了一个工作日的数据库故障不是这样,周期在故障里走完的那些事件会标记为失败。

端点那边什么事都没有。第 11 次尝试失败时,标记失败的只有事件,端点原样留着,还活着,还订阅着。没有计数,也没有什么需要重新打开。整个下午都躺着的接收端回来时,失败事件排着队等重放。

控制台按最新在前显示最近 100 条事件的投递状态、尝试次数和下次重试时间,再往前翻不了。把它当成最近一段流量的观察窗,存档是你自己那张投递表。

密钥轮换的那 24 小时

轮换之后的 24 小时里,签名头带的是两个 v1 值而不是一个,任意一个对得上,这次投递就有效。这个窗口存在的意义,是让接收端按自己的发版节奏换密钥。

v1 当成字段而不是列表来读的接收端,这段时间里每一次投递都失败。每次失败都是梯子上的一级。16 小时之后那些事件标记为失败,第一个能看见的症状通常是一张始终没有发货的订单。

所以把 v1 解析成列表,每个候选值用定时安全的函数比一遍,第一个对上就接受。验签页写明了哈希的是什么、顺序是什么。八个官方 SDK 都已经这样做,一致性测试里钉着两值请求头这条用例,用 SDK 搭的集成自然继承了这个行为。

订阅按类目给,分支写在处理器里

端点订阅的是类目,不是单个事件名,类目里面的名字挑不出来。订了账单的端点会收到全部八种账单事件,想只要 invoice.paid,判断写在你的处理器里。

这一条直接咬到去重。同一张账单一路走过 invoice.confirminginvoice.paid,两条都会到你这儿,资源 ID 一样,事件 ID 不同。把去重键建在资源上的代码,在这里丢掉的正是第二条。

一个商户环境最多 10 个端点。数量本身很少构成约束,会咬人的是另一件事:同一笔付款扇出到两个端点,就是两个不同的 evt_ 值。

怎么证明接收端真的幂等?

靠重放,不靠读代码。要测的性质是关于第二次运行的断言,而单元测试通常正好制造不出第二次运行。

API Playground 在控制台里,用商户自己的凭证发真实 HMAC 签名请求,只在沙盒里跑,它的 webhook Playground 产生的是一次真实投递。拿它对着预发环境的接收端把整套检查走一遍:

  1. 发一次投递,让处理器跑完,记下它建的订单行。
  2. 把同一个事件再发一次。它应该进到你的表,找到那个 ID,返回 2xx,订单一动不动。
  3. 让接收端一直卡着:撑过 10 秒超时,再撑过后面那一分钟退避,重试才会在第一次还没跑完的时候到。唯一索引就是为这次撞车准备的。
  4. 修好接收端之后重放一条失败事件,确认已经记账的订单还是只记一次。
  5. 拿你的投递表和 API 报出的已付账单对一遍。缺行是订阅或者防火墙的问题,重复履约是键的问题。

这五条在接收端上线之前跑完,重试梯子就变成背景噪音。第 11 次尝试和第 1 次一样处理:ID 已经在册,下游什么都不动。

去重键怎么选,各自会坏在哪(2026年9月)
接收端拿什么当键同一次投递重试一笔付款,两个端点confirming 之后是 paid
两件事都用 event_id挡住订单记两遍都处理到
两件事都用账单 ID挡住挡住paid 丢掉
event_id 去重,账单 ID 记订单挡住挡住都处理到

常见问题

为什么同一条支付 webhook 收到了两次?

投递是至少一次。一次超时的尝试照样会重试,哪怕接收端已经处理完了,因为从 发送这一侧看,请求丢了和回复丢了长得一模一样。手动重放也会产生一次重复。

去重该用 event_id 还是账单 ID?

投递去重用 event_id,它和 X-Webhook-Id 请求头是同一个值。订单本身按账单 ID 记。 一笔付款扇出到两个端点会有两个不同的 event_id,账单 ID 在哪儿都一样,账单经过 的每一个状态也还是它。

已经处理过的事件,处理器该返回什么?

立刻返回 2xx。认出来的重复就是一次成功投递。返回错误只会把这个事件重新推回 重试梯子,没有任何好处。

重放过来的 webhook 会换一个新的事件 ID 吗?

不会。重放保留这次投递自己的 evt_ 标识,所以第一次就把它存下来的接收端, 一次索引查询就能认出这是重复。

失败的 webhook 会一直重试多久?

11 次尝试,约 16 小时,间隔从 1 分钟长到 8 小时。之后事件标记为失败,可以手动 重放。端点本身照常活着。

什么时候不该用用 webhook 驱动履约

  • 如果接收端只跑在笔记本上或者内网里,投递没有落点。不能从公网路由到的目标会 拒发,跳进内网的重定向也不跟随。开发阶段先用 API 轮询账单状态。
  • 如果每天只有几笔付款、而且本来就有人在看,投递表、队列和去重键这一整套, 比这个量级需要的机器多。
  • 如果订单流转还挡不住对同一次状态变化的重复处理,先补这件事再接端点。跑两遍 的履约路径前面加一把去重键,只是把窗口收窄,没有关上。
  • 如果想要的是某一个事件名的单独推送,订阅给不了。订阅按类目走,类目里的名字 挑不出来,订了账单的端点会收到全部账单事件。分支写在处理器里。

参考来源

  1. 1. HTTP Semantics (RFC 9110), section 9.2.2 Idempotent Methods (accessed 2026-09-15)
  2. 2. Google Cloud Pub/Sub — Subscription overview (at-least-once delivery) (accessed 2026-09-15)
  3. 3. Stripe — Receive Stripe events in your webhook endpoint (accessed 2026-09-15)
  4. 4. Chris Richardson — Pattern: Transactional outbox (accessed 2026-09-15)
  5. 5. Two Generals' Problem — Akkoyunlu, Ekanadham and Huber (1975) (accessed 2026-09-15)

最近复核:2026年9月15日

#webhook#幂等#发件箱模式#至少一次投递#集成
分享