我先講一個我到現在還記得很清楚的晚上。
那是某年大促的第一波尖峰,流量大概是平常的二十倍。我們的下單流程當時是這樣寫的:使用者按下「確認付款」,後端在同一個 HTTP request 裡,從頭到尾把所有事情做完——呼叫第三方金流扣款、扣庫存、產發票、加會員積分、寄付款成功通知簡訊。一條龍,全部同步,做完才回 200 給前端。
平常這條鏈跑完大概 400 到 600 毫秒,沒人有感覺。那天晚上九點半,第三方金流的回應從平常的 200 毫秒飆到 3 秒以上,因為大家都在打它。我們的應用伺服器執行緒被一筆筆卡在「等金流回應」的請求佔滿,連線池瞬間見底。接著發票服務也開始 timeout,因為它平常根本沒被這種量打過。
結果就是雪崩。使用者那端看到的是轉圈圈轉到天荒地老,最後吃到 504。但更糟的是後面:有些使用者重複按了好幾次「確認付款」,因為他覺得卡住了。那一晚我們有一批訂單,金流那邊扣了兩次款,但我們系統因為前面 timeout、交易 rollback,訂單狀態還停在「待付款」。對帳的時候那個數字對不起來,我跟另一個同事熬到凌晨四點在撈 log、跟金流商的客服一筆筆核。
那次之後我才真正懂一件事:把所有事情塞進同一個同步流程,不是「簡單」,是「脆弱」。整條鏈的可用性等於每一環可用性的乘積,任何一個下游慢了、掛了,使用者的主流程就跟著陪葬。訊息佇列就是我後來重寫這套系統的核心。這篇講我實際怎麼做,以及它幫我解決問題的同時,又丟給我哪些新的坑。
先想清楚:哪些事情「必須當場做完」,哪些可以晚一點
重寫之前我做的第一件事,不是選 Kafka 還是 RabbitMQ,而是把那條一條龍流程攤開來,一項一項問:「這件事,使用者真的需要在按下按鈕的那一刻就完成嗎?」
答案分得很清楚。
扣款本身,以及把訂單狀態從「待付款」推進到「已成立」,這是主流程,使用者要當場知道結果。但是寄簡訊、產發票、加積分、更新推薦系統的銷售數字——這些全部都是「訂單成立之後該做的事」。使用者不需要、也不在乎這些在第幾毫秒完成。他只要知道「我付款成功了」。
所以重寫後的流程變成:主流程只做兩件事,扣款 + 把訂單標記成立,然後往佇列丟一則 OrderCreated 事件,就直接回應使用者。發票、簡訊、積分各自是一個獨立的消費者(consumer),去訂閱這則事件,非同步地做自己那一塊。
這個翻轉帶來的好處非常具體:
- 回應時間從原本最差會到好幾秒,壓回穩定的一百多毫秒,因為主流程不再等那一串下游
- 解耦:後來行銷說「訂單成立後要同步到 CRM」,我只要再寫一個消費者去訂閱 OrderCreated,主流程一行都不用動。這在以前是要去那條一條龍裡硬插一段、然後祈禱不要弄壞別的
- 削峰:尖峰那一波瞬間湧入的工作,現在是堆在佇列裡,消費者用它自己撐得住的速度慢慢嗑,發票服務不會再被瞬間打爆
- 容錯:簡訊服務掛了?沒關係,訊息還在佇列裡,等它復活再消費,主流程完全不受影響,使用者照樣付款成功
如果故事到這裡結束,那訊息佇列簡直是萬靈丹。但它不是。它把我從「同步的脆弱」救出來,然後一腳把我踹進「分散式系統的經典難題」裡。下面這些,你只要用佇列,遲早全部會遇到。
坑一:訊息會重複,所以消費者必須冪等——這在金流是生死問題
我吃過最大的虧就是這個,而且是用真錢吃的。
絕大多數的佇列,給你的保證是 at-least-once(至少送達一次),不是「剛好一次」。意思是:同一則訊息,可能因為網路抖動、消費者處理完還沒來得及 ack 就重啟、或是 broker 重新投遞,被送到你面前好幾次。
我第一版的積分消費者沒處理這件事。某次部署滾動更新,有個消費者實例在處理完「加 500 積分」但還沒回 ack 的瞬間被 kill 掉,broker 認為這則訊息沒被處理,重新投遞給另一個實例,於是同一個使用者被加了兩次積分。積分還算小事,但同樣的事情如果發生在「扣款」或「退款」消費者上,那就是真的重複扣使用者的錢。
解法就是一句話:每個消費者都必須是冪等的。所謂冪等,就是同一則訊息,處理一次跟處理十次,最終結果完全一樣。
我實際的做法是,每則訊息都帶一個全域唯一的 message id(或用業務上的唯一鍵,比如訂單號 + 事件類型)。消費者處理之前,先去一張去重表(我們用 Redis 存近期的,搭配 DB 做持久保險)問:「這個 id 我處理過了嗎?」處理過就直接跳過、ack 掉。沒處理過才做,而且「標記已處理」這個動作要跟業務操作在同一個交易裡,不然你又會在這兩步之間留一個重複的縫。
這句話我想對所有做金流的人講三次:消費者冪等不是 nice to have,是不可妥協的底線。沒有它,你的佇列遲早會幫你重複扣某個倒楣使用者的錢,然後你會在某個凌晨四點撈 log。
坑二:順序不保證,所以別假設順序,用狀態機把關
第二個讓我栽過跟頭的,是訊息順序。
如果你有多個消費者實例在平行消費,或是訊息走了不同的 partition,那訊息到達的順序往往不等於你發出去的順序。
我遇到的實際案例:一筆訂單先「付款成功」,使用者很快又申請「退款」,兩則事件接連發出。但因為平行處理,退款的事件竟然先被某個下游消費掉了,付款成功的事件後到。如果那個消費者笨笨地照收到的順序做,它會先把單子標成「已退款」,然後又被後到的「付款成功」覆蓋回「已付款」——一筆已經退掉的錢,狀態卻顯示還在付款成功。
我學到的原則是:永遠不要假設訊息會照順序到。與其去拼命保證全域順序(那會逼你犧牲掉平行度,得不償失),不如讓消費者自己有判斷力。
我的做法是用狀態機:每個訂單狀態只允許合法的轉移。已經是「已退款」的單子,收到一則「付款成功」事件,合法的反應是忽略它(或記一筆告警),而不是傻傻照做。狀態機這層守門,讓我可以放心接受亂序,因為非法的轉移會被自然擋下來。
當然,真的有少數場景需要嚴格順序(比如同一個帳戶的餘額變動),那我才會用「同一個 key 路由到同一個 partition」這種手段,把需要保序的訊息綁在一起。但這是例外,不是預設。預設永遠是:假設會亂序。
坑三:有些訊息就是一直失敗——給它一個死信佇列,別讓它卡住全世界
第三個現實問題:總會有那種怎麼處理都失敗的訊息。
可能是上游發了一則格式壞掉的事件,可能是某筆資料引用了一個已經被刪掉的 ID,反正消費者一碰它就拋例外。如果你的策略是「失敗就重試,重試到成功為止」,那這則毒訊息會像塞在水管裡的頭髮一樣,卡住整個佇列,後面正常的訊息全都跟著上不來。我見過一次因為一則壞訊息無限重試,結果整個積分佇列堵了四十分鐘,直到有人發現。
我現在的標準配置是:
- 重試要有上限,而且要 backoff。不是失敗了就立刻重打——那只會用同樣的速度撞同樣的牆。要用指數退避(第一次等 1 秒、再來 2 秒、4 秒……),給下游喘息的機會,很多暫時性的失敗(下游剛好在重啟)會在重試中自己好起來
- 超過重試上限,就把它丟進死信佇列(Dead Letter Queue,DLQ)。DLQ 是一個專門收「處理不了的訊息」的地方。訊息進了 DLQ,就從主佇列移開,不再卡住正常流量
- DLQ 一定要接告警。死信佇列最大的價值不是「存壞訊息」,而是「讓人知道有壞訊息」。我們的 DLQ 一旦有訊息進來就會發 Slack 告警,有人去看是什麼出了問題、要修資料還是修 code。最怕的就是壞訊息被默默吞掉,過三個月才在對帳時發現一堆東西沒處理
死信佇列這東西,平常完全用不到,但它是你系統的安全網。沒有它,一則壞訊息就能拖垮一整條管線。
坑四:最終一致,不是即時一致——產品體驗要一起改
最後一個,與其說是技術坑,不如說是觀念坑,而且它會逼你連產品設計一起改。
非同步的本質是:「主流程成功」跟「附帶工作完成」之間,有時間差。使用者付款成功的那一刻,他的積分可能還沒加上,發票可能還在產。系統最終會一致,但不是在那一瞬間。
這件事如果你沒跟產品和前端講清楚,就會出包。我們早期就遇到,使用者付完款立刻去看「我的積分」,發現沒加,就來客訴說系統壞了。其實沒壞,只是那則積分訊息還在佇列裡排隊,過兩秒就好了。
所以非同步不是後端自己關起門來做的決定,它會外溢到使用者體驗。我們後來的處理是:會立刻有結果的(訂單成立),就明確告訴使用者成功;會延遲的(發票),前端就顯示「發票處理中,稍後可在訂單詳情查看」,而不是假裝它已經好了。讓使用者看到的狀態,誠實反映系統真正的狀態,比任何技術手段都重要。
一個值得補一句的東西:主流程怎麼「可靠地」把訊息丟進佇列
上面講的都是消費端。但發送端其實藏著一個很容易被忽略的縫:你先寫了 DB(訂單成立),然後要發訊息到佇列——如果 DB 成功了、發訊息那一步卻失敗了(佇列剛好抖一下),那就會有一筆「成立了但沒人知道」的訂單,所有下游消費者都收不到事件。
這個縫的標準解法叫 outbox pattern(發件匣模式):你不直接發訊息,而是在寫訂單的同一個資料庫交易裡,順手把「要發的訊息」也寫進一張 outbox 表。因為它跟訂單在同一個交易,要嘛一起成功要嘛一起失敗,不會有中間態。然後另一個背景程序去輪詢 outbox 表,把還沒發出去的訊息可靠地投遞到佇列。這樣就把「寫 DB」跟「發訊息」這兩件原本各做各的事,綁成了一個原子操作。這部分細節我另外寫過,這裡只先點出來:消費端要冪等,發送端要可靠,兩邊都顧到,整套才站得住。
寫在最後
回頭看那個熬到凌晨四點對帳的晚上,我其實滿感謝它的。它逼我認清一件事:訊息佇列不是一個「讓系統變快」的小工具,它是一個你選擇進入分散式世界的決定。它把同步的強耦合,換成了非同步的解耦、削峰跟容錯——這是真實而巨大的好處。但它同時把重複、亂序、失敗、最終一致這四個分散式系統的老朋友,一次全部請到你家。
我現在每次要在一個流程裡引入佇列,都會先逼自己回答三個問題:這個消費者冪等了嗎?我有沒有偷偷假設了訊息順序?壞掉的訊息有地方去、有人會知道嗎?這三題答得乾淨,佇列就是讓系統可擴展又有韌性的基礎建設;答得含糊,它就會在某個你最不想的時刻,變成那個幫你重複扣款、半夜把你叫醒的東西。技術從來不是免費的,訊息佇列只是把這個道理,用最直接的方式教了我一次。
留言討論
有想法、有不同經驗、或想糾正我?歡迎在下面留言,免註冊,填個暱稱就能留。