這篇在講什麼
我做了一個訂單/付款的非同步佇列實驗專案(Express + RabbitMQ + Postgres + Redis),但重點不是「寫了哪些功能」,而是我到底證明了哪些「會出事的情況」其實不會出事。
心法很單純:每加一個東西,我都在問自己同一句——「這種情況會不會爆?我能不能用一條指令當場證明它不會爆?」 而且我不接受「單元測試綠就算數」,我要能真的把容器殺掉、把整個 broker 重啟,看它自己爬起來。
以下的數字都是在本機 Docker Compose 環境實測的結果(單元+整合測試 129 個全綠,加上一組可重跑的 failover/演示指令),不是 production 數據。
我怕的第一件事:會不會丟訊息/重複扣款
這是付款系統的命根,錯了就是真的錢出問題。我把幾種「會出事」的路都堵了:
- 寫了 DB 但 publish 掛掉 → 事件漏發:這就是經典的 dual-write。解法是 Transactional Outbox——狀態跟事件寫在同一個交易,事件先進 outbox 表,由 relay 去發、發成功才標記。broker 掛了?publish 失敗那筆就不標記,下一輪自己重發。producer 端一則都不會漏。(原理見 Outbox pattern)
- 同一單被處理兩次 → 重複扣款:冪等我不靠 Redis,我靠 DB 的
orders.status當真相,已經 paid 就 skip。實驗:同一個 Idempotency-Key 送兩次,回同一個 orderId、審計只有一筆 paid。(consumer 端冪等的一般做法見 Consumer 端冪等性) - 消費到一半 crash:晚 ack(處理成功才 ack),失敗要轉走的還會先等 publisher confirm、確認副本落地才 ack 原訊息。crash → 訊息沒 ack → 重投 → 冪等 skip。不丟不重。
- 毒訊息卡佇列:壞 JSON 直接進 DLQ 不重試(演示時注一筆爛 JSON,看它落進 DLQ);沒有 queue 接的訊息也有 unroutable 佇列兜底,不會靜默消失。(重試與 DLQ 的分工見 Retry 跟 DLQ)
「晚 ack + 冪等」這組搭配不是我發明的,RabbitMQ 文件本身就這樣講:使用手動 ack 時,channel 或 connection 關閉(包括 TCP 斷線、consumer process 掛掉)時尚未 ack 的訊息會自動 requeue,重投的訊息會帶 redeliver 旗標,所以 consumer 應該要冪等(RabbitMQ: Consumer Acknowledgements and Publisher Confirms)。
再來:節點掛了到底會怎樣
單節點就是單點故障,所以每一層我都問「掛了誰接手」,而且每一層都有一條指令直接殺給你看:
| 殺什麼 | 結果(本機 Docker 實測) |
|---|---|
| RabbitMQ leader | 殺掉 → 自動選新 leader → 20 則零丟失 |
| Postgres primary(付款真相!) | 殺掉 → promote replica → 20 筆零丟失還能寫 |
| Redis master | sentinel 自動 promote → key 還在、client 自己跟過去新 master |
| 單一 API 實例 | nginx LB 撐著,殺一台 9/9 還是通 |
重點不是「我有做 HA」,是「我可以當場殺給你看它自己活過來」。
最硬的一題:整個 broker 死掉重啟呢
不是殺一個節點,是三個 rabbit 節點全 docker kill、然後重啟。這題我本來也怕,結果——
發 10 單 → 殺光 cluster → 重啟 → 10 筆全部 paid、零重複扣款、outbox 全部發完。
為什麼沒事?是四層防線疊出來的,不是靠單一機制:
- quorum queue 持久化:quorum queue 是以 Raft 為基礎的複寫佇列,所有資料先寫進磁碟才做後續動作(RabbitMQ: Quorum Queues),重啟從磁碟撿回來。publisher 端訊息也標成 persistent——RabbitMQ 文件提醒,要撐過重啟必須「durable queue+訊息標為 persistent」兩者都做,以 transient 發出的訊息即使在 durable queue 裡也會在恢復時被丟掉(RabbitMQ: Queues)。quorum queue 本身強制 durable。
- Outbox:broker 掛的時候 publish 一直失敗 → 那些事件就積在 PG 的 outbox(真相在 PG,不在 broker),broker 回來 relay 慢慢補發。
- 晚 ack:處理中掛掉的,訊息沒 ack,重啟會重投。
- DB 冪等:重投回來已經 paid 就 skip,不會重複扣。
最關鍵的體悟是 Outbox——有了它,「訊息在 broker 裡有沒有活下來」根本不是掉單的關鍵,因為真相在 PG。broker 只要「別重複、能恢復」就好。這一下就把「整組重啟」從恐怖問題變成可以證明的事。
誠實說一句:重啟那段時間是「暫停」不是「掉單」。quorum queue 需要過半成員在線才能運作(RabbitMQ: Quorum Queues),majority 沒回來前吞吐會停,但零丟失,回來就自己追上。
依賴掛了會不會把主流程拖死
Redis 掛不該讓人下不了單。實驗:把 Redis 停掉 → POST /orders 還是回 202(非關鍵的寫 fail-open,而且 commandTimeout 讓它快速失敗、不會卡在那等重連)→ /readyz 誠實回 503 degraded、/livez 還是 200(進程活著)。復原後 readyz 回 200。
這個專案的起點:連線池被排程疊爆
一開始的痛點就是「1000 個長短不一的 job、排程一疊上去把 AP 弄爆」。實驗:
- 不走 queue、無界併發直接打,搶爆連線池,約 19 筆失敗;走 queue、用 prefetch 鎖住併發,0 失敗。這組對照最有感。
- 長 job 堵短 job → 快慢車道分流。
- 多個 API/worker 撞爆 PG
max_connections→ pgbouncer transaction pooling 收斂連線。
(naive event-driven 上 production 會撞到哪些問題,整體脈絡見 Production EDA 入門)
付款本身對不對(不是佇列的事,是業務)
佇列可靠只是地基,付款域自己也要對:
- 狀態別亂跳:狀態機
assertTransition,誰都不能 pending 直接跳 refunded。 - 要有不可竄改的軌跡:審計 ledger,每次轉移同交易 append,而且用 DB trigger 擋 UPDATE/DELETE(想改都改不動),
/history回完整時間軸。 - 錢別用浮點:amount 一律整數分(bigint)端到端,非整數直接 400。0.1+0.2≠0.3 這種雷不要碰。
- 退款/取消:退款走非同步(大額還走慢車道)、取消同步;一樣冪等,取消已經 paid 的單會擋成 409。
帳到底對不對(對帳)
跑久了各表會不會漂移?我做了內部一致性對帳(沒有真金流商,就比自己各表):卡半空中的單、outbox 積壓、毒列、審計漂移(現況 status ≠ 最新 audit)、paid 但沒通知。本機實跑出來 auditDrift=0——狀態機+ledger 真的一致。(對帳作為後端 pattern 的完整討論見 對帳不是會計的事)
一路踩到、之後別再踩的坑
這些是可以直接搬去別的專案的:
| 坑 | 怎麼解 |
|---|---|
| compose 做 HA,base 的 hostname 讓節點名對不上 peer 列 | HA 的 override 檔把 hostname 對齊 service 名 |
| 埠衝突 | compose 用 !override 重設 ports |
initdb.d 對「已經有資料的 volume」不會執行(官方 image 文件:只有資料目錄為空時才跑,postgres image) | 改成冪等的 post-start 腳本,別碰現有資料 |
| replica 被 promote 過就不再複製了 | entrypoint 偵測「沒 standby.signal」就自動重 basebackup 自癒 |
| pgbouncer transaction pooling 會弄壞 session 級的 advisory lock(官方功能表:transaction pooling 下「Never」支援,PgBouncer features) | 那條連線直連 PG、繞過 pgbouncer |
| 手邊 image 都沒 openssl CLI(要生自簽憑證) | 用 alpine/openssl 這個小 image |
| 通知去重用 order_id → 退款通知被付款那筆去重誤擋 | 改用 envelope 的穩定 eventId 去重 |
中文 bash 腳本 $VAR 後面緊接全形字(括號、頓號)→ set -u 直接噴 unbound | 一律 ${VAR} 界定;寫完 grep -P '\$\w+[^\x00-\x7F]' 掃一遍 |
收尾
這個專案的價值不是「做了什麼」,是「證明了哪些情況不會出事」。 從丟訊息、重複扣款,到節點掛、broker 整組重啟、依賴降級、連線池爆,一路到付款狀態機/審計/退款/對帳——每一種「會爆」的情況,我都留了一條指令或一個測試,可以當場再證一次。