<em>嗨,我是 Maneshwar,我正在打造 LiveReview——一個能感知影響範圍的 AI 程式碼審查工具,專為你的關鍵業務系統而設計。請 幫我們按星 讓更多開發者認識這個專案、試用看看,並分享你的回饋,幫助我們持續改進產品。</em>
你團隊裡一定會有人這樣說,而且大概會在規劃會議上,盯著一張只有三個字的 Jira 工單時說出來。
「Webhook?那不就一個 POST 請求而已嗎。半天搞定。」
而他對 POST 請求這件事,確實沒說錯。
Webhook 本質上真的就只是這樣。
你這邊發生了某件事,客戶想知道,你送出一個 HTTP 請求。完成。
然後你把它上線,六個星期後,你就會在一通電話裡,向客戶解釋為什麼他們在自己伺服器掛掉的那段時間內,漏掉了 4,000 筆付款事件。
所以我們來真的把這東西做出來。
一天一千萬筆事件,平均大約每秒 115 筆,尖峰時還會更多。
我會先做最天真的版本,然後故意一次又一次把它弄壞,直到最後做出一個能在真實客戶面前撐得住的版本。
最直覺的做法。事件在你的請求處理器裡發生時,你把它 POST 到客戶的 URL,等一個 200 OK。
def on_payment_succeeded(payment):
db.save(payment)
requests.post(customer.webhook_url, json=payment.to_dict()) # 🙃
return {"ok": True}
這在 staging 環境裡運作得很漂亮,因為「客戶」只是一個你在另一個視窗開著的 webhook.site 分頁。
到了 production,就會變成這樣。
你客戶的端點是一個 Rails app,跑在一台小機器上,而那台機器同時也在跑 cron job。
下午 3 點,他們的 cron 開始執行,伺服器開始需要八秒才回應,這時候 你的 請求處理器就只能卡在那裡,替別人的基礎設施當人質。
你的延遲圖表暴衝。連線池被耗盡。
你自己的使用者,明明跟這件事一點關係都沒有,開始看到逾時。
然後最糟的是:你的請求逾時了,程序往下走,而那個事件也就消失了。
你從來沒有把它寫下來。它只存在於某個函式裡的一個變數,而那個函式現在已經回傳了。

這裡的 bug 不是「它很慢」。真正的 bug 是你把自己的可用性綁定在客戶的可用性上,而且還把這件事放在熱路徑裡。

分散式系統的第一條規則,說真的也是人生的第一條規則:先寫下來,再想辦法處理。
所以處理器不再直接 POST。它會把事件寫進一張資料表,而且和導致這個事件的業務變更放在同一個交易裡,然後回傳。
這就是 transactional outbox pattern,而且它做了一件很微妙但值得大聲說出來的事。
如果你先儲存付款,然後再把事件送到 queue,這就是兩個系統,而它們之間有一段空窗。
如果在這段空窗當機了,你就會得到一筆付款,但沒有任何事件。
把事件列和付款放進 同一個 資料庫交易裡,兩者要嘛都成功,要嘛都失敗。沒有空窗。
BEGIN;
INSERT INTO payments (id, amount, status) VALUES (...);
INSERT INTO webhook_outbox (customer_id, event_type, payload, status)
VALUES (..., 'payment.succeeded', ..., 'pending');
COMMIT;
接著由另一個 worker 輪詢 pending 的資料列,實際去發送。
你的處理器又變快了。你的事件也有持久化了。
如果傳送失敗,它會在你看得到、也能重試的地方失敗,而不是消失在一個已經結束的堆疊框架裡。
但你只是把一個問題,換成另一個更陰險的問題。
你只有一個 worker,或是一組 worker,按順序從同一個 queue 裡抓工作。
客戶 A 的端點逾時要 10 秒。
任何接到客戶 A 工作的 worker,都會被卡住 10 秒。
同時,客戶 B 到 Z 的事件正排在後面,明明都能送,卻哪裡也去不了。
這就是 head-of-line blocking,也就是披著 queue 外衣的 noisy neighbour 問題。
一個爛端點的客戶會拖慢所有人。最糟的客戶決定了每個人的速度。

解法是公平,而公平需要有人來執行。
在 worker pool 前面放一個 dispatcher。
它的唯一工作,就是決定 下一筆事件該輪到誰,而且不能只拿最舊的那筆。
dispatcher 會記錄每個客戶目前有多少 worker 正在忙。
客戶 A 已經有 3 筆 in flight,而且上限就是 3?那就跳過它,先拿下一個客戶的事件。等等再回來處理 A。
這就是每個租戶的並發限制,這也是整個設計裡最有槓桿的一件事。
並發限制同時也是 Stripe 使用 的一級速率限制原語,原因正是如此:它限制的是傷害,而不只是統計請求數。
效果就是,客戶 A 的災難現在被封頂了。只有三個 worker 會卡在他們那裡。
其餘所有 worker 都能愉快地服務其他客戶。
慢速客戶現在只會拖慢自己,這才是痛苦應該落下的位置。
如果你想更進一步,dispatcher 也是你放權重公平性的地方,這樣你的企業方案就不會被一個每小時產生一百萬筆事件的免費方案客戶餓死。
下面就是路由邏輯,也就是整個系統的核心:
flowchart TD
A[讀取下一筆 pending 事件] --> B{客戶是否已達<br/>並發上限?}
B -->|是| C[跳過,改試下一個客戶]
B -->|否| D{端點 circuit<br/>已開啟?}
D -->|是| E[暫停,等冷卻結束]
D -->|否| F[交給空閒 worker]
C --> A
E --> A
F --> G[POST 已簽章的 payload]
classDef decision fill:#f4d35e,stroke:#b8991f,color:#1a1a1a
classDef start fill:#e9ecef,stroke:#6c757d,color:#1a1a1a
classDef action fill:#5ee6c8,stroke:#1f9c86,color:#1a1a1a
classDef wait fill:#ff9a5c,stroke:#c26a33,color:#1a1a1a
class B,D decision
class A start
class F,G action
class C,E wait
在有了並發上限之後,這個 circuit breaker 分支就很值得加上。
如果某個客戶的端點已經連續失敗了 20 次,你其實早就知道下一次也會失敗。
不要再浪費 worker 去驗證這件事了。

現在我們把視角拉到最細。某個 worker 接到了一個工作。接下來呢?
短逾時。10 秒,不是 60 秒。慢端點就是壞端點,當你還在查明狀況時,不該讓 worker 被它綁架。
接著看回應內容,而重點是:不是所有失敗都屬於同一種失敗。
200、201、204:已送達。標記完成,繼續下一筆。502、503、429:暫時性問題。他們的伺服器現在有點狀況。請以指數退避重試,並加上抖動(jitter),這樣對方機器恢復時,你不會在同一毫秒把所有重試積壓全丟上去。AWS 有一篇 關於 jitter 的經典文章,值得你花十分鐘看看。404、410、DNS 無法解析、TLS 握手失敗:永久性問題。URL 錯了,或端點已經不存在。把這個重試 12 次、拖 24 小時,這不叫韌性,這只是你在對不存在的地方製造流量,並延後客戶發現設定壞掉的時間。要快速而大聲地失敗。第三類通常是團隊最容易跳過的,而它正是把你的重試 queue 變成垃圾場的兇手。
既然講到這裡,還有兩件事不是可選項。
替 payload 簽章。 每個事件送出時,都要在 header 裡帶上 body 加時間戳記的 HMAC。
你的客戶會用共享密鑰重新計算,確認這個事件確實來自你。
如果沒有這個,任何猜得到你 webhook 端點 URL 的人,都可以對它 POST 偽造的「付款成功」事件。
時間戳記要包含在簽章內容裡,這樣擷取到的請求就不能下週被重放給他們。
假設他們會處理兩次。 重試意味著至少一次送達。
這不是你可以靠工程手段消除的缺陷,而是問題本身的形狀:如果你的請求逾時了,你真的無法知道對方到底有沒有處理成功。
所以每個事件都要有穩定的 id,要告訴客戶用它來做 key,並清楚寫進文件。
exactly-once delivery 是行銷話術。at-least-once 加上 idempotency 才是工程。

重試總有用完的一天。這很正常。對方的端點在整個 8 小時重試視窗內都掛著,而你已經沒有更多可重試的空間了。
事件不會被刪掉。它會進入死信佇列。
而這裡是我最想強調的地方,因為大多數實作都會在這一步早停一格:一個沒人看得到的死信佇列,其實只是更慢地遺失資料。
要把它做成 UI。做成你產品裡的一個儀表板,讓客戶可以看到自己失敗的送達紀錄。
事件類型、時間戳記、嘗試次數、以及你從他們伺服器收到的實際回應內容。
最後這個欄位能省下超多客服工單,因為「我們在下午 3:04 從你的伺服器收到 502 Bad Gateway」這句話,往往就能終結一場本來會拖上四天的「webhook 壞掉了」的爭論。
接著給他們一個 Replay 按鈕。
他們修好部署後,點一下 replay,事件就會回到 outbox,變成 pending,並重新走過完全相同的 pipeline。
再加上一個可依時間區間批次重播的功能,讓他們可以一鍵補回整段故障期間的事件。
你剛剛把你最糟的客服對話,轉成了一個自助操作。這是非常划算的交換。

flowchart LR
APP[應用程式在同一個交易中寫入事件<br/>+ 業務變更] --> OUT[(Outbox)]
OUT --> DISP[Dispatcher<br/>每個客戶限制並發]
DISP --> W[Worker pool]
W -->|HMAC 簽章 POST| CUST[客戶端點]
CUST -->|2xx| DONE[已送達]
CUST -->|5xx / timeout| RETRY[退避 + jitter]
CUST -->|4xx permanent| DLQ[(Dead letter queue)]
RETRY --> W
RETRY -->|重試次數用盡| DLQ
DLQ --> UI[Replay UI]
UI -->|客戶點擊重播| OUT
classDef store fill:#9d8cff,stroke:#5b4bcc,color:#1a1a1a
classDef proc fill:#5ee6c8,stroke:#1f9c86,color:#1a1a1a
classDef ext fill:#6ea8ff,stroke:#3565bd,color:#1a1a1a
classDef bad fill:#ff9a5c,stroke:#c26a33,color:#1a1a1a
class OUT,DLQ store
class APP,DISP,W,UI,DONE proc
class CUST ext
class RETRY bad
把它讀成一句話:先寫下來再送出,公平地決定下一個送給誰,用短逾時和簽章送出,重試那些值得重試的失敗,並把不值得重試的失敗讓真正能修的人看見。

有幾件事不太適合塞進前面的版本裡,但它們一定會來找你。
順序性。 一定會有人要求這個。每個客戶的有序送達,意味著該客戶的並發只能是 1,也就代表一次慢回應會卡住他們整條串流。這是一個真實的取捨,不是免費功能。通常更好的做法是送一個序號,讓對方自己排序,或者送「有變動,請來抓資料」的 ping,而不是直接把狀態本體送出去。
Payload 大小。 不要在 webhook 裡放一個 4MB 的物件。送 id 和事件類型就好,剩下的讓他們呼叫你的 API 取得。薄 payload 在 outbox 裡更省空間,重試成本也更低,而且可以避開一個尷尬問題:事件觸發到他們讀取之間,狀態可能已經變了,那該怎麼辦。
SSRF。 客戶給你一個 URL,而你讓你的伺服器去抓它。這就是教科書等級的 server-side request forgery。要解析主機名、拒絕私人網段與 link-local 位址,並且在 redirect 時重新檢查,因為 http://customer.com/hook 轉址到 169.254.169.254,通常代表有人想讀你的雲端 metadata 憑證。OWASP 有完整清單。
Poison events。 只要有一筆事件在反序列化時會把你的 worker 弄掛,它就會永遠被重試,並且每次都帶著同一個 worker 一起死。你自己的失敗也要設上限,不只是對方的失敗。
迷因點子 3 — 範本:They Don't Know(派對角落裡獨自站著的那個人,思考泡泡)

POST 請求確實只要半天。
剩下的 95%,是讓事件活下來的 outbox,是阻止單一客戶毀掉大家下午的 dispatcher,是知道「再試一次」和「這永遠不會成功」差在哪裡的重試分類器,以及把資料遺失事件變成一個按鈕的 replay UI。
這些都不花俏。就是一張表、一個 dispatcher 迴圈,再加上一點對失敗模式的紀律。
但它們正是「客戶信任的 webhook 系統」和「客戶會自己寫防禦性輪詢程式包著它用」之間的差別,而只要你漏了一個事件、又說不清它去哪了,他們就會立刻這麼做。
先寫下來。其他一切都從這裡開始。
如果你一直在這樣的 webhook pipeline 上開發,我很想知道你現在卡在哪一個版本。
我的猜測是版本二,而且造成塞車的那個客戶,你甚至能直接從記憶裡說出名字。
<em>
你團隊的注意力是有限的,而大量 AI 生成程式碼正在讓你更難在不拖慢速度的情況下,確保 production 程式碼的安全。
我正在打造 <strong>LiveReview</strong>,一個能感知影響範圍的 AI 程式碼審查工具,專為你的關鍵業務系統而設計。
LiveReview 不會把每個 diff 都用同等重要性來呈現,而是會根據 blast radius——也就是某個變更沿著你的呼叫圖所能影響到的範圍——來為每個變更評分,讓你把注意力集中在真正重要的地方。**
把程式碼審查的力氣花在業務風險最高的地方,而不是平均分散在每個 diff 上。
<b>在你的程式碼庫上試試 LiveReview:</b>
</em>
<a href="https://hexmos.com/livereview"><img src="https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/vls0pq7nymbrll98je6s.png" alt="LiveReview Banner" /></a>
原文出處:https://dev.to/lovestaco/designing-a-webhook-delivery-system-for-10-million-events-a-day-2p5d