对外 Webhook:事件签名、重试、幂等与死信队列

从一次付款成功事件漏投的教学案例讲起,拆解对外 Webhook 的事件信封与版本、HMAC 签名与时间戳防重放、带抖动的重试窗口与幂等键、死信队列与订阅方自助排障,并给出可复算的有效投递率和重复投递率验收口径,以及本地接收器的复现步骤。

客户漏掉一次付款成功事件,比接口报错更难解释。

一、一次付款成功,客户系统里没有记录

一位独立开发者为一套订阅计费 API 做了对外 Webhook:客户在自己的系统里配置回调地址,付款成功时接收 payment.succeeded 事件。上线第二周,客户财务对账时发现一笔 99 美元的付款没有进他们的订单表,而作者后台显示扣款成功,两边对了整整两天。

以下情境为教学假设,人物、引语和数字不代表经核实的真实案例。

作者的投递日志里只有一条记录:首次 POST 返回 502,因为客户服务当时正在发布,网关在 5 秒后超时。他当时的实现是「发一次,失败就写日志」,没有队列、没有重试、也没有补发入口。事件就此消失,客户甚至不知道曾经发生过一次投递。

HTTP 回调本身不提供至少一次保证。 本文设计的是持久化事件、允许重复、在有界窗口内重试的投递服务;窗口耗尽后进入死信,不能声称每条事件最终必达。发送方不能假定「发过一次就算送达」,接收方也不能假定「同一事件只会来一次」。一次 5xx、一次连接重置、一次读到响应前的超时,都需要有明确的处理路径。

业务提交与事件产生也要一致。 例如付款落库与 outbox 事件写入同一个数据库事务,由后台投递器读取 outbox。不要先提交付款,再用一次可能失败的网络调用临时生成通知,否则事件可能在进入重试队列之前就丢失。

翻译成工程语言,一条对外事件至少要有四样东西:稳定的信封、可验证的签名、有上限的重试窗口,以及超过窗口后的兜底路径。缺任何一项,漏投都只是时间问题。

漏投的代价不对称。 对发送方,一次漏投只是日志里多一条失败记录;对订阅方,它意味着订单、库存或账目少了一笔,而且往往几天后才在对账时暴露。这种代价不对称决定了投递可靠性不能按「成功率差不多」来验收。

先定义什么叫送达。 本文把「2xx」视为接收方已可靠接收:业务事务已提交,或事件已进入可恢复的持久队列;它不一定表示异步业务已经完成。本文选择把 4xx(除 429)视为永久失败、不再重试,把 429、5xx、超时和连接错误视为可重试失败。这是本产品的契约,不是所有 Webhook 的统一规则,必须写进公开文档。

二、事件信封与版本

订阅方收到的不是一个裸对象,而是一个信封。信封的设计决定了接收方能否去重、能否判断自己是否理解这条事件、能否在半年后回放它。

字段类型作用常见错误
idstring事件唯一标识,跨重试保持不变每次重试重新生成 UUID
typestring事件类型,形如 resource.action用自然语言句子表达类型
createdRFC 3339事件发生时间,用于排序与排查用投递时间冒充发生时间
api_versionstring生成该事件时的契约版本只写在订阅 URL 里
dataobject业务对象快照或引用塞入跨多个对象的巨型聚合

信封不是业务对象。 信封只回答「发生了什么、什么时候发生、按哪一版契约生成」,业务字段全部放进 data。这样接收方可以先按 type 路由,再决定是否解析 data;遇到不认识的事件类型可以安全忽略,而不是解析失败后整批退回。

类型命名用 resource.action,动词用过去式。 payment.succeeded、subscription.canceled、invoice.payment_failed 都表示已经发生的事实,而不是请求对方去做某件事。类型一旦公开就不要修改语义;需要改变含义时新增一个类型,让旧类型继续按原语义投递一段时间。

版本随信封走,而不是随 URL。 同一个回调地址可能同时收到灰度版本和旧版本的事件,URL 只有一个,无法表达「这一条按哪版契约生成」。把 api_version 放进每个信封,接收方才能逐条判断字段是否齐全;升级时也只是新增版本,而不是让老订阅方突然解析失败。

事件载荷不变,投递签名重新生成。 发送方保存不可变的原始请求体字节与事件 id;每次重试或人工重放都保留它们,但使用本次投递时间戳与当前有效密钥重新签名。不要原样复制旧签名头,否则会被接收方的时间窗拒绝;也不要生成新事件 id,否则同一业务事实会被当成新事件。

data 放快照还是引用。 放快照让接收方一次拿全,不必回调查询,但事件体积大、且可能包含后来已变更的字段;放引用让事件小、始终最新,但接收方要多一次请求。常见折中是关键字段放快照,完整对象留一个 resource_url。

事件类型要可枚举。 公开一份事件类型清单和一个「订阅全部」的选项,比让客户猜类型名更省支持成本。新增类型属于向后兼容变更,但接收方应默认忽略未知类型,而不是让整条队列卡住。

三、签名、时间戳与防重放

如果订阅方无法确认「这条请求真的来自你」,那么任何人只要知道回调地址,就能伪造一条付款成功事件。回调地址通常会出现在日志、截图和工单里,它不是秘密。

签名方案不必复杂:HMAC-SHA256,密钥按客户独立,签名内容为「时间戳 + . + 原始请求体字节」,结果放在请求头里,例如 X-Webhook-Signature: t=1690000000,v1=...。接收方用同一份密钥重算并做常量时间比较。

风险对策验收
伪造请求每客户独立密钥 + HMAC 签名用错误密钥构造请求,接收方必须拒绝
重放旧请求签名覆盖时间戳,接收方校验容忍窗把 10 分钟前的合法请求重发,必须拒绝
密钥泄露双密钥轮换,新旧并存一个过渡窗口轮换后旧密钥签名的请求在窗口内仍可用
请求体被篡改对原始字节签名,不对解析后的对象签名保留原签名,只改请求体一个字段,验证必须失败
时钟偏差误杀容忍窗写进文档,并提供服务器时间接口偏差 4 分钟通过,偏差 6 分钟拒绝

签名只保护完整性,不保护机密性。 Webhook 内容通常是明文传输,靠 HTTPS 保护通道。不要把超出必要范围的个人数据塞进事件,也不要把密钥写进日志或错误信息。

密钥按客户、环境与端点隔离。 测试与生产不能共用同一密钥;不同订阅端点按独立凭据管理,泄露时可单独撤销。密钥展示一次,之后只允许重置;计划轮换可安排新旧密钥过渡窗口,已确认泄露的旧密钥应立即撤销,不能继续留作兼容。

签名失败不要回显细节。 接收方拒绝请求时返回 401 和一个通用原因即可,不要把「期望签名」写进响应体;发送方一侧也不要把密钥或完整签名写进投递日志。

时间戳要由发送方生成,不能让接收方补。 如果签名只覆盖请求体,重放窗口就取决于日志保留时间;把发送方的 Unix 时间戳纳入签名内容,接收方才能在本地判断「这条请求是不是太旧了」。

四、重试、幂等、顺序与死信

至少一次投递意味着重复是设计的一部分。发送方的责任是把重复控制在可接受范围,接收方的责任是把重复处理成一次效果,两边都不能假设对方会替自己兜底。

幂等键用事件 id,不要用业务对象 id。 同一个订单可能先后产生 payment.succeeded 和 invoice.paid 两条事件,用订单号做幂等键会把第二条误判成重复。多租户接收器用「发送方/租户 + 事件 id」作唯一键。唯一索引只说明记录存在,不能单凭冲突推断业务已经完成。

本地业务副作用与去重记录同事务提交。 验签后开启事务,插入去重记录,执行业务更新,全部成功才提交并返回 2xx。失败时两者一起回滚,下次投递仍可处理。并发重复请求必须等待已有事务结果;不能在另一个请求尚未提交时就承诺处理完成。

外部副作用改走持久 inbox/outbox。 发邮件、调第三方支付等不能被本地数据库事务回滚。接收器应先可靠写入 inbox/任务表,再返回 2xx;工作进程保留 pending/processing/completed 等状态与重试记录,并为下游操作传递稳定幂等键。不要先记 completed 再执行外部操作,也不要把“已入队”误称为“业务已完成”。

下面是完整的 Python 3 本地事务演示,可保存为 webhook_transaction.py 后执行 python3 webhook_transaction.py。它只验证 SQLite 中的回滚与去重,不包含 HTTP、签名验证或外部副作用。

import sqlite3

db = sqlite3.connect(":memory:")
db.executescript("""
CREATE TABLE processed_events (event_id TEXT PRIMARY KEY);
CREATE TABLE orders (event_id TEXT PRIMARY KEY);
""")

def handle(event_id, fail=False):
    with db:
        inserted = db.execute(
            "INSERT INTO processed_events VALUES (?) ON CONFLICT DO NOTHING",
            (event_id,),
        ).rowcount
        if inserted == 0:
            return False
        if fail:
            raise RuntimeError("business operation failed")
        db.execute("INSERT INTO orders VALUES (?)", (event_id,))
    return True

try:
    handle("evt_1", fail=True)
except RuntimeError:
    pass
assert db.execute("SELECT count(*) FROM processed_events").fetchone()[0] == 0
assert handle("evt_1") is True
assert handle("evt_1") is False
assert db.execute("SELECT count(*) FROM orders").fetchone()[0] == 1
db.close()
print("PASS: rollback permits retry; duplicate has one business effect")

重试用退避,并公开最大窗口。 下表使用预设的递增间隔,首次等待 1 秒,共尝试 6 次(首次加 5 次重试),并非严格的固定倍率指数序列。实际实现还要加抖动,并为单次请求设置超时;所有耗时都应计入截止时间。

尝试延迟累计
首次1 秒1 秒
第 2 次5 秒6 秒
第 3 次30 秒36 秒
第 4 次120 秒156 秒
第 5 次600 秒756 秒
第 6 次3600 秒4356 秒

顺序不保证,接收方要按事件时间判断新旧。 subscription.canceled 可能先于 payment.succeeded 到达,也可能晚到几个小时。接收方应比较信封里的 created 和对象自身的版本号,而不是用到达顺序直接覆盖状态;丢失顺序信息时,宁可记录冲突也不要静默覆盖。

死信不是垃圾桶。 超过最大窗口仍未收到 2xx 的事件进入死信队列,保留原载荷、最后一次状态码和尝试次数。提供单条与按时间范围的重放接口,并让订阅方在自己的控制台里看到投递日志。订阅方自助排障的入口通常包括三类:每次尝试的时间与状态码、本次投递的响应摘要、以及一个「重发这条」按钮。

投递状态含义订阅方可做的动作
pending已入队,尚未投递无
delivered收到 2xx查看响应摘要
retrying已失败,等待下次重试查看下次重试时间
dead超过窗口仍未成功手动重发或联系支持

重试要区分错误类型。 429 应按响应里的重试提示等待,5xx 和超时按退避重试,400/401/403/404 通常重试也无用,应直接进入死信并提示订阅方检查配置。把所有失败一视同仁地重试六次,只会放大无效流量。

投递日志要能回答三个问题。 这条事件现在是什么状态、上一次失败的原因是什么、下一次会在什么时候尝试。三个问题答不上来,订阅方就只能发工单,支持成本会随调用方数量线性上涨。

五、行动清单与复算验收

  • 为每条事件生成稳定 id,所有重试复用同一个值。
  • 在信封里写入 type、created、api_version,并冻结字段语义。
  • 为每个客户签发独立签名密钥,支持双密钥轮换。
  • 签名覆盖时间戳与原始请求体字节,接收方校验 ±5 分钟。
  • 配置指数退避与抖动,最大窗口写进公开文档。
  • 接收方对事件 id 建唯一索引,重复投递不产生二次副作用。
  • 超过窗口的事件进入死信队列,提供单条重放接口。
  • 提供投递日志与手动重发入口,让订阅方能自己排障。

验收标准:抽取最近 1000 条事件,首次成功与重试成功之和除以事件总数不低于 99%;重复投递能被订阅方幂等吸收;死信中的每一条都有明确原因与重放记录。

有效投递率 =(首次成功 + 重试成功)÷ 事件总数
示例:(982 + 13)÷ 1000 = 99.5%
累计等待 = 1 + 5 + 30 + 120 + 600 + 3600 = 4356 秒 ≈ 72.6 分钟
实际投递窗口还包含请求耗时;例如契约另设 75 分钟总截止时间
重复投递率 = 重复接收事件数 ÷ 投递成功事件数(订阅方应能接受 > 0)
死信判定:超过最大窗口仍未 2xx 的事件

使用边界与实操验收

本文讨论的是对外 HTTP 回调这条线。如果你的场景是订阅式推送或消息队列,重试语义、顺序保证和消费位点都不一样,不能直接套用这里的窗口。

与通用重试机制的分工。 Webhook 重试与幂等性设计:指数退避、死信队列与去重实战 讲的是通用的重试与幂等机制,包括退避算法、去重存储和队列消费;本文讲的是对外的部分:事件信封怎么设计、签名怎么覆盖时间戳、订阅方如何自助排障。两者可以叠加使用,但不能互相替代。

与订阅式方案的边界。 GraphQL 事件驱动集成:订阅、Webhook 与消息队列 讨论的是订阅式方案如何与回调、消息队列配合;本文只做 HTTP 回调这一条线,不涉及长连接和推送协议。选择哪种取决于你的调用方是否有稳定的公网入口。

本地可做的四项验收。 用接收器模拟 502、读到响应前超时、业务写入失败、同一事件重复投递:前两种进入重试;第三种不能留下已完成标记,重投可恢复;第四种只有在已可靠接收的条件下才返回 2xx。还应验证人工重放保留事件 id,但刷新时间戳与签名。上面的 SQLite 程序只覆盖第三、四项的本地事务路径。

不适用于什么。 对账要求强一致的金融核心系统通常需要更严格的顺序与确认机制,不能用「最多重试 6 次」当作可靠性承诺;那类场景应改用可查询的批量对账接口,Webhook 只做提醒。

验收要留下可复核的记录。 每次模拟失败都应保存请求时间、状态码、重试次数和最终状态;只截一张「成功收到」的图,无法证明失败路径也被覆盖。

文档要写清尝试次数与放弃条件。 本例共 6 次尝试、5 次重试,累计等待约 72.6 分钟,另设 75 分钟总截止时间;到期或次数耗尽即入死信。实际公开这些数字前,应在请求超时、抖动与队列积压条件下核验截止行为。

关键参考与核验范围

以下链接是规范与厂商文档入口,用于核对签名头、时间戳与投递语义的公开约定。本文的重试窗口、阈值和算例均为教学假设,不构成对任何厂商行为的承诺。核验日期:2026-10-25。

本文引用的两个外部入口只用于核对签名与投递语义,不涉及具体定价或配额;实际接入时仍应以对应厂商当期的文档为准。

系列导航

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「Indie」更多文章

  1. 开源项目验收:十项能力、四维评分与三种不该开源的项目
  2. 治理、fork 与退出:17 位贡献者、82% 的提交与一次改协议的代价
  3. 双许可与企业版:九家询价、两家成交与 4,800 美元