01

總覽與定位

廉價磁碟為何撐得住高吞吐:把訊息做成可定址的分散式日誌,用順序寫與作業系統快取換吞吐量。

訊息佇列基礎篇把訊息骨幹拆成 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 才有座標。

1 · 定址
Topic · Partition · Offset
一則紀錄有固定地址。並行與順序都以 partition 為單位,不是以整個 topic。
2 · 寫入路徑
acks · 批次 · 冪等 PID
acks 決定「誰點頭才算寫成」。PID 加序號消掉重試造成的重複,不管端到端效果。
3 · 存活與清理
ISR · Rebalance · Retention
誰跟得上 Leader、群組怎麼換主人、日誌要留多久或只留每個 key 的最後一筆。

本篇停在 Kafka 自己的機制。通用複寫與一致性理論見 資料複寫基礎 與進階複寫篇;key 到分片的通用理論見 資料分片基礎——這裡只談 Kafka 的 partition 映射與擴容後的重新指派。事件該帶多少欄位、Transactional Outbox 怎麼讓訊息跟交易同生共死,留給 Event-Driven Architecture。Windowing 與 Watermark 留給 Stream Processing。

座標Kafka 是可重放的分散式 commit log,不是「比較快的 RabbitMQ」

消費者各自持有游標,broker 不因 ACK 刪除紀錄。這讓重放、多組獨立消費、以及用 compaction 做 changelog 成為同一套儲存模型的衍生,而不是外掛功能。

一句話答法Kafka 用順序寫、Page Cache 與 zero-copy 把廉價磁碟做成高吞吐日誌;並行與順序都切在 partition 上。
02

Topic、Partition 與 Offset

定址模型決定你能承諾的順序、並行度,以及擴容時哪些紀錄會「搬家」。

Topic 是命名空間。真正的實體是 Partition:一條只追加、不可改寫的有序序列。Offset 是這條序列上的單調位址——從 0 起跳的整數,不是時間戳,也不在跨 partition 之間可比。

topic · orders — 命名空間,本身沒有順序
P0 01234 append ▸
P1 012 append ▸
P2 012345 append ▸

Topic 只是名字,有序的是每一條 partition:offset 在同一條內單調遞增、只在尾端追加(綠色格是目前的尾巴),跨條之間不可比——所以「全域順序」在這張圖上根本沒有位置可畫。

Key 怎麼落到 partition

帶 key 的預設行為是 hash(key) % n:同一個 key 穩定進同一個 partition,於是「同一訂單的事件有序」這句話才成立。沒有 key 時,現代客戶端用 sticky partitioning——先黏在一個 partition 上把批次填滿,再換下一個——為的是批次效率,不是公平輪詢。

這裡不要滑去通用分片理論(一致性雜湊、虛擬節點、hot shard 治理)。Kafka 這一層只承諾兩件事:映射函式穩定時,同 key 同 partition;以及 單一 partition 內 offset 單調、紀錄有序。

你能保證的順序
per-partition · 同 key
  • 同一 partition 內,offset 小的先被寫入、也按此被消費。
  • 同 key 因為進同一 partition,所以同實體有序。
  • 跨 partition 沒有全域序。需要全域序就只開一個 partition,並接受吞吐上限。
擴容時的重新指派
只影響新寫入 · 舊紀錄不搬家
  • 把 n 從 3 加到 6,hash(key) % n 對大多數 key 會變。
  • 已經躺在舊 partition 的紀錄不會被搬到新的映射位置。
  • 同 key 之後的新事件可能寫進另一條 partition——順序承諾在擴容當下斷掉。

因此 partition 數要在流量規劃時一次給夠,而不是當成可隨意加減的旋鈕。真要重切,得做離線重寫或接受窗口內亂序。

陷阱「加 partition 就能線性擴吞吐」忽略了順序與重放

消費者並行度的上限是 partition 數:一個 Consumer Group 裡,同一時間每個 partition 只有一個主人。加 partition 能加工人,但會改寫 key 的落點,讓「同 key 有序」與「從舊 offset 重放得到同一條時間線」同時失效。

一句話答法順序與並行都以 partition 為單位;擴容改的是新紀錄的映射,舊 offset 上的資料原地不動。
03

Segment、Page Cache 與 zero-copy

吞吐來自順序寫與把讀路徑交給作業系統,不是來自把 payload 塞進 JVM heap。

每個 partition 在磁碟上是一組 Segment:到達大小或時間上限就關閉舊檔、開新的 active segment。Active segment 只在尾巴追加;較舊的 segment 變成不可變,才能被 Retention 整段刪除,或被 Compaction 重寫。旁邊還有稀疏的 offset index 與時間索引,讓「從 offset 12345 開始讀」不必掃完整條 log。

00000.log
+ 00000.index(稀疏)
已關閉 · 不可變
可整檔刪 / 可壓
▸
00107.log
+ 00107.index(稀疏)
active · append-only

箭頭方向就是 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 壓力。

順序寫 + Page Cache
寫路徑
  • Append 到 active segment,磁碟看到的是連續區塊。
  • Page Cache 讓剛寫入的資料立刻可被其他消費者讀到,不必等 fsync。
  • Heap 留給索引、請求緩衝與 metadata,不扛 TB 級 payload。
zero-copy 讀
讀路徑
  • Consumer fetch 對上的是檔案偏移,不是物件圖。
  • sendfile:Page Cache → socket,略過應用層緩衝區。
  • 冷讀才會真正打到磁碟;這也是為什麼「消費者遠落後」會突然變慢。
關鍵Kafka 是日誌系統,順便會用記憶體

把機器記憶體估成「快取容量」是對的;把它估成「資料都在 heap 裡」是錯的。記憶體不夠時,Page Cache 被擠出,吞吐從「記憶體頻寬」掉回「磁碟頻寬」——監控要看 cache hit 與磁碟讀,不是只看 JVM heap。

一句話答法順序追加進 Segment,讀走 Page Cache 加 zero-copy;heap 不扛訊息本體,這才是廉價磁碟能高吞吐的原因。
04

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 的事務。

PID + sequence 管得到
同一 Producer、同一 partition
  • 超時重試不再變成兩筆。
  • Broker 以序號窗口做去重,客戶端不必自己編 idempotency key。
  • 對「至少投遞一次」的寫入路徑,這是 Kafka 逼近「寫入恰好一次」的機制。
管不到的邊界
換 PID、跨系統、消費端
  • 進程重啟會拿新 PID,去重窗口從頭算。
  • 下游 HTTP/資料庫的副作用仍要自己做冪等——定義見訊息佇列基礎篇談三種交付語義的那一節。
  • consume–transform–produce 的原子性要靠 transactional producer,不是單開 idempotence。
陷阱打開 enable.idempotence 不等于端到端恰好一次

它只保證「這個 Producer 對這個 partition 的重試不會雙寫」。消費者仍可能看到 at-least-once 的重投(rebalance、未提交 offset)。面試把這顆 checkbox 講成 exactly-once 全家桶,會立刻被追問下游 API。

一句話答法acks 決定誰點頭;批次與壓縮決定吞吐;PID 加序號只消寫入重試的重複,不管消費端效果。
05

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 的主人
P0C1H1
P1C1H1
P2C2H2
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 沒有人消費。面試要能講出這個停頓,而不是只背「自動負載均衡」。

Eager(stop-the-world)
Range / RoundRobin 等舊協定
  • 所有成員先 revoke 手上全部 partition。
  • 整個 group 停消費,等新指派發完。
  • 哪怕只是第三個工人加入,前兩人也必須放下正在做的區。
Sticky assignor 與 Cooperative 協定
兩件事 · 各砍一半
  • 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 與撤銷流程想清楚。

互動 · partition 指派計算器 真的跑三套 assignor;換手數與停頓數即時重算
留(沒換手、也沒停頓) 搬(換手;換手期間必然停頓) 停(沒換手卻仍被 revoke 而停頓)
partition 歸屬條帶 · before / 換手 / after
每名 consumer 的持有量(after 指派)
三種策略的換手成本(同一組輸入下同時計算)
策略 換手條數 停頓條數 停頓佔比 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 數這道天花板。

關鍵Rebalance 的帳單是延遲與重複,不是「分配不均」

停頓造成 lag;revoke 時若 offset 還沒提交,新主人會從上一個 commit 重拉,於是 at-least-once 再次現身。這是機制,不是 broker 故障。處理側仍要冪等——定義在訊息佇列基礎篇講過,這裡只指出 Kafka 在換手時會把它再次觸發。

一句話答法Group 裡一區一人。Eager 換人會讓全組停手;sticky 只動必要的區,static membership 讓短暫停機不必換人。
06

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 的紀錄,對消費者仍是不可見,也還談不上「已提交」。

acks=all 成功 ⇔ 目前 ISR 皆寫入該 offset,且 |ISR| ≥ min.insync.replicas

通用的 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,就會看到它為什麼從「可寫入」翻成「拒絕寫入」。

寫入被拒是保護成功
NOT_ENOUGH_REPLICAS
  • ISR 少於 min.isr 時,拒絕比默默寫在單點上更正確。
  • 客戶端應重試並告警,不要把這當成「降級成 acks=1」的暗示。
  • 監控 ISR 大小與 under-replicated partitions,不要只看 QPS。
Unclean election 的資料遺失
過期 LEO 成為新的 HWM
  • 舊 Leader 寫過、沒抄到這張落後副本的紀錄,對新叢集而言不存在。
  • 已經被下游消費的 offset 也可能從 log 裡消失——重放會出現空洞。
  • 這是可用性換耐久。打開它要能講出「哪些 offset 可能蒸發」。
互動 · durability 決策器 四顆旋鈕加「掛幾台」,判定與理由即時重算
判定
—
    建議設定
      可用性容忍度1台故障仍可寫
      耐久容忍度2台故障仍不丟

      —

      該刻已複製到此副本 副本已下線 該刻拒寫(ISR < 需求) unclean 接班會截掉的 offset High Watermark
      副本 × 時刻 · 存活與複製矩陣(每刻嘗試寫入 1 筆,共 8 筆)
      —

      四組值得停下來比對的設定。(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 完全沒有在保護你。

      陷阱複製因子 3 不是三份安全

      RF 只決定「最多抄幾份」。真正決定寫入是否可丟的是 ISR 當下有誰、acks 等誰、min.isr 擋不擋、以及接班者是否必須來自 ISR。三個 replica 配置若允許 unclean election,仍可能在一次連續故障後少掉已經 ack 的資料。

      一句話答法ISR 是跟得上的副本;acks=all 等 ISR,min.isr 防止 ISR 被縮到不安全;關掉 unclean election 是用停機換不丟資料。
      07

      Retention 與 Log Compaction

      兩種清理語義:依時間/容量丟掉舊段,或每個 key 只留最後一筆。

      Log 模型「不因消費而刪」不代表永遠不刪。Kafka 提供兩條彼此不同的清理軸,混用前先問資料是事件歷史還是狀態快照。

      Retention(刪段)
      log.retention.hours / bytes
      • 關閉的 segment 整段過期或超出容量就刪。
      • 適合審計流、點擊流、不可 compact 的事件。
      • 重放窗口 = 你願意付錢的磁碟時間。過期後 offset 上的資料不在了。
      Log Compaction(留最後一筆)
      cleanup.policy=compact
      • 同一 key 只保留最新 value;舊版本在背景被重寫掉。
      • 適合 changelog:使用者資料、資料庫映像、組態。
      • value 為 null 是 tombstone,讓下游刪除對應 key;tombstone 本身也會過期。

      Compaction 不保證立刻發生:active segment 不會被壓,dirty ratio 未達門檻時舊值仍在。它也不是備份——被壓掉的歷史無法重放。需要「每個 key 的現況 + 一段可重放歷史」時,用 compact,delete 並分開設定兩段保留,或乾脆拆成兩個 topic。

      關鍵先選資料形狀,再選清理語義

      訂單已成立這類事實事件用 Retention:你要的是時間線。購物車當下內容用 Compaction:你要的是每個 cart_id 的最後狀態。把事實流拿去 compact,等於主動銷毀審計軌跡。

      一句話答法Retention 按時間或容量刪段;Compaction 每 key 留最後一筆。事件歷史用前者,changelog 用後者。
      08

      面試折衷與邊界

      先答定址與耐久旋鈕,再談產品周邊。事件建模、串流視窗、通用複寫各有自己的篇。

      面試常問 先答這句 不要滑去
      為什麼磁碟還能這麼快? 順序追加 + 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。複寫協定與分片理論請走資料面那兩條線,不要在本篇展開。

      關鍵Kafka 的每一顆旋鈕都在買同一種東西:誰可以落後、落後多久、落後的後果由誰承擔

      Partition 數買並行、賣擴容後的順序。acks 與 min.isr 買耐久、賣寫入可用性。Rebalance 協定買成員彈性、賣空窗。Compaction 買最新狀態、賣歷史。講得出「誰承擔後果」,比背參數預設值更像做過線上系統的人。

      一句話答法定址在 partition,吞吐在順序寫,耐久在 ISR 與 min.isr,停頓在 rebalance。效應一次仍寫在 handler,不寫在 broker checkbox。