Webhook 流程图指南
怎么画一张说到点子上的 webhook 流程图:200 该放在哪、重试会对你做什么,以及收发两端各自可直接改的三个例子。
一、什么是 webhook 流程图
Webhook 流程图 画的是一个你没有请求过、却到达了你服务器的 HTTP 请求 —— 以及(这才是它值得画的原因)当你的响应不是 2xx 时,发送方会做什么。
普通的接口图是一去一回,调用失败了调用方当场就知道,然后自己决定怎么办。Webhook 把这一切全部倒过来:你是被调方,调用方是一个不归你运维的系统,而「失败之后怎么办」这个决策是别人做的,写在他们的重试计划里。你返回的那个 200 不是返回值,它是一个对陌生人许下的承诺 —— 承诺他们可以不用再拿着这个事件了。
正因为有这个承诺,投递保证是 至少一次 而不是恰好一次,也因此这张图诚实的主题是「重复」,不是成功路径。你数据库提交完之后网络超时,在发送方看起来和「请求根本没到」一模一样 —— 于是它会再发一次。任何跑得够久的 webhook 接收方都处理过同一个事件两次;唯一的问题是它有没有发现。
所以这张图有两件事要做:标出确认到底在哪一刻发出、那一刻有什么已经落盘了;以及画出同一个事件的第二份副本会造成什么。剩下的都只是箭头。
二、一张 webhook 流程图要画出哪些东西
六样。第一样是这张图的脊椎 —— 它错了,后面五样都救不回来。
- 确认边界,画成一条线。 标出
200在哪儿返回,并写清那一刻有什么已经可靠落盘。线以上是你正在兑现的承诺,线以下是你自己的事。Webhook 处理最常见的设计错误就是把工作放在线以上 —— 于是一个变慢的数据库会变成一场你无权限流的重试风暴。 - 签名校验,对原始字节,在解析之前。 把它画成你的接口内部第一条箭头。它必须对「收到时原样」的 body 做,因为任何一次 JSON 往返都会改变空白和键顺序,签名就对不上了。罪魁祸首通常是自动解析 body 的框架中间件 —— 而顺序不对这件事,是在图上被看出来的。
- 事件 id,以及它被记在哪儿。 幂等不是一个可以许愿得到的性质,它是某一列上的唯一索引。把这条 insert 画出来,并画出撞车时会怎样 —— 因为「这个我们已经有了」是成功不是错误,在那儿返回 500 等于保证发送方会一直重试。
- 重试计划,带真实数字。 「它会重试」 不是设计。把次数和退避写在图上 —— 5 次,间隔 10 秒、1 分、10 分、1 小时、6 小时,是个常见形状 —— 因为这些数字决定了一次坏发布会持续伤多久,以及最糟糕的时刻你的数据能落后多远。
- 到达顺序不是事件顺序。 相隔一秒产生的两个事件,到达顺序可能是任意的;一个旧事件的重试可能落在一个新事件之后。如果你的处理逻辑直接把 payload 里的字段写库,就要画出那个「版本号或时间戳比较」—— 它拦住旧事件覆盖新数据。如果没有这个比较,那这张图刚刚告诉了你一个 bug:一个月复现一次,而且在测试环境永远不出现。
- 重试用完之后会怎样。 死信队列、告警、手动重放 —— 你有哪个就画哪个。没有这条分支的系统不会大声地丢事件,它们悄悄地丢 —— 然后你从一个「盯着一个再没动过的订单」的客户那里得知这件事。
这是骨架,而它里面就带着那个错误。每条箭头都对,形状也正是多数团队上线的样子:收到、干活、回复。问题出在最后一条箭头的 位置 —— 200 排在干活后面,于是发送方的超时时间实际上成了你的处理预算,而这个预算是一个从没见过你代码的人定的。
看 Mermaid 源码
sequenceDiagram
participant Src as Source System
participant Hook as Your Endpoint
participant DB as Your Database
Src->>Hook: POST an event
Hook->>DB: do all the work
DB-->>Hook: done
Hook-->>Src: 200 OK三、Webhook 流程图怎么画
三步,而第一步是一个「画之前必须先回答」的问题。
第 1 步 · 先定你画的是哪一端
接收 webhook 和发送 webhook 是两张几乎没有交集的图,想一次画完只会得到一张两边都没说清的图。
接收方。 参与者是源系统、你的接口、你的存储,通常还有一个队列和一个 worker。失败模式是:验不了签的事件、已经见过的事件、暂时还处理不了的事件、以及乱序到达的事件。重试行为你一点都控制不了 —— 而这恰恰是它该出现在你图上的理由:它是你设计的输入条件。
发送方。 参与者是你的应用、一个投递服务,以及一个可用性和你毫无关系的、属于别人的接口。失败模式是:订阅方挂了、订阅方慢到占满你的 worker、订阅方对一切都返回 200(包括它根本没看懂的东西)、以及订阅方的 URL 半年前就不存在了。这一端的重试计划是你自己在做的决策,而图是让这个决策显形到足以被拿出来吵的地方。
选一端。两端都做就画两张,而且分开放。
第 2 步 · 先画确认边界,别的都往后放
在接收端,先落两条箭头,一个功能都别急着画:请求进来,和 200 回去。然后在图上用文字回答一个问题 —— 这个 200 离开的那一刻,有什么已经可靠落盘了?
如果答案是「事件本身,除此之外什么都还没发生」,那形状就对了。验签、落盘、确认,然后异步处理。发送方在毫秒级被释放,你的处理时间重新归你所有,处理中的失败是一个你按自己的节奏重试的 bug,而不是一次被发送方放大的故障。
如果答案里牵涉到你的业务逻辑,那么有三件事会跟着来,而且是一起来的。你接口的延迟上限,从此由发送方碰巧设的超时决定。一个变慢的依赖会把每一个在途事件都变成一次重试,于是负载恰好在你最扛不住的时候 上升。而中途失败会让你同时拥有:仍然攥着这个事件的发送方(这还能活),以及你已经写下去的那部分状态(这经常活不了)。
有一个正当的例外:处理逻辑真的就是一次幂等写入、几毫秒完成。那就画出来,并保持这样 —— 同时预期在第一次有人往里面加一封邮件发送时,这个决定要重新审一遍。
第 3 步 · 画出来
用句子描述,让 text2diagram 排版 ——「源系统 POST 一个事件,我们对原始 body 验签,把事件 id 插进一张带唯一索引的表,返回 200,然后 worker 从队列里取出来处理;重复的事件 id 直接返回 200 什么也不做」,还回来的是一段分支已经嵌好、可以直接改的 sequenceDiagram。
或者把下面三张打开改参与方名字。值得留下的是分支结构。
四、三个 webhook 流程图例子
三张图:正确地收一个、发一个并处理一个不配合的订阅方,以及那两个只在生产环境才出现的到达问题。
例 1 · 接收,确认放在正确的位置
这里的 Note 是在干实事的 —— 它标出确认边界,而这是读者唯一不该靠猜的东西。它上面的一切发生在发送方等待期间;它下面的一切用的是你自己的时间。
三个细节值得原样抄。验签是 第一件事,对原始字节,在任何解析之前 —— 并且注意拒绝分支什么都不存,因为「把没验过的数据写下去」正是一个公网接口变成一个公网数据库的方式。
重复分支返回的是 200,不是 409。这条箭头是最常画错的。重复意味着发送方其实已经成功了、只是丢了响应;告诉它失败了,等于保证它会为一个你早已处理完的事件继续重试。唯一索引让这件事很便宜 —— 由数据库来判,而不是一个「先读再写」的竞态。
还有,真正的处理在确认之后。worker 挂了,那是你的重试、你的节奏、你的日志。发送方永远不会知道,因为在它看来这个事件已经投递成功了 —— 而这正是你承诺的。
看 Mermaid 源码
sequenceDiagram
autonumber
participant Src as Source System
participant Hook as Webhook Endpoint
participant Log as Event Log
participant Q as Queue
participant W as Worker
Src->>Hook: POST raw body plus signature header
Hook->>Hook: verify the signature on the raw bytes
alt signature does not match
Hook-->>Src: 401 and nothing is stored
else signature is valid
Hook->>Log: insert event_id, unique index
alt event_id is already there
Log-->>Hook: duplicate
Hook-->>Src: 200 already handled
else first time we see it
Log-->>Hook: stored
Hook->>Q: enqueue event_id
Hook-->>Src: 200 accepted
Note over Hook,Src: the ack ends here - everything below is our own time
Q->>W: deliver event_id
W->>Log: load the payload and process it
end
end例 2 · 发送 —— 一次投递,从产生到死信
这张用状态图,因为一次投递尝试是一个有生命周期的对象,不是一段对话。谁问「他们的接口挂了会怎样」,就把这张图摆到他面前 —— 答案是图上的一条路径,而这条路径要走七个小时。
Failed 和 Dropped 的分野是真正值得吵的地方。5xx 或超时的意思是 再试试,他们可能会恢复;4xx 的意思是 你的 payload 不对,一个小时后它还是不对 —— 重试它只是在两个系统里制造噪音。唯一的例外是 429,它是一个明确表示「请重试」的 4xx,应该归到 Failed 那边。这条分野往哪个方向弄错都很贵:全都重试,你会为一份对方永远不会接受的 payload 反复捶打订阅方;全都不重试,一分钟的小故障就让一个客户丢了数据。
DeadLetter 有一条箭头回到 Delivering,这是「可运维的系统」和「事故以一句道歉收场的系统」之间的区别。总会有人的接口挂上一整天。它恢复的时候,你需要一个按钮。
看 Mermaid 源码
stateDiagram-v2
[*] --> Pending: event created
Pending --> Delivering: attempt 1
Delivering --> Delivered: 2xx
Delivering --> Failed: 5xx, timeout or connection refused
Delivering --> Dropped: 4xx other than 429
Failed --> Waiting: attempts remaining
Waiting --> Delivering: backoff 10s, 1m, 10m, 1h, 6h
Failed --> DeadLetter: all 5 attempts used
DeadLetter --> Delivering: manual replay
Delivered --> [*]
Dropped --> [*]
note right of Dropped
a 4xx means our payload is wrong
an hour of backoff will not fix that
end note例 3 · 只在生产环境发生的两件事
这张图的两半描述的都是「单独看投递得完美无缺」的事件。这里没有任何失败。这也正是它们在测试里不出现的原因 —— 测试环境里的事件是一个人一次造一个,造完还盯着结果看。
上半部分 是「至少一次」投递在严格履行它的承诺。你的处理成功了,响应在回程丢了,发送方做了它唯一能做的正确的事。唯一索引把一次潜在的重复扣款变成了空操作,而回复仍然是 200。
下半部分 会产出那种没人能复现的工单。一个旧事件的重试落在了一个新事件之后,而一个「直接把 payload 字段写库」的处理逻辑会开开心心地把一个已送达的订单退回成已发货。修复就是一次比较 —— 版本号不比库里的新就拒绝写入 —— 在图上是一条箭头。它的缺席在图上同样是一条箭头,而这就是画这张图的全部理由。
看 Mermaid 源码
sequenceDiagram
autonumber
participant Src as Source System
participant Hook as Webhook Endpoint
participant DB as Order Table
Note over Src,Hook: same event twice - our 200 was lost on the way back
Src->>Hook: evt_9 order.shipped
Hook->>DB: insert evt_9, then set status shipped
Hook-->>Src: 200
Src->>Hook: evt_9 order.shipped again
Hook->>DB: insert evt_9
DB-->>Hook: unique violation
Hook-->>Src: 200 without touching the order
Note over Src,Hook: the newer event arrives first
Src->>Hook: evt_11 order.delivered, version 11
Hook->>DB: set status delivered, store version 11
Src->>Hook: evt_10 order.shipped, version 10, retried
Hook->>DB: compare version 10 against the stored 11
DB-->>Hook: stale, the write is refused
Hook-->>Src: 200把确认线以下全部遮住,然后问自己刚刚丢了什么
把画完的接收端图拿来,把 200 之后的每一条箭头都遮住。剩下的部分,是发送方唯一会知道的部分,也是唯一「失败了还有重试」的部分。
然后问:如果机器正好死在那条线上,会丢掉什么?如果答案是「什么都不丢 —— 事件已经存了,worker 会捡起来」,那这个设计立得住。如果答案里包含任何你真正需要做的工作,那这部分工作是彻底没有重试的:发送方已经被告知投递成功、永远不会再发,而你这边也没有任何东西攥着它。这不是「慢一点」也不是「降级」,这是一个被悄悄丢掉的事件 —— 而在客户替你发现它之前,这张图是它唯一现形的地方。
常见问题
Webhook 处理逻辑该干活,还是只入队?
入队,几乎所有情况下都是。验签、落盘、确认、稍后处理。理由不是性能,是控制权:一旦工作被放进请求内部,发送方的超时就成了你的截止时间、他们的重试策略就成了你的负载曲线,而这两样你都改不了。例外是处理逻辑真的只是一次几毫秒的幂等写入 —— 那没问题,而且会一直没问题,直到有人往里面加一条通知。图上一条有用的判据:如果「收到请求」和「返回 200」之间超过一条箭头,就问一句为什么。
怎么让 webhook 处理逻辑幂等?
把发送方的事件 id 存进一个带 唯一索引 的列,并且和业务写入放在同一个事务里插。插入失败 本身 就是你的重复检查 —— 先
SELECT再写是竞态,而在重试场景下这个竞态一点都不罕见。如果发送方不给 id,就对原始 body 取哈希当 id,代价是两个真正相同的事件会被并成一个。然后对重复返回 200:它意味着发送方其实已经成功、只是丢了你的响应,所以任何错误状态码都只会招来更多重试。在图上这是一个alt块、碰撞分支返回成功 —— 看着别扭,直到你想起这个 200 是给谁的。图上该画几次重试、什么退避?
七个小时左右、五次 —— 10 秒、1 分、10 分、1 小时、6 小时 —— 是个常见且站得住的形状,上面的例子用的就是它。带抖动的指数退避比具体数字更重要:没有抖动,故障期间失败的一切会在故障结束的同一瞬间一起重试,于是你的恢复变成第二次故障。图上必须画出来的是重试在哪儿 停止,因为那才是决策,时间表不是。另外把「哪些状态码才重试」也画上:5xx、超时和 429 重试;其他 4xx 不重试,因为那份 payload 一小时后同样是非法的。
如果 webhook 的 URL 是保密的,还需要验签吗?
需要。URL 在任何有用的意义上都不是秘密 —— 它会经过代理、CDN 日志、错误追踪、浏览器历史,以及工单里的截图,而这些地方没有一个把它当敏感信息对待。更重要的是,保密 URL 至多证明「有人知道这个 URL」;签名证明的是「这个具体的 body 来自持有共享密钥的一方,且传输中没被改过」。在图上,验签是 你接口内部的第一条箭头,对原始字节计算、在任何 JSON 解析之前 —— 因为重新序列化会改变空白和键顺序,比较就不成立了。把它画在第一位,也正是你发现「body 解析中间件已经悄悄把原始流吃掉了」的方式。
Webhook 乱序到达,图上怎么处理?
画一次 比较,而不是一个队列。跨独立 HTTP 请求的顺序无法保证,而做重排缓冲意味着要为一个可能永远不来的事件阻塞。正确做法是让每个事件带上版本号或源端时间戳,处理逻辑拒绝任何「不比库里更新」的写入 —— 一个
alt块,上面第三个例子里画了。什么时候这还不够:当你需要的是 状态转移过程 而不是最终值。那时候把 webhook 当成一个通知,回头去调发送方的接口取当前状态 —— 那是另一张图,多一条箭头,而且完全没有顺序问题。免费吗?
免费。没登录每天 20 次,登录后每天 500 次。把本页任何一个例子在编辑器里打开完全不计次 —— 那条路径根本不打模型。