01

總覽與定位

後端服務之間的資料骨幹,先分清兩種模型:訊息被消費即消失的 Queue,以及可重放的有序 Log。

同步 RPC 把呼叫者釘在被呼叫者的延遲、重試與容量上。訊息骨幹把這條耦合拆開:生產者把工作或事件交給 broker,消費者用自己的節奏處理。面試與設計文件裡「加一個 queue」常被當成萬靈丹,真正要先答的是這根骨幹是哪一種模型——因為兩種模型對「誰拿走這則訊息、能不能回頭再看一次、第二組消費者看不看得到」給出完全相反的答案。

1 · Queue
消費即消失 · Competing Consumers
一則訊息只給一個工人。ACK 之後 broker 不再持有它,後來者無法回溯。適合任務分派。
2 · Log
保留 · 獨立游標 · 可重放
訊息追加後依保留策略留下。每一組消費者自己移動游標,互不搶奪。適合事件流。
3 · 交付與失敗
租約 · 語義 · DLQ
Visibility timeout 是租約。At-least-once 是預設;效果只發生一次必須靠冪等。Poison 要進 DLQ。

本篇是 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 與 Log:前者消費即消失、工人互搶;後者保留歷史、各組獨立游標。交付語意、租約與 DLQ 建立在這條分界之上。
02

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 當事件匯流排

「訂單建立」若要同時通知庫存、積分、搜尋索引與稽核,塞進同一條 Queue 再靠第一個工人轉發,等於把扇出寫進業務程式。第二個訂閱者週三才上線,週一週二的訂單它永遠看不到。這不是工人不夠,是模型選錯——事件扇出是 Log(或至少是可保留的 pub/sub)的工作。

一句話答法Queue 把訊息當工作:Competing Consumers 互搶,ACK 後刪除,無法回溯。適合任務分派,不適合當多組獨立讀者的事件源。
03

Log 模型:可重放的有序日誌

訊息追加後留下。每一組消費者自己握游標,互不搶奪;重放是把游標往回撥。

Log 的單位是已發生的事實。生產者把訊息追加到一條有序日誌;broker 依保留策略(時間或容量)持有它,不因為有人讀過就刪。消費者以組為單位各自記住「我讀到哪裡」——這個位置叫游標。產品常把游標叫做 offset,那是實作名稱;本篇只需要語意:游標屬於消費者組,不屬於訊息本身。

同一條 Log,兩組讀者
游標獨立,互不刪除對方的資料
  • 組 A 讀完 M1–M4,游標停在末端
  • 組 B 週三才上線,仍可從保留起點重讀
  • 組 A 把游標撥回起點,即可重放
  • 訊息還在不取決於有沒有人 ACK
Queue 做不到的三件事
因為 ACK 等價於刪除
  • 晚到的訂閱者補歷史
  • 同一批訊息餵給獨立的下游
  • 處理邏輯改版後重跑過去的資料

Log 並不是無限硬碟。保留策略一到,舊訊息被丟掉,游標若還停在已被回收的位置,消費者必須決定「從現在的最早位置接著讀」還是「視為資料缺口」。把 Log 講成「永遠可重放」是行銷句,工程句是在保留窗口內可重放。

關鍵ACK 在 Log 裡只移動自己的游標

Queue 的 ACK 對全世界生效:訊息消失。Log 的 ACK(或 commit cursor)只對這一個消費者組生效:其他人的游標不動,日誌本體不動。面試把兩種 ACK 講成同一件事,後面的重放、重試、Exactly-once 討論會全部錯位。分區怎麼切、游標怎麼提交、組員怎麼重排,是 Kafka 架構 的協定細節,本篇停在「游標是組私有的」這一句。

一句話答法Log 把訊息當可保留的有序事實:各消費者組獨立移動游標,保留窗口內可重放。ACK 只推進自己的位置,不刪日誌。
04

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。

陷阱把兩種 Backpressure 講成同一件事

即時連線擴展 談的是:gateway 對這條長連線 fan-out 時,裝置或下游 socket 的接收窗口滿了怎麼辦。本篇談的是:broker 對工人投遞時,工人的處理隊列滿了怎麼辦。前者卡住會影響同房間其他人的推送路徑;後者卡住應該讓訊息留在 broker(或變成積壓指標),不要灌進即將 OOM 的工人。解法可以長得很像(credit、停推),座標不同,選錯層會修錯元件。

一句話答法Push 要靠 credit / prefetch 才有流控;Pull 與長輪詢讓工人用「取」表達自己的容量。這裡的 Backpressure 在 broker 與消費者之間,不是長連線扇出。
05

ACK 與 Visibility Timeout:租約語意

ACK 的含義是「副作用做完了」,不是「我收到了」。沒 ACK 之前,訊息只是被租走。

工人從 broker 拿到一則訊息時,這則訊息並沒有被刪除。它進入不可見窗口:其他 Competing Consumers 暫時看不到它,避免兩個人同時做同一份工作。這個窗口就是 Visibility Timeout,本質是一張租約:在到期前,持有者必須 ACK(做完)或續租(還在做);到期而沒有 ACK,訊息重新可見,下一個工人可以再拿走。

租約內 ACK → 刪除或推進游標;租約到期未 ACK → 重新可見(重投)

因此 ACK 的時機幾乎只有一個正確答案:外部副作用成功之後。先 ACK 再處理,等於選擇 At-most-once——進程在 ACK 與副作用之間崩潰,這則工作永遠消失。先處理再 ACK,崩潰會重投,走 At-least-once。沒有第三條既不丟又不重複、又完全不靠應用層的免費路徑。

處理耗時必須小於租約,或你得會續租

租約長度是一道工程參數,不是協定魔法:

  • 太短:正常處理還沒做完,訊息已重新可見。第二個工人接手,第一個工人稍後也 ACK——同一則工作的副作用發生兩次。
  • 太長:工人崩潰後,訊息要等整段租約才被救回。積壓看起來「有貨沒人做」,其實卡在一張過期很慢的租約上。
  • 可變工時:長尾請求必須能延長租約(heartbeat / change-visibility)。固定 timeout 假設「每則工作一樣久」,長尾一到就重投。
互動 · 租約長度掃描 把 VT 門檻線推過工時分佈,看重投與救援等待怎麼反向拉扯
拖動旋鈕,看租約長度怎麼決定重投與救援等待。

橫軸是處理耗時,兩根柱子是這批 200 則工作的兩點分佈(刻意簡化:一般工作全部落在 p50、長尾工作全部落在 p99)。紅色虛線是租約門檻——線右邊的柱子會被重投,每滿一個租約就被搶走一次。最下面那條橘色標尺是同一個租約的反面:工人崩潰後訊息最壞要等多久才被救回。要看的是這兩者往相反方向被拉。

這批的規模是固定的:200 則訊息、8 個工人平行處理,只當分母用。

預設值下(租約 10s、長尾 25s、長尾佔 8%),長尾那 16 則每則被重投 2 次、共多出 32 次投遞、16% 的訊息會被重複做,工人有 25.2% 的時間在白燒。把租約拉到 30s 重投歸零,但崩潰救援從 10s 變成 30s;改開續租則兩邊都低——這就是「可變工時必須能延長租約」的量化理由。再把一般工作的耗時拉到 20s(超過租約),兩根柱子會同時越線,重複副作用佔比直接頂到 100%。

關鍵重投不是 bug,是租約的定義

Visibility timeout 到期後的第二次投遞,是系統在說「我不確定上一任做完沒有」。這跟協調服務的 Session 過期是同一類契約:集群側的時鐘說了算,工人本地「我還在做」不算數。GC pause、長 IO、被 SIGSTOP,都會讓本地計時器說謊。所以消費者必須把每一則訊息當成可能是重投,處理路徑從第一天就按冪等來寫。

一句話答法Visibility timeout 是處理中的租約:ACK 代表副作用完成,到期未 ACK 就重投。ACK 必須發生在副作用之後,否則你選的是 At-most-once。
06

三種交付語義,以及「效果只發生一次」

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。

面試高分句是把「投遞」和「效果」拆開:

效果只發生一次 = At-least-once 投遞 + 冪等處理(或去重鍵)

Broker 就算宣稱 Exactly-once,保證的通常是「這則紀錄不會在這條管線裡被提交兩次」——例如消費與寫入下一個 topic 落在同一事務。它看不見你對付款 API、郵件、第三方倉儲發出的副作用。那些呼叫崩潰重試一次,效果就是兩次。能把「效果只發生一次」變成不變量的,是:

  • 冪等處理:同一則訊息做兩次,狀態與第一次相同(條件寫入、set 語意、自然冪等)。
  • 去重鍵:訊息帶穩定 idempotency-key / 業務單號,副作用端用唯一約束擋住第二次。
  • 收斂寫入:寫的是「最新值」而不是「加一」,亂序與重複都落到同一終態。
陷阱「我們開了 Exactly-once,所以 handler 可以不冪等」

產品的 Exactly-once 有範圍:通常止於該產品認識的儲存與游標。一跨出進程、一碰到外部 HTTP、一寫進另一套資料庫,範圍就結束。把 checkbox 當成全域物理定律,是生產事故的固定劇本。正確說法是:broker 減少管線內的重複提交;效果層的不變量仍由你寫。

一句話答法At-most-once 可能丟、At-least-once 可能重複。Exactly-once 投遞是產品管線的範圍聲明;效果只發生一次必須靠冪等或去重,不能只靠 broker 的 checkbox。
07

重試、退避與 Dead Letter Queue

暫時故障該退後再試;Poison 必須分流。DLQ 是人工介入的入口,不是自動成功。

At-least-once 意味著失敗會再來一次。問題是「再來一次」的節奏與上限。兩類失敗要分開:

暫時故障
下一秒可能好
  • 下游 503、鎖衝突、短暫超時
  • 指數退避 + jitter,避免同步重試打爆下游
  • 租約內續租,避免還沒退完就被別人搶走
  • 有預算:最多試 N 次或最長 T
Poison / 永久失敗
再試一百次仍會炸
  • schema 對不上、必填欄缺失、業務拒絕
  • 無限重試只會佔住工人與租約
  • 達上限後移出主路徑,寫入 DLQ
  • 主路徑繼續走,不被一則壞訊息塞死

Dead Letter Queue 的工作是隔離:把無法自動完成的訊息從熱路徑拿開,保留原文、失敗原因、投遞次數,等人工或離線作業處理。它不是第二條會自己變成功的管道。常見誤用:

  • 沒人看的 DLQ:等於靜默丟棄,只是換了個比較體面的桶。
  • DLQ 再自動打回主佇列且不修資料:Poison 會在兩條佇列之間振盪。
  • 沒有告警與積壓 SLO:DLQ 深度是事故指標,應跟主佇列一樣被看著。
關鍵重試次數是產品決策,不是越大越可靠

每次重試都可能再做一次副作用。上限太高,Poison 在主路徑上燒工時;上限太低,暫時抖動被誤判成死信。實務上把「可重試錯誤」與「不可重試錯誤」從 handler 明確拋出:後者直接進 DLQ,前者才走退避。不要讓所有 exception 共用同一條指數曲線。

一句話答法暫時故障用帶 jitter 的退避與有限次數重試;Poison 達上限進 DLQ,由人處理。DLQ 是隔離與可見性,不是自動修復。
08

順序保證與 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 這張帳單。

一句話答法Competing Consumers 換吞吐、放棄全域序;單一有序流換順序、付出 HOL blocking。先定義有序範圍,再決定能不能並行。
09

互動 · 同一批訊息,兩種模型

同一組生產與消費參數推到兩側:Queue 走完後 broker 上剩下什麼、Log 的保留窗口留下什麼,以及三組讀者各自讀得到哪一段。

互動 · Queue vs Log 同一組輸入同時餵給兩側,差異自己跑出來
拖動上面六個旋鈕,兩側會同時重算。

兩帶共用同一條 y 軸:M1 在最上、序號往下遞增,所以同一列就是同一則訊息在兩種模型下的下場。左帶全是虛線空框——Queue 的 ACK 等於刪除,走完之後 broker 上什麼都不剩;右帶灰色剪影是已經超出保留窗口而被回收的那幾則。右邊三條軌是三組讀者各自的游標與讀取區間,要看的是三條軌的起點差多少。

已刪除 / 已回收 仍在保留窗口內 組 B 上線那一刻還在佇列裡 重放缺口

三組讀者是固定編制(組 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 的 ACK 對全世界生效(刪除),所以晚到者只拿得到「還沒被做掉的待辦」,而且積壓一清空就連那個都沒有;Log 的 ACK 只推進自己那一組的游標,日誌本體不動,晚到與重放都只受保留窗口限制。
10

面試折衷與邊界

先答模型與語意,再談產品。內部結構、事件邊界、串流運算各有自己的篇。

面試常問 先答這句 不要滑去
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,等於先承認「誰有權看歷史」與「失敗時重不重做」。產品名可以晚點再寫;這兩句寫不清楚,後面任何吞吐數字都站不住。

一句話答法面試先畫 Queue vs Log,再講租約重投、三種交付與 DLQ。效果只發生一次寫在 handler,不寫在 broker 的設定頁。