總覽與定位
後端服務之間的資料骨幹,先分清兩種模型:訊息被消費即消失的 Queue,以及可重放的有序 Log。
同步 RPC 把呼叫者釘在被呼叫者的延遲、重試與容量上。訊息骨幹把這條耦合拆開:生產者把工作或事件交給 broker,消費者用自己的節奏處理。面試與設計文件裡「加一個 queue」常被當成萬靈丹,真正要先答的是這根骨幹是哪一種模型——因為兩種模型對「誰拿走這則訊息、能不能回頭再看一次、第二組消費者看不看得到」給出完全相反的答案。
本篇是 Messaging & Streaming 這一群的定義層與入口。只用中立模型講語意與取捨,不走進任何單一產品的內部結構:Partition、Offset 協定、ISR、Rebalance 留給 Kafka 架構。用事件重劃服務邊界與 Transactional Outbox 留給 Event-Driven Architecture。有狀態運算與時間視窗留給 Stream Processing。資料庫變更日誌從哪裡來,留給 CDC。
相鄰兩篇也請分清座標:MQTT 與 IoT Messaging 處理裝置端受限網路下的 pub/sub 與 QoS 0/1/2;本篇處理後端服務之間的資料骨幹。即時連線擴展 的 Backpressure 是長連線 fan-out 視角(這條連線還吞不吞得下下一則推送);本篇的流控發生在 broker 與消費者之間(這個工人還接不接得下一則工作)。
同一個產品可以同時暴露兩種臉:工作佇列模式走 Queue 語意,主題訂閱走 Log 語意。面試被問「Kafka 跟 SQS 差在哪」時,先答「一個預設是可重放的有序日誌、一個預設是消費即消失的工作佇列」,再補產品細節。反過來先背 API,分數會停在使用說明。
Queue 模型:消費即消失
一則訊息是一份工作。誰先拿走誰做,做完就從 broker 消失,後來者看不到歷史。
Queue 的單位是工作,不是「大家都該看見的事實」。生產者把一則訊息放進佇列;一組 Competing Consumers 互相競爭,broker 把每一則最多交給其中一個工人。成功 ACK 之後,這則訊息從 broker 刪除——它的生命週期結束了。
sequenceDiagram
participant P as Producer
participant B as Broker Queue
participant WA as Worker A
participant WB as Worker B
P->>B: enqueue(M1)
Note over B: depth = 1,M1 可見
B->>WA: deliver M1,one-of-N
Note over B: M1 轉不可見,租約中
B--xWB: 同一則不再給第二人
WA-->>B: ACK M1
Note over B: 刪除 M1,depth = 0
B--xWB: 晚到者拉不到歷史
ACK 是往回送的,刪除發生在 broker 端——這一刪,晚到的 Worker B 就再也看不到 M1。
這份語意買到兩件事:
- 任務只做一次的意圖(在不發生重投的理想路徑上)。縮圖、寄信、扣庫存這類「有一個工人把它做完就好」的工作,對上 Queue。
- 水平擴充工人即可提高吞吐。N 個 Competing Consumers 同時拉工作,佇列深度是積壓指標,工人數是處理容量。
代價同樣清楚:沒有歷史。新上線的服務、要重算過去一小時的報表、要把同一批事件再餵給另一個團隊——Queue 給不了。訊息走了就是走了。若你發現自己在 Queue 前面再加一個「備份表」好讓別人重放,那是在用手刻 Log 模型,應該換模型而不是繼續補洞。
「訂單建立」若要同時通知庫存、積分、搜尋索引與稽核,塞進同一條 Queue 再靠第一個工人轉發,等於把扇出寫進業務程式。第二個訂閱者週三才上線,週一週二的訂單它永遠看不到。這不是工人不夠,是模型選錯——事件扇出是 Log(或至少是可保留的 pub/sub)的工作。
Log 模型:可重放的有序日誌
訊息追加後留下。每一組消費者自己握游標,互不搶奪;重放是把游標往回撥。
Log 的單位是已發生的事實。生產者把訊息追加到一條有序日誌;broker 依保留策略(時間或容量)持有它,不因為有人讀過就刪。消費者以組為單位各自記住「我讀到哪裡」——這個位置叫游標。產品常把游標叫做 offset,那是實作名稱;本篇只需要語意:游標屬於消費者組,不屬於訊息本身。
- 組 A 讀完 M1–M4,游標停在末端
- 組 B 週三才上線,仍可從保留起點重讀
- 組 A 把游標撥回起點,即可重放
- 訊息還在不取決於有沒有人 ACK
- 晚到的訂閱者補歷史
- 同一批訊息餵給獨立的下游
- 處理邏輯改版後重跑過去的資料
Log 並不是無限硬碟。保留策略一到,舊訊息被丟掉,游標若還停在已被回收的位置,消費者必須決定「從現在的最早位置接著讀」還是「視為資料缺口」。把 Log 講成「永遠可重放」是行銷句,工程句是在保留窗口內可重放。
Queue 的 ACK 對全世界生效:訊息消失。Log 的 ACK(或 commit cursor)只對這一個消費者組生效:其他人的游標不動,日誌本體不動。面試把兩種 ACK 講成同一件事,後面的重放、重試、Exactly-once 討論會全部錯位。分區怎麼切、游標怎麼提交、組員怎麼重排,是 Kafka 架構 的協定細節,本篇停在「游標是組私有的」這一句。
Push、Pull 與 broker–consumer 流控
誰決定下一則何時出門?流控發生在 broker 與工人之間,不是長連線往裝置扇出的那一條。
模型決定「訊息活多久、誰看得到」;傳遞方向決定「慢消費者會不會被灌爆」。這裡的 Backpressure 問的是:這個工人處理不過來時,broker 還要不要繼續塞?
| Push | Pull / Long-poll | |
|---|---|---|
| 下一則由誰發起 | Broker 主動送出 | 消費者來取,或掛著等 |
| 天然流控 | 弱。必須另做 credit / prefetch | 強。不取就不來 |
| 空佇列成本 | 低(有貨才推) | 短輪詢浪費;長輪詢把等待留給 broker |
| 典型代價 | 慢工人記憶體被灌滿、ACK 落後 | 多一趟「我準備好了」的往返 |
長輪詢是折衷:消費者發出「有貨再回我」,broker 在超時前一直握著這次請求。空佇列不打轉,有貨時延遲接近 Push。
純 Push 並非不能用,但必須有信用額度:消費者先聲明「最多同時在途 N 則」,broker 送滿就停,ACK 回來再補。這就是 prefetch / credit-based flow control。沒有額度的 Push,等於假設每個工人都是無限快——流量一尖,先爆的是消費者進程,不是 broker。
即時連線擴展 談的是:gateway 對這條長連線 fan-out 時,裝置或下游 socket 的接收窗口滿了怎麼辦。本篇談的是:broker 對工人投遞時,工人的處理隊列滿了怎麼辦。前者卡住會影響同房間其他人的推送路徑;後者卡住應該讓訊息留在 broker(或變成積壓指標),不要灌進即將 OOM 的工人。解法可以長得很像(credit、停推),座標不同,選錯層會修錯元件。
ACK 與 Visibility Timeout:租約語意
ACK 的含義是「副作用做完了」,不是「我收到了」。沒 ACK 之前,訊息只是被租走。
工人從 broker 拿到一則訊息時,這則訊息並沒有被刪除。它進入不可見窗口:其他 Competing Consumers 暫時看不到它,避免兩個人同時做同一份工作。這個窗口就是 Visibility Timeout,本質是一張租約:在到期前,持有者必須 ACK(做完)或續租(還在做);到期而沒有 ACK,訊息重新可見,下一個工人可以再拿走。
因此 ACK 的時機幾乎只有一個正確答案:外部副作用成功之後。先 ACK 再處理,等於選擇 At-most-once——進程在 ACK 與副作用之間崩潰,這則工作永遠消失。先處理再 ACK,崩潰會重投,走 At-least-once。沒有第三條既不丟又不重複、又完全不靠應用層的免費路徑。
處理耗時必須小於租約,或你得會續租
租約長度是一道工程參數,不是協定魔法:
- 太短:正常處理還沒做完,訊息已重新可見。第二個工人接手,第一個工人稍後也 ACK——同一則工作的副作用發生兩次。
- 太長:工人崩潰後,訊息要等整段租約才被救回。積壓看起來「有貨沒人做」,其實卡在一張過期很慢的租約上。
- 可變工時:長尾請求必須能延長租約(heartbeat / change-visibility)。固定 timeout 假設「每則工作一樣久」,長尾一到就重投。
橫軸是處理耗時,兩根柱子是這批 200 則工作的兩點分佈(刻意簡化:一般工作全部落在 p50、長尾工作全部落在 p99)。紅色虛線是租約門檻——線右邊的柱子會被重投,每滿一個租約就被搶走一次。最下面那條橘色標尺是同一個租約的反面:工人崩潰後訊息最壞要等多久才被救回。要看的是這兩者往相反方向被拉。
這批的規模是固定的:200 則訊息、8 個工人平行處理,只當分母用。
預設值下(租約 10s、長尾 25s、長尾佔 8%),長尾那 16 則每則被重投 2 次、共多出 32 次投遞、16% 的訊息會被重複做,工人有 25.2% 的時間在白燒。把租約拉到 30s 重投歸零,但崩潰救援從 10s 變成 30s;改開續租則兩邊都低——這就是「可變工時必須能延長租約」的量化理由。再把一般工作的耗時拉到 20s(超過租約),兩根柱子會同時越線,重複副作用佔比直接頂到 100%。
Visibility timeout 到期後的第二次投遞,是系統在說「我不確定上一任做完沒有」。這跟協調服務的 Session 過期是同一類契約:集群側的時鐘說了算,工人本地「我還在做」不算數。GC pause、長 IO、被 SIGSTOP,都會讓本地計時器說謊。所以消費者必須把每一則訊息當成可能是重投,處理路徑從第一天就按冪等來寫。
三種交付語義,以及「效果只發生一次」
Broker 能保證的是投遞次數的上界或下界。「效果只發生一次」是應用不變量,不是 checkbox。
| 語義 | Broker 側通常怎麼做 | 失敗時 | 適用 |
|---|---|---|---|
| At-most-once | 送出即丟,或 ACK 先於處理 | 可能遺失,不會重複 | 指標、日誌、可容忍缺口的取樣 |
| At-least-once | 未 ACK 則重投;租約到期再可見 | 可能重複,盡力不丟 | 預設。多數業務事件 |
| Exactly-once(投遞) | 端到端原子:產出與游標同一交易 | 產品能力,範圍有限 | 同系統內的讀→寫管線 |
MQTT 的 QoS 0/1/2 是裝置端協定標籤,對應「最多一次 / 至少一次 / 握手去重」;那是 MQTT 與 IoT Messaging 的範圍。後端骨幹請用這張表的語意說話,不要把 QoS 2 直接翻譯成業務 Exactly-once。
面試高分句是把「投遞」和「效果」拆開:
Broker 就算宣稱 Exactly-once,保證的通常是「這則紀錄不會在這條管線裡被提交兩次」——例如消費與寫入下一個 topic 落在同一事務。它看不見你對付款 API、郵件、第三方倉儲發出的副作用。那些呼叫崩潰重試一次,效果就是兩次。能把「效果只發生一次」變成不變量的,是:
- 冪等處理:同一則訊息做兩次,狀態與第一次相同(條件寫入、set 語意、自然冪等)。
- 去重鍵:訊息帶穩定
idempotency-key/ 業務單號,副作用端用唯一約束擋住第二次。 - 收斂寫入:寫的是「最新值」而不是「加一」,亂序與重複都落到同一終態。
產品的 Exactly-once 有範圍:通常止於該產品認識的儲存與游標。一跨出進程、一碰到外部 HTTP、一寫進另一套資料庫,範圍就結束。把 checkbox 當成全域物理定律,是生產事故的固定劇本。正確說法是:broker 減少管線內的重複提交;效果層的不變量仍由你寫。
重試、退避與 Dead Letter Queue
暫時故障該退後再試;Poison 必須分流。DLQ 是人工介入的入口,不是自動成功。
At-least-once 意味著失敗會再來一次。問題是「再來一次」的節奏與上限。兩類失敗要分開:
- 下游 503、鎖衝突、短暫超時
- 指數退避 + jitter,避免同步重試打爆下游
- 租約內續租,避免還沒退完就被別人搶走
- 有預算:最多試 N 次或最長 T
- schema 對不上、必填欄缺失、業務拒絕
- 無限重試只會佔住工人與租約
- 達上限後移出主路徑,寫入 DLQ
- 主路徑繼續走,不被一則壞訊息塞死
Dead Letter Queue 的工作是隔離:把無法自動完成的訊息從熱路徑拿開,保留原文、失敗原因、投遞次數,等人工或離線作業處理。它不是第二條會自己變成功的管道。常見誤用:
- 沒人看的 DLQ:等於靜默丟棄,只是換了個比較體面的桶。
- DLQ 再自動打回主佇列且不修資料:Poison 會在兩條佇列之間振盪。
- 沒有告警與積壓 SLO:DLQ 深度是事故指標,應跟主佇列一樣被看著。
每次重試都可能再做一次副作用。上限太高,Poison 在主路徑上燒工時;上限太低,暫時抖動被誤判成死信。實務上把「可重試錯誤」與「不可重試錯誤」從 handler 明確拋出:後者直接進 DLQ,前者才走退避。不要讓所有 exception 共用同一條指數曲線。
順序保證與 Competing Consumers
全域有序用單一工人換來,代價是隊頭阻塞。要吞吐,就接受放棄全域順序。
「訊息要按順序」聽起來無害,直到你問哪一個範圍的順序。三種常見契約,吞吐與延遲完全不同:
| 契約 | 怎麼實現(模型層) | 吞吐 | 主要代價 |
|---|---|---|---|
| 無序 | Competing Consumers,任意工人拿下一則 | 高,近乎工人數倍增 | 同一實體的先後可能顛倒 |
| 每把 key 有序 | 同 key 進同一條有序流,不同 key 並行 | 中,受最熱 key 限制 | 熱 key 變成單一小佇列 |
| 全域有序 | 單一有序流 + 單一活躍消費者 | 低,等於一個人的速度 | Head-of-line blocking |
三列互斥,選一列就同時簽下它右邊那一格代價——沒有「既要吞吐又要全域序」的那一列。「每把 key 有序」在產品裡常落成「同一分區有序」;分區怎麼映射、怎麼再平衡是 Kafka 篇的事,這裡只需記住:有序範圍愈大,可並行的工人愈少。
Head-of-line blocking 是全域(或單流)有序的帳單:隊頭那則訊息若處理很慢、或卡在重試,後面所有已經到達、其實可以做的訊息都得等。你用順序換來的是「M2 絕不先於 M1 生效」;你付出去的是整條流的尾延遲。Competing Consumers 把這張帳單撕掉:任何人都能拿下一則,慢請求只擋住自己,但 M2 可能先於 M1 被處理。
多數業務要的其實是「同一張訂單的事件不要亂」,不是「全站所有訂單排成一列」。前者用訂單編號當 key 即可並行;後者把全站吞吐釘死在一個人身上。面試先問範圍,再決定工人數。不要為了一個沒寫清楚的「要有序」,主動送出 HOL 這張帳單。
互動 · 同一批訊息,兩種模型
同一組生產與消費參數推到兩側:Queue 走完後 broker 上剩下什麼、Log 的保留窗口留下什麼,以及三組讀者各自讀得到哪一段。
兩帶共用同一條 y 軸:M1 在最上、序號往下遞增,所以同一列就是同一則訊息在兩種模型下的下場。左帶全是虛線空框——Queue 的 ACK 等於刪除,走完之後 broker 上什麼都不剩;右帶灰色剪影是已經超出保留窗口而被回收的那幾則。右邊三條軌是三組讀者各自的游標與讀取區間,要看的是三條軌的起點差多少。
三組讀者是固定編制(組 A 從頭跟、組 B 晚到、組 C 重放);tick 的語意是「一輪生產+一輪消費」。
四組操作值得自己推一次:(1) 預設值下 Queue 側峰值積壓 12 則,組 B 在第 8 則上線時看得到 6 則——但那是還沒被做掉的待辦,不是歷史;同一刻 Log 給得出 8 則歷史。(2) 把競爭消費者從 1 拉到 8(追上生產速率),Queue 側積壓歸零、組 B 拿到的變成 0 則,而 Log 側三條軌一動也不動——「晚到者兩手空空」是 workers ≥ prodRate 的推論,不是模型的口號。(3) 把保留窗口降到 6、往回撥拉到 14,Log 帶上方 10 則變成灰色剪影,組 C 的游標被卡在窗口起點,出現 8 則缺口、只讀得到 6 則——這就是「在保留窗口內可重放」那句工程句。(4) 看最後一列:同一批 16 則訊息,Queue 總共交付 16 次,Log 是 38 次;多出來的不是浪費,是「同一批事實餵給獨立下游」這件事的價格。
面試折衷與邊界
先答模型與語意,再談產品。內部結構、事件邊界、串流運算各有自己的篇。
| 面試常問 | 先答這句 | 不要滑去 |
|---|---|---|
| Queue 和 Log 差在哪? | Queue 消費即消失、工人互搶;Log 保留、各組獨立游標、可重放 | Kafka 分區、ISR、Rebalance 細節 |
| 為什麼會收到重複訊息? | Visibility timeout 是租約,到期未 ACK 就重投;這是 At-least-once | 「broker bug」或把重複當異常警報 |
| 怎麼 Exactly-once? | 效果只發生一次 = 至少一次投遞 + 冪等或去重。Broker checkbox 只管它認識的管線 | QoS 2、產品行銷句、假設外部 API 也原子 |
| ACK 應該何時發? | 副作用成功之後。先 ACK 就是 At-most-once | 收到即 ACK 以「降低延遲」 |
| DLQ 做什麼? | 隔離 Poison,保留證據等人工處理;要有告警,不要自動空轉打回 | 當第二條會自己變成功的熱路徑 |
| 要保證順序怎麼辦? | 先定義範圍。全域序 = 單流 + HOL;同實體有序 = 按 key 分流 | 直接單消費者,卻說不清有序範圍 |
| Push 還是 Pull? | Pull / 長輪詢自帶流控;Push 必須有 credit。對象是工人,不是裝置連線 | 長連線 fan-out、gateway 推送窗口 |
| 什麼時候不該用訊息骨幹? | 需要同步回傳、跨步立即一致、或其實只是進程內工作排程 | 為了「解耦」把請求-回應硬拆成兩跳 |
本篇停在中立模型。接著讀 Kafka 架構 看日誌的分區與重平衡;用事件重劃服務邊界見後續 Event-Driven 篇;裝置端 QoS 見 MQTT 與 IoT Messaging;長連線扇出見 即時連線擴展。
每一則跨進程的訊息都付出序列化、磁碟或複製、至少一次投遞的重試語義。它換到的是時間與所有權上的解耦,不是免費的正確性。選 Queue 還是 Log,等於先承認「誰有權看歷史」與「失敗時重不重做」。產品名可以晚點再寫;這兩句寫不清楚,後面任何吞吐數字都站不住。