總覽與定位
廉價磁碟為何撐得住高吞吐:把訊息做成可定址的分散式日誌,用順序寫與作業系統快取換吞吐量。
訊息佇列基礎篇把訊息骨幹拆成 Queue 與 Log 兩種模型。Kafka 選的是後者,並且把它做成可水平擴的產品:一則紀錄的地址不是「佇列裡的下一筆」,而是 topic + partition + offset。本篇不再重述 at-least-once 與 exactly-once 的定義——那些是交付語意;這裡只講 Kafka 用哪些旋鈕去逼近它們。
面試裡最常被低估的一句是:Kafka 的吞吐不是靠「記憶體資料庫」撐起來的。它把資料追加寫進磁碟上的區段檔,讓作業系統的 Page Cache 當讀取緩衝,再用 zero-copy 把頁面直接送上 socket。順序寫讓 HDD/普通 SSD 看起來像高速裝置;堆疊上的 JVM heap 刻意不扛 payload。先承認這件事,後面的 Partition、acks 與 ISR 才有座標。
本篇停在 Kafka 自己的機制。通用複寫與一致性理論見 資料複寫基礎 與進階複寫篇;key 到分片的通用理論見 資料分片基礎——這裡只談 Kafka 的 partition 映射與擴容後的重新指派。事件該帶多少欄位、Transactional Outbox 怎麼讓訊息跟交易同生共死,留給 Event-Driven Architecture。Windowing 與 Watermark 留給 Stream Processing。
消費者各自持有游標,broker 不因 ACK 刪除紀錄。這讓重放、多組獨立消費、以及用 compaction 做 changelog 成為同一套儲存模型的衍生,而不是外掛功能。
Topic、Partition 與 Offset
定址模型決定你能承諾的順序、並行度,以及擴容時哪些紀錄會「搬家」。
Topic 是命名空間。真正的實體是 Partition:一條只追加、不可改寫的有序序列。Offset 是這條序列上的單調位址——從 0 起跳的整數,不是時間戳,也不在跨 partition 之間可比。
Topic 只是名字,有序的是每一條 partition:offset 在同一條內單調遞增、只在尾端追加(綠色格是目前的尾巴),跨條之間不可比——所以「全域順序」在這張圖上根本沒有位置可畫。
Key 怎麼落到 partition
帶 key 的預設行為是 hash(key) % n:同一個 key 穩定進同一個 partition,於是「同一訂單的事件有序」這句話才成立。沒有 key 時,現代客戶端用 sticky partitioning——先黏在一個 partition 上把批次填滿,再換下一個——為的是批次效率,不是公平輪詢。
這裡不要滑去通用分片理論(一致性雜湊、虛擬節點、hot shard 治理)。Kafka 這一層只承諾兩件事:映射函式穩定時,同 key 同 partition;以及 單一 partition 內 offset 單調、紀錄有序。
- 同一 partition 內,offset 小的先被寫入、也按此被消費。
- 同 key 因為進同一 partition,所以同實體有序。
- 跨 partition 沒有全域序。需要全域序就只開一個 partition,並接受吞吐上限。
- 把 n 從 3 加到 6,
hash(key) % n對大多數 key 會變。 - 已經躺在舊 partition 的紀錄不會被搬到新的映射位置。
- 同 key 之後的新事件可能寫進另一條 partition——順序承諾在擴容當下斷掉。
因此 partition 數要在流量規劃時一次給夠,而不是當成可隨意加減的旋鈕。真要重切,得做離線重寫或接受窗口內亂序。
消費者並行度的上限是 partition 數:一個 Consumer Group 裡,同一時間每個 partition 只有一個主人。加 partition 能加工人,但會改寫 key 的落點,讓「同 key 有序」與「從舊 offset 重放得到同一條時間線」同時失效。
Segment、Page Cache 與 zero-copy
吞吐來自順序寫與把讀路徑交給作業系統,不是來自把 payload 塞進 JVM heap。
每個 partition 在磁碟上是一組 Segment:到達大小或時間上限就關閉舊檔、開新的 active segment。Active segment 只在尾巴追加;較舊的 segment 變成不可變,才能被 Retention 整段刪除,或被 Compaction 重寫。旁邊還有稀疏的 offset index 與時間索引,讓「從 offset 12345 開始讀」不必掃完整條 log。
可整檔刪 / 可壓
箭頭方向就是 base offset 遞增:只有最右邊的 active segment 在被追加,左邊已關閉且不可變,Retention 才能整檔刪除、Compaction 才能整檔重寫。index 依附在同一個 segment 上,不是另一個並列元件。
為什麼廉價磁碟夠用
隨機寫會把磁碟變成延遲抖動器;Kafka 把熱路徑收成一條順序追加。寫入先進入 OS Page Cache,由作業系統決定何時刷盤(broker 的 flush 與 replication 是另一條耐久軸,見後文 acks/ISR)。讀取端更關鍵:broker 不把訊息本體緩存在 JVM heap。熱資料本來就在 Page Cache 裡,sendfile 這類 zero-copy 路徑把頁面從快取直接搬到 NIC,少一次使用者態拷貝,也少一次 GC 壓力。
- Append 到 active segment,磁碟看到的是連續區塊。
- Page Cache 讓剛寫入的資料立刻可被其他消費者讀到,不必等 fsync。
- Heap 留給索引、請求緩衝與 metadata,不扛 TB 級 payload。
- Consumer fetch 對上的是檔案偏移,不是物件圖。
sendfile:Page Cache → socket,略過應用層緩衝區。- 冷讀才會真正打到磁碟;這也是為什麼「消費者遠落後」會突然變慢。
把機器記憶體估成「快取容量」是對的;把它估成「資料都在 heap 裡」是錯的。記憶體不夠時,Page Cache 被擠出,吞吐從「記憶體頻寬」掉回「磁碟頻寬」——監控要看 cache hit 與磁碟讀,不是只看 JVM heap。
Producer:acks、批次與冪等 PID
寫入端三顆旋鈕:誰點頭、一次塞多少、重試會不會變成兩筆。
Producer 不是「送出一則 RPC」。它把紀錄累成 batch(linger.ms、batch.size),可選壓縮(lz4/zstd/snappy),再依 acks 等待不同範圍的確認。壓縮在批次內最划算:小批次加壓縮只是在燒 CPU。
| acks | 誰點頭 | 延遲 | Leader 掛掉時 |
|---|---|---|---|
0 |
不等待 | 最低 | 可能根本沒落地,客戶端卻以為成功 |
1 |
只有 Leader | 中 | Follower 尚未抄走就可能丟 |
all(-1) |
目前 ISR 內的副本 | 最高 | 仍受 min.insync.replicas 與 unclean election 約束 |
acks=all 等的是 ISR,不是「複製因子 RF 張副本都活著」。ISR 若被縮到只剩 Leader,all 等價於 1——除非 min.insync.replicas 把寫入擋下來。這條等價關係可以在「ISR、min.insync.replicas 與 unclean election」那一節的決策器裡,把 min.insync.replicas 拖到 1 直接看出來。
冪等 Producer:消掉重試造成的重複
網路超時後客戶端重試,Leader 其實已經寫入——這會在同一 partition 留下兩筆內容相同、offset 不同的紀錄。Idempotent Producer 給每個 Producer 一個 PID,並對每個 partition 維護單調的 sequence。Broker 記住「這個 PID 在這個 partition 上見過的最大序號」,重複序號直接丟棄並回成功。這條路徑要求 acks=all 與有上限的 in-flight batches;它修的是寫入端重試,不是消費端的效果一次,也不是跨 topic 的事務。
- 超時重試不再變成兩筆。
- Broker 以序號窗口做去重,客戶端不必自己編 idempotency key。
- 對「至少投遞一次」的寫入路徑,這是 Kafka 逼近「寫入恰好一次」的機制。
- 進程重啟會拿新 PID,去重窗口從頭算。
- 下游 HTTP/資料庫的副作用仍要自己做冪等——定義見訊息佇列基礎篇談三種交付語義的那一節。
- consume–transform–produce 的原子性要靠 transactional producer,不是單開 idempotence。
它只保證「這個 Producer 對這個 partition 的重試不會雙寫」。消費者仍可能看到 at-least-once 的重投(rebalance、未提交 offset)。面試把這顆 checkbox 講成 exactly-once 全家桶,會立刻被追問下游 API。
Consumer Group 與 Rebalance
一個群組裡,每個 partition 同一時間只有一個主人;換主人的那段空窗叫 Rebalance。
同一個 group.id 的消費者合力消費一個 topic:Group Coordinator 把 partition 指派出去,每個 partition 最多一個主人。這不是 Queue 的 competing consumers——訊息不會因為被其中一人讀走而消失;另一個 group 可以從自己的 offset 再讀一遍。Group 提交的 offset 存在內部 topic __consumer_offsets,重啟後從提交點接續。
| Partition | group G 的主人 | group H 的主人 |
|---|---|---|
P0 | C1 | H1 |
P1 | C1 | H1 |
P2 | C2 | H2 |
| offset 記在哪 | __consumer_offsets · G 的那份 |
__consumer_offsets · H 的那份 |
一人一區:同一個 group 內每條 partition 只有一個主人,所以消費並行度的上限就是 partition 數。可重放:換到 group H,同一批紀錄從 H 自己的 offset 再讀一遍——這才是 Kafka 與 Queue competing consumers 的分界。
什麼會觸發 Rebalance
成員加入、離開、被判定死亡(session.timeout.ms 內沒有 heartbeat)、以及 partition 數變更。Rebalance 期間,舊指派被收回、新指派尚未生效——這段時間該 partition 沒有人消費。面試要能講出這個停頓,而不是只背「自動負載均衡」。
- 所有成員先 revoke 手上全部 partition。
- 整個 group 停消費,等新指派發完。
- 哪怕只是第三個工人加入,前兩人也必須放下正在做的區。
- Sticky assignor 砍的是搬家數:新指派盡量沿用既有歸屬,只動必要的區。但它仍走 eager 協定——全組照樣先 revoke,停頓沒有變短。
- Cooperative 協定 砍的是停頓數:分兩輪 JoinGroup,只收回真正換手的那幾條,其餘繼續拉。
- Static membership(
group.instance.id):進程重啟在 timeout 內回來,視為同一人,不觸發 rebalance。
關鍵在左右兩欄砍的不是同一個數字:Eager Range 搬得多也停得久;Sticky 搬得少、卻因為仍走 eager 協定而停得一樣久;只有 Cooperative Sticky 兩個都少。Sticky 也不是「永不停頓」——該換手的區仍會暫停。Static membership 換到的是部署友好:滾動重啟不再震盪整個 group,但實例 ID 衝突或僵屍成員會佔住指派,要把 session timeout 與撤銷流程想清楚。
| 策略 | 換手條數 | 停頓條數 | 停頓佔比 | JoinGroup 輪數 |
|---|
先看成本表為什麼中間那一列只砍了一半:預設 6 條 partition、2 名成員、第 3 名加入時,Eager Range 換手 3 條、停頓 6 條;換成 Sticky,換手掉到 2 條,停頓還是 6 條——因為 sticky 只是換了 assignor,協定仍是 eager,全組照樣先 revoke;換成 Cooperative Sticky,指派結果與 Sticky 完全相同(換手仍是 2 條),但停頓掉到 2 條、佔比 33%,代價是要跑 2 輪 JoinGroup。所以 StickyAssignor 與 CooperativeStickyAssignor 的換手數本來就一樣,差的是協定。另外兩組值得比對:把成員變動改成「離開一名」(3 名減為 2 名),Range 換手 3 條、Sticky 與 Cooperative 都是 2 條;把 partition 拖到 2、consumer 留 3、成員變動選「無變動」,並行度上限變成 min(2, 3) = 2,第 3 名 consumer 拿不到任何 partition、負載橫條是空的——加工人加不過 partition 數這道天花板。
停頓造成 lag;revoke 時若 offset 還沒提交,新主人會從上一個 commit 重拉,於是 at-least-once 再次現身。這是機制,不是 broker 故障。處理側仍要冪等——定義在訊息佇列基礎篇講過,這裡只指出 Kafka 在換手時會把它再次觸發。
ISR、min.insync.replicas 與 unclean election
Kafka 自己的複寫觀測:誰跟得上 Leader、寫入要幾張嘴點頭、落後者能不能接班。
每個 partition 有一個 Leader 與若干 Follower。Follower 向 Leader fetch,抄寫同一條 log。跟得上的副本組成 ISR(In-Sync Replicas):落後超過 replica.lag.time.max.ms 就會被踢出。消費者能讀到的不是 Leader 的 log end offset(LEO),而是 High Watermark——ISR 裡最小的那個 LEO。沒進 HWM 的紀錄,對消費者仍是不可見,也還談不上「已提交」。
通用的 quorum/任期/日誌匹配規則請回複寫與 Raft 那兩篇。本段只使用 Kafka 的觀測語言:Leader、Follower、ISR、HWM、unclean election。
| 旋鈕 | 擋住什麼 | 放開時的代價 |
|---|---|---|
acks=all |
Leader 獨寫就回成功 | ISR 縮成 1 時,all 退化成 1 |
min.insync.replicas=2 |
ISR 少於 2 時拒絕寫入 | 可用性下降:只剩 Leader 活著就不能寫 |
unclean.leader.election=false |
落後副本接班、HWM 倒退 | ISR 全沒時 partition 不可用,直到同步副本回來 |
unclean.leader.election=true |
沒人接班的停機 | 過期副本帶著較小 LEO 上台,中間的 offset 永久消失 |
RF=3、min.insync.replicas=2、acks=all、關閉 unclean election,是常見的耐久組合:允許少一張副本,但不允許「只寫在 Leader 上」或「用過期日誌接班」。下方的決策器預設就是這一組(所以它的建議清單是空的);把「同時掛掉幾台」從 1 拖到 2,就會看到它為什麼從「可寫入」翻成「拒絕寫入」。
- ISR 少於 min.isr 時,拒絕比默默寫在單點上更正確。
- 客戶端應重試並告警,不要把這當成「降級成 acks=1」的暗示。
- 監控 ISR 大小與 under-replicated partitions,不要只看 QPS。
- 舊 Leader 寫過、沒抄到這張落後副本的紀錄,對新叢集而言不存在。
- 已經被下游消費的 offset 也可能從 log 裡消失——重放會出現空洞。
- 這是可用性換耐久。打開它要能講出「哪些 offset 可能蒸發」。
其餘理由
—
四組值得停下來比對的設定。(1) 預設 RF=3、min.isr=2、acks=all、unclean=off、掛 1 台:8 筆全部接受、HWM=8,但兩條刻度尺停在不同格——可用性容忍 1 台、耐久容忍 2 台,先撐不住的是可用性。(2) 把「同時掛掉幾台」拖到 2:ISR 剩 1 台 < 需求 2 台,後 2 筆被擋(矩陣右側兩欄變成 ✕),判定翻成「拒絕寫入 — 保護成功」,並算出「要在 2 台掛掉下仍可寫,RF 至少要 4」。(3) 維持掛 2 台再勾選 unclean:耐久容忍度從 2 掉到 1,序列②算出落後副本 r2 會以 LEO=3 接班,offset 3–5 共 3 筆永久消失——矩陣上那三欄被畫上刪除線。(4) 把 min.insync.replicas 拖到 4(超過 RF=3):矩陣整個空掉,8 筆全被擋,因為 acks=all 要求的 ISR 數已經超過副本總數——這組設定自相矛盾。接著把 acks 切到 1,同一組矛盾設定卻寫得進去:acks=0/1 根本不查 min.insync.replicas,於是 min.isr 完全沒有在保護你。
RF 只決定「最多抄幾份」。真正決定寫入是否可丟的是 ISR 當下有誰、acks 等誰、min.isr 擋不擋、以及接班者是否必須來自 ISR。三個 replica 配置若允許 unclean election,仍可能在一次連續故障後少掉已經 ack 的資料。
Retention 與 Log Compaction
兩種清理語義:依時間/容量丟掉舊段,或每個 key 只留最後一筆。
Log 模型「不因消費而刪」不代表永遠不刪。Kafka 提供兩條彼此不同的清理軸,混用前先問資料是事件歷史還是狀態快照。
- 關閉的 segment 整段過期或超出容量就刪。
- 適合審計流、點擊流、不可 compact 的事件。
- 重放窗口 = 你願意付錢的磁碟時間。過期後 offset 上的資料不在了。
- 同一 key 只保留最新 value;舊版本在背景被重寫掉。
- 適合 changelog:使用者資料、資料庫映像、組態。
- value 為 null 是 tombstone,讓下游刪除對應 key;tombstone 本身也會過期。
Compaction 不保證立刻發生:active segment 不會被壓,dirty ratio 未達門檻時舊值仍在。它也不是備份——被壓掉的歷史無法重放。需要「每個 key 的現況 + 一段可重放歷史」時,用 compact,delete 並分開設定兩段保留,或乾脆拆成兩個 topic。
訂單已成立這類事實事件用 Retention:你要的是時間線。購物車當下內容用 Compaction:你要的是每個 cart_id 的最後狀態。把事實流拿去 compact,等於主動銷毀審計軌跡。
面試折衷與邊界
先答定址與耐久旋鈕,再談產品周邊。事件建模、串流視窗、通用複寫各有自己的篇。
| 面試常問 | 先答這句 | 不要滑去 |
|---|---|---|
| 為什麼磁碟還能這麼快? | 順序追加 + Page Cache + zero-copy;heap 不扛 payload | 「Kafka 是記憶體佇列」或拿隨機 IOPS 比 |
| 順序怎麼保證? | 單一 partition 內有序;同 key 進同一區。沒有跨區全域序 | 通用分片理論、一致性雜湊環 |
| 能不能隨時加 partition? | 舊紀錄不搬家,新寫入的 key 映射會變,同 key 順序在當下斷掉 | 當成免費的水平擴展旋鈕 |
| acks 要設多少? | 要耐久就 all 加 min.isr≥2;0/1 是吞吐換遺失風險 |
只報 RF=3 卻不提 ISR 當下人數 |
| 冪等 Producer 做什麼? | PID+序號消掉寫入重試的重複;不管消費端效果,也不跨重啟 PID | 把它講成端到端 exactly-once |
| Rebalance 為什麼會卡消費? | 換手期間該區沒有主人。Eager 全停;sticky 只停要搬家的區 | 怪罪「broker 變慢」或忽略未提交 offset 的重拉 |
| 什麼是 unclean election? | 讓 ISR 以外的落後副本接班,HWM 倒退,中間 offset 消失 | 與 Raft 任期、通用 quorum 混為一談 |
| Retention 和 Compaction 怎麼選? | 事件歷史用時間/容量刪段;changelog 每 key 留最後一筆 | 把事實流拿去 compact,或把 compaction 當備份 |
下一站:用事件重劃服務邊界與 Outbox,見 Event-Driven Architecture;把資料庫日誌變成事件流,見 CDC;視窗與水位線見後續 Stream Processing。複寫協定與分片理論請走資料面那兩條線,不要在本篇展開。
Partition 數買並行、賣擴容後的順序。acks 與 min.isr 買耐久、賣寫入可用性。Rebalance 協定買成員彈性、賣空窗。Compaction 買最新狀態、賣歷史。講得出「誰承擔後果」,比背參數預設值更像做過線上系統的人。