<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Kafka on Tarragon</title><link>https://tarrragon.github.io/blog/tags/kafka/</link><description>Recent content in Kafka on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Mon, 22 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/kafka/index.xml" rel="self" type="application/rss+xml"/><item><title>Queue 緩衝</title><link>https://tarrragon.github.io/blog/devops/07-burst-traffic/queue-buffering/</link><pubDate>Sat, 20 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/devops/07-burst-traffic/queue-buffering/</guid><description>&lt;p>Message queue 放在 ingestion（接收事件）和 processing（寫入 storage）之間，把兩者解耦。Ingestion 只負責驗證和寫入 queue，processing 按自己的速度從 queue 消費。Queue 做 burst 的時間緩衝 — 高峰時 queue 積壓、低峰時 worker 追上。&lt;/p>
&lt;h2 id="為什麼不直接寫-db">為什麼不直接寫 DB&lt;/h2>
&lt;p>直接寫 DB（SQLite / PostgreSQL）的問題是 ingestion 速度被 DB 寫入速度限制。DB 寫入慢（鎖定、WAL flush、索引更新）時，HTTP handler 的 goroutine 等在 &lt;code>Storage.Store()&lt;/code> 上 — goroutine 積壓 → 記憶體上升 → 最終 OOM 或 response timeout。&lt;/p>
&lt;p>Queue 的解決方式是把「接收」和「寫入」分開：接收端只做 JSON 驗證 + 寫入 queue（微秒級），處理端從 queue 讀取 + 寫入 DB（毫秒級）。接收端的吞吐量不再受 DB 限制。&lt;/p>
&lt;h3 id="取捨">取捨&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>維度&lt;/th>
 &lt;th>直接寫 DB&lt;/th>
 &lt;th>經過 Queue&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>延遲&lt;/td>
 &lt;td>事件寫完 DB 即可查詢&lt;/td>
 &lt;td>事件要等 worker 消費後才可查詢&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>吞吐&lt;/td>
 &lt;td>受 DB 寫入速度限制&lt;/td>
 &lt;td>受 queue 寫入速度限制（通常遠高於 DB）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>複雜度&lt;/td>
 &lt;td>一個元件&lt;/td>
 &lt;td>三個元件（collector + queue + worker）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>故障模式&lt;/td>
 &lt;td>DB 掛了事件丟失（除非有背壓）&lt;/td>
 &lt;td>Queue 做持久化，DB 掛了事件在 queue 等待&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>自用工具場景不需要 queue — 單 collector + SQLite 的直接寫入足夠。Queue 的引入條件是「直接寫 DB 的背壓開始頻繁觸發」。&lt;/p>
&lt;h2 id="候選類型">候選類型&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Queue&lt;/th>
 &lt;th>特點&lt;/th>
 &lt;th>適用場景&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;strong>Kafka&lt;/strong>&lt;/td>
 &lt;td>高吞吐、持久化、消費者群組&lt;/td>
 &lt;td>大規模（&amp;gt; 10 萬 events/sec）、多消費者&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>NATS JetStream&lt;/strong>&lt;/td>
 &lt;td>輕量、低延遲、Go 原生&lt;/td>
 &lt;td>中型（千 ~ 萬 events/sec）、Go 生態&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Redis Streams&lt;/strong>&lt;/td>
 &lt;td>用既有 Redis、XADD/XREAD API&lt;/td>
 &lt;td>中型、已有 Redis 基礎設施&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="選型判斷">選型判斷&lt;/h3>
&lt;p>已有 Redis → 先用 Redis Streams（零新增元件）。Go 為主的技術棧 → NATS JetStream（Go 原生 client、單 binary 部署）。需要跨消費者群組或日誌級持久化 → Kafka。&lt;/p>
&lt;h3 id="引入條件">引入條件&lt;/h3>
&lt;p>Queue 的引入是架構複雜度的顯著上升（一個元件變三個）。明確的觸發條件：&lt;/p>
&lt;ul>
&lt;li>背壓（429 回應）頻繁觸發（每天 &amp;gt; 100 次）且持續（不只是瞬間 burst）&lt;/li>
&lt;li>寫入延遲的 P95 超過 500ms（DB 成為瓶頸）&lt;/li>
&lt;li>需要多個 consumer（同一批事件要送到不同的下游 — analytics DB、alert engine、archive）&lt;/li>
&lt;/ul>
&lt;h2 id="監控系統的-queue-架構">監控系統的 Queue 架構&lt;/h2>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">SDK ──→ Collector (ingestion only)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> ├─ 驗證 JSON Schema
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> ├─ Redaction
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> └─ 寫入 Queue
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> ├── Worker A → PostgreSQL（主 storage）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl"> ├── Worker B → 降採樣 → Summary tables
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl"> └── Worker C → Rule engine → Alert&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Collector 瘦身為 ingestion-only — 只做接收、驗證、redaction 和寫入 queue。Storage 寫入、降採樣、rule engine 都移到 worker 群。Collector 的吞吐瓶頸從 DB 寫入變成 queue 寫入（queue 的寫入吞吐通常是 DB 的 10-100 倍）。&lt;/p></description><content:encoded><![CDATA[<p>Message queue 放在 ingestion（接收事件）和 processing（寫入 storage）之間，把兩者解耦。Ingestion 只負責驗證和寫入 queue，processing 按自己的速度從 queue 消費。Queue 做 burst 的時間緩衝 — 高峰時 queue 積壓、低峰時 worker 追上。</p>
<h2 id="為什麼不直接寫-db">為什麼不直接寫 DB</h2>
<p>直接寫 DB（SQLite / PostgreSQL）的問題是 ingestion 速度被 DB 寫入速度限制。DB 寫入慢（鎖定、WAL flush、索引更新）時，HTTP handler 的 goroutine 等在 <code>Storage.Store()</code> 上 — goroutine 積壓 → 記憶體上升 → 最終 OOM 或 response timeout。</p>
<p>Queue 的解決方式是把「接收」和「寫入」分開：接收端只做 JSON 驗證 + 寫入 queue（微秒級），處理端從 queue 讀取 + 寫入 DB（毫秒級）。接收端的吞吐量不再受 DB 限制。</p>
<h3 id="取捨">取捨</h3>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>直接寫 DB</th>
          <th>經過 Queue</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>延遲</td>
          <td>事件寫完 DB 即可查詢</td>
          <td>事件要等 worker 消費後才可查詢</td>
      </tr>
      <tr>
          <td>吞吐</td>
          <td>受 DB 寫入速度限制</td>
          <td>受 queue 寫入速度限制（通常遠高於 DB）</td>
      </tr>
      <tr>
          <td>複雜度</td>
          <td>一個元件</td>
          <td>三個元件（collector + queue + worker）</td>
      </tr>
      <tr>
          <td>故障模式</td>
          <td>DB 掛了事件丟失（除非有背壓）</td>
          <td>Queue 做持久化，DB 掛了事件在 queue 等待</td>
      </tr>
  </tbody>
</table>
<p>自用工具場景不需要 queue — 單 collector + SQLite 的直接寫入足夠。Queue 的引入條件是「直接寫 DB 的背壓開始頻繁觸發」。</p>
<h2 id="候選類型">候選類型</h2>
<table>
  <thead>
      <tr>
          <th>Queue</th>
          <th>特點</th>
          <th>適用場景</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>Kafka</strong></td>
          <td>高吞吐、持久化、消費者群組</td>
          <td>大規模（&gt; 10 萬 events/sec）、多消費者</td>
      </tr>
      <tr>
          <td><strong>NATS JetStream</strong></td>
          <td>輕量、低延遲、Go 原生</td>
          <td>中型（千 ~ 萬 events/sec）、Go 生態</td>
      </tr>
      <tr>
          <td><strong>Redis Streams</strong></td>
          <td>用既有 Redis、XADD/XREAD API</td>
          <td>中型、已有 Redis 基礎設施</td>
      </tr>
  </tbody>
</table>
<h3 id="選型判斷">選型判斷</h3>
<p>已有 Redis → 先用 Redis Streams（零新增元件）。Go 為主的技術棧 → NATS JetStream（Go 原生 client、單 binary 部署）。需要跨消費者群組或日誌級持久化 → Kafka。</p>
<h3 id="引入條件">引入條件</h3>
<p>Queue 的引入是架構複雜度的顯著上升（一個元件變三個）。明確的觸發條件：</p>
<ul>
<li>背壓（429 回應）頻繁觸發（每天 &gt; 100 次）且持續（不只是瞬間 burst）</li>
<li>寫入延遲的 P95 超過 500ms（DB 成為瓶頸）</li>
<li>需要多個 consumer（同一批事件要送到不同的下游 — analytics DB、alert engine、archive）</li>
</ul>
<h2 id="監控系統的-queue-架構">監控系統的 Queue 架構</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">SDK ──→ Collector (ingestion only)
</span></span><span class="line"><span class="ln">2</span><span class="cl">           │
</span></span><span class="line"><span class="ln">3</span><span class="cl">           ├─ 驗證 JSON Schema
</span></span><span class="line"><span class="ln">4</span><span class="cl">           ├─ Redaction
</span></span><span class="line"><span class="ln">5</span><span class="cl">           └─ 寫入 Queue
</span></span><span class="line"><span class="ln">6</span><span class="cl">                 │
</span></span><span class="line"><span class="ln">7</span><span class="cl">                 ├── Worker A → PostgreSQL（主 storage）
</span></span><span class="line"><span class="ln">8</span><span class="cl">                 ├── Worker B → 降採樣 → Summary tables
</span></span><span class="line"><span class="ln">9</span><span class="cl">                 └── Worker C → Rule engine → Alert</span></span></code></pre></div><p>Collector 瘦身為 ingestion-only — 只做接收、驗證、redaction 和寫入 queue。Storage 寫入、降採樣、rule engine 都移到 worker 群。Collector 的吞吐瓶頸從 DB 寫入變成 queue 寫入（queue 的寫入吞吐通常是 DB 的 10-100 倍）。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>突發流量的分類 → <a href="/blog/devops/07-burst-traffic/burst-classification/" data-link-title="突發流量的分類" data-link-desc="可預期 vs 不可預期的突發流量 — 不同來源、持續時間和倍率決定不同的應對策略">突發流量的分類</a></li>
<li>降級策略 → <a href="/blog/devops/07-burst-traffic/degradation-strategy/" data-link-title="降級策略" data-link-desc="系統超載時犧牲什麼保住什麼 — 動態取樣、事件優先級、功能降級、聚合前移四種策略">降級策略</a></li>
<li>規模分級的完整應對 → <a href="/blog/devops/07-burst-traffic/scale-tier-response/" data-link-title="規模分級應對表" data-link-desc="自用級 → 中型 → 大型 → 商業網站級的四級應對方案 — 每級的觸發條件、架構組成和成本">規模分級應對表</a></li>
<li>Queue 的選型和操作實務 → <a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">backend 非同步佇列</a></li>
</ul>
]]></content:encoded></item><item><title>CoreWeave 收購 Bufstream：整併週期下的賽道判讀與基礎設施重組</title><link>https://tarrragon.github.io/blog/business/case-analyses/bufstream-acquisition/</link><pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/business/case-analyses/bufstream-acquisition/</guid><description>&lt;p>CoreWeave 在 2025 末收購 Bufstream、揭露 Kafka 生態系兩個同步發生的結構性訊號：串流市場進入 &lt;a href="https://tarrragon.github.io/blog/business/knowledge-cards/consolidation-cycle/" data-link-title="Consolidation Cycle" data-link-desc="說明整併週期的階段特徵">整併週期&lt;/a> 末段、以及算力廠商把資料基礎設施視為 &lt;a href="https://tarrragon.github.io/blog/business/knowledge-cards/rigid-demand/" data-link-title="Rigid Demand" data-link-desc="說明剛性需求的判讀訊號">剛需&lt;/a> 而垂直整合。本篇拆解兩個趨勢的疊加效應、Diskless Kafka 的市場格局、以及對資料工程師職涯的訊號。&lt;/p>
&lt;h2 id="事件本身">事件本身&lt;/h2>
&lt;p>2025 末 CoreWeave 收購 Bufstream。Bufstream 來自 Buf 公司、Buf 從 Google 開源的 Protobuf 生態系做起、發展出 Schema Registry 跟相容 Kafka 的串流基礎設施。CoreWeave 從 Crypto 轉型成 GPU 算力租借巨頭、2024 上市、市值規模達數百億美元。&lt;/p>
&lt;p>這起收購接在 2024 年 WarpStream 被 Confluent 收購、Aiven 跟 AutoMQ 各自鞏固位置之後、屬於串流市場整併週期的一環。&lt;/p>
&lt;p>理解 Bufstream 的策略路徑、需要先理解 Schema vs non-Schema（raw bytes）的長期爭論。資料庫領域奠基者之一 Mike Stonebraker（圖靈獎得主）近年先後公開批評 MapReduce 脫離 Schema 是設計缺失、streaming 上沒有 Schema 也屬同類議題。Buf 的整套主張—從 Protobuf 生態系到 Buf Schema Registry 再到 Bufstream—延續 Schema 派立場：Schema 應當是企業內部所有微服務通訊、資料儲存與串流處理的「唯一真實來源」。Bufstream 是 schema-first 哲學在 streaming 層的延伸、不是純粹的技術產品。&lt;/p>
&lt;p>主流公開討論集中在「又一筆 M&amp;amp;A」的表面敘事。本篇焦點在這起收購揭露的兩個結構性趨勢、以及對資料工程師職涯的意涵。&lt;/p>
&lt;h2 id="串流市場的整併週期">串流市場的整併週期&lt;/h2>
&lt;p>什麼是 Kafka？它是一個資料管路工具、讓不同系統之間的資料即時流動（例如使用者下訂單後、訂單資料即時流給庫存系統、出貨系統、會計系統）。Kafka 是 LinkedIn 開源的、市場上有多家廠商基於 Kafka 賣商業服務。&lt;/p>
&lt;p>2024-2025 年這個 Kafka 商業服務市場玩家收斂明顯：&lt;/p>
&lt;ul>
&lt;li>2024 年 WarpStream（一家做 Diskless Kafka 架構的新創）被 Confluent 收購&lt;/li>
&lt;li>2025 年 Bufstream（Buf 公司的 Kafka 服務）被 CoreWeave 收購&lt;/li>
&lt;li>未來幾年可能還有後續整併&lt;/li>
&lt;/ul>
&lt;p>市場進入殘酷的 &lt;a href="https://tarrragon.github.io/blog/business/knowledge-cards/consolidation-cycle/" data-link-title="Consolidation Cycle" data-link-desc="說明整併週期的階段特徵">整併週期&lt;/a>（一個市場成熟之後、玩家數量會從多收斂到少、靠併購完成）—新進者沒有獨家差異化資產就很難留下。&lt;/p>
&lt;p>Buf 在 streaming（即時資料流動）賽道的位置就反映這個結構。Buf 持有的差異化是 Schema（資料的結構描述、確保系統之間溝通有共識）哲學深度、但在 streaming 層缺三個關鍵資產：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>自有銷售通路&lt;/strong>：Confluent 由 Kafka 原作者創辦、自帶銷售管道跟 Kafka 社群信任；Buf 沒有這個&lt;/li>
&lt;li>&lt;strong>Diskless 架構先發優勢&lt;/strong>：Diskless 是把 Kafka 從「自己管硬碟」改成「丟到雲端便宜物件儲存（如 AWS S3）」、成本可顯著低於傳統架構；WarpStream 是 Diskless 先驅、AutoMQ 也已起步、Bufstream 後發&lt;/li>
&lt;li>&lt;strong>自有生態系&lt;/strong>：Aiven（北歐託管多種開源資料服務的公司）已建立託管平台、客戶在 Aiven 上同時用多個服務；Buf 沒有這層&lt;/li>
&lt;/ul>
&lt;p>在這個競爭格局裡、Bufstream 進市場時已處於 &lt;a href="https://tarrragon.github.io/blog/business/knowledge-cards/red-ocean-blue-ocean/" data-link-title="Red Ocean / Blue Ocean" data-link-desc="說明紅海競爭與藍海空白的賽道狀態">紅海&lt;/a>（已經被大家搶得頭破血流的成熟市場）後段、繼續競爭的邊際報酬遞減、整併出場是合理選項。這是整併週期的標準劇本—新進者缺差異化、整併或收掉是兩條主要出路。&lt;/p>
&lt;p>對想進串流市場的新創來說、這個整併週期的意涵是：在 Confluent 主導 + Diskless 已有先發 + 託管市場 Aiven 卡位之後、第四個進場的差異化空間有限。要進這個市場、得帶顛覆性差異化（例如新一代非 Kafka 的串流架構、或極端垂直化的應用層）、否則整併是合理預期出路。&lt;/p>
&lt;h2 id="算力廠商垂直整合資料基礎設施">算力廠商垂直整合資料基礎設施&lt;/h2>
&lt;p>CoreWeave 出手的動機跟傳統 SaaS 公司買競爭對手不一樣。傳統 SaaS 買競爭對手是為了市佔率（買掉對手讓自己市佔變大）。CoreWeave 這種算力廠商買 streaming 工具的動機完全不同—是為了把「資料管路」這層放進自己控制範圍、不要被第三方廠商卡脖子。&lt;/p>
&lt;p>為什麼？因為訓練大型 AI 模型的經濟結構很特殊：&lt;/p>
&lt;p>訓練一個 AI 模型需要數以萬計的 GPU 節點同時運作。每個 GPU 一小時租金可能上千美元、數萬個 GPU 同時跑、一小時的營收規模驚人。但這些 GPU 一邊跑訓練、一邊產生海量資料：&lt;/p>
&lt;ul>
&lt;li>遙測資料（每個 GPU 的健康狀況、溫度、效能指標）&lt;/li>
&lt;li>模型權重快照（訓練過程的階段性備份、Disaster Recovery 用）&lt;/li>
&lt;li>梯度更新紀錄（演算法每一步調整模型的紀錄）&lt;/li>
&lt;li>線上評估指標（模型表現好不好的即時數字）&lt;/li>
&lt;/ul>
&lt;p>這些資料必須即時傳輸跟儲存。如果資料管路（也就是 streaming）出問題、GPU 就只能等資料、不能算—GPU 閒置一秒就是一秒的營收損失。&lt;/p></description><content:encoded><![CDATA[<p>CoreWeave 在 2025 末收購 Bufstream、揭露 Kafka 生態系兩個同步發生的結構性訊號：串流市場進入 <a href="/blog/business/knowledge-cards/consolidation-cycle/" data-link-title="Consolidation Cycle" data-link-desc="說明整併週期的階段特徵">整併週期</a> 末段、以及算力廠商把資料基礎設施視為 <a href="/blog/business/knowledge-cards/rigid-demand/" data-link-title="Rigid Demand" data-link-desc="說明剛性需求的判讀訊號">剛需</a> 而垂直整合。本篇拆解兩個趨勢的疊加效應、Diskless Kafka 的市場格局、以及對資料工程師職涯的訊號。</p>
<h2 id="事件本身">事件本身</h2>
<p>2025 末 CoreWeave 收購 Bufstream。Bufstream 來自 Buf 公司、Buf 從 Google 開源的 Protobuf 生態系做起、發展出 Schema Registry 跟相容 Kafka 的串流基礎設施。CoreWeave 從 Crypto 轉型成 GPU 算力租借巨頭、2024 上市、市值規模達數百億美元。</p>
<p>這起收購接在 2024 年 WarpStream 被 Confluent 收購、Aiven 跟 AutoMQ 各自鞏固位置之後、屬於串流市場整併週期的一環。</p>
<p>理解 Bufstream 的策略路徑、需要先理解 Schema vs non-Schema（raw bytes）的長期爭論。資料庫領域奠基者之一 Mike Stonebraker（圖靈獎得主）近年先後公開批評 MapReduce 脫離 Schema 是設計缺失、streaming 上沒有 Schema 也屬同類議題。Buf 的整套主張—從 Protobuf 生態系到 Buf Schema Registry 再到 Bufstream—延續 Schema 派立場：Schema 應當是企業內部所有微服務通訊、資料儲存與串流處理的「唯一真實來源」。Bufstream 是 schema-first 哲學在 streaming 層的延伸、不是純粹的技術產品。</p>
<p>主流公開討論集中在「又一筆 M&amp;A」的表面敘事。本篇焦點在這起收購揭露的兩個結構性趨勢、以及對資料工程師職涯的意涵。</p>
<h2 id="串流市場的整併週期">串流市場的整併週期</h2>
<p>什麼是 Kafka？它是一個資料管路工具、讓不同系統之間的資料即時流動（例如使用者下訂單後、訂單資料即時流給庫存系統、出貨系統、會計系統）。Kafka 是 LinkedIn 開源的、市場上有多家廠商基於 Kafka 賣商業服務。</p>
<p>2024-2025 年這個 Kafka 商業服務市場玩家收斂明顯：</p>
<ul>
<li>2024 年 WarpStream（一家做 Diskless Kafka 架構的新創）被 Confluent 收購</li>
<li>2025 年 Bufstream（Buf 公司的 Kafka 服務）被 CoreWeave 收購</li>
<li>未來幾年可能還有後續整併</li>
</ul>
<p>市場進入殘酷的 <a href="/blog/business/knowledge-cards/consolidation-cycle/" data-link-title="Consolidation Cycle" data-link-desc="說明整併週期的階段特徵">整併週期</a>（一個市場成熟之後、玩家數量會從多收斂到少、靠併購完成）—新進者沒有獨家差異化資產就很難留下。</p>
<p>Buf 在 streaming（即時資料流動）賽道的位置就反映這個結構。Buf 持有的差異化是 Schema（資料的結構描述、確保系統之間溝通有共識）哲學深度、但在 streaming 層缺三個關鍵資產：</p>
<ul>
<li><strong>自有銷售通路</strong>：Confluent 由 Kafka 原作者創辦、自帶銷售管道跟 Kafka 社群信任；Buf 沒有這個</li>
<li><strong>Diskless 架構先發優勢</strong>：Diskless 是把 Kafka 從「自己管硬碟」改成「丟到雲端便宜物件儲存（如 AWS S3）」、成本可顯著低於傳統架構；WarpStream 是 Diskless 先驅、AutoMQ 也已起步、Bufstream 後發</li>
<li><strong>自有生態系</strong>：Aiven（北歐託管多種開源資料服務的公司）已建立託管平台、客戶在 Aiven 上同時用多個服務；Buf 沒有這層</li>
</ul>
<p>在這個競爭格局裡、Bufstream 進市場時已處於 <a href="/blog/business/knowledge-cards/red-ocean-blue-ocean/" data-link-title="Red Ocean / Blue Ocean" data-link-desc="說明紅海競爭與藍海空白的賽道狀態">紅海</a>（已經被大家搶得頭破血流的成熟市場）後段、繼續競爭的邊際報酬遞減、整併出場是合理選項。這是整併週期的標準劇本—新進者缺差異化、整併或收掉是兩條主要出路。</p>
<p>對想進串流市場的新創來說、這個整併週期的意涵是：在 Confluent 主導 + Diskless 已有先發 + 託管市場 Aiven 卡位之後、第四個進場的差異化空間有限。要進這個市場、得帶顛覆性差異化（例如新一代非 Kafka 的串流架構、或極端垂直化的應用層）、否則整併是合理預期出路。</p>
<h2 id="算力廠商垂直整合資料基礎設施">算力廠商垂直整合資料基礎設施</h2>
<p>CoreWeave 出手的動機跟傳統 SaaS 公司買競爭對手不一樣。傳統 SaaS 買競爭對手是為了市佔率（買掉對手讓自己市佔變大）。CoreWeave 這種算力廠商買 streaming 工具的動機完全不同—是為了把「資料管路」這層放進自己控制範圍、不要被第三方廠商卡脖子。</p>
<p>為什麼？因為訓練大型 AI 模型的經濟結構很特殊：</p>
<p>訓練一個 AI 模型需要數以萬計的 GPU 節點同時運作。每個 GPU 一小時租金可能上千美元、數萬個 GPU 同時跑、一小時的營收規模驚人。但這些 GPU 一邊跑訓練、一邊產生海量資料：</p>
<ul>
<li>遙測資料（每個 GPU 的健康狀況、溫度、效能指標）</li>
<li>模型權重快照（訓練過程的階段性備份、Disaster Recovery 用）</li>
<li>梯度更新紀錄（演算法每一步調整模型的紀錄）</li>
<li>線上評估指標（模型表現好不好的即時數字）</li>
</ul>
<p>這些資料必須即時傳輸跟儲存。如果資料管路（也就是 streaming）出問題、GPU 就只能等資料、不能算—GPU 閒置一秒就是一秒的營收損失。</p>
<p>舉個算式：</p>
<ul>
<li>假設 CoreWeave 一個 GPU 一小時租金 5 美元、一個訓練集群有 1 萬個 GPU</li>
<li>集群每小時營收 = 5 × 10,000 = 5 萬美元</li>
<li>如果 streaming 故障讓 GPU 閒置 1 小時、損失 5 萬美元</li>
<li>如果第三方 streaming 廠商的 SLA（服務等級協議、保證最低可用性）寫的是「99.9% 可用」、意思是一年最多可以閒置 8.76 小時、損失上限 43 萬美元</li>
</ul>
<p>對按小時計費的算力服務商來說、streaming 不是「可選的工具」、是「直接決定營收的命脈」（<a href="/blog/business/knowledge-cards/rigid-demand/" data-link-title="Rigid Demand" data-link-desc="說明剛性需求的判讀訊號">剛需</a>、客戶非要不可的需求）。CoreWeave 收 Bufstream 的本質、是把 streaming 從「外部第三方依賴」轉為「內部自己控制的基礎設施」、避免外部 SLA 成為訓練流程的瓶頸。</p>
<p>這個動機跟 CoreWeave 過去收購軌跡一致—Weights &amp; Biases（AI 訓練的觀測平台）、Conductor AI（AI 工作流編排）、Bufstream（streaming）—都是 vertical AI stack（從硬體到應用的整套垂直 AI 平台）的拼圖、目標是對抗 AWS Bedrock、Azure ML 這些 Hyperscaler（超大規模雲端廠商）的 AI 平台堆疊。</p>
<p>當算力廠商成為主要併購買方、市場整併方向就會偏向「服務 AI workload（AI 工作負載）的基礎設施」、不是傳統 IT 基礎設施。這個訊號對未來幾年資料基礎設施的併購輪廓很有參考價值—下一輪會被買的目標、可能是 observability（系統觀測工具）、storage（儲存系統）、metadata 管理工具等、同樣對 AI workload 是剛需的工具。</p>
<h2 id="diskless-kafka-的未來與市場格局">Diskless Kafka 的未來與市場格局</h2>
<p>這起收購最大的市場討論點是 Diskless Kafka 的未來。</p>
<p>傳統 Kafka 設計：每台 Kafka 伺服器都有自己的硬碟、資料寫進來先存在本地硬碟、再複製到其他伺服器當備份。可靠但成本高—要買一堆 Kafka 專用的高效能硬碟伺服器、而且還要存好幾份。</p>
<p>Diskless 架構：Kafka 伺服器不存本地硬碟了、直接把資料丟到便宜的雲端物件儲存（像 AWS S3）。成本可顯著低於傳統架構、但效能、延遲是技術挑戰。</p>
<p>既然 Kafka 依然是資料工程中無可替代的角色、而在 <a href="/blog/business/knowledge-cards/red-ocean-blue-ocean/" data-link-title="Red Ocean / Blue Ocean" data-link-desc="說明紅海競爭與藍海空白的賽道狀態">紅海</a> 競爭下「成本」已經成為最大亮點、市場上能選的大型方案收斂到剩下：</p>
<table>
  <thead>
      <tr>
          <th>玩家</th>
          <th>定位</th>
          <th>訊號</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Confluent</td>
          <td>Kafka 官方商業版、原作者公司</td>
          <td>業界龍頭、整併買方</td>
      </tr>
      <tr>
          <td>WarpStream</td>
          <td>Diskless 先驅、2024 被 Confluent 收購</td>
          <td>已併入 Confluent</td>
      </tr>
      <tr>
          <td>Aiven</td>
          <td>北歐託管多種開源資料服務（含 Kafka）</td>
          <td>走託管路線、不爭架構創新</td>
      </tr>
      <tr>
          <td>AutoMQ</td>
          <td>主打 Diskless 架構、開源策略</td>
          <td>Diskless 架構推動者</td>
      </tr>
      <tr>
          <td>Bufstream</td>
          <td>Schema-first 串流、2025 被 CoreWeave 收購</td>
          <td>已併入 CoreWeave、退出公開市場</td>
      </tr>
  </tbody>
</table>
<p>至於 Apache Kafka 社群版 Diskless 架構、預期仍需數個版本週期才能達到生產就緒—開源社群協調速度比商業公司慢、但技術方向跟商業版的成本壓力一致。</p>
<h2 id="兩個趨勢的疊加效應">兩個趨勢的疊加效應</h2>
<p>「整併週期」跟「算力廠商垂直整合」兩個趨勢同時發生並互相強化。整併週期的買方需要明確的「為什麼買」理由、算力廠商剛好提供了這個理由：垂直整合資料基礎設施、避免外部 SLA 拖累自己的單位營收。</p>
<p>兩個趨勢疊加產生的次生效應：</p>
<ul>
<li>整併市場的買方結構從「同業 + PE」變成「同業 + PE + 算力廠商」</li>
<li>被併購標的的估值判讀要納入「對算力廠商的戰略價值」、不只「同業 ARR multiple」</li>
<li>留下的獨立玩家面對「同業 + 算力廠商雙重收購壓力」、自主路線越來越難維持</li>
</ul>
<h2 id="長期影響">長期影響</h2>
<p>長期看：</p>
<p><strong>整併週期</strong>：串流市場玩家會繼續往少數玩家收斂、新進者很難找空間、除非有顛覆性差異化（例如新一代非 Kafka 串流架構）。</p>
<p><strong>算力廠商垂直整合</strong>：CoreWeave 不會是最後一個—未來會有更多算力廠商收購資料基礎設施（streaming、observability、storage）。原因是按小時計費的 GPU 服務不能受制於第三方—任何資料管路延遲都是直接的營收損失。</p>
<p><strong>對資料工程師</strong>：資料工程的戰略位置從「服務內部 BI / 報表」升級為「直接影響 GPU 利用率與訓練吞吐量」。過去資料工程屬於後端營運層、影響範圍限於內部報表與分析；現在因為 AI 訓練對資料流動是剛需、資料管路效能直接決定 GPU 利用率、進而決定算力服務商的單位營收。</p>
<h2 id="對資料工程師職涯的訊號">對資料工程師職涯的訊號</h2>
<p>過去資料工程屬於後端營運層、影響範圍限於內部報表與分析。現在因為 AI 訓練對資料流動是剛需、資料工程的影響範圍延伸到算力服務商的單位營收與訓練吞吐量。CoreWeave 願意以併購規模投資串流基礎設施、反映該層對算力商業模式是不可外包的依賴項。</p>
<p>職涯方向訊號：</p>
<ul>
<li>往「服務 AI workload 的資料基礎設施」走：GPU 遙測、模型快照、梯度紀錄、評估指標的 streaming</li>
<li>累積跨服務的整合能力：<a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">訊息佇列</a>、Object Storage、Observability 的銜接</li>
<li>理解上游算力商業化的 GTM：知道為什麼算力廠商要垂直整合、就能判斷自己該往哪走</li>
</ul>
<h2 id="預警訊號何時要重新評估這個分析">預警訊號：何時要重新評估這個分析</h2>
<p>關鍵假設要監控：</p>
<p><strong>假設一：AI 訓練對 streaming IO 的剛需會持續。</strong> 監控訊號：訓練模式變革（例如純檔案系統訓練、不需要 streaming），或新硬體大幅降低 IO 瓶頸（例如 PCIe 6.0、CXL）。如果剛需減弱、算力廠商不再有垂直整合動機。</p>
<p><strong>假設二：串流市場真的進到整併末段。</strong> 監控訊號：新一輪融資金額、新公司獲投情況。如果有新一波創新出現（例如 Iceberg-style 開放標準改變整個市場結構）、整併可能逆轉成新一輪百家爭鳴。</p>
<p><strong>假設三：開源 Apache Kafka Diskless 會醞釀成功。</strong> 監控訊號：Apache Kafka 社群版 KIP 提案的合併進度。如果開源版本成熟、商業版的價值會被擠壓。</p>
<p>下面任一具體訊號出現、要重新評估這套分析：</p>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>觸發的修正方向</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>主要算力廠商一年內裁掉資料基礎設施團隊</td>
          <td>垂直整合動機消失、判讀過時</td>
      </tr>
      <tr>
          <td>新一代非 Kafka 串流架構大規模採用</td>
          <td>整併判讀過時、市場可能重新洗牌</td>
      </tr>
      <tr>
          <td>開源 Apache Kafka Diskless 主線版本釋出</td>
          <td>商業版價值受壓、現有玩家估值要重估</td>
      </tr>
      <tr>
          <td>訓練模式變革讓 streaming 不再剛需</td>
          <td>算力廠商與資料基礎設施鬆綁、垂直整合趨勢逆轉</td>
      </tr>
  </tbody>
</table>
<h2 id="判讀框架">判讀框架</h2>
<table>
  <thead>
      <tr>
          <th>判讀對象</th>
          <th>看什麼</th>
          <th>主要訊號</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>串流市場玩家</td>
          <td>是大廠還是新進者、有無 <a href="/blog/business/knowledge-cards/fat-data-fat-skill/" data-link-title="Fat Data / Fat Skill" data-link-desc="說明獨家資料與行業隱性能力作為 AI 時代的護城河">Fat Skill</a></td>
          <td>自有銷售通路、自有生態系、價格戰能力</td>
      </tr>
      <tr>
          <td>賽道生命週期</td>
          <td><a href="/blog/business/knowledge-cards/red-ocean-blue-ocean/" data-link-title="Red Ocean / Blue Ocean" data-link-desc="說明紅海競爭與藍海空白的賽道狀態">紅海</a> 進到哪一段</td>
          <td>整併新聞密集度、新一輪融資金額、玩家數量收斂速度</td>
      </tr>
      <tr>
          <td>算力廠商買方</td>
          <td>是否自有資料基礎設施</td>
          <td>是否買下 streaming / storage / observability 工具</td>
      </tr>
      <tr>
          <td>資料工程師職涯</td>
          <td>公司資料流是否服務 AI 訓練或推論</td>
          <td>是否處理 GPU 遙測、模型快照、梯度紀錄等 AI workload</td>
      </tr>
  </tbody>
</table>
<p>這個框架的可遷移性：當任何按用量計費的基礎服務商（算力、頻寬、儲存）開始垂直整合相鄰基礎設施時、同樣可以套這個結構問—「整併到哪一段了」「為什麼這個 buyer 出現」「對下游從業者意味著什麼」。</p>
<h2 id="延伸閱讀">延伸閱讀</h2>
<ul>
<li><a href="/blog/business/case-analyses/fde-arms-race/" data-link-title="FDE 軍備競賽：SaaS 支柱鬆動下的結構性轉變" data-link-desc="用 WRAP 框架拆解三家基礎模型供應商同時押 FDE 模式背後的 SaaS 商業前提鬆動，並判讀 FDE 是過渡狀態還是長期結構">FDE 軍備競賽：SaaS 三支柱鬆動下的結構性轉變</a> — 上游模型供應商的商業化壓力</li>
<li><a href="/blog/business/case-analyses/claude-for-legal/" data-link-title="Claude for Legal 之後：應用層、新創、知識工作者的三層擠壓" data-link-desc="用 WRAP 框架拆解基礎模型供應商進入垂直市場觸發的三層結構轉變：應用層 SaaS 毛利擠壓、新創淘汰、知識工作者判斷賭注放大">Claude for Legal 之後：應用層、新創、知識工作者的三層擠壓</a> — 應用層怎麼被擠壓</li>
<li>Backend 模組的 <a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">訊息佇列章節</a> — Kafka 技術細節</li>
<li><a href="/blog/business/knowledge-cards/consolidation-cycle/" data-link-title="Consolidation Cycle" data-link-desc="說明整併週期的階段特徵">整併週期</a>、<a href="/blog/business/knowledge-cards/rigid-demand/" data-link-title="Rigid Demand" data-link-desc="說明剛性需求的判讀訊號">剛需</a>、<a href="/blog/business/knowledge-cards/red-ocean-blue-ocean/" data-link-title="Red Ocean / Blue Ocean" data-link-desc="說明紅海競爭與藍海空白的賽道狀態">紅海</a> 卡片</li>
</ul>
]]></content:encoded></item><item><title>Kafka Consumer Group Rebalance 與 Lag 診斷：從 protocol 到故障演練</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/consumer-rebalance-lag-diagnosis/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/consumer-rebalance-lag-diagnosis/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka&lt;/a> overview「進階主題」的 implementation-layer deep article，承接 overview「Consumer lag 暴增」與「Rebalance storm」兩段判讀原則的展開。Overview 給判讀方向，本文給 protocol 機制、診斷指令與故障演練。&lt;/p>&lt;/blockquote>
&lt;h2 id="rebalance-是-consumer-group-重新分配-partition-所有權的協調過程">Rebalance 是 consumer group 重新分配 partition 所有權的協調過程&lt;/h2>
&lt;p>Rebalance 是 consumer group coordinator 把 topic 的 partition 重新分配給 group 內 consumer 的協調動作，承擔「在成員數變動時維持每個 partition 恰好被一個 consumer 消費」這個責任。觸發條件是 group membership 改變：consumer 加入、consumer 離開、consumer 被判定失效，或 topic partition 數增加。Rebalance 完成前，受影響的 partition 暫停消費，這段空窗就是 rebalance 對 lag 的直接代價。&lt;/p>
&lt;p>Consumer group 是 Kafka 把「一份 event stream 分給多個 worker 平行處理」與「同一份 stream 給多個獨立應用各自 replay」兩種需求統一的抽象。同一個 group 內的 consumer 瓜分 partition、彼此不重複消費；不同 group 各自維護 offset、互不干擾。Rebalance 只在 group 內部發生，調整的是 group 內 partition 對 consumer 的 mapping。本文聚焦 group 內 rebalance 的機制與診斷，group 概念本身見 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer-group/" data-link-title="Consumer Group" data-link-desc="說明一組 consumer 如何共同分攤 stream 或 topic 的處理責任">consumer group 知識卡&lt;/a>。&lt;/p>
&lt;p>實機觀察 partition 如何在兩個 consumer 間分配：同一 group 起兩個 consumer，coordinator 把 3 個 partition 拆給它們。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">GROUP CONSUMER-ID CLIENT-ID #PARTITIONS CURRENT-ASSIGNMENT
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">live-cg consumer-A-... consumer-A 2 orders:0,1
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">live-cg consumer-B-... consumer-B 1 orders:2
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">GROUP ASSIGNMENT-STRATEGY STATE #MEMBERS
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">live-cg range Stable 2&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>consumer-A 拿到 partition 0、1，consumer-B 拿到 partition 2，STATE 是 Stable 代表 rebalance 已收斂。&lt;code>ASSIGNMENT-STRATEGY&lt;/code> 顯示 range，是預設的 partition 分配演算法。&lt;/p>
&lt;h2 id="eager-與-cooperative-incremental-是兩種-rebalance-protocol">Eager 與 cooperative incremental 是兩種 rebalance protocol&lt;/h2>
&lt;p>Rebalance protocol 決定「rebalance 期間 consumer 要不要交出手上全部 partition」，這個選擇直接決定 rebalance 的 stop-the-world 範圍。Kafka 提供兩種：eager 與 cooperative incremental。&lt;/p>
&lt;p>Eager rebalance 是早期預設行為：rebalance 觸發時，group 內所有 consumer 先放棄手上全部 partition（revoke all），等 coordinator 算完新分配後再各自重新 assign。代價是 rebalance 期間整個 group 完全停止消費，即使某個 consumer 的 partition 在新舊分配中根本沒變，它也得先放掉再拿回。Group 規模越大、partition 越多，這個全停窗口越痛。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka</a> overview「進階主題」的 implementation-layer deep article，承接 overview「Consumer lag 暴增」與「Rebalance storm」兩段判讀原則的展開。Overview 給判讀方向，本文給 protocol 機制、診斷指令與故障演練。</p></blockquote>
<h2 id="rebalance-是-consumer-group-重新分配-partition-所有權的協調過程">Rebalance 是 consumer group 重新分配 partition 所有權的協調過程</h2>
<p>Rebalance 是 consumer group coordinator 把 topic 的 partition 重新分配給 group 內 consumer 的協調動作，承擔「在成員數變動時維持每個 partition 恰好被一個 consumer 消費」這個責任。觸發條件是 group membership 改變：consumer 加入、consumer 離開、consumer 被判定失效，或 topic partition 數增加。Rebalance 完成前，受影響的 partition 暫停消費，這段空窗就是 rebalance 對 lag 的直接代價。</p>
<p>Consumer group 是 Kafka 把「一份 event stream 分給多個 worker 平行處理」與「同一份 stream 給多個獨立應用各自 replay」兩種需求統一的抽象。同一個 group 內的 consumer 瓜分 partition、彼此不重複消費；不同 group 各自維護 offset、互不干擾。Rebalance 只在 group 內部發生，調整的是 group 內 partition 對 consumer 的 mapping。本文聚焦 group 內 rebalance 的機制與診斷，group 概念本身見 <a href="/blog/backend/knowledge-cards/consumer-group/" data-link-title="Consumer Group" data-link-desc="說明一組 consumer 如何共同分攤 stream 或 topic 的處理責任">consumer group 知識卡</a>。</p>
<p>實機觀察 partition 如何在兩個 consumer 間分配：同一 group 起兩個 consumer，coordinator 把 3 個 partition 拆給它們。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">GROUP    CONSUMER-ID    CLIENT-ID    #PARTITIONS  CURRENT-ASSIGNMENT
</span></span><span class="line"><span class="ln">2</span><span class="cl">live-cg  consumer-A-... consumer-A   2            orders:0,1
</span></span><span class="line"><span class="ln">3</span><span class="cl">live-cg  consumer-B-... consumer-B   1            orders:2
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl">GROUP    ASSIGNMENT-STRATEGY  STATE    #MEMBERS
</span></span><span class="line"><span class="ln">6</span><span class="cl">live-cg  range                Stable   2</span></span></code></pre></div><p>consumer-A 拿到 partition 0、1，consumer-B 拿到 partition 2，STATE 是 Stable 代表 rebalance 已收斂。<code>ASSIGNMENT-STRATEGY</code> 顯示 range，是預設的 partition 分配演算法。</p>
<h2 id="eager-與-cooperative-incremental-是兩種-rebalance-protocol">Eager 與 cooperative incremental 是兩種 rebalance protocol</h2>
<p>Rebalance protocol 決定「rebalance 期間 consumer 要不要交出手上全部 partition」，這個選擇直接決定 rebalance 的 stop-the-world 範圍。Kafka 提供兩種：eager 與 cooperative incremental。</p>
<p>Eager rebalance 是早期預設行為：rebalance 觸發時，group 內所有 consumer 先放棄手上全部 partition（revoke all），等 coordinator 算完新分配後再各自重新 assign。代價是 rebalance 期間整個 group 完全停止消費，即使某個 consumer 的 partition 在新舊分配中根本沒變，它也得先放掉再拿回。Group 規模越大、partition 越多，這個全停窗口越痛。</p>
<p>Cooperative incremental rebalance 改成「只 revoke 真正要換手的 partition」。Consumer 先回報自己想保留的 partition，coordinator 算出哪些 partition 需要從 A 搬到 B，只有這些 partition 經歷一次 revoke + reassign，其餘 partition 持續消費不中斷。代價是一次完整 rebalance 可能需要兩輪（第一輪 revoke、第二輪 assign），但每輪只影響少數 partition，整體可用性遠高於 eager。Kafka 2.4 起的 <code>CooperativeStickyAssignor</code> 實作這套協議。</p>
<p>實機驗證 cooperative-sticky 可由 consumer 端 config 啟用，<code>ASSIGNMENT-STRATEGY</code> 欄位反映實際生效的策略：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">kafka-console-consumer.sh --topic orders --bootstrap-server localhost:9092 <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --group coop-cg <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --consumer-property partition.assignment.strategy<span class="o">=</span>org.apache.kafka.clients.consumer.CooperativeStickyAssignor</span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">GROUP    ASSIGNMENT-STRATEGY  STATE    #MEMBERS
</span></span><span class="line"><span class="ln">2</span><span class="cl">coop-cg  cooperative-sticky   Stable   1</span></span></code></pre></div><p>選 protocol 的判準是 group 規模與消費中斷的容忍度：</p>
<table>
  <thead>
      <tr>
          <th>Protocol</th>
          <th>revoke 範圍</th>
          <th>rebalance 期間消費</th>
          <th>適用</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Eager (range / sticky)</td>
          <td>全部 partition</td>
          <td>全停</td>
          <td>小 group、partition 少、rebalance 不頻繁</td>
      </tr>
      <tr>
          <td>Cooperative incremental</td>
          <td>僅換手 partition</td>
          <td>未換手 partition 持續</td>
          <td>大 group、partition 多、要求消費連續性</td>
      </tr>
  </tbody>
</table>
<p>對 partition 數上百、consumer 數十的 group，eager 的全停窗口會讓每次 deploy 都產生明顯 lag spike。Walmart 每天 trillions of message、25K+ consumer 跑在 K8s，pod scaling 與 deploy 觸發的 rebalance 是最大痛點（<a href="/blog/backend/03-message-queue/cases/kafka-walmart-mps-rebalance/" data-link-title="3.C17 Walmart：Messaging Proxy Service 解 rebalance storm" data-link-desc="Walmart 每天 trillions of message、25K&#43; consumer 在 K8s、partition-consumer 1:1 模型撞到擴張極限。">3.C17</a>）；這種規模下 eager 的全停代價無法接受，cooperative 把中斷限縮到換手 partition 是基本要求。但 Walmart 進一步發現，即使換成 cooperative，partition-consumer 1:1 模型本身在 K8s 規模仍撞到擴張極限，最終把 consumer 解耦成 stateless service。Protocol 選擇降低單次 rebalance 代價，架構解耦才解決 rebalance 頻率本身。</p>
<p>切換 protocol 不能直接全量改：eager 與 cooperative 的 consumer 不能在同一 group 共存。滾動升級時，consumer 需先支援兩種 protocol、再分批切換 config，否則混用會導致 rebalance 失敗或 assignment 不一致。</p>
<h2 id="三個-timeout-各自負責不同的失效判定">三個 timeout 各自負責不同的失效判定</h2>
<p>Consumer 存活由三個 timeout 共同把關，每個負責不同層次的失效訊號，混為一談是 rebalance 誤判的主要來源。</p>
<p><code>session.timeout.ms</code> 是 coordinator 等待 consumer heartbeat 的上限。Consumer 背景執行緒週期性送 heartbeat，coordinator 在這個時間內沒收到就判定 consumer 死亡、觸發 rebalance。預設 45 秒（早期版本 10 秒）。值太小，短暫 GC pause 或網路抖動就誤判離線；值太大，真正死掉的 consumer 要拖很久才被踢出，lag 持續累積。</p>
<p><code>heartbeat.interval.ms</code> 是 consumer 送 heartbeat 的頻率，必須明顯小於 <code>session.timeout.ms</code>，慣例設成 1/3。它決定 coordinator 多快能感知 consumer 變化，也決定 rebalance 訊號的傳播速度。值太大，session window 內 heartbeat 次數不足，容錯空間消失。</p>
<p><code>max.poll.interval.ms</code> 是兩次 <code>poll()</code> 呼叫之間的上限，負責偵測「consumer 活著但卡住」。Consumer 主執行緒在 <code>poll()</code> 之間處理拉到的訊息，如果單批處理太久（下游 I/O 慢、batch 太大、業務邏輯重）超過這個時間，coordinator 判定 consumer 失去處理能力、把它踢出 group。預設 5 分鐘。它跟 session.timeout.ms 的分工是：heartbeat 偵測「行程是否還在」，max.poll.interval 偵測「行程是否還在前進」。</p>
<table>
  <thead>
      <tr>
          <th>Timeout</th>
          <th>偵測對象</th>
          <th>預設</th>
          <th>調整方向</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>session.timeout.ms</code></td>
          <td>heartbeat 是否中斷</td>
          <td>45000</td>
          <td>環境抖動大調高、要求快速偵測死亡調低</td>
      </tr>
      <tr>
          <td><code>heartbeat.interval.ms</code></td>
          <td>heartbeat 傳送頻率</td>
          <td>3000</td>
          <td>維持在 session.timeout 的 1/3 左右</td>
      </tr>
      <tr>
          <td><code>max.poll.interval.ms</code></td>
          <td>兩次 poll 的間隔</td>
          <td>300000</td>
          <td>單批處理慢就調高，或縮小 max.poll.records</td>
      </tr>
  </tbody>
</table>
<p>這三個值的常見錯配，是把處理變慢誤當成 consumer 死亡。下游 DB 變慢導致每批處理超過 <code>max.poll.interval.ms</code>，consumer 被踢出觸發 rebalance，partition 搬到別的 consumer，那個 consumer 同樣被同一個慢下游拖垮，再次被踢，形成連環 rebalance。這種情況調 <code>session.timeout.ms</code> 沒用，因為 heartbeat 執行緒一直正常送；要調的是 <code>max.poll.interval.ms</code> 或縮小 <code>max.poll.records</code> 讓單批更快做完。</p>
<h2 id="static-group-membership-讓-consumer-重啟不觸發-rebalance">Static group membership 讓 consumer 重啟不觸發 rebalance</h2>
<p>Static membership 給 consumer 一個固定身分 <code>group.instance.id</code>，讓 coordinator 在 consumer 短暫離線後保留它的 partition 分配，承擔「滾動重啟與短暫中斷不觸發 rebalance」的責任。沒有 static membership 時，consumer 每次重啟都產生一個新的 member id，coordinator 視為「舊成員離開、新成員加入」、觸發兩次 rebalance。</p>
<p>設定方式是給每個 consumer 一個跨重啟穩定的 <code>group.instance.id</code>。Coordinator 看到帶 instance id 的 consumer 離線時，不立即 revoke 它的 partition，而是等到 <code>session.timeout.ms</code> 真正超時才判定永久離線。在這個窗口內 consumer 帶同一個 instance id 回來，直接接回原本的 partition，不觸發 rebalance。</p>
<p>實機驗證 <code>group.instance.id</code> 生效後，<code>--members</code> 輸出多出 <code>GROUP-INSTANCE-ID</code> 欄位：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">kafka-console-consumer.sh --topic orders --bootstrap-server localhost:9092 <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --group static-cg --consumer-property group.instance.id<span class="o">=</span>static-member-1</span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">GROUP      CONSUMER-ID            GROUP-INSTANCE-ID  CLIENT-ID  #PARTITIONS
</span></span><span class="line"><span class="ln">2</span><span class="cl">static-cg  static-member-1-...    static-member-1    static-A   3</span></span></code></pre></div><p>static membership 的關鍵搭配是把 <code>session.timeout.ms</code> 設得比預期的重啟時間長。K8s 滾動更新一個 pod 重啟可能 10-30 秒，session.timeout.ms 要涵蓋這段，否則 pod 還在重啟、coordinator 已判定永久離線、partition 已搬走，static membership 失去意義。代價是真正死掉的 consumer 也要拖到 session.timeout.ms 才被踢出，這段 partition 無人消費。Static membership 用「容忍較長的真實故障偵測延遲」換「消除重啟造成的 rebalance」，適合重啟頻繁但硬故障罕見的環境。</p>
<h2 id="用-kafka-consumer-groupssh-讀-lag-分布">用 kafka-consumer-groups.sh 讀 lag 分布</h2>
<p>診斷 lag 的起點是 <code>kafka-consumer-groups.sh --describe</code>，它逐 partition 列出 current offset、log end offset 與兩者差值 lag，承擔「定位 lag 集中在哪、規模多大」的責任。Lag 是某 partition 已產出的最新 offset 減去 consumer 已 commit 的 offset，代表還沒被消費的訊息量。</p>
<p>實機製造 lag：produce 30 筆訊息、consumer 只消費 12 筆就停掉，<code>--describe</code> 顯示逐 partition 的消費進度落後：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group analytics-cg</span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">GROUP         TOPIC   PARTITION  CURRENT-OFFSET  LOG-END-OFFSET  LAG  CONSUMER-ID
</span></span><span class="line"><span class="ln">2</span><span class="cl">analytics-cg  orders  0          9               9               0    -
</span></span><span class="line"><span class="ln">3</span><span class="cl">analytics-cg  orders  1          3               9               6    -
</span></span><span class="line"><span class="ln">4</span><span class="cl">analytics-cg  orders  2          0               12              12   -</span></span></code></pre></div><p>這份輸出本身就是診斷的第一個分岔點：lag 是均勻分布還是集中在少數 partition。這裡 partition 0 lag=0、partition 1 lag=6、partition 2 lag=12，明顯集中在後兩個 partition，指向 partition 層的不平衡而非整體 consumer 不足。</p>
<p><code>--state</code> 看 group 的健康狀態與分配策略，<code>--members --verbose</code> 看每個 consumer 實際拿到哪些 partition：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group live-cg --state</span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">GROUP    COORDINATOR (ID)     ASSIGNMENT-STRATEGY  STATE    #MEMBERS
</span></span><span class="line"><span class="ln">2</span><span class="cl">live-cg  localhost:9092 (1)   range                Stable   2</span></span></code></pre></div><p>STATE 的取值是診斷訊號：<code>Stable</code> 代表分配已收斂正常消費；<code>PreparingRebalance</code> / <code>CompletingRebalance</code> 代表正在 rebalance；<code>Empty</code> 代表 group 沒有 active member（offset 還在但沒人消費），對應上面 lag 輸出裡 <code>CONSUMER-ID</code> 全是 <code>-</code> 的情況。看到 lag 持續累積又長期停在 rebalance 狀態，問題就在 rebalance 本身而非消費速度。</p>
<h2 id="lag-均勻分布與集中單一-partition-指向不同根因">Lag 均勻分布與集中單一 partition 指向不同根因</h2>
<p>Lag 的分布形狀是診斷的主軸：均勻分布指向消費總能力不足，集中在少數 partition 指向 key 分布或單 partition 的局部問題。同樣是 lag 高，這兩種形狀的修法完全相反，先讀分布再決定方向。</p>
<p>Lag 均勻分布在所有 partition，代表 consumer group 整體消費速度跟不上 producer 寫入速度。根因在消費側的總吞吐：consumer 數量不足、單 consumer 處理慢（CPU / GC / 下游 I/O）、或 producer 突發流量超過 group 設計容量。修法是擴消費能力：加 consumer（上限是 partition 數）、優化單筆處理、或對下游加 batch。如果 lag 隨時間線性成長且各 partition 同步成長，是穩態的容量不足，要重新評估 partition 數與 consumer 數。</p>
<p>Lag 集中在少數 partition、其餘 partition lag 接近零，代表負載不均，根因通常在 key 分布。Producer 用 key 決定 partition（<code>hash(key) % partition_count</code>），如果某些 key 是熱點（例如某個大客戶的 id、某個 null key 全落同一 partition），對應 partition 的訊息量遠高於其他，負責它的 consumer 再快也追不上，而其他 consumer 閒著。加 consumer 不解決這個問題，因為瓶頸 partition 仍只能被一個 consumer 消費。修法在 key 設計：拆熱點 key、加 salt 打散、或對熱點走獨立 topic。</p>
<p>Airbnb 的 logging pipeline 遇到的正是 partition 層 skew：event size 從幾百 bytes 到幾百 KB、QPS 跨數個量級，Spark 一個 partition 對一個 task，造成 data skew，catch-up 一個 4 小時 lag 要再花 4 小時（<a href="/blog/backend/03-message-queue/cases/kafka-airbnb-spark-streaming-rebalance/" data-link-title="3.C15 Airbnb：Spark Streaming Kafka reader rebalance" data-link-desc="Airbnb logging pipeline 解 partition-task 1:1 造成的 data skew、catch-up 4 小時 lag 要再花 4 小時的反效率。">3.C15</a>）。它的解法揭露一個關鍵判準：partition 數不該等同 consumer parallelism。當 lag 集中在少數重 partition，加 consumer 受限於 partition 數的天花板無效，要把 parallelism 從 partition 數解耦、按 event volume × size 重新分派 work。這把「lag 集中」的診斷從 key 分布延伸到了 work 分派模型本身。</p>
<table>
  <thead>
      <tr>
          <th>Lag 分布形狀</th>
          <th>根因方向</th>
          <th>修法</th>
          <th>加 consumer 是否有效</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>均勻分布、各 partition 相近</td>
          <td>消費總能力不足</td>
          <td>加 consumer、優化處理、batch 下游</td>
          <td>有效（上限 partition 數）</td>
      </tr>
      <tr>
          <td>集中少數 partition</td>
          <td>key 分布熱點 / data skew</td>
          <td>拆 key、salt、熱點獨立 topic、解耦 parallelism</td>
          <td>無效（瓶頸 partition 仍單線）</td>
      </tr>
  </tbody>
</table>
<p>判讀順序固定：先 <code>--describe</code> 看分布形狀，再決定往「擴容」還是「重分布」走。跳過分布判讀直接加 consumer，遇到熱點 partition 場景會白花資源還解不了 lag。</p>
<h2 id="production-故障演練">Production 故障演練</h2>
<h3 id="case-1consumer-處理慢被踢出-group-形成-rebalance-連環">Case 1：consumer 處理慢被踢出 group 形成 rebalance 連環</h3>
<p>徵兆：consumer log 反覆出現 <code>Member ... sending LeaveGroup request</code> 與 <code>Attempt to heartbeat failed since group is rebalancing</code>；lag 持續成長；group STATE 在 <code>Stable</code> 與 <code>PreparingRebalance</code> 之間反覆跳；同一批 partition 在不同 consumer 間反覆搬移。</p>
<p>根因：下游 I/O 變慢（DB 連線池打滿、外部 API 延遲升高），consumer 單批 <code>poll()</code> 後處理超過 <code>max.poll.interval.ms</code>（預設 5 分鐘），coordinator 判定該 consumer 失去處理能力、踢出 group、觸發 rebalance。partition 搬到另一個 consumer，後者面對同樣慢的下游、同樣超時被踢，rebalance 連環觸發，每次 rebalance 又讓所有 consumer 暫停消費，lag 加速惡化。</p>
<p>修法：</p>
<ol>
<li>確認瓶頸是處理慢而非 heartbeat 中斷：consumer log 若有正常 heartbeat 但仍被踢，問題在 <code>max.poll.interval.ms</code> 不是 <code>session.timeout.ms</code>。</li>
<li>縮小 <code>max.poll.records</code>：一次拉少一點，讓單批在 <code>max.poll.interval.ms</code> 內做完，這是不改下游就能止血的第一步。</li>
<li>拉高 <code>max.poll.interval.ms</code>：給單批更長處理時間，但這只是延後而非解決，要搭配下游修復。</li>
<li>修復下游根因：DB 連線池、外部 API 超時、batch 寫入策略，這才是消除連環 rebalance 的根本。</li>
</ol>
<h3 id="case-2lag-集中單一-partition加-consumer-無效">Case 2：lag 集中單一 partition、加 consumer 無效</h3>
<p>徵兆：<code>--describe</code> 顯示一兩個 partition lag 數十萬、其餘 partition lag 接近零；加了 consumer 之後 lag 不降，新 consumer 處於閒置（<code>--members</code> 顯示它分到的 partition 都沒 lag）。</p>
<p>根因：producer 的 key 分布有熱點，大量訊息落在同一 partition。Partition 是 Kafka 平行消費的最小單位，一個 partition 只能被 group 內一個 consumer 消費，熱點 partition 的消費速度被單 consumer 鎖死，加再多 consumer 都分不到這個 partition 的工作。</p>
<p>修法：</p>
<ol>
<li><code>--describe</code> 確認 lag 集中形狀，排除「整體容量不足」的均勻分布情境。</li>
<li>找出熱點 key：抽樣訊息看 key 分布，常見是 null key（全落同一 partition）或單一大租戶 id。</li>
<li>重設計 key：對熱點加 salt 打散到多 partition，或讓熱點走獨立 topic 用更多 partition。</li>
<li>若 work 本身有 skew（單筆訊息處理成本差異大），把 parallelism 從 partition 數解耦，按工作量重新分派，如 Airbnb 的 balanced reader（<a href="/blog/backend/03-message-queue/cases/kafka-airbnb-spark-streaming-rebalance/" data-link-title="3.C15 Airbnb：Spark Streaming Kafka reader rebalance" data-link-desc="Airbnb logging pipeline 解 partition-task 1:1 造成的 data skew、catch-up 4 小時 lag 要再花 4 小時的反效率。">3.C15</a>）。</li>
</ol>
<blockquote>
<p>key 重分布需要 producer 端配合改 key 策略，對既有 topic 是破壞性變更（舊訊息 key 不變），通常搭配新 topic 切換。本文未實機驗證 producer key 重設計的線上切換流程，依官方分區語義說明。</p></blockquote>
<h3 id="case-3deploy-每次都產生-lag-spike">Case 3：deploy 每次都產生 lag spike</h3>
<p>徵兆：每次滾動部署 consumer 服務，lag 在部署窗口內明顯上升、部署完成後緩慢回落；group STATE 在部署期間進入 rebalance；部署越頻繁，累積 lag 越明顯。</p>
<p>根因：每個 consumer pod 重啟，coordinator 看到舊 member 離開、新 member 加入，觸發 rebalance；若用 eager protocol，每次 rebalance 全 group 停止消費；滾動部署逐個重啟 N 個 pod 就觸發 N 次 rebalance，每次全停，lag 在這串全停窗口中累積。</p>
<p>修法：</p>
<ol>
<li>啟用 static membership：給每個 consumer 固定 <code>group.instance.id</code>，重啟時帶同一身分回來、不觸發 rebalance。</li>
<li>把 <code>session.timeout.ms</code> 設得比 pod 重啟時間長：涵蓋 K8s 重啟一個 pod 的 10-30 秒，否則 static membership 在窗口內失效。</li>
<li>切換到 cooperative incremental protocol：即使仍有 rebalance，只有換手 partition 中斷，未換手 partition 持續消費。</li>
<li>控制部署並行度：一次重啟太多 pod 會放大同時 rebalance 的影響，分批滾動。</li>
</ol>
<p>Walmart 在 25K+ consumer 規模下，正是 pod scaling / deploy / heartbeat fail 三類事件持續觸發 rebalance lag spike（<a href="/blog/backend/03-message-queue/cases/kafka-walmart-mps-rebalance/" data-link-title="3.C17 Walmart：Messaging Proxy Service 解 rebalance storm" data-link-desc="Walmart 每天 trillions of message、25K&#43; consumer 在 K8s、partition-consumer 1:1 模型撞到擴張極限。">3.C17</a>）；static membership 與 cooperative 降低單次代價，但它最終把 consumer 解耦成可獨立 auto-scale 的 stateless service，從架構層消除 rebalance 與 partition 數的綁定。</p>
<h3 id="case-4scale-to-zero-後冷啟動-lag">Case 4：scale-to-zero 後冷啟動 lag</h3>
<p>徵兆：低流量時段 consumer 被縮到 0，流量回來時 lag 已累積一批、需要一段 catch-up；autoscaler 若看 CPU / memory 反應遲鈍，因為 sink 多為 I/O bottleneck、CPU 平坦不觸發擴容。</p>
<p>根因：event-driven workload 的工作量是 backlog（lag）而非 resource usage。用 CPU / memory 當 scaling signal，在 I/O-bound 的 sink consumer 上失靈：訊息堆積但 CPU 不高，autoscaler 不動，lag 持續成長。</p>
<p>修法：</p>
<ol>
<li>用 consumer lag 當 scaling signal：lag 超過閾值就擴 consumer、lag 清空就縮，直接對齊工作量。</li>
<li>接受 scale-to-zero 的冷啟動 lag 為設計取捨：minReplicaCount=0 省下 idle 成本，代價是流量回來時的 catch-up 窗口，對非即時 sink 可接受。</li>
<li>設 lag 閾值與擴容步長：閾值太高 catch-up 久、太低頻繁擴縮，依 SLA 對 backlog 的容忍度設定。</li>
</ol>
<p>Trivago 跨 3 region 跑 50+ Kafka sink、每個 always-on 用 1 CPU + 1 GB，CPU/mem autoscaling 對 I/O-bound sink 無效；改用 KEDA 以 consumer lag 為 scaling signal、minReplicaCount=0 達到 scale-to-zero，daily replica-hour 從 50 降到 1-2（<a href="/blog/backend/03-message-queue/cases/kafka-trivago-keda-scale-to-zero/" data-link-title="3.C22 Trivago：KEDA scale-to-zero by Kafka lag" data-link-desc="Trivago 50&#43; Kafka sink、CPU/mem autoscaling 無效（I/O bottleneck）、KEDA 以 consumer lag 為訊號達到 scale-to-zero。">3.C22</a>）。這個案例的判準是 resource usage 不等於工作量，event-driven 場景該看 backlog signal。</p>
<h2 id="capacity-與-cost">Capacity 與 cost</h2>
<p>Rebalance 與 lag 的容量規劃圍繞三個變數：partition 數、consumer 數、單次 rebalance 的中斷成本。partition 數是消費平行度的天花板，consumer 數超過 partition 數時多出的 consumer 閒置，所以 partition 數要按峰值需要的平行度規劃，但 partition 過多會推高 metadata 壓力與 rebalance 計算成本。</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>估算</th>
          <th>警戒</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Consumer 數上限</td>
          <td>等於 partition 數，超出即閒置</td>
          <td>consumer = partition 仍跟不上要加 partition</td>
      </tr>
      <tr>
          <td>Eager rebalance 中斷</td>
          <td>全 group 停止消費直到分配收斂</td>
          <td>partition 多、group 大時窗口顯著</td>
      </tr>
      <tr>
          <td>Cooperative rebalance</td>
          <td>僅換手 partition 中斷，可能兩輪</td>
          <td>換手比例高時優勢縮小</td>
      </tr>
      <tr>
          <td>session.timeout.ms 窗口</td>
          <td>consumer 死亡到被踢出、partition 無人消費</td>
          <td>設太大則故障偵測慢、lag 累積</td>
      </tr>
      <tr>
          <td>加 partition 的代價</td>
          <td>提高平行度上限，但增加 rebalance 與 metadata 成本</td>
          <td>過度分區推高 controller 壓力</td>
      </tr>
  </tbody>
</table>
<p>實務 default：partition 數按峰值平行度設、保留成長餘量但不過度分區；consumer 數對齊 partition 數、用 lag 而非 CPU 當 autoscaling signal；rebalance 頻繁的環境優先 static membership + cooperative，再評估是否需要把 consumer 從 partition 解耦。加 partition 是單向操作（無法縮回），且改變既有 key 的 partition 對應，要在規劃期一次設足而非事後頻繁調整。</p>
<h2 id="整合與下一步">整合與下一步</h2>
<p>Rebalance 與 lag 診斷接在 consumer 設計與交付語義之上：commit 策略決定 lag 的計算基準與 rebalance 後的重複消費風險，交付語義決定 rebalance 中斷期間訊息是否可能丟失或重放。</p>
<h3 id="跟-consumer-設計對位">跟 consumer 設計對位</h3>
<p><a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計</a> 涵蓋 commit 策略（auto vs manual）、commit 時機與 partition 分配的整體設計。本文的 rebalance 是 consumer 設計在「成員變動」維度的展開，lag 是 commit 進度的可觀測量。commit 策略選錯會在 rebalance 後放大重複消費或丟失。</p>
<h3 id="跟交付與復原語義對位">跟交付與復原語義對位</h3>
<p><a href="/blog/backend/03-message-queue/processing-recovery-semantics/" data-link-title="3.6 Processing Semantics 與 Recovery Semantics" data-link-desc="說明投遞成功、處理成功與恢復成功為何是三個不同判斷。">3.6 processing 與 recovery 語義</a> 涵蓋 rebalance 中斷期間的 at-least-once / at-most-once 行為。rebalance revoke partition 時，未 commit 的進度會在新 consumer 接手後重放（at-least-once）；commit 太早則可能在 rebalance 中丟失（at-most-once）。idempotency 與 replay 的整體設計見 <a href="/blog/backend/06-reliability/idempotency-replay/" data-link-title="6.12 Idempotency 與 Replay 驗證" data-link-desc="把重試、重播與冪等性從口頭約定變成可驗證屬性">6.12 idempotency / replay</a>。</p>
<h3 id="相關案例">相關案例</h3>
<ul>
<li><a href="/blog/backend/03-message-queue/cases/kafka-airbnb-spark-streaming-rebalance/" data-link-title="3.C15 Airbnb：Spark Streaming Kafka reader rebalance" data-link-desc="Airbnb logging pipeline 解 partition-task 1:1 造成的 data skew、catch-up 4 小時 lag 要再花 4 小時的反效率。">3.C15 Airbnb Spark Streaming</a> — partition-task 1:1 造成 data skew、parallelism 從 partition 數解耦</li>
<li><a href="/blog/backend/03-message-queue/cases/kafka-walmart-mps-rebalance/" data-link-title="3.C17 Walmart：Messaging Proxy Service 解 rebalance storm" data-link-desc="Walmart 每天 trillions of message、25K&#43; consumer 在 K8s、partition-consumer 1:1 模型撞到擴張極限。">3.C17 Walmart MPS</a> — 25K+ consumer 在 K8s 的 rebalance storm、consumer 解耦成 stateless service</li>
<li><a href="/blog/backend/03-message-queue/cases/kafka-trivago-keda-scale-to-zero/" data-link-title="3.C22 Trivago：KEDA scale-to-zero by Kafka lag" data-link-desc="Trivago 50&#43; Kafka sink、CPU/mem autoscaling 無效（I/O bottleneck）、KEDA 以 consumer lag 為訊號達到 scale-to-zero。">3.C22 Trivago KEDA</a> — consumer lag 驅動 scale-to-zero、backlog signal 取代 resource usage</li>
</ul>
<h3 id="相關連結">相關連結</h3>
<ul>
<li>上游 vendor 頁：<a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Apache Kafka</a></li>
<li>知識卡：<a href="/blog/backend/knowledge-cards/consumer-lag/" data-link-title="Consumer Lag" data-link-desc="說明 consumer lag 如何反映訊息堆積、處理能力與容量風險">consumer lag</a>、<a href="/blog/backend/knowledge-cards/consumer-group/" data-link-title="Consumer Group" data-link-desc="說明一組 consumer 如何共同分攤 stream 或 topic 的處理責任">consumer group</a>、<a href="/blog/backend/knowledge-cards/partition/" data-link-title="Partition" data-link-desc="說明事件流如何切分成多個可並行處理的有序片段">partition</a></li>
<li>下游能力：<a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計</a>、<a href="/blog/backend/03-message-queue/processing-recovery-semantics/" data-link-title="3.6 Processing Semantics 與 Recovery Semantics" data-link-desc="說明投遞成功、處理成功與恢復成功為何是三個不同判斷。">3.6 processing 與 recovery 語義</a></li>
</ul>
]]></content:encoded></item><item><title>RabbitMQ → Kafka：從『處理即承諾』到『寫入即承諾 + 可 replay』的 paradigm shift</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/rabbitmq/migrate-to-kafka/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/rabbitmq/migrate-to-kafka/</guid><description>&lt;blockquote>
&lt;p>本文是跨 vendor migration playbook、cross-link 到 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/rabbitmq/" data-link-title="RabbitMQ" data-link-desc="Classic message broker、AMQP routing 為主">RabbitMQ&lt;/a> 跟 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka&lt;/a>。跟同類產品的 drop-in 或 operational 遷移不同、本篇是 &lt;em>paradigm shift&lt;/em> — 兩端不是「同類 broker 的不同實作」、是 &lt;em>不同責任模型的 messaging system&lt;/em>：RabbitMQ 是「處理即承諾」的 work queue、Kafka 是「寫入即承諾、可長期 replay」的 event log。&lt;/p>&lt;/blockquote>
&lt;h2 id="rabbitmq--kafka-不是把-queue-換成-topic">RabbitMQ → Kafka 不是把 queue 換成 topic&lt;/h2>
&lt;p>RabbitMQ 跟 Kafka 都被歸在「message queue」這個傘狀詞下、但兩者承擔的責任不同。RabbitMQ 的可靠性建立在 &lt;em>consumer 處理完才 ack、未 ack 的訊息 broker 重新投遞&lt;/em>；訊息一旦被成功消費就從 queue 移除、broker 是「任務分派 + 重試」的中介。Kafka 的可靠性建立在 &lt;em>訊息寫進 partition log 就持久化、consumer 各自維護 offset&lt;/em>；訊息在 retention 期內一直留著、broker 是「事件儲存 + 多方各自讀取」的 log。&lt;/p>
&lt;p>把 RabbitMQ「migration」成 Kafka 的字面理解通常是：queue 對 topic、exchange 對 producer key、consumer 對 consumer group。這個對映在 transport 層成立、在責任層不成立。RabbitMQ 一個 message 被 ack 後就消失、Kafka 一個 message 寫進 log 後對所有 consumer group 都還在；RabbitMQ 的 routing 由 broker 端 exchange + binding 決定、Kafka 的「routing」由 producer 端 partition key 決定、broker 不做內容路由。先確認這層差異、再決定哪些 workload 值得遷。&lt;/p>
&lt;h2 id="6-維-diff-dimension-audit">6 維 diff dimension audit&lt;/h2>
&lt;p>跨 vendor 遷移前先盤點 source 跟 target 在六個維度的落差、用最大落差維度決定 playbook 結構、而不是反過來套既有模板。RabbitMQ → Kafka 的 audit 結果：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>維度&lt;/th>
 &lt;th>落差&lt;/th>
 &lt;th>說明&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Schema / API&lt;/td>
 &lt;td>中&lt;/td>
 &lt;td>AMQP client → Kafka client、wire protocol 全換、但都是 publish / consume 心智模型&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Operational model&lt;/td>
 &lt;td>中&lt;/td>
 &lt;td>單 broker + management UI → multi-broker + KRaft / Schema Registry / Connect、運維資產變重&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Abstraction/paradigm&lt;/td>
 &lt;td>&lt;strong>高&lt;/strong>&lt;/td>
 &lt;td>work queue「處理即承諾、ack 後即刪」→ event log「寫入即承諾、offset replay」、責任模型整個不同&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Number of components&lt;/td>
 &lt;td>低&lt;/td>
 &lt;td>兩端都是單一 messaging system、不是一站式拆多工具&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Application change&lt;/td>
 &lt;td>&lt;strong>高&lt;/strong>&lt;/td>
 &lt;td>consumer 要重設計（ack → offset commit）、producer 要重設計（exchange routing → partition key）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Data topology&lt;/td>
 &lt;td>&lt;strong>高&lt;/strong>&lt;/td>
 &lt;td>exchange + queue + binding 的 routing 拓樸 → topic + partition + key 的 log 拓樸、資料分佈邏輯不同&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>三個維度 High：paradigm、application change、data topology。其中 paradigm 是主導維度 —— application change 跟 data topology 的落差都是 paradigm 落差的下游結果。consumer 要重寫，是因為「ack 後即刪」變成「offset 不刪」；資料拓樸要重劃，是因為「broker 路由到 queue」變成「producer 決定 partition」。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是跨 vendor migration playbook、cross-link 到 <a href="/blog/backend/03-message-queue/vendors/rabbitmq/" data-link-title="RabbitMQ" data-link-desc="Classic message broker、AMQP routing 為主">RabbitMQ</a> 跟 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka</a>。跟同類產品的 drop-in 或 operational 遷移不同、本篇是 <em>paradigm shift</em> — 兩端不是「同類 broker 的不同實作」、是 <em>不同責任模型的 messaging system</em>：RabbitMQ 是「處理即承諾」的 work queue、Kafka 是「寫入即承諾、可長期 replay」的 event log。</p></blockquote>
<h2 id="rabbitmq--kafka-不是把-queue-換成-topic">RabbitMQ → Kafka 不是把 queue 換成 topic</h2>
<p>RabbitMQ 跟 Kafka 都被歸在「message queue」這個傘狀詞下、但兩者承擔的責任不同。RabbitMQ 的可靠性建立在 <em>consumer 處理完才 ack、未 ack 的訊息 broker 重新投遞</em>；訊息一旦被成功消費就從 queue 移除、broker 是「任務分派 + 重試」的中介。Kafka 的可靠性建立在 <em>訊息寫進 partition log 就持久化、consumer 各自維護 offset</em>；訊息在 retention 期內一直留著、broker 是「事件儲存 + 多方各自讀取」的 log。</p>
<p>把 RabbitMQ「migration」成 Kafka 的字面理解通常是：queue 對 topic、exchange 對 producer key、consumer 對 consumer group。這個對映在 transport 層成立、在責任層不成立。RabbitMQ 一個 message 被 ack 後就消失、Kafka 一個 message 寫進 log 後對所有 consumer group 都還在；RabbitMQ 的 routing 由 broker 端 exchange + binding 決定、Kafka 的「routing」由 producer 端 partition key 決定、broker 不做內容路由。先確認這層差異、再決定哪些 workload 值得遷。</p>
<h2 id="6-維-diff-dimension-audit">6 維 diff dimension audit</h2>
<p>跨 vendor 遷移前先盤點 source 跟 target 在六個維度的落差、用最大落差維度決定 playbook 結構、而不是反過來套既有模板。RabbitMQ → Kafka 的 audit 結果：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>落差</th>
          <th>說明</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Schema / API</td>
          <td>中</td>
          <td>AMQP client → Kafka client、wire protocol 全換、但都是 publish / consume 心智模型</td>
      </tr>
      <tr>
          <td>Operational model</td>
          <td>中</td>
          <td>單 broker + management UI → multi-broker + KRaft / Schema Registry / Connect、運維資產變重</td>
      </tr>
      <tr>
          <td>Abstraction/paradigm</td>
          <td><strong>高</strong></td>
          <td>work queue「處理即承諾、ack 後即刪」→ event log「寫入即承諾、offset replay」、責任模型整個不同</td>
      </tr>
      <tr>
          <td>Number of components</td>
          <td>低</td>
          <td>兩端都是單一 messaging system、不是一站式拆多工具</td>
      </tr>
      <tr>
          <td>Application change</td>
          <td><strong>高</strong></td>
          <td>consumer 要重設計（ack → offset commit）、producer 要重設計（exchange routing → partition key）</td>
      </tr>
      <tr>
          <td>Data topology</td>
          <td><strong>高</strong></td>
          <td>exchange + queue + binding 的 routing 拓樸 → topic + partition + key 的 log 拓樸、資料分佈邏輯不同</td>
      </tr>
  </tbody>
</table>
<p>三個維度 High：paradigm、application change、data topology。其中 paradigm 是主導維度 —— application change 跟 data topology 的落差都是 paradigm 落差的下游結果。consumer 要重寫，是因為「ack 後即刪」變成「offset 不刪」；資料拓樸要重劃，是因為「broker 路由到 queue」變成「producer 決定 partition」。</p>
<p>主導維度是 paradigm、對映 <em>Type E paradigm shift</em> 結構：先講「字面 migration 不成立」、再講適配度（什麼能遷什麼不能）、再講 application 重設計與部分 cutover、最後是長期混合架構。application change 跟 data topology 這兩個高維度不另起 playbook、而是落在 application 重設計段與故障演練段裡展開。</p>
<h3 id="為什麼-paradigm-是主導不是-application-change">為什麼 paradigm 是主導、不是 application change</h3>
<p>application change 看起來工作量最大（consumer / producer 都要改），直覺會把它當主導維度。但 application change 的方向跟難度是由 paradigm 決定的：如果只是 AMQP client 換 Kafka client、心智模型不變，那 application change 是機械式翻譯、屬於 Schema/API 維度。實際上 consumer 不只是換 SDK、是要把「處理完才 ack、失敗就 nack 重投」的設計改成「拉一批、處理、commit offset、失敗自己重試或寫 DLQ topic」—— 這是責任模型的改變，不是 API 的改變。所以主結構走 paradigm、application change 是它的展開。</p>
<h2 id="什麼-workload-真該遷什麼不該">什麼 workload 真該遷、什麼不該</h2>
<table>
  <thead>
      <tr>
          <th>Application 模式</th>
          <th>RabbitMQ 適配</th>
          <th>Kafka 適配</th>
          <th>遷移可行性</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>任務分派（寄信 / 轉檔 / webhook）</td>
          <td>強</td>
          <td>中（overkill）</td>
          <td>不該遷（保留 RabbitMQ）</td>
      </tr>
      <tr>
          <td>複雜 routing（topic exchange + binding）</td>
          <td>強</td>
          <td>弱（broker 不做路由）</td>
          <td>不該遷或要重新設計拓樸</td>
      </tr>
      <tr>
          <td>RPC over messaging（request-reply）</td>
          <td>強</td>
          <td>弱（不適合）</td>
          <td>不該遷</td>
      </tr>
      <tr>
          <td>Event sourcing（多 consumer 各自 replay）</td>
          <td>弱（ack 即刪）</td>
          <td>強</td>
          <td>該遷（這是 Kafka 的主場）</td>
      </tr>
      <tr>
          <td>CDC / 跨系統事件總線</td>
          <td>弱</td>
          <td>強</td>
          <td>該遷</td>
      </tr>
      <tr>
          <td>高吞吐事件流 + 長期 retention</td>
          <td>弱</td>
          <td>強</td>
          <td>該遷</td>
      </tr>
      <tr>
          <td>同一事件要被多個獨立團隊各自消費</td>
          <td>中（多 queue）</td>
          <td>強（多 consumer group）</td>
          <td>該遷</td>
      </tr>
  </tbody>
</table>
<p>判讀的核心問題是：<em>這個 workload 需要的是「處理一次就完成的任務」、還是「被多方各自讀取、可回放的事件」</em>。</p>
<p>任務分派場景不該遷。寄信、轉檔、生成縮圖這類 workload 的本質是「有一個工人池、把任務做完就結束」、RabbitMQ 的 manual ack + prefetch + DLX 對這條路徑是貼合的設計。把它搬到 Kafka 會引入不需要的複雜度：partition 數要規劃、consumer group rebalance 要管、offset commit 時機要自己設計、而換來的 replay 能力在「任務做完就丟」的場景根本用不到。單純 work queue 不需要 Kafka 是這篇 playbook 最該先說清楚的判讀。</p>
<p>事件流場景該遷。當同一份事件要被 analytics pipeline、search index sync、audit log、下游微服務各自消費、而且各自進度不同、偶爾要回放過去 N 天重算 —— RabbitMQ 的「ack 後即刪」就會逼出「為每個 consumer 複製一份 queue」的反模式，這正是 Kafka 的 consumer group + retention 要解的問題。</p>
<p>複雜 routing 場景要重新設計、不是平移。RabbitMQ 的 topic exchange 用 <code>order.*.created</code> 這種 binding pattern 在 broker 端做內容路由、consumer 訂閱 binding 就收到符合的訊息。Kafka broker 不做內容路由，要嘛把路由邏輯前移到 producer（按內容決定寫哪個 topic / partition key），要嘛 consumer 端全收後自己 filter。直接平移會發現 Kafka 沒有 exchange 這個概念，routing 拓樸必須重新設計。</p>
<h2 id="為什麼會考慮這個-paradigm-shift">為什麼會考慮這個 paradigm shift</h2>
<p>實務上從 RabbitMQ 評估遷往 Kafka 通常由三條 driver 觸發：</p>
<ol>
<li><strong>同一事件要 fan-out 給愈來愈多 consumer</strong>：初期一個 queue 一個 worker、後來下游團隊一個個來要「也給我一份」。RabbitMQ 要嘛加 fanout exchange + 每團隊一個 queue、要嘛 consumer 互搶。Kafka 的 consumer group 天然支援「N 個獨立團隊各自從頭讀」、這是最常見的 driver。</li>
<li><strong>需要 replay 重算</strong>：下游邏輯出 bug、要重跑過去 7 天的事件修資料；RabbitMQ ack 後訊息已刪、無從回放。Kafka retention 期內可以從任意 offset 重讀。</li>
<li><strong>吞吐量壓到 RabbitMQ 的設計邊界</strong>：單 queue 的 throughput 受限於單一 queue 的處理模型、量大時要拆 queue 手動分流；Kafka 的 partition 並行是 first-class。</li>
</ol>
<p>這三條 driver 都指向 event streaming 的特性、不是「Kafka 普遍比較好」。任務隊列場景套不上這三條 driver、就不該被這個評估帶著走。</p>
<h2 id="migration-結構application-重設計--部分-cutover--長期混合">Migration 結構：application 重設計 + 部分 cutover + 長期混合</h2>
<p>RabbitMQ → Kafka 不是一次性 cutover，是按 workload 拆分、漸進遷移、長期共存：</p>
<ol>
<li><strong>Phase 0：workload 盤點</strong> — 把現有 queue / exchange 逐一分類「適合 Kafka（event 性質）」vs「保留 RabbitMQ（task 性質）」。盤點輸出是清單，不是「全遷」。</li>
<li><strong>Phase 1：application code 重設計</strong> — 對判定要遷的 workload，重寫 producer（exchange routing → topic + partition key）跟 consumer（manual ack → offset commit + 自管重試 / DLQ）。這是 paradigm 翻譯，不是 SDK 替換。</li>
<li><strong>Phase 2：dual-write 並行</strong> — producer 同時寫 RabbitMQ 跟 Kafka、新 consumer 從 Kafka shadow consume 驗證行為對齊、舊 consumer 持續從 RabbitMQ 消費。</li>
<li><strong>Phase 3：cutover 個別 workload</strong> — shadow 驗證通過後、把該 workload 的真正消費切到 Kafka、停掉 RabbitMQ 端的對應 consumer 與 dual-write。</li>
<li><strong>Phase 4：長期混合</strong> — task 性質的 workload 永遠留在 RabbitMQ、event 性質的在 Kafka。兩者共存是終態、不是過渡。</li>
</ol>
<p>整體不是「把 RabbitMQ 換成 Kafka」、是「把適合 event log 的部分搬到 Kafka、其餘留在 RabbitMQ」。多數環境的終態是兩者並存。</p>
<h2 id="application-重設計範例manual-ack--offset-commit">Application 重設計範例：manual ack → offset commit</h2>
<p>RabbitMQ consumer 的核心是 <em>每個 message 處理完顯式 ack、broker 才認定投遞成功</em>；失敗就 nack、broker 重投或進 DLX。Kafka consumer 沒有 per-message ack 的概念、是 <em>批次拉取、處理、commit offset</em>；commit 的是「讀到哪了」、不是「哪幾條成功了」。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># RabbitMQ 端：manual ack、per-message 成敗</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">channel</span><span class="o">.</span><span class="n">basic_qos</span><span class="p">(</span><span class="n">prefetch_count</span><span class="o">=</span><span class="mi">10</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="k">def</span> <span class="nf">on_message</span><span class="p">(</span><span class="n">ch</span><span class="p">,</span> <span class="n">method</span><span class="p">,</span> <span class="n">properties</span><span class="p">,</span> <span class="n">body</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="n">process</span><span class="p">(</span><span class="n">body</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="n">ch</span><span class="o">.</span><span class="n">basic_ack</span><span class="p">(</span><span class="n">delivery_tag</span><span class="o">=</span><span class="n">method</span><span class="o">.</span><span class="n">delivery_tag</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">except</span> <span class="ne">Exception</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="c1"># 拒絕並不重新入列、由 DLX 接住</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="n">ch</span><span class="o">.</span><span class="n">basic_nack</span><span class="p">(</span><span class="n">delivery_tag</span><span class="o">=</span><span class="n">method</span><span class="o">.</span><span class="n">delivery_tag</span><span class="p">,</span> <span class="n">requeue</span><span class="o">=</span><span class="kc">False</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="n">channel</span><span class="o">.</span><span class="n">basic_consume</span><span class="p">(</span><span class="n">queue</span><span class="o">=</span><span class="s2">&#34;orders&#34;</span><span class="p">,</span> <span class="n">on_message_callback</span><span class="o">=</span><span class="n">on_message</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="n">channel</span><span class="o">.</span><span class="n">start_consuming</span><span class="p">()</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># Kafka 端：批次 poll、處理後 commit offset</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">consumer</span> <span class="o">=</span> <span class="n">KafkaConsumer</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="s2">&#34;orders&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="n">group_id</span><span class="o">=</span><span class="s2">&#34;orders-worker&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="n">enable_auto_commit</span><span class="o">=</span><span class="kc">False</span><span class="p">,</span>        <span class="c1"># 關掉 auto commit、自己控制時機</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="n">auto_offset_reset</span><span class="o">=</span><span class="s2">&#34;earliest&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="n">max_poll_records</span><span class="o">=</span><span class="mi">10</span><span class="p">,</span>             <span class="c1"># 對應 RabbitMQ 的 prefetch</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="k">for</span> <span class="n">batch</span> <span class="ow">in</span> <span class="n">iter_batches</span><span class="p">(</span><span class="n">consumer</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">for</span> <span class="n">msg</span> <span class="ow">in</span> <span class="n">batch</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">            <span class="n">process</span><span class="p">(</span><span class="n">msg</span><span class="o">.</span><span class="n">value</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="k">except</span> <span class="ne">Exception</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">            <span class="n">send_to_dlq_topic</span><span class="p">(</span><span class="n">msg</span><span class="p">)</span>   <span class="c1"># 自建 DLQ topic、Kafka broker 不提供 DLX</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="n">consumer</span><span class="o">.</span><span class="n">commit</span><span class="p">()</span>                <span class="c1"># commit 的是 offset、不是個別 message</span></span></span></code></pre></div><p>差異的關鍵不在 API 形狀、在責任邊界：</p>
<ul>
<li>RabbitMQ 一條失敗就 nack 一條、其餘正常 ack；Kafka commit 的是 offset 這個「水位線」、水位線以下視為已處理。失敗的單條訊息無法「跳過不 commit 但繼續往後」—— 要嘛阻塞、要嘛自己寫 DLQ topic 後讓 offset 照常前進。</li>
<li>RabbitMQ 重試由 broker 負責（重投 / DLX）；Kafka 重試要 application 自己設計（原地重試 / 寫 retry topic / 寫 DLQ topic）。</li>
<li>RabbitMQ prefetch 控制「broker 一次推幾條未 ack 的給我」；Kafka <code>max.poll.records</code> 控制「我一次 poll 拉幾條」—— 方向相反，一個是 broker push、一個是 consumer pull。</li>
</ul>
<h2 id="production-故障演練">Production 故障演練</h2>
<h3 id="case-1manual-ack-觀念帶到-offset-commit誤判已處理">Case 1：manual ack 觀念帶到 offset commit、誤判「已處理」</h3>
<p><strong>徵兆</strong>：cutover 後某 worker crash 重啟、發現一批訊息被重複處理；或反過來、一批訊息明明沒處理成功卻再也讀不到。RabbitMQ 端跑了多年的 ack 邏輯搬過來就出事。</p>
<p><strong>根因</strong>：把 RabbitMQ 的「per-message ack」心智直接套到 Kafka 的 offset commit。常見錯法是 <code>enable.auto.commit=true</code> + 預設 <code>auto.commit.interval.ms</code>、消費迴圈還沒處理完、背景 thread 已經把 offset commit 出去了 —— crash 後 offset 已前進、未處理的訊息永遠跳過（資料遺失）。或反過來、處理完才 commit 但 commit 失敗、重啟後從舊 offset 重讀（重複處理）。RabbitMQ 的 ack 是「這一條我處理完了」、Kafka 的 commit 是「這個 offset 之前我都讀過了」—— 後者是水位線、不是逐條確認。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>關掉 auto commit、手動 commit</strong>：<code>enable.auto.commit=false</code>、在一批訊息確實處理完之後才 <code>commit()</code>。</li>
<li><strong>接受 at-least-once、設計 idempotency</strong>：Kafka 的預設語意是 at-least-once、重啟重讀無法完全避免、consumer 端要用 message key + dedup store 顯式去重。對應 <a href="/blog/backend/06-reliability/idempotency-replay/" data-link-title="6.12 Idempotency 與 Replay 驗證" data-link-desc="把重試、重播與冪等性從口頭約定變成可驗證屬性">6.12 idempotency / replay</a>。</li>
<li><strong>commit 時機對齊處理邊界</strong>：批次處理完才 commit、不要一邊處理一邊讓背景 commit 跑在前面。</li>
</ol>
<h3 id="case-2routing-key--partition-keyordering-邊界悄悄改變">Case 2：routing key → partition key、ordering 邊界悄悄改變</h3>
<p><strong>徵兆</strong>：cutover 後同一個訂單的 <code>created</code> / <code>paid</code> / <code>shipped</code> 事件偶爾亂序到達 consumer；RabbitMQ 端用 consistent hash exchange 跑了兩年、同一訂單的事件一直是有序的。</p>
<p><strong>根因</strong>：RabbitMQ 用 consistent hash exchange 把同 key 的訊息路由到同一個 queue、單一 consumer 順序處理就有序。Kafka 的 ordering 保證範圍是 <em>單一 partition 內</em>、跨 partition 無序。如果 producer 沒設 partition key、或設了但 key 選得不對（例如用 event type 當 key 而不是 order id）、同一訂單的事件就散到不同 partition、被不同 consumer 並行處理、ordering 就斷了。RabbitMQ 的 ordering 邊界是「queue」、Kafka 的 ordering 邊界是「partition key」—— 邊界從 broker 端的 binding 移到了 producer 端的 key 選擇。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>ordering 單位當 partition key</strong>：需要保序的單位（order id / user id）設成 partition key、同 key 落同 partition。</li>
<li><strong>盤點現有 RabbitMQ 的保序假設</strong>：哪些 queue 隱含「同 key 有序」、把那個 key 顯式提升為 Kafka partition key。</li>
<li><strong>接受 partition 數限制並行</strong>：保序的代價是同 key 只能單一 partition、partition 數是並行上限；保序需求跟並行度需要一起設計。對應 <a href="/blog/backend/knowledge-cards/partition/" data-link-title="Partition" data-link-desc="說明事件流如何切分成多個可並行處理的有序片段">Partition</a> 卡。</li>
</ol>
<h3 id="case-3dlx--自建-dlq-topic毒訊息卡住整個-partition">Case 3：DLX → 自建 DLQ topic、毒訊息卡住整個 partition</h3>
<p><strong>徵兆</strong>：某條訊息 application 處理永遠拋例外、consumer 不斷在這條上重試、整個 partition 後面的訊息全卡住、consumer lag 暴增；RabbitMQ 端這種毒訊息會被 nack 進 DLX、不影響後面。</p>
<p><strong>根因</strong>：RabbitMQ 有原生 DLX、處理失敗的訊息 nack 後自動進 dead-letter exchange、queue 繼續往下。Kafka broker 沒有 DLX 概念、也沒有「跳過這一條」的機制 —— offset 是連續水位線、要往後就得處理掉當前這條。如果 application 在毒訊息上無限重試、offset 永遠不前進、後面所有訊息餓死。把 RabbitMQ「broker 幫我處理毒訊息」的假設帶過來、就會卡死。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>自建 DLQ topic</strong>：consumer 端設重試上限、超過上限把訊息寫進專屬的 <code>orders.DLQ</code> topic、然後 commit offset 讓主流程前進。對應 <a href="/blog/backend/knowledge-cards/dead-letter-queue/" data-link-title="Dead-Letter Queue" data-link-desc="說明 dead-letter queue 如何隔離多次處理失敗的訊息">Dead-letter queue</a> 卡。</li>
<li><strong>retry topic 分層</strong>：仿 RabbitMQ 的延遲重試、可以設 <code>orders.retry.5s</code> / <code>orders.retry.1m</code> 多層 retry topic、由獨立 consumer 延遲後重投主 topic。</li>
<li><strong>DLQ 要有人看</strong>：自建 DLQ topic 不像 RabbitMQ management UI 有現成可視化、要主動監控 DLQ topic 的訊息數、否則毒訊息靜默堆積。</li>
</ol>
<h3 id="case-4prefetch--maxpollrecordspoll-間隔超時觸發-rebalance">Case 4：prefetch → max.poll.records，poll 間隔超時觸發 rebalance</h3>
<p><strong>徵兆</strong>：consumer 處理一批訊息花的時間偏長、Kafka 突然判定這個 consumer 死了、觸發 rebalance、partition 被重新分配、同一批訊息被另一個 consumer 重複處理；RabbitMQ 端用 prefetch 控制併發從沒這問題。</p>
<p><strong>根因</strong>：RabbitMQ prefetch 只控制「broker 一次最多推幾條未 ack 給這個 consumer」、處理多久 broker 不管。Kafka 用 <code>max.poll.interval.ms</code> 監控「兩次 poll 之間最多隔多久」、如果一批 <code>max.poll.records</code> 拉太多、處理超過 <code>max.poll.interval.ms</code> 還沒回來 poll、broker 認定 consumer 卡死、踢出 group 觸發 rebalance。把 prefetch 的數值直接套成 <code>max.poll.records</code>、又沒考慮單批處理時間、就會超時。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong><code>max.poll.records</code> 配合單條處理時間設</strong>：一批的總處理時間要明顯小於 <code>max.poll.interval.ms</code>；處理慢就把 batch 設小。</li>
<li><strong>長處理 workload 調大 <code>max.poll.interval.ms</code></strong>：單條本來就慢（呼叫外部 API）的、把 interval 放寬、或把處理移到另一個 thread pool、poll 迴圈只負責拉取。</li>
<li><strong>理解 push vs pull 的差異</strong>：RabbitMQ 是 broker push、consumer 慢只是堆積；Kafka 是 consumer pull、consumer 慢會被誤判為死亡。這層差異是 prefetch 跟 max.poll.records 不能直接對映的根因。對應 <a href="/blog/backend/knowledge-cards/consumer-group/" data-link-title="Consumer Group" data-link-desc="說明一組 consumer 如何共同分攤 stream 或 topic 的處理責任">Consumer group</a> 卡。</li>
</ol>
<h3 id="case-5rabbitmq-即刪-vs-kafka-retentionreplay-行為差異炸出資料量">Case 5：RabbitMQ 即刪 vs Kafka retention、replay 行為差異炸出資料量</h3>
<p><strong>徵兆</strong>：團隊以為 Kafka「跟 RabbitMQ 一樣處理完就沒了」、結果 disk 持續長大；或反過來、需要 replay 時才發現 retention 設太短、要回放的事件已經被清掉。RabbitMQ 心智下「訊息消費完就不佔空間」的假設不成立。</p>
<p><strong>根因</strong>：RabbitMQ ack 後訊息即刪、queue 的空間隨消費釋放。Kafka 寫進 log 後在 <em>retention 期內一直留著</em>、不管有沒有被消費 —— 這正是 replay 能力的來源、也是 disk 成本的來源。沒設好 retention，要嘛留太久 disk 爆、要嘛留太短該 replay 時沒得 replay。RabbitMQ 沒有「retention」這個旋鈕（它是 ack 即刪），Kafka 必須顯式設 retention policy。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>按 replay 需求設 retention</strong>：event sourcing 要回放幾天就設幾天的 <code>retention.ms</code>、不是抄 RabbitMQ 的「處理完即刪」心智。</li>
<li><strong>算清 retention 的 disk 成本</strong>：retention × 寫入速率 = 佔用空間、納入容量規劃；對比 RabbitMQ 只佔「未消費」的量、Kafka 佔「retention 期內全部」的量。</li>
<li><strong>compact topic 給狀態類資料</strong>：如果只需要「每個 key 最新值」（像 RabbitMQ 不存在的場景）、用 <code>cleanup.policy=compact</code> 而非 time-based delete、避免無限長大。對應 <a href="/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">Topic</a> 卡的 retention policy。</li>
</ol>
<h2 id="漸進-cutoverdual-write-與-shadow-consume">漸進 cutover：dual-write 與 shadow consume</h2>
<p>paradigm shift 不能一次切換、因為 consumer 行為（offset 語意、ordering、DLQ、重試）全變了、需要在真實流量下驗證新 consumer 跟舊 consumer 結果一致才敢切。漸進 cutover 用兩個機制：</p>
<p><strong>dual-write</strong>：producer 同時往 RabbitMQ 跟 Kafka 寫同一份事件。RabbitMQ 端維持舊 consumer 正常生產、Kafka 端讓新 consumer 接收。dual-write 期間 RabbitMQ 仍是 source of truth、Kafka 只是並行驗證。要處理的細節是雙寫的一致性 —— 寫了 RabbitMQ 但 Kafka 寫失敗時怎麼辦、實務上通常容忍 Kafka 端短期缺漏（因為還沒切過去）、但要監控雙端的訊息數落差。</p>
<p><strong>shadow consume</strong>：新的 Kafka consumer 跑完整處理邏輯、但 <em>side effect 導到影子環境</em>（寫影子 DB、不發真實 webhook、不寄真實信）。把 Kafka consumer 的處理結果跟 RabbitMQ consumer 的真實結果比對、確認 ordering、去重、DLQ 行為都對齊。shadow 期是 paradigm 翻譯正確性的驗證窗口、不是效能測試。</p>
<p>cutover 是 per-workload 的：某個 workload shadow 驗證通過、就把它的真實消費切到 Kafka、停掉該 workload 的 RabbitMQ consumer 與 dual-write；其他 workload 維持原狀繼續驗證。不是全站一次切。</p>
<h2 id="capacity--cost-對照">Capacity / cost 對照</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>RabbitMQ（self-managed）</th>
          <th>Kafka（self-managed）</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Cluster baseline</td>
          <td>1-3 node（含 management plugin）</td>
          <td>3-5 broker + KRaft controller</td>
      </tr>
      <tr>
          <td>RAM / node baseline</td>
          <td>4-16GB</td>
          <td>16-64GB</td>
      </tr>
      <tr>
          <td>Storage 模型</td>
          <td>未消費訊息量（ack 即刪）</td>
          <td>retention 期內全部訊息（與消費無關）</td>
      </tr>
      <tr>
          <td>Operational FTE</td>
          <td>0.2-0.5 FTE</td>
          <td>0.5-2 FTE</td>
      </tr>
      <tr>
          <td>額外運維元件</td>
          <td>通常無</td>
          <td>Schema Registry / Connect / 監控 lag</td>
      </tr>
      <tr>
          <td>Throughput / node</td>
          <td>數萬到數十萬 msg/s</td>
          <td>100K-1M+ msg/s</td>
      </tr>
      <tr>
          <td>Replay 能力</td>
          <td>無（ack 即刪）</td>
          <td>retention 期內任意 offset</td>
      </tr>
      <tr>
          <td>複雜 routing</td>
          <td>強（exchange + binding）</td>
          <td>弱（producer 端決定、broker 不路由）</td>
      </tr>
      <tr>
          <td>學習與運維成本</td>
          <td>低</td>
          <td>高（partition / offset / rebalance 都要懂）</td>
      </tr>
  </tbody>
</table>
<p>判讀：純 work queue 場景 RabbitMQ 的運維成本顯著低、Kafka 的 storage 跟運維是為了 replay 與高吞吐付的價。如果 workload 用不到 replay 跟跨 consumer group fan-out、遷到 Kafka 是用更高的成本換用不到的能力。</p>
<h2 id="整合--下一步">整合 / 下一步</h2>
<h3 id="混合架構是-long-term-default">混合架構是 long-term default</h3>
<p>多數環境的終態是 RabbitMQ 與 Kafka 共存、各管各的責任：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">[task 分派：寄信 / 轉檔 / webhook]        [event log：CDC / 事件總線 / replay]
</span></span><span class="line"><span class="ln">2</span><span class="cl">         RabbitMQ                                    Kafka
</span></span><span class="line"><span class="ln">3</span><span class="cl">         │                                            │
</span></span><span class="line"><span class="ln">4</span><span class="cl">         └──────── Bridge（Connect source / 自寫）────┘</span></span></code></pre></div><p>RabbitMQ 跑「處理即承諾」的任務隊列、Kafka 跑「寫入即承諾」的事件流。需要從任務流產生事件記錄時、用 Kafka Connect 的 RabbitMQ source connector 或自寫 bridge 把選定的訊息搬到 Kafka topic。</p>
<h3 id="跟-outbox-pattern-對位">跟 outbox pattern 對位</h3>
<p>從 RabbitMQ 遷往 Kafka 常伴隨 <em>資料庫交易與事件發布一致性</em> 的需求 —— 因為 event sourcing 場景要求事件不能丟。直接在交易中寫 Kafka 有雙寫一致性問題、應該走 <a href="/blog/backend/03-message-queue/outbox-pattern/" data-link-title="3.3 outbox pattern 與發佈一致性" data-link-desc="把 transaction 與 event publish 分離">3.3 outbox pattern</a>：交易內只寫 outbox 表、再由 <a href="/blog/backend/01-database/vendors/postgresql/logical-replication-debezium/" data-link-title="PostgreSQL Logical Replication &#43; Debezium CDC：replication slot × failure × recovery 對照" data-link-desc="PostgreSQL logical replication slot 跟 Debezium CDC 的失效模式對照表：slot lag 撐爆 primary disk / schema change 斷流 / 初始 COPY 鎖表 / zombie slot 不釋放 / replay storm 後 offset reset；publication / subscription / pgoutput 配置、跟 Kafka outbox pattern 整合">Debezium CDC</a> 把 outbox 變更發到 Kafka topic。</p>
<h3 id="跟其他-migration-結構的對照">跟其他 migration 結構的對照</h3>
<table>
  <thead>
      <tr>
          <th>篇</th>
          <th>Schema 差</th>
          <th>Operational 差</th>
          <th>Paradigm 差</th>
          <th>結構</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Kafka ↔ NATS</td>
          <td>中</td>
          <td>中</td>
          <td><strong>高</strong></td>
          <td>partial + 混合</td>
      </tr>
      <tr>
          <td>RabbitMQ → Kafka（本篇）</td>
          <td>中</td>
          <td>中</td>
          <td><strong>高</strong></td>
          <td>partial + 混合</td>
      </tr>
  </tbody>
</table>
<p>兩篇都是 paradigm shift、都是 partial migration + 長期混合。差別在落差的方向：Kafka ↔ NATS 是 log vs subject messaging 的抽象層差異、RabbitMQ → Kafka 是 work queue vs event log 的責任模型差異 —— 後者的核心翻譯是「處理即承諾」如何重新表達成「寫入即承諾 + offset replay」。</p>
<h2 id="相關連結">相關連結</h2>
<ul>
<li>Source / target vendor：<a href="/blog/backend/03-message-queue/vendors/rabbitmq/" data-link-title="RabbitMQ" data-link-desc="Classic message broker、AMQP routing 為主">RabbitMQ</a> / <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka</a></li>
<li>平行 vendor：<a href="/blog/backend/03-message-queue/vendors/nats/" data-link-title="NATS" data-link-desc="Lightweight messaging、JetStream 加持久化與 streams">NATS</a> / <a href="/blog/backend/03-message-queue/vendors/redis-streams/" data-link-title="Redis Streams" data-link-desc="Redis 生態內的 streams、append-only log &#43; consumer group">Redis Streams</a></li>
<li>平行 migration playbook：<a href="/blog/backend/03-message-queue/vendors/kafka/migrate-from-to-nats/" data-link-title="Kafka ↔ NATS：不是 migration、是 messaging paradigm 重設計" data-link-desc="Kafka 跟 NATS 不是同類產品（log-based event streaming vs subject-based messaging）、&#39;migration&#39; 字面上不成立；本文釐清兩家 paradigm 邊界、什麼情境真的能換、application 模式重設計的 5 個踩雷（consumer offset 觀念差 / retention model / exactly-once 假設 / schema registry 缺位 / fan-out 模式差）、跟 JetStream 對位 &#43; 混合架構">Kafka ↔ NATS</a> / <a href="/blog/backend/03-message-queue/vendors/kafka/migrate-to-msk/" data-link-title="Self-managed Kafka → AWS MSK：把 $15K/month operational cost 拆解到 managed" data-link-desc="Kafka self-managed → MSK 是 Type C operational redesign — protocol 完全相容、operational stack（ZooKeeper / brokers / monitoring / patching）全託管；本文用 cost 拆解開頭、5 個 production 踩雷（client connection pattern / version pinning / metric pipeline / IAM auth / cross-cluster mirror）">Kafka → MSK</a></li>
<li>關鍵概念卡：<a href="/blog/backend/knowledge-cards/partition/" data-link-title="Partition" data-link-desc="說明事件流如何切分成多個可並行處理的有序片段">Partition</a> / <a href="/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">Topic</a> / <a href="/blog/backend/knowledge-cards/consumer-group/" data-link-title="Consumer Group" data-link-desc="說明一組 consumer 如何共同分攤 stream 或 topic 的處理責任">Consumer group</a> / <a href="/blog/backend/knowledge-cards/dead-letter-queue/" data-link-title="Dead-Letter Queue" data-link-desc="說明 dead-letter queue 如何隔離多次處理失敗的訊息">Dead-letter queue</a> / <a href="/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">Ack/nack</a></li>
<li>下游能力：<a href="/blog/backend/03-message-queue/outbox-pattern/" data-link-title="3.3 outbox pattern 與發佈一致性" data-link-desc="把 transaction 與 event publish 分離">3.3 outbox pattern</a> / <a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計</a> / <a href="/blog/backend/06-reliability/idempotency-replay/" data-link-title="6.12 Idempotency 與 Replay 驗證" data-link-desc="把重試、重播與冪等性從口頭約定變成可驗證屬性">6.12 idempotency / replay</a></li>
<li>Methodology：<a href="/blog/posts/migration-playbook-%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84stage-0-variant-%E8%A6%8F%E5%8A%83%E6%8A%8A-collapse-%E7%8E%87%E5%BE%9E-60-%E9%99%8D%E5%88%B0-0/" data-link-title="Migration Playbook 方法論的演化紀錄：Stage 0 variant 規劃把 collapse 率從 60% 降到 0%" data-link-desc="跨 vendor migration playbook 需要獨立寫作方法論的依據，以及這套方法論從三輪 batch dogfood 中演化出來的驗證證據。">Migration Playbook 寫作方法論</a></li>
</ul>
]]></content:encoded></item><item><title>Redis Streams → Kafka：從 embedded stream 長成 dedicated event streaming</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/redis-streams/migrate-to-kafka/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/redis-streams/migrate-to-kafka/</guid><description>&lt;blockquote>
&lt;p>本文是跨 vendor migration playbook、cross-link 到 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/redis-streams/" data-link-title="Redis Streams" data-link-desc="Redis 生態內的 streams、append-only log &amp;#43; consumer group">Redis Streams&lt;/a> 跟 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka&lt;/a>。對位 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/migrate-from-to-nats/" data-link-title="Kafka ↔ NATS：不是 migration、是 messaging paradigm 重設計" data-link-desc="Kafka 跟 NATS 不是同類產品（log-based event streaming vs subject-based messaging）、&amp;#39;migration&amp;#39; 字面上不成立；本文釐清兩家 paradigm 邊界、什麼情境真的能換、application 模式重設計的 5 個踩雷（consumer offset 觀念差 / retention model / exactly-once 假設 / schema registry 缺位 / fan-out 模式差）、跟 JetStream 對位 &amp;#43; 混合架構">Kafka ↔ NATS&lt;/a> 的 &lt;em>paradigm shift&lt;/em> 模板 — 兩端不是同類產品的不同實作、是不同抽象層的系統：一個是 Redis 行程內的 append-only log data structure、一個是專用的 distributed event streaming platform。&lt;/p>&lt;/blockquote>
&lt;h2 id="redis-streams-跟-kafka-是不同抽象層的東西">Redis Streams 跟 Kafka 是不同抽象層的東西&lt;/h2>
&lt;p>Redis Streams 是 Redis 行程內的一個 data structure、Kafka 是一整套獨立的 distributed event streaming platform。這個區別決定整趟遷移的性質：要把 messaging 能力從「既有 Redis 行程的一塊記憶體」搬到「自成一格、要獨立運維的多節點叢集」，遠超過換個相容 broker 的工作量。&lt;/p>
&lt;p>Redis Streams 的責任邊界是「在已經跑著的 Redis 裡多一個 append-only log」。它共用 Redis 的記憶體、持久化（AOF / RDB）、failover（Sentinel / Cluster）跟運維團隊。寫入用 &lt;code>XADD&lt;/code>、消費用 &lt;code>XREADGROUP&lt;/code>，consumer group 跟 pending entries list（PEL）都活在同一個 Redis 行程。它的設計取捨偏向「低延遲、低運維增量、跟 Redis 生命週期綁定」。&lt;/p>
&lt;p>Kafka 的責任邊界是「成為跨系統的事件總線」。它把訊息寫成 partition 化的 log、落在獨立 broker 的磁碟、用 replication 保護、用 consumer group offset 追蹤各 consumer 進度，可長期保留並隨意 replay。它的設計取捨偏向「寫入即承諾、磁碟級長期保留、多 consumer 各自重播、水平擴展吞吐」。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>維度&lt;/th>
 &lt;th>Redis Streams&lt;/th>
 &lt;th>Kafka&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>部署形態&lt;/td>
 &lt;td>Redis 行程內的 data structure&lt;/td>
 &lt;td>獨立 broker 叢集（3-5 broker + KRaft）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>儲存後端&lt;/td>
 &lt;td>RAM-bound（受 &lt;code>maxmemory&lt;/code> 限制）&lt;/td>
 &lt;td>Broker 本地磁碟（可加 tiered storage to S3）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>拓樸單位&lt;/td>
 &lt;td>單一 stream key（綁單一 shard）&lt;/td>
 &lt;td>Topic + 多 partition（跨 broker 分布）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Retention 機制&lt;/td>
 &lt;td>&lt;code>MAXLEN&lt;/code> / &lt;code>MINID&lt;/code>、application 主動 trim&lt;/td>
 &lt;td>Broker 端 retention policy（time / size）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>消費進度&lt;/td>
 &lt;td>PEL + &lt;code>XACK&lt;/code>（broker 維護待 ack 集合）&lt;/td>
 &lt;td>Consumer offset commit（per partition）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>失敗接管&lt;/td>
 &lt;td>&lt;code>XCLAIM&lt;/code> / &lt;code>XAUTOCLAIM&lt;/code>（手動 / 半自動）&lt;/td>
 &lt;td>Rebalance protocol（broker 協調自動分配）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Replay&lt;/td>
 &lt;td>從 entry ID 重讀（受 retention 內資料限制）&lt;/td>
 &lt;td>從任意 offset 重讀（受磁碟 retention 限制）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>延遲&lt;/td>
 &lt;td>亞毫秒（記憶體操作）&lt;/td>
 &lt;td>5-50ms&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>運維增量&lt;/td>
 &lt;td>近乎零（沿用 Redis）&lt;/td>
 &lt;td>顯著（多養一套叢集 + schema / connect 生態）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>關鍵在「拓樸單位」這列。Redis Streams 的一個 stream key 只能落在單一 shard、沒有 partition 概念，吞吐與資料量受單 shard 的記憶體與單執行緒處理能力封頂。Kafka 的 topic 天然切成多 partition、分散到多 broker，這是兩者在規模上的分水嶺，也是後面所有對位與故障演練的根。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是跨 vendor migration playbook、cross-link 到 <a href="/blog/backend/03-message-queue/vendors/redis-streams/" data-link-title="Redis Streams" data-link-desc="Redis 生態內的 streams、append-only log &#43; consumer group">Redis Streams</a> 跟 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka</a>。對位 <a href="/blog/backend/03-message-queue/vendors/kafka/migrate-from-to-nats/" data-link-title="Kafka ↔ NATS：不是 migration、是 messaging paradigm 重設計" data-link-desc="Kafka 跟 NATS 不是同類產品（log-based event streaming vs subject-based messaging）、&#39;migration&#39; 字面上不成立；本文釐清兩家 paradigm 邊界、什麼情境真的能換、application 模式重設計的 5 個踩雷（consumer offset 觀念差 / retention model / exactly-once 假設 / schema registry 缺位 / fan-out 模式差）、跟 JetStream 對位 &#43; 混合架構">Kafka ↔ NATS</a> 的 <em>paradigm shift</em> 模板 — 兩端不是同類產品的不同實作、是不同抽象層的系統：一個是 Redis 行程內的 append-only log data structure、一個是專用的 distributed event streaming platform。</p></blockquote>
<h2 id="redis-streams-跟-kafka-是不同抽象層的東西">Redis Streams 跟 Kafka 是不同抽象層的東西</h2>
<p>Redis Streams 是 Redis 行程內的一個 data structure、Kafka 是一整套獨立的 distributed event streaming platform。這個區別決定整趟遷移的性質：要把 messaging 能力從「既有 Redis 行程的一塊記憶體」搬到「自成一格、要獨立運維的多節點叢集」，遠超過換個相容 broker 的工作量。</p>
<p>Redis Streams 的責任邊界是「在已經跑著的 Redis 裡多一個 append-only log」。它共用 Redis 的記憶體、持久化（AOF / RDB）、failover（Sentinel / Cluster）跟運維團隊。寫入用 <code>XADD</code>、消費用 <code>XREADGROUP</code>，consumer group 跟 pending entries list（PEL）都活在同一個 Redis 行程。它的設計取捨偏向「低延遲、低運維增量、跟 Redis 生命週期綁定」。</p>
<p>Kafka 的責任邊界是「成為跨系統的事件總線」。它把訊息寫成 partition 化的 log、落在獨立 broker 的磁碟、用 replication 保護、用 consumer group offset 追蹤各 consumer 進度，可長期保留並隨意 replay。它的設計取捨偏向「寫入即承諾、磁碟級長期保留、多 consumer 各自重播、水平擴展吞吐」。</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>Redis Streams</th>
          <th>Kafka</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>部署形態</td>
          <td>Redis 行程內的 data structure</td>
          <td>獨立 broker 叢集（3-5 broker + KRaft）</td>
      </tr>
      <tr>
          <td>儲存後端</td>
          <td>RAM-bound（受 <code>maxmemory</code> 限制）</td>
          <td>Broker 本地磁碟（可加 tiered storage to S3）</td>
      </tr>
      <tr>
          <td>拓樸單位</td>
          <td>單一 stream key（綁單一 shard）</td>
          <td>Topic + 多 partition（跨 broker 分布）</td>
      </tr>
      <tr>
          <td>Retention 機制</td>
          <td><code>MAXLEN</code> / <code>MINID</code>、application 主動 trim</td>
          <td>Broker 端 retention policy（time / size）</td>
      </tr>
      <tr>
          <td>消費進度</td>
          <td>PEL + <code>XACK</code>（broker 維護待 ack 集合）</td>
          <td>Consumer offset commit（per partition）</td>
      </tr>
      <tr>
          <td>失敗接管</td>
          <td><code>XCLAIM</code> / <code>XAUTOCLAIM</code>（手動 / 半自動）</td>
          <td>Rebalance protocol（broker 協調自動分配）</td>
      </tr>
      <tr>
          <td>Replay</td>
          <td>從 entry ID 重讀（受 retention 內資料限制）</td>
          <td>從任意 offset 重讀（受磁碟 retention 限制）</td>
      </tr>
      <tr>
          <td>延遲</td>
          <td>亞毫秒（記憶體操作）</td>
          <td>5-50ms</td>
      </tr>
      <tr>
          <td>運維增量</td>
          <td>近乎零（沿用 Redis）</td>
          <td>顯著（多養一套叢集 + schema / connect 生態）</td>
      </tr>
  </tbody>
</table>
<p>關鍵在「拓樸單位」這列。Redis Streams 的一個 stream key 只能落在單一 shard、沒有 partition 概念，吞吐與資料量受單 shard 的記憶體與單執行緒處理能力封頂。Kafka 的 topic 天然切成多 partition、分散到多 broker，這是兩者在規模上的分水嶺，也是後面所有對位與故障演練的根。</p>
<h2 id="先確認是不是真的該遷多數中小規模不該遷">先確認是不是真的該遷：多數中小規模不該遷</h2>
<p>決定遷移前先做反向確認：在中小規模、且團隊已熟 Redis 的情境，Redis Streams 往往已經夠用，把它換成 Kafka 多半是引入運維負擔而非解決問題。遷移的正當理由來自規模或保留需求真的超出 Redis Streams 的能力邊界，而不是 Kafka 更主流。</p>
<p><a href="/blog/backend/03-message-queue/cases/redis-streams-arcjet-replace-kafka/" data-link-title="3.C43 Arcjet：Redis Streams 取代 Kafka 省 6 位數 $" data-link-desc="Arcjet security 平台、Kafka managed 6 位數 $/yr、用 Redis Streams 約 $1k/yr、自寫 Janitor 監控 retention。">Arcjet</a> 的方向恰好相反、值得當反向參照。Arcjet 的 security / bot detection 平台需要低延遲請求處理，原本評估 Kafka，發現 managed Kafka 要六位數美元年費、自管運維難度也高；他們把既有的 Redis cache 層升級成 Streams，總成本掉到約一千美元年費。代價是 Redis Streams 沒有自動 retention，他們自寫一個 Janitor process，依約每分鐘一百則的實際處理速度監測 stream 長度跟 consumer group 狀態、selectively trim。</p>
<p>Arcjet 的判讀對遷移方向的啟示：當 workload 是低延遲、資料量留在記憶體可承受的範圍、團隊本來就在跑 Redis，Redis Streams 是務實且便宜的選擇；願意自寫 retention 工具就能補上它缺的治理能力。這條路成立時，遷去 Kafka 是用六位數年費跟一整套叢集運維，去換一個現有方案已能覆蓋的需求。</p>
<p><a href="/blog/backend/03-message-queue/cases/redis-streams-bitso-reliable-streams/" data-link-title="3.C42 Bitso：Reliable Redis Streams 抽象 &#43; 自建 DLQ" data-link-desc="Bitso 加密交易所、千 msg/sec/stream &#43; 亞毫秒延遲、自建 Reliable Streams 封裝 PEL &#43; retry &#43; DLQ、idempotent processing。">Bitso</a> 是另一個 Redis Streams 站得住的高壓案例。Bitso 的撮合引擎微服務要扛每秒上千則訊息、亞毫秒延遲、撐住 BTC 價格暴動的尖峰；他們先後評估 Kafka（延遲不符）跟 SQS（vendor lock-in + 延遲）後選 Redis Streams，自建一層 Reliable Streams 抽象封裝 PEL + retry + DLQ，走 idempotent processing 接受重複勝過遺失。Bitso 揭露 Redis Streams 是「資料結構」而非「broker 系統」，可靠性責任在 application 層；但在亞毫秒延遲是硬指標的撮合場景，這個取捨反而讓 Redis Streams 勝過 Kafka。</p>
<p>兩個案例共同點：當延遲是硬指標、資料量在 RAM 可承受範圍、團隊能自建缺的治理層，Redis Streams 就站得住。遷去 Kafka 的決策該建立在這些前提不再成立之上，而不是建立在 Kafka 更有名之上。</p>
<h2 id="真正該遷的訊號">真正該遷的訊號</h2>
<p>決定遷移的依據是 Redis Streams 的三個能力邊界被實際 workload 突破：retention 需求超出 RAM 的成本曲線、需要長期 replay、consumer group 或 partition 規模超出單一 Redis 行程。三個訊號中任一個被觸發、且自建工具補不回來時，遷去 Kafka 才划算。</p>
<p>第一個訊號是 retention 超出 RAM 的成本翻轉。Redis Streams 的資料活在記憶體，保留越久、stream 越長、佔的 RAM 越多，而 RAM 是 Redis 叢集裡最貴的資源。當 retention 需求從「幾小時的緩衝」長到「數天到數週的事件保留」，把這些資料留在 RAM 的成本會快速超過 Kafka 把同樣資料留在 broker 磁碟（甚至 tiered storage 到 S3）的成本。<a href="/blog/backend/03-message-queue/cases/redis-streams-learning-com-event-source-retreat/" data-link-title="3.C46 Learning.com：Redis 事件源退場（反例）" data-link-desc="Learning.com 把 microservice event store 放 Redis、1 年累積 GB/週、AOF&#43;EBS 變 latency 痛點、退到 PostgreSQL。">Learning.com 退場案例</a>就是這條線被突破的反例 — 把 Redis 當長期事件儲存（Stream 是其中一塊），事件量每週以 GB 成長、AOF fsync 與 EBS I/O 變成 latency 痛點，最終退回 PostgreSQL。成本曲線翻轉是最常見、也最該觸發遷移的訊號。</p>
<p>第二個訊號是需要長期 replay。事件溯源（event sourcing）或合規稽核場景，需要保留並重播數週、數月甚至數年的歷史事件。Redis Streams 的 replay 只能重讀 retention 內還在的資料，而 retention 受 RAM 限制無法拉得很長；Kafka 的磁碟保留加 tiered storage 讓長期 replay 變成 first-class 能力。當 replay 視窗的需求超出 RAM 能承受的 retention，這個訊號成立。</p>
<p>第三個訊號是 consumer group 或 partition 規模超出單一 Redis。Redis Streams 的單一 stream key 綁在單一 shard，吞吐受單 shard 封頂、沒有 partition 可以水平拆分並行度；要跨 shard 只能手動用 hash tag 切成多個獨立 stream，application 自己路由。當單一邏輯 stream 的吞吐需求、或 consumer 並行度需求超過單 shard 能給的，且手動切 stream 的複雜度已經失控，Kafka 的原生 partition 才值得換。</p>
<p>這三個訊號之外，還有一個放大條件：是否需要 Kafka 生態（Schema Registry、Connect / Debezium CDC、Streams 流處理）。如果遷移同時要接上 CDC pipeline 或 schema 強制治理，那 Kafka 帶來的不只是 retention 跟 partition、而是整套生態，這會讓遷移的價值天平更傾向 Kafka。但若只是想要更長 retention、生態用不到，先評估 Redis tiered 方案或自建 Janitor 是否更便宜。</p>
<h2 id="概念對位xaddxreadgroupxackmaxlenxclaim">概念對位：XADD/XREADGROUP/XACK/MAXLEN/XCLAIM</h2>
<p>遷移的核心工作是把 Redis Streams 的五個核心操作對應到 Kafka 的等價概念、並理解每個對位背後語意的偏移，這比換 SDK 重得多。直接照字面搬會在 retention、消費進度、失敗接管三處踩雷，這三處正是後面故障演練的來源。</p>
<table>
  <thead>
      <tr>
          <th>Redis Streams 操作</th>
          <th>Kafka 等價</th>
          <th>語意偏移</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>XADD stream * field val</code></td>
          <td><code>producer.send(topic, key, val)</code></td>
          <td>Kafka 用 key 決定 partition、Redis 單 stream 無 partition</td>
      </tr>
      <tr>
          <td><code>XREADGROUP GROUP g c</code></td>
          <td>consumer group + <code>poll()</code></td>
          <td>Kafka rebalance 自動分配 partition、Redis 要手動 <code>XCLAIM</code></td>
      </tr>
      <tr>
          <td><code>XACK stream g id</code></td>
          <td>offset commit</td>
          <td>PEL 是逐則待 ack 集合、offset 是單調位移、語意不同</td>
      </tr>
      <tr>
          <td><code>MAXLEN</code> / <code>MINID</code> / <code>XTRIM</code></td>
          <td>retention policy（time / size）</td>
          <td>application 主動 trim → broker 端被動 retention</td>
      </tr>
      <tr>
          <td><code>XCLAIM</code> / <code>XAUTOCLAIM</code></td>
          <td>rebalance protocol</td>
          <td>手動 / 半自動接管 → broker 協調自動 reassign</td>
      </tr>
  </tbody>
</table>
<p><code>XADD</code> 對 <code>producer.send</code> 的最大偏移是 partition key。Redis 的單一 stream key 沒有 partition，所有 entry 都在同一條序列上嚴格有序；Kafka 把訊息依 key 雜湊分到不同 partition，只有同一 partition 內保證有序。遷移時要決定哪個欄位當 partition key、這個決定同時決定了 ordering 的範圍跟 hot partition 的風險。</p>
<p><code>XREADGROUP</code> 對 consumer group 的偏移在 rebalance。Redis consumer group 沒有自動 rebalance，consumer 掛掉後它名下未 ack 的訊息留在 PEL，要靠其他 consumer 主動 <code>XCLAIM</code> 接管；Kafka 的 consumer group 有 rebalance protocol，consumer 加入或離開時 broker 自動把 partition 重新分配。從手動接管搬到自動 rebalance，application 端負責接管的那段邏輯可以刪掉、但要改成理解 rebalance 行為。</p>
<p><code>XACK</code> 對 offset commit 是最容易誤用的一處，獨立成下一節的故障演練。<code>MAXLEN</code> 對 retention policy 是成本模型翻轉的核心，也獨立成故障演練。</p>
<h2 id="production-故障演練">Production 故障演練</h2>
<h3 id="case-1retention-模型從-ram-限制翻成-log-成本磁碟與成本失準">Case 1：Retention 模型從 RAM 限制翻成 log 成本，磁碟與成本失準</h3>
<p><strong>徵兆</strong>：團隊把 Redis Streams 的 <code>MAXLEN 100000</code>（保留最近十萬則、控制 RAM）習慣直接對映成 Kafka 的某個數字，結果 cutover 後不是 broker 磁碟暴漲超出預期、就是資料保留遠短於業務需要、replay 視窗對不上。</p>
<p><strong>根因</strong>：Redis Streams 的 <code>MAXLEN</code> 是 application 在每次 <code>XADD</code> 主動修剪的「條數上限」，目的是壓住 RAM 佔用，是一個 count-based 的記憶體預算旋鈕。Kafka 的 retention 是 broker 端被動執行的 policy、預設是 time-based（<code>retention.ms</code>）或 size-based（<code>retention.bytes</code>），目的是控制磁碟保留窗，而磁碟比 RAM 便宜一到兩個數量級。兩者的單位、執行主體、成本曲線都不同 — 把「保留十萬則以省 RAM」直接搬成 Kafka 設定，會錯估磁碟用量，也會把 Redis 時代「為了省 RAM 而被迫短保留」的限制錯誤地帶進一個本來就能長保留的系統。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>從業務需求重算 retention、不沿用 Redis 的 RAM 預算</strong>：Redis 的 <code>MAXLEN</code> 數字是 RAM 成本的妥協、不是業務的真實保留需求；遷移時回到「業務需要 replay 多久」重新算 <code>retention.ms</code>，這正是遷移要解鎖的能力。</li>
<li><strong>改用 time-based 為主、size-based 當保險絲</strong>：Kafka 設 <code>retention.ms</code> 對齊業務 replay 窗、再設 <code>retention.bytes</code> 防單 partition 磁碟失控。</li>
<li><strong>長保留接 tiered storage</strong>：retention 需求拉到數週數月時，把冷資料分層到 S3、熱資料留本地磁碟，成本曲線進一步壓平，而這在 Redis 的 RAM 模型下做不到。</li>
</ol>
<h3 id="case-2pel-觀念被帶進-offset造成重複或漏消費">Case 2：PEL 觀念被帶進 offset，造成重複或漏消費</h3>
<p><strong>徵兆</strong>：遷移後 consumer 出現「明明處理過的訊息又被重新消費」或「某些訊息整批沒被處理」；團隊照 Redis 時代「逐則 <code>XACK</code>」的心智模型管理 Kafka offset commit，結果對不上。</p>
<p><strong>根因</strong>：PEL 跟 offset 是兩個不同的進度模型。Redis Streams 的 PEL 是 broker 維護的「逐則待 ack 集合」，每則訊息獨立追蹤是否已 ack，consumer 可以亂序 ack 某幾則、其他留在 PEL；<code>XACK</code> 是針對特定 entry ID 的點狀確認。Kafka 的 offset 是 per partition 的單調位移、代表「這個位置之前都算消費完」，commit offset N 意味著 0 到 N-1 全部視為已處理。把 PEL 的逐則語意套到 offset 上會出兩種錯：一是處理完亂序的訊息後 commit 了較大的 offset，中間沒處理完的訊息被當成已消費而漏掉；二是 commit 時機錯置（auto-commit 在處理前就 commit），crash 後從錯誤位置重讀造成重複。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>理解 offset 是區間承諾、不是逐則確認</strong>：commit offset 前確保該 offset 之前的訊息都已處理完、不要對亂序處理的批次 commit 最大 offset。</li>
<li><strong>關 auto-commit、改 manual commit 在處理之後</strong>：<code>enable.auto.commit=false</code>，處理完一批再 commit，對齊 at-least-once。</li>
<li><strong>保留 application 端 idempotency</strong>：這點從 Redis 時代就該有、遷到 Kafka 仍成立 — at-least-once 下重複難免，用 message ID + dedup store 顯式去重，對位 <a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency 卡</a>跟 <a href="/blog/backend/03-message-queue/cases/redis-streams-bitso-reliable-streams/" data-link-title="3.C42 Bitso：Reliable Redis Streams 抽象 &#43; 自建 DLQ" data-link-desc="Bitso 加密交易所、千 msg/sec/stream &#43; 亞毫秒延遲、自建 Reliable Streams 封裝 PEL &#43; retry &#43; DLQ、idempotent processing。">Bitso 的 idempotent processing</a>。</li>
</ol>
<h3 id="case-3單-stream-key-換成多-partitionordering-假設破裂">Case 3：單 stream key 換成多 partition，ordering 假設破裂</h3>
<p><strong>徵兆</strong>：遷移前所有事件在單一 Redis stream 上嚴格有序、downstream 依賴這個順序（例如同一筆訂單的 created → paid → shipped）；切到 Kafka 多 partition 後，同一筆訂單的事件被分到不同 partition、處理順序錯亂。</p>
<p><strong>根因</strong>：Redis Streams 的單一 stream key 綁單一 shard、所有 entry 在一條序列上全域有序，application 不需要思考 ordering 範圍就免費得到全序。Kafka 把 topic 切成多 partition 來換取水平吞吐，代價是只保證 <em>同一 partition 內</em> 有序、partition 之間無序。遷移時若沒指定 partition key、訊息會被 round-robin 或依預設雜湊散開，同一個業務實體（訂單、帳戶、裝置）的事件落到不同 partition，全序假設就破了。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>用業務實體當 partition key</strong>：把需要保序的實體 ID（訂單 ID、帳戶 ID）當 Kafka message key，同 key 雜湊到同 partition、partition 內保序，把「全域有序」收斂成「per-entity 有序」這個多數業務真正需要的粒度。</li>
<li><strong>辨識哪些流真的需要全序</strong>：若某條流真的需要全域嚴格有序且無法拆成 per-entity，設單 partition topic（犧牲該 topic 的水平吞吐）；這也是個訊號 — 若大量流都需要全序，遷 Kafka 的吞吐優勢用不上、該重新評估遷移。</li>
<li><strong>規劃 partition 數對齊並行度跟 hot key</strong>：partition 數決定 consumer 並行上限，同時注意熱門 key 造成的 hot partition，對位 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka topic 設計</a>的 key 策略段。</li>
</ol>
<h3 id="case-4redis-既有低延遲被-kafka-吞吐換掉延遲敏感路徑受傷">Case 4：Redis 既有低延遲被 Kafka 吞吐換掉，延遲敏感路徑受傷</h3>
<p><strong>徵兆</strong>：遷移後某些原本靠 Redis Streams 亞毫秒延遲的路徑（即時風控判斷、撮合前置）延遲跳到數十毫秒，下游 SLA 破線。</p>
<p><strong>根因</strong>：Redis Streams 的亞毫秒延遲來自記憶體操作 + 行程內 data structure；Kafka 為了長期保留跟高吞吐，訊息要落磁碟、過 replication、走網路到獨立 broker，單則訊息延遲落在 5-50ms 區間，這是它換吞吐跟持久性付出的代價。把延遲敏感路徑無差別搬上 Kafka，等於用一個為吞吐優化的系統去服務一個為延遲優化的需求。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>按延遲需求分流、不要全遷</strong>：把延遲敏感的即時路徑留在 Redis Streams（或 Redis 其他結構）、把需要長保留 / 高吞吐 / replay 的事件流遷到 Kafka，這正是 Bitso 在撮合場景堅持 Redis Streams 的理由。</li>
<li><strong>接受混合架構是常態</strong>：Redis Streams 跟 Kafka 共存、各自服務適配的 workload，不追求「全部統一到 Kafka」；對位 <a href="/blog/backend/03-message-queue/vendors/kafka/migrate-from-to-nats/" data-link-title="Kafka ↔ NATS：不是 migration、是 messaging paradigm 重設計" data-link-desc="Kafka 跟 NATS 不是同類產品（log-based event streaming vs subject-based messaging）、&#39;migration&#39; 字面上不成立；本文釐清兩家 paradigm 邊界、什麼情境真的能換、application 模式重設計的 5 個踩雷（consumer offset 觀念差 / retention model / exactly-once 假設 / schema registry 缺位 / fan-out 模式差）、跟 JetStream 對位 &#43; 混合架構">Kafka ↔ NATS 的混合架構是 long-term default</a> 思路。</li>
<li><strong>若 Kafka 延遲必須壓低</strong>：調 producer <code>linger.ms=0</code> + <code>acks=1</code>、consumer <code>fetch.min.bytes=1</code> 換取較低延遲，但這會犧牲吞吐與部分可靠性、是 trade-off 不是免費午餐。</li>
</ol>
<h2 id="migration-結構漸進-cutover--長期混合">Migration 結構：漸進 cutover + 長期混合</h2>
<p>這趟遷移的結構是漸進拆分而非一次性切換：先按 workload 性質分流、再對需要遷的事件流做 dual-write 並行、逐流 cutover、最終留下 Redis Streams 跟 Kafka 共存的混合架構。一次性把所有 stream 搬上 Kafka 既無必要、也會把延遲敏感路徑拖下水。</p>
<ol>
<li><strong>Phase 0：scope 分流</strong> — 對每條 stream 跑前面三個訊號的判讀，分成「該遷 Kafka」（retention / replay / 規模超界）跟「留 Redis Streams」（延遲敏感 / 規模在範圍內）兩類。這一步直接決定後續工作量、也避免無差別遷移。</li>
<li><strong>Phase 1：Kafka 叢集與 topic 設計</strong> — 建 broker 叢集、依 Case 3 的 partition key 設計建 topic、依 Case 1 的業務需求設 retention，這時做的是基礎設施準備、還沒碰流量。</li>
<li><strong>Phase 2：dual-write 並行</strong> — producer 同時寫 Redis Streams 跟 Kafka、新 consumer 接 Kafka 驗證正確性、舊 consumer 持續吃 Redis Streams，這是可逆階段、出問題退回只讀 Redis 即可。</li>
<li><strong>Phase 3：逐流 cutover</strong> — 逐條 stream 把流量切到 Kafka、確認 consumer 進度（offset）跟 idempotency 都對、再停掉該 stream 的 Redis 端寫入；cutover 以 stream 為單位、不是整批。</li>
<li><strong>Phase 4：長期混合</strong> — 留在 Redis Streams 的延遲敏感流跟遷到 Kafka 的事件流共存、各自運維；需要時用 bridge（消費 Redis Streams 寫入 Kafka、或反向）同步必要資料。</li>
</ol>
<p>dual-write 階段的可逆性是這個結構的安全邊界：在 Phase 2 之前一切可退回純 Redis、Phase 3 逐流 cutover 把不可逆動作（停 Redis 寫入）切到最小粒度，單條 stream 出問題不影響其他流。</p>
<h2 id="capacity--cost-對照">Capacity / cost 對照</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>Redis Streams（既有 Redis 內）</th>
          <th>Kafka（self-managed）</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>部署增量</td>
          <td>近乎零（沿用 Redis 行程）</td>
          <td>3-5 broker + KRaft、獨立叢集</td>
      </tr>
      <tr>
          <td>儲存成本曲線</td>
          <td>RAM-bound（最貴的資源）</td>
          <td>磁碟為主（便宜 1-2 數量級）+ tiered to S3</td>
      </tr>
      <tr>
          <td>Retention 上限</td>
          <td>受 <code>maxmemory</code> 限制、實務數小時到數天</td>
          <td>數週到數月（磁碟）、數年（tiered storage）</td>
      </tr>
      <tr>
          <td>吞吐 / 單邏輯 stream</td>
          <td>受單 shard 封頂</td>
          <td>多 partition 水平擴展</td>
      </tr>
      <tr>
          <td>延遲</td>
          <td>亞毫秒</td>
          <td>5-50ms</td>
      </tr>
      <tr>
          <td>運維 FTE 增量</td>
          <td>近乎零</td>
          <td>0.5-2 FTE（含 schema / connect 生態）</td>
      </tr>
      <tr>
          <td>Replay 能力</td>
          <td>retention 內重讀（受 RAM 限制）</td>
          <td>任意 offset 重讀（受磁碟 retention 限制）</td>
      </tr>
      <tr>
          <td>生態</td>
          <td>Redis 工具鏈</td>
          <td>Schema Registry / Connect / Streams</td>
      </tr>
  </tbody>
</table>
<p><strong>判讀</strong>：成本的核心翻轉在「儲存成本曲線」這列。Redis Streams 把資料壓在最貴的 RAM、retention 越長越貴，所以實務上被迫短保留；Kafka 把資料攤到便宜的磁碟、再分層到 S3，讓長保留變得可負擔。但這個翻轉只在「retention 需求真的長」時成立 — 若 retention 只需數小時、資料量小，Redis Streams 沒有獨立叢集跟 0.5-2 FTE 的運維增量，總成本反而低，這正是 Arcjet 的處境。遷移划不划算取決於 retention 跟規模需求落在這條曲線的哪一段。</p>
<h2 id="整合--下一步">整合 / 下一步</h2>
<h3 id="混合架構是常見終態">混合架構是常見終態</h3>
<p>多數從 Redis Streams 起步、因規模長出 Kafka 需求的系統，終態是兩者共存而非取代：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">[延遲敏感即時路徑]                    [長保留 / replay / 高吞吐事件流]
</span></span><span class="line"><span class="ln">2</span><span class="cl">   Redis Streams                              Kafka
</span></span><span class="line"><span class="ln">3</span><span class="cl">        │                                       │
</span></span><span class="line"><span class="ln">4</span><span class="cl">        └──────────── Bridge（雙向同步）────────┘</span></span></code></pre></div><p>Redis Streams 服務亞毫秒延遲的即時路徑（風控、撮合前置）、Kafka 服務需要長保留與 replay 的事件流；需要打通時寫一段 bridge 同步必要 stream。這跟 <a href="/blog/backend/03-message-queue/vendors/kafka/migrate-from-to-nats/" data-link-title="Kafka ↔ NATS：不是 migration、是 messaging paradigm 重設計" data-link-desc="Kafka 跟 NATS 不是同類產品（log-based event streaming vs subject-based messaging）、&#39;migration&#39; 字面上不成立；本文釐清兩家 paradigm 邊界、什麼情境真的能換、application 模式重設計的 5 個踩雷（consumer offset 觀念差 / retention model / exactly-once 假設 / schema registry 缺位 / fan-out 模式差）、跟 JetStream 對位 &#43; 混合架構">Kafka ↔ NATS 的混合架構是 long-term default</a> 是同一個 paradigm shift 結論的兩個實例。</p>
<h3 id="接上-kafka-生態">接上 Kafka 生態</h3>
<p>遷到 Kafka 後可解鎖 Redis Streams 沒有的生態能力：</p>
<ul>
<li>Schema 治理：用 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Schema Registry</a> 強制 producer / consumer 契約，補上 Redis Streams 缺的 schema enforcement（對位 <a href="/blog/backend/03-message-queue/cases/redis-streams-bitso-reliable-streams/" data-link-title="3.C42 Bitso：Reliable Redis Streams 抽象 &#43; 自建 DLQ" data-link-desc="Bitso 加密交易所、千 msg/sec/stream &#43; 亞毫秒延遲、自建 Reliable Streams 封裝 PEL &#43; retry &#43; DLQ、idempotent processing。">Bitso 自建抽象層</a>的紀律性責任）。</li>
<li>CDC pipeline：接 <a href="/blog/backend/01-database/vendors/postgresql/logical-replication-debezium/" data-link-title="PostgreSQL Logical Replication &#43; Debezium CDC：replication slot × failure × recovery 對照" data-link-desc="PostgreSQL logical replication slot 跟 Debezium CDC 的失效模式對照表：slot lag 撐爆 primary disk / schema change 斷流 / 初始 COPY 鎖表 / zombie slot 不釋放 / replay storm 後 offset reset；publication / subscription / pgoutput 配置、跟 Kafka outbox pattern 整合">Debezium</a> 把資料庫變更流進 Kafka topic，做事件溯源主軸。</li>
<li>長期 replay：tiered storage 把冷事件分層到 S3、支援數年 replay。</li>
</ul>
<h3 id="反向確認的-tripwire">反向確認的 tripwire</h3>
<p>遷移後若觀察到：延遲敏感路徑 SLA 破線、Kafka 叢集運維成本超出省下的 RAM 成本、實際 retention 需求遠短於規劃 — 這些是「該遷的訊號其實不成立」的回溯訊號，應重新評估該 stream 是否該退回 Redis Streams，對位 <a href="/blog/backend/03-message-queue/cases/redis-streams-arcjet-replace-kafka/" data-link-title="3.C43 Arcjet：Redis Streams 取代 Kafka 省 6 位數 $" data-link-desc="Arcjet security 平台、Kafka managed 6 位數 $/yr、用 Redis Streams 約 $1k/yr、自寫 Janitor 監控 retention。">Arcjet</a> 的成本判讀。</p>
<h2 id="相關連結">相關連結</h2>
<ul>
<li>Source / target vendor：<a href="/blog/backend/03-message-queue/vendors/redis-streams/" data-link-title="Redis Streams" data-link-desc="Redis 生態內的 streams、append-only log &#43; consumer group">Redis Streams</a> / <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka</a></li>
<li>反向案例：<a href="/blog/backend/03-message-queue/cases/redis-streams-arcjet-replace-kafka/" data-link-title="3.C43 Arcjet：Redis Streams 取代 Kafka 省 6 位數 $" data-link-desc="Arcjet security 平台、Kafka managed 6 位數 $/yr、用 Redis Streams 約 $1k/yr、自寫 Janitor 監控 retention。">Arcjet Redis Streams 取代 Kafka</a> / <a href="/blog/backend/03-message-queue/cases/redis-streams-bitso-reliable-streams/" data-link-title="3.C42 Bitso：Reliable Redis Streams 抽象 &#43; 自建 DLQ" data-link-desc="Bitso 加密交易所、千 msg/sec/stream &#43; 亞毫秒延遲、自建 Reliable Streams 封裝 PEL &#43; retry &#43; DLQ、idempotent processing。">Bitso Reliable Streams</a> / <a href="/blog/backend/03-message-queue/cases/redis-streams-learning-com-event-source-retreat/" data-link-title="3.C46 Learning.com：Redis 事件源退場（反例）" data-link-desc="Learning.com 把 microservice event store 放 Redis、1 年累積 GB/週、AOF&#43;EBS 變 latency 痛點、退到 PostgreSQL。">Learning.com 退場</a></li>
<li>平行 migration playbook（同 paradigm shift）：<a href="/blog/backend/03-message-queue/vendors/kafka/migrate-from-to-nats/" data-link-title="Kafka ↔ NATS：不是 migration、是 messaging paradigm 重設計" data-link-desc="Kafka 跟 NATS 不是同類產品（log-based event streaming vs subject-based messaging）、&#39;migration&#39; 字面上不成立；本文釐清兩家 paradigm 邊界、什麼情境真的能換、application 模式重設計的 5 個踩雷（consumer offset 觀念差 / retention model / exactly-once 假設 / schema registry 缺位 / fan-out 模式差）、跟 JetStream 對位 &#43; 混合架構">Kafka ↔ NATS</a></li>
<li>平行 vendor：<a href="/blog/backend/03-message-queue/vendors/rabbitmq/" data-link-title="RabbitMQ" data-link-desc="Classic message broker、AMQP routing 為主">RabbitMQ</a> / <a href="/blog/backend/03-message-queue/vendors/nats/" data-link-title="NATS" data-link-desc="Lightweight messaging、JetStream 加持久化與 streams">NATS</a></li>
<li>Methodology：<a href="/blog/posts/migration-playbook-%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84stage-0-variant-%E8%A6%8F%E5%8A%83%E6%8A%8A-collapse-%E7%8E%87%E5%BE%9E-60-%E9%99%8D%E5%88%B0-0/" data-link-title="Migration Playbook 方法論的演化紀錄：Stage 0 variant 規劃把 collapse 率從 60% 降到 0%" data-link-desc="跨 vendor migration playbook 需要獨立寫作方法論的依據，以及這套方法論從三輪 batch dogfood 中演化出來的驗證證據。">Migration Playbook 寫作方法論</a>（Type E paradigm shift）</li>
</ul>
]]></content:encoded></item><item><title>Kafka ↔ NATS：不是 migration、是 messaging paradigm 重設計</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/migrate-from-to-nats/</link><pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/migrate-from-to-nats/</guid><description>&lt;blockquote>
&lt;p>本文是跨 vendor migration playbook、cross-link 到 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka&lt;/a> 跟 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/nats/" data-link-title="NATS" data-link-desc="Lightweight messaging、JetStream 加持久化與 streams">NATS&lt;/a>。跟前四篇 migration（schema 差 / drop-in / operational redesign / multi-tool 拆分）對照、本篇是 &lt;em>paradigm shift&lt;/em> — 兩端不是「同類產品的不同實作」、是 &lt;em>不同抽象層的 messaging system&lt;/em>。&lt;/p>&lt;/blockquote>
&lt;h2 id="kafka--nats-migration字面上不成立">「Kafka → NATS migration」字面上不成立&lt;/h2>
&lt;p>前面四篇 migration 都隱含一個前提：source 跟 target 是 &lt;em>同類產品&lt;/em>、只是不同實作或 deployment 模型。「Kafka → NATS」字面上看起來也是 &lt;em>messaging migration&lt;/em>、但實際上：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>維度&lt;/th>
 &lt;th>Kafka&lt;/th>
 &lt;th>NATS Core&lt;/th>
 &lt;th>NATS JetStream&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Core abstraction&lt;/td>
 &lt;td>Distributed log（partition + offset）&lt;/td>
 &lt;td>Pub/Sub subject（fire-and-forget）&lt;/td>
 &lt;td>Stream（subject group + retention）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Message persistence&lt;/td>
 &lt;td>Default persistent（log retention）&lt;/td>
 &lt;td>&lt;strong>不持久化&lt;/strong>（subscriber 缺席 = lost）&lt;/td>
 &lt;td>持久化（K/V backend / file）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Delivery semantic&lt;/td>
 &lt;td>At-least-once / exactly-once（事務）&lt;/td>
 &lt;td>At-most-once&lt;/td>
 &lt;td>At-least-once / exactly-once&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Consumer model&lt;/td>
 &lt;td>Consumer group + offset&lt;/td>
 &lt;td>Subscriber + subject pattern&lt;/td>
 &lt;td>Durable consumer + pull / push&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Ordering&lt;/td>
 &lt;td>Per partition strict&lt;/td>
 &lt;td>無 ordering guarantee&lt;/td>
 &lt;td>Per stream / per consumer&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Replay&lt;/td>
 &lt;td>隨意 from offset&lt;/td>
 &lt;td>&lt;strong>無&lt;/strong>&lt;/td>
 &lt;td>from sequence number&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Throughput&lt;/td>
 &lt;td>高（M msg/s）&lt;/td>
 &lt;td>極高（10M+ msg/s）&lt;/td>
 &lt;td>中（100K-1M msg/s）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Latency&lt;/td>
 &lt;td>5-50ms&lt;/td>
 &lt;td>&amp;lt; 1ms&lt;/td>
 &lt;td>5-20ms&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Kafka 跟 NATS Core 是 &lt;em>不同類產品&lt;/em> — 一個是 durable event log、一個是 transient pub/sub。「migration」需要先決定 &lt;em>target 是 NATS Core 還是 JetStream&lt;/em>、然後判斷 &lt;em>application 模式能否重設計&lt;/em> 對應。&lt;/p>
&lt;h2 id="什麼情境真的能換什麼不能">什麼情境真的能換、什麼不能&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Application 模式&lt;/th>
 &lt;th>Kafka 適配度&lt;/th>
 &lt;th>NATS Core 適配&lt;/th>
 &lt;th>NATS JetStream 適配&lt;/th>
 &lt;th>「migration」可行性&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Event sourcing（replay 過去事件）&lt;/td>
 &lt;td>強&lt;/td>
 &lt;td>不可（無 replay）&lt;/td>
 &lt;td>中（JetStream replay）&lt;/td>
 &lt;td>部分（移到 JetStream）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Microservice async messaging&lt;/td>
 &lt;td>強&lt;/td>
 &lt;td>強&lt;/td>
 &lt;td>強&lt;/td>
 &lt;td>高&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Real-time pub/sub（低延遲、可丟）&lt;/td>
 &lt;td>中&lt;/td>
 &lt;td>強&lt;/td>
 &lt;td>中&lt;/td>
 &lt;td>高（移到 Core）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>跨 service 命令 / RPC&lt;/td>
 &lt;td>弱（不適合）&lt;/td>
 &lt;td>強（request-reply）&lt;/td>
 &lt;td>弱&lt;/td>
 &lt;td>不需要遷&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>大量 log / metric / event collection&lt;/td>
 &lt;td>強&lt;/td>
 &lt;td>弱&lt;/td>
 &lt;td>中&lt;/td>
 &lt;td>低（保留 Kafka）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Multi-tenant message bus&lt;/td>
 &lt;td>中&lt;/td>
 &lt;td>強&lt;/td>
 &lt;td>強&lt;/td>
 &lt;td>高&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Strict ordering + transactional&lt;/td>
 &lt;td>強&lt;/td>
 &lt;td>不可&lt;/td>
 &lt;td>中（per stream）&lt;/td>
 &lt;td>部分（部分功能犧牲）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>5+ 年歷史 retention&lt;/td>
 &lt;td>強&lt;/td>
 &lt;td>不可&lt;/td>
 &lt;td>中（retention 設長）&lt;/td>
 &lt;td>部分&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>判讀&lt;/strong>：&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是跨 vendor migration playbook、cross-link 到 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka</a> 跟 <a href="/blog/backend/03-message-queue/vendors/nats/" data-link-title="NATS" data-link-desc="Lightweight messaging、JetStream 加持久化與 streams">NATS</a>。跟前四篇 migration（schema 差 / drop-in / operational redesign / multi-tool 拆分）對照、本篇是 <em>paradigm shift</em> — 兩端不是「同類產品的不同實作」、是 <em>不同抽象層的 messaging system</em>。</p></blockquote>
<h2 id="kafka--nats-migration字面上不成立">「Kafka → NATS migration」字面上不成立</h2>
<p>前面四篇 migration 都隱含一個前提：source 跟 target 是 <em>同類產品</em>、只是不同實作或 deployment 模型。「Kafka → NATS」字面上看起來也是 <em>messaging migration</em>、但實際上：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>Kafka</th>
          <th>NATS Core</th>
          <th>NATS JetStream</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Core abstraction</td>
          <td>Distributed log（partition + offset）</td>
          <td>Pub/Sub subject（fire-and-forget）</td>
          <td>Stream（subject group + retention）</td>
      </tr>
      <tr>
          <td>Message persistence</td>
          <td>Default persistent（log retention）</td>
          <td><strong>不持久化</strong>（subscriber 缺席 = lost）</td>
          <td>持久化（K/V backend / file）</td>
      </tr>
      <tr>
          <td>Delivery semantic</td>
          <td>At-least-once / exactly-once（事務）</td>
          <td>At-most-once</td>
          <td>At-least-once / exactly-once</td>
      </tr>
      <tr>
          <td>Consumer model</td>
          <td>Consumer group + offset</td>
          <td>Subscriber + subject pattern</td>
          <td>Durable consumer + pull / push</td>
      </tr>
      <tr>
          <td>Ordering</td>
          <td>Per partition strict</td>
          <td>無 ordering guarantee</td>
          <td>Per stream / per consumer</td>
      </tr>
      <tr>
          <td>Replay</td>
          <td>隨意 from offset</td>
          <td><strong>無</strong></td>
          <td>from sequence number</td>
      </tr>
      <tr>
          <td>Throughput</td>
          <td>高（M msg/s）</td>
          <td>極高（10M+ msg/s）</td>
          <td>中（100K-1M msg/s）</td>
      </tr>
      <tr>
          <td>Latency</td>
          <td>5-50ms</td>
          <td>&lt; 1ms</td>
          <td>5-20ms</td>
      </tr>
  </tbody>
</table>
<p>Kafka 跟 NATS Core 是 <em>不同類產品</em> — 一個是 durable event log、一個是 transient pub/sub。「migration」需要先決定 <em>target 是 NATS Core 還是 JetStream</em>、然後判斷 <em>application 模式能否重設計</em> 對應。</p>
<h2 id="什麼情境真的能換什麼不能">什麼情境真的能換、什麼不能</h2>
<table>
  <thead>
      <tr>
          <th>Application 模式</th>
          <th>Kafka 適配度</th>
          <th>NATS Core 適配</th>
          <th>NATS JetStream 適配</th>
          <th>「migration」可行性</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Event sourcing（replay 過去事件）</td>
          <td>強</td>
          <td>不可（無 replay）</td>
          <td>中（JetStream replay）</td>
          <td>部分（移到 JetStream）</td>
      </tr>
      <tr>
          <td>Microservice async messaging</td>
          <td>強</td>
          <td>強</td>
          <td>強</td>
          <td>高</td>
      </tr>
      <tr>
          <td>Real-time pub/sub（低延遲、可丟）</td>
          <td>中</td>
          <td>強</td>
          <td>中</td>
          <td>高（移到 Core）</td>
      </tr>
      <tr>
          <td>跨 service 命令 / RPC</td>
          <td>弱（不適合）</td>
          <td>強（request-reply）</td>
          <td>弱</td>
          <td>不需要遷</td>
      </tr>
      <tr>
          <td>大量 log / metric / event collection</td>
          <td>強</td>
          <td>弱</td>
          <td>中</td>
          <td>低（保留 Kafka）</td>
      </tr>
      <tr>
          <td>Multi-tenant message bus</td>
          <td>中</td>
          <td>強</td>
          <td>強</td>
          <td>高</td>
      </tr>
      <tr>
          <td>Strict ordering + transactional</td>
          <td>強</td>
          <td>不可</td>
          <td>中（per stream）</td>
          <td>部分（部分功能犧牲）</td>
      </tr>
      <tr>
          <td>5+ 年歷史 retention</td>
          <td>強</td>
          <td>不可</td>
          <td>中（retention 設長）</td>
          <td>部分</td>
      </tr>
  </tbody>
</table>
<p><strong>判讀</strong>：</p>
<ul>
<li><em>Microservice async messaging + 低延遲需求</em> → NATS Core 更合適、是 <em>真正的 migration</em></li>
<li><em>Event sourcing + replay</em> → JetStream 部分對等、但 partition / offset 觀念變了</li>
<li><em>Log collection / event streaming</em> → 不該遷、保留 Kafka</li>
</ul>
<h2 id="為什麼會考慮這個-paradigm-shift">為什麼會考慮這個 paradigm shift</h2>
<p>實務上觸發評估 NATS 通常三條 driver：</p>
<ol>
<li><strong>Cost + operational complexity</strong>：Kafka cluster + ZooKeeper（或 KRaft）+ Schema Registry + Connect 是重資產、3-5 broker + ops 1+ FTE；NATS 單 binary、無依賴、輕量</li>
<li><strong>Latency 要求 &lt; 1ms</strong>：Kafka 對單 message latency 不是 SLA、NATS Core 是</li>
<li><strong>Multi-tenant / multi-region 簡化</strong>：NATS 內建 <em>account</em> + <em>leaf node</em> 拓樸、跨 region 是 first-class</li>
</ol>
<p>但這三條 driver 都 <em>只在特定 application 模式有效</em>。不是普世 better、是 <em>某類 workload 適合</em>。</p>
<h2 id="migration-結構application-重設計--部分-stream-cutover">Migration 結構：application 重設計 + 部分 stream cutover</h2>
<p>跟前面四篇 migration 結構都不同、Kafka ↔ NATS 是 <em>混合</em>：</p>
<ol>
<li><strong>Phase 0：scope 判讀</strong> — 列 application、區分「適合 NATS」vs「保留 Kafka」</li>
<li><strong>Phase 1：application code 重設計</strong> — 不是 SDK 換、是 <em>messaging pattern 改</em>（event sourcing → message bus / consumer group → durable consumer）</li>
<li><strong>Phase 2：部分 stream parallel run</strong> — 新 application 走 NATS、舊 application 持續 Kafka</li>
<li><strong>Phase 3：cutover 適合的 stream</strong></li>
<li><strong>Phase 4：長期混合架構</strong> — Kafka 跟 NATS <em>共存</em>、不消滅一邊</li>
</ol>
<p>整體不是 <em>一次 migration</em>、是 <em>漸進拆分</em>。多數 production 環境 <em>永遠</em> 是混合架構。</p>
<h2 id="application-重設計範例consumer-group--durable-consumer">Application 重設計範例：consumer group → durable consumer</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// Kafka 端 consumer group pattern</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="nx">consumer</span> <span class="o">:=</span> <span class="nx">kafka</span><span class="p">.</span><span class="nf">NewConsumer</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">kafka</span><span class="p">.</span><span class="nx">ConfigMap</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="s">&#34;bootstrap.servers&#34;</span><span class="p">:</span> <span class="s">&#34;kafka:9092&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="s">&#34;group.id&#34;</span><span class="p">:</span>          <span class="s">&#34;myapp-orders&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="s">&#34;auto.offset.reset&#34;</span><span class="p">:</span> <span class="s">&#34;earliest&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="p">})</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="nx">consumer</span><span class="p">.</span><span class="nf">SubscribeTopics</span><span class="p">([]</span><span class="kt">string</span><span class="p">{</span><span class="s">&#34;orders&#34;</span><span class="p">},</span> <span class="kc">nil</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="k">for</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nx">msg</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">consumer</span><span class="p">.</span><span class="nf">ReadMessage</span><span class="p">(</span><span class="o">-</span><span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="c1">// process msg.Value</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nx">consumer</span><span class="p">.</span><span class="nf">CommitMessage</span><span class="p">(</span><span class="nx">msg</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// NATS JetStream durable consumer</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="nx">js</span><span class="p">,</span> <span class="nx">_</span> <span class="o">:=</span> <span class="nx">nc</span><span class="p">.</span><span class="nf">JetStream</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="nx">sub</span><span class="p">,</span> <span class="nx">_</span> <span class="o">:=</span> <span class="nx">js</span><span class="p">.</span><span class="nf">PullSubscribe</span><span class="p">(</span><span class="s">&#34;orders.&gt;&#34;</span><span class="p">,</span> <span class="s">&#34;myapp-orders&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">nats</span><span class="p">.</span><span class="nf">AckExplicit</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">nats</span><span class="p">.</span><span class="nf">MaxAckPending</span><span class="p">(</span><span class="mi">100</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="k">for</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">msgs</span><span class="p">,</span> <span class="nx">_</span> <span class="o">:=</span> <span class="nx">sub</span><span class="p">.</span><span class="nf">Fetch</span><span class="p">(</span><span class="mi">10</span><span class="p">,</span> <span class="nx">nats</span><span class="p">.</span><span class="nf">MaxWait</span><span class="p">(</span><span class="mi">5</span><span class="o">*</span><span class="nx">time</span><span class="p">.</span><span class="nx">Second</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">msg</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">msgs</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="c1">// process msg.Data</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">msg</span><span class="p">.</span><span class="nf">Ack</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>差異：</p>
<ul>
<li>Kafka <code>auto.offset.reset</code> → NATS <code>DeliverPolicy</code>（多種選項）</li>
<li>Kafka commit message → NATS explicit Ack（per message）</li>
<li>Kafka partition → NATS subject hierarchy（<code>orders.&gt;</code> 通配）</li>
<li>Kafka rebalance → NATS 不需要、durable consumer 跨 instance 共享</li>
</ul>
<p>Application 邏輯改動 30-60%、不是 SDK 換。</p>
<h2 id="production-故障演練">Production 故障演練</h2>
<h3 id="case-1consumer-offset-觀念差replay-不對等">Case 1：Consumer offset 觀念差，replay 不對等</h3>
<p><strong>徵兆</strong>：application 設計「跑歷史 7 天事件 catch-up」、Kafka 設 <code>auto.offset.reset=earliest</code> + <code>seek_to(timestamp)</code> 跑；換 NATS JetStream 後找不到 <code>seek_to</code> 等價 API、catch-up 失敗。</p>
<p><strong>根因</strong>：Kafka offset 是 <em>broker-side 維護 + consumer-side commit</em>；NATS JetStream 用 <em>sequence number</em> + <code>DeliverPolicy.ByStartTime</code>、但 time-based seek 精度低、且 application code 必須改。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>預先設計</strong>：NATS JetStream 用 <code>DeliverPolicy.ByStartSequence</code> + 自管 sequence-time mapping</li>
<li><strong>保留 Kafka 給 replay-heavy use case</strong>：不是所有 application 都遷</li>
<li><strong>混合架構</strong>：歷史 replay 走 Kafka、新事件流走 NATS、application 處理雙來源</li>
</ol>
<h3 id="case-2retention-model-差異磁碟使用炸">Case 2：Retention model 差異、磁碟使用炸</h3>
<p><strong>徵兆</strong>：NATS JetStream stream 設 <code>retention=interest</code>（subscriber 收到就刪）、cutover 後 disk 持續長大；預期跟 Kafka log retention 7 天類似、實際資料留 30+ 天沒清。</p>
<p><strong>根因</strong>：NATS JetStream retention 有 3 種：<code>limits</code> / <code>interest</code> / <code>workqueue</code>。<code>interest</code> 是 <em>至少一個 subscriber 還沒 ack 就保留</em>；application 端 silent consumer（已下線但沒 unsubscribe）讓 message 永留。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>預設 <code>retention=limits</code></strong>：用 <code>MaxAge</code> / <code>MaxBytes</code> 跟 Kafka log retention 對應、明確控制</li>
<li><strong><code>interest</code> retention 慎用</strong>：只在 <em>確認所有 subscriber lifecycle 受控</em> 場景</li>
<li><strong>Subscriber cleanup</strong>：application graceful shutdown 必須主動 unsubscribe、不留 zombie consumer</li>
</ol>
<h3 id="case-3exactly-once-假設不對等">Case 3：Exactly-once 假設不對等</h3>
<p><strong>徵兆</strong>：cutover 後發現某 application（payment processor）開始出現 <em>duplicate transaction</em>；Kafka 端用 transactional producer + idempotent consumer 跑了 2 年沒問題。</p>
<p><strong>根因</strong>：Kafka exactly-once 是 <em>producer transaction + consumer offset commit atomic</em>；NATS JetStream exactly-once 概念不一樣 — 是 <em>publish ack</em> + <em>consumer ack</em> 跨層 atomic、application 端要主動處理 idempotency。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>重新審視 application 端 idempotency</strong>：用 message ID + dedup store（Redis SETEX）顯式 dedup</li>
<li><strong>NATS JetStream 對 exactly-once 不該假設「自動」</strong>：application 端責任、不是 broker 端</li>
<li><strong>Payment / financial 場景慎遷</strong>：保留 Kafka transactional pattern 較穩</li>
</ol>
<h3 id="case-4schema-registry-缺位ad-hoc-schema-漂移">Case 4：Schema registry 缺位、ad-hoc schema 漂移</h3>
<p><strong>徵兆</strong>：NATS 部署 3 個月後、producer / consumer 間 schema 對不上、application bug；Kafka 端有 Confluent Schema Registry 強 enforce、NATS 沒對等服務。</p>
<p><strong>根因</strong>：NATS 哲學是 <em>minimalist</em>、不內建 schema registry；application 自己決定 payload format。Kafka 生態的 Avro / Protobuf + Registry 模式不直接搬。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>外部 schema management</strong>：用 BSR（Buf Schema Registry）或自家 Git-based registry、producer / consumer build-time 驗證</li>
<li><strong>NATS Object Store</strong>：JetStream 提供 K/V + Object Store、可存 schema 文件</li>
<li><strong>接受紀律性 trade-off</strong>：NATS 簡潔代價是 application 端紀律、不能靠 broker 強 enforce</li>
</ol>
<h3 id="case-5fan-out-模式跟-kafka-不一致">Case 5：Fan-out 模式跟 Kafka 不一致</h3>
<p><strong>徵兆</strong>：同一 event 要送 5 個 downstream service、Kafka 端用 consumer group + 5 個 group 跑；NATS 端設計 5 個 durable consumer、結果某些 message 漏 fan-out。</p>
<p><strong>根因</strong>：Kafka consumer group 對 <em>同 group 內 partition 分配</em>、不同 group 各自完整消費；NATS JetStream <code>Durable consumer</code> 預設行為跟 group 不同 — <em>單 durable consumer 是 shared subscription</em>、要 fan-out 需多個獨立 durable。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>明確設計 fan-out</strong>：N 個 downstream 對應 N 個 <em>獨立 durable consumer</em>、不共用</li>
<li><strong>用 <code>AckPolicy.None</code> + push subscriber</strong>：不需要 ack 的 fan-out 場景、用 ephemeral push subscriber</li>
<li><strong>檢查 application stream config</strong>：fan-out 失敗多半是 consumer config 錯、不是 NATS bug</li>
</ol>
<h2 id="capacity--cost-對照">Capacity / cost 對照</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>Kafka（self-managed）</th>
          <th>NATS（JetStream）</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Cluster size baseline</td>
          <td>3-5 broker + ZooKeeper / KRaft</td>
          <td>3 server（含 JetStream cluster）</td>
      </tr>
      <tr>
          <td>RAM / broker baseline</td>
          <td>16-64GB</td>
          <td>2-16GB</td>
      </tr>
      <tr>
          <td>Storage requirement</td>
          <td>高（log retention）</td>
          <td>中（JetStream file backend）</td>
      </tr>
      <tr>
          <td>Operational FTE</td>
          <td>0.5-2 FTE</td>
          <td>0.1-0.3 FTE</td>
      </tr>
      <tr>
          <td>Throughput / single node</td>
          <td>100K-1M msg/s</td>
          <td>NATS Core：10M+、JetStream：100K-1M</td>
      </tr>
      <tr>
          <td>Latency p99</td>
          <td>5-50ms</td>
          <td>NATS Core：&lt; 1ms、JetStream：5-20ms</td>
      </tr>
      <tr>
          <td>Retention 1TB / month cost</td>
          <td>$400-800（含 HA）</td>
          <td>$200-400</td>
      </tr>
      <tr>
          <td>Operational complexity</td>
          <td>高（Schema Registry / Connect / Streams）</td>
          <td>低</td>
      </tr>
      <tr>
          <td>Ecosystem maturity</td>
          <td>高（10+ 年）</td>
          <td>中（JetStream 2021+）</td>
      </tr>
  </tbody>
</table>
<p><strong>判讀</strong>：簡單 messaging workload NATS 顯著便宜；complex event streaming（Schema Registry / Streams / Connect 重度用）Kafka 不替代。</p>
<h2 id="整合--下一步">整合 / 下一步</h2>
<h3 id="混合架構是-long-term-default">混合架構是 long-term default</h3>
<p>多數 production 環境最終是 <em>Kafka + NATS 共存</em>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">[event sourcing / log collection]        [microservice async messaging]
</span></span><span class="line"><span class="ln">2</span><span class="cl">         Kafka                                       NATS
</span></span><span class="line"><span class="ln">3</span><span class="cl">         │                                            │
</span></span><span class="line"><span class="ln">4</span><span class="cl">         └──────── Bridge (Connect / Custom) ────────┘</span></span></code></pre></div><p>NATS 跑微服務間 messaging、Kafka 跑 event log / analytics pipeline；中間用 Kafka Connect NATS connector 或自寫 bridge 同步必要 stream。</p>
<h3 id="跟-logical-replication--debezium-對位">跟 <a href="/blog/backend/01-database/vendors/postgresql/logical-replication-debezium/" data-link-title="PostgreSQL Logical Replication &#43; Debezium CDC：replication slot × failure × recovery 對照" data-link-desc="PostgreSQL logical replication slot 跟 Debezium CDC 的失效模式對照表：slot lag 撐爆 primary disk / schema change 斷流 / 初始 COPY 鎖表 / zombie slot 不釋放 / replay storm 後 offset reset；publication / subscription / pgoutput 配置、跟 Kafka outbox pattern 整合">Logical Replication + Debezium</a> 對位</h3>
<p>CDC pipeline 設計：</p>
<ul>
<li>DB → Debezium → Kafka topic（event sourcing 主軸）</li>
<li>Kafka → NATS bridge → microservice fan-out</li>
<li>不直接 DB → Debezium → NATS（Debezium 不原生支援 NATS sink）</li>
</ul>
<h3 id="跟前-4-篇-migration-的結構對照">跟前 4 篇 migration 的結構對照</h3>
<table>
  <thead>
      <tr>
          <th>篇</th>
          <th>Schema 差</th>
          <th>Operational 差</th>
          <th>Paradigm 差</th>
          <th>結構</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Splunk → Elastic</td>
          <td>高</td>
          <td>中</td>
          <td>低</td>
          <td>6-phase</td>
      </tr>
      <tr>
          <td>Redis → DragonflyDB</td>
          <td>無</td>
          <td>低</td>
          <td>低</td>
          <td>6-section + audit</td>
      </tr>
      <tr>
          <td>PostgreSQL → Aurora</td>
          <td>無</td>
          <td>高</td>
          <td>低</td>
          <td>hybrid</td>
      </tr>
      <tr>
          <td>Datadog → Grafana Stack</td>
          <td>中</td>
          <td>中</td>
          <td>低</td>
          <td>parallel streams</td>
      </tr>
      <tr>
          <td>Kafka ↔ NATS（本篇）</td>
          <td>中</td>
          <td>中</td>
          <td><strong>高</strong></td>
          <td>partial + 混合</td>
      </tr>
  </tbody>
</table>
<p><strong>結論</strong>：migration 結構由 <em>最大差異維度</em> 決定、不是 universal phased playbook。</p>
<h2 id="相關連結">相關連結</h2>
<ul>
<li>Source / target vendor：<a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka</a> / <a href="/blog/backend/03-message-queue/vendors/nats/" data-link-title="NATS" data-link-desc="Lightweight messaging、JetStream 加持久化與 streams">NATS</a></li>
<li>平行 vendor：<a href="/blog/backend/03-message-queue/vendors/rabbitmq/" data-link-title="RabbitMQ" data-link-desc="Classic message broker、AMQP routing 為主">RabbitMQ</a> / <a href="/blog/backend/03-message-queue/vendors/redis-streams/" data-link-title="Redis Streams" data-link-desc="Redis 生態內的 streams、append-only log &#43; consumer group">Redis Streams</a></li>
<li>平行 migration playbook：<a href="/blog/backend/07-security-data-protection/vendors/splunk/migrate-to-elastic-security/" data-link-title="Splunk → Elastic Security Detection Rule Migration：6 段 phased playbook 跟 5 大踩雷" data-link-desc="從 Splunk Enterprise Security 遷到 Elastic Security 的 detection rule translation playbook：SPL ↔ KQL/ES|QL schema 對位、AI-assisted translation pipeline、parallel run 比對、cutover routing、5 個 production 踩雷（macro 沒對應 / time zone 差異 / summary index 不對位 / alert dedup key 衝突 / 過早 decommission）、capacity / cost 對照">Splunk → Elastic Security</a> / <a href="/blog/backend/02-cache-redis/vendors/redis/migrate-to-dragonflydb/" data-link-title="Redis → DragonflyDB：drop-in 相容下的容量躍升 &#43; 5 個踩雷" data-link-desc="DragonflyDB 號稱 Redis drop-in 替代、單機 throughput 25x、記憶體效率 30% 提升；遷移流程簡單但有 5 個 production 踩雷（RDB 版本差 / Lua 腳本不全支援 / Pub-Sub fanout 行為差異 / Cluster mode 兼容度 / Modules 不支援）、跟 Sentinel / Cluster 模式對位">Redis → DragonflyDB</a> / <a href="/blog/backend/01-database/vendors/postgresql/migrate-to-aurora/" data-link-title="PostgreSQL → Aurora Migration：protocol 相容、operational 重設計" data-link-desc="Aurora 號稱 PostgreSQL-compatible 但 operational model 不同（storage decouple / cluster endpoint / instance class / 自家備份）；遷移流程是混合（protocol drop-in &#43; operational phased）、5 個 production 踩雷（extension 不支援 / replication slot 不直通 / autovacuum 行為差 / IAM 認證強制 / cost model 換算）、跟 Patroni / read replica / DR 對位">PostgreSQL → Aurora</a> / <a href="/blog/backend/04-observability/vendors/datadog/migrate-to-grafana-stack/" data-link-title="Datadog → Grafana Stack：把 $50K/month bill 拆解到 self-hosted observability" data-link-desc="Datadog 五層計費（host APM / metric / log ingest / log retention / RUM）拆解、對位 Grafana Stack（Mimir / Loki / Tempo / Grafana / Alloy）的 5 層責任；OTel-based agent migration、5 個 production 踩雷（cardinality 爆 / log volume cost / dashboard 不直接轉 / alert routing 換邏輯 / SLO definition 差異）、cost reality check">Datadog → Grafana Stack</a></li>
<li>Methodology：<a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章的寫作方法論</a></li>
</ul>
]]></content:encoded></item><item><title>3.C11 Pinterest：Kafka tiered storage broker-decoupled</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-pinterest-tiered-storage/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-pinterest-tiered-storage/</guid><description>&lt;p>這個案例的核心責任是說明 tiered storage 不只是「冷資料 offload」、是 broker 與儲存解耦的架構選擇。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>Pinterest 從 Kafka broker 卸 ~200 TB/day 熱資料到 S3、2024 年 5 月起 20+ production topic 上線、跟 KIP-405 native tiered storage 不同、採 broker-decoupled 設計。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>Broker-decoupled 設計讓 consumer 直接從 S3 拉、broker 不再是熱路徑。揭露「broker resource 跟 cross-AZ network cost」其實該分離治理、而非綁在 broker 容量擴張上。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>Kafka 進階主題：tiered storage / 跨層儲存成本。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/broker-basics/" data-link-title="3.1 broker 基礎與投遞模型" data-link-desc="先理解 broker、queue、consumer 與 delivery semantics">3.1 broker basics&lt;/a>。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://medium.com/pinterest-engineering/pinterest-tiered-storage-for-apache-kafka-%EF%B8%8F-a-broker-decoupled-approach-c33c69e9958b">Pinterest Tiered Storage for Apache Kafka — a Broker-Decoupled Approach&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 tiered storage 不只是「冷資料 offload」、是 broker 與儲存解耦的架構選擇。</p>
<h2 id="觀察">觀察</h2>
<p>Pinterest 從 Kafka broker 卸 ~200 TB/day 熱資料到 S3、2024 年 5 月起 20+ production topic 上線、跟 KIP-405 native tiered storage 不同、採 broker-decoupled 設計。</p>
<h2 id="判讀">判讀</h2>
<p>Broker-decoupled 設計讓 consumer 直接從 S3 拉、broker 不再是熱路徑。揭露「broker resource 跟 cross-AZ network cost」其實該分離治理、而非綁在 broker 容量擴張上。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>Kafka 進階主題：tiered storage / 跨層儲存成本。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/broker-basics/" data-link-title="3.1 broker 基礎與投遞模型" data-link-desc="先理解 broker、queue、consumer 與 delivery semantics">3.1 broker basics</a>。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://medium.com/pinterest-engineering/pinterest-tiered-storage-for-apache-kafka-%EF%B8%8F-a-broker-decoupled-approach-c33c69e9958b">Pinterest Tiered Storage for Apache Kafka — a Broker-Decoupled Approach</a></li>
</ul>
]]></content:encoded></item><item><title>Kafka → Google Cloud Pub/Sub：從 partition 到 topic-subscription 的模型轉換</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/google-pubsub/migrate-from-kafka/</link><pubDate>Mon, 22 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/google-pubsub/migrate-from-kafka/</guid><description>&lt;blockquote>
&lt;p>本文是跨 vendor migration playbook、cross-link 到 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Apache Kafka&lt;/a>（source）跟 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Google Cloud Pub/Sub&lt;/a>（target）。跑 6 維 diff dimension audit 後判定為 &lt;strong>Type E paradigm shift&lt;/strong>：兩者投遞模型本質不同（partition-based log vs topic-subscription pub/sub）。&lt;/p>&lt;/blockquote>
&lt;h2 id="為什麼從-kafka-遷到-pubsub">為什麼從 Kafka 遷到 Pub/Sub&lt;/h2>
&lt;p>這個遷移的 driver 通常是平台策略：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>All-in GCP&lt;/strong>：組織決定收斂到 GCP 生態，Kafka 是唯一非 GCP 的 stateful 服務，維運孤島成本高&lt;/li>
&lt;li>&lt;strong>運維簡化&lt;/strong>：自管 Kafka cluster 的 broker、ZooKeeper/KRaft、partition rebalance、retention 管理需要專職團隊；Pub/Sub 是全託管&lt;/li>
&lt;li>&lt;strong>GCP 整合&lt;/strong>：下游是 BigQuery、Dataflow、Cloud Run — Pub/Sub 原生串接，Kafka 要加 connector 層&lt;/li>
&lt;li>&lt;strong>全球路由&lt;/strong>：Pub/Sub topic 是 global（不綁 region），Kafka 需要 MirrorMaker 做跨 region 同步&lt;/li>
&lt;/ul>
&lt;p>遷移的工作量不在資料搬遷（message queue 通常不搬歷史資料），在 &lt;strong>模型轉換&lt;/strong> — Kafka 的 partition ordering、consumer group、offset commit 跟 Pub/Sub 的 topic-subscription、ack deadline、ordering key 是不同抽象。&lt;/p>
&lt;h2 id="6-維-diff-dimension-audit">6 維 diff dimension audit&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>維度&lt;/th>
 &lt;th>評估&lt;/th>
 &lt;th>等級&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Schema / API&lt;/td>
 &lt;td>Kafka producer/consumer API → Pub/Sub client library，完全不同 API&lt;/td>
 &lt;td>High&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Operational model&lt;/td>
 &lt;td>自管 broker/ZK/KRaft → 全託管&lt;/td>
 &lt;td>High（方向：簡化）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Abstraction / paradigm&lt;/td>
 &lt;td>partition-based log vs topic-subscription pub/sub&lt;/td>
 &lt;td>High&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Number of components&lt;/td>
 &lt;td>Kafka + Schema Registry + Connect → Pub/Sub + (optional) Dataflow&lt;/td>
 &lt;td>Medium&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Application change&lt;/td>
 &lt;td>Producer/Consumer 全部改寫&lt;/td>
 &lt;td>High&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Data topology&lt;/td>
 &lt;td>Partition × offset → Topic × subscription × ack&lt;/td>
 &lt;td>High&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>五維 High — &lt;strong>Type E paradigm shift&lt;/strong>，是兩套模型的橋接，工程量遠超 drop-in 或翻譯。&lt;/p>
&lt;h2 id="模型差異對照">模型差異對照&lt;/h2>
&lt;p>遷移前必須理解兩套模型的對應關係。對應不是一對一 — 有些概念在對方沒有直接等價物。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Kafka 概念&lt;/th>
 &lt;th>Pub/Sub 對應&lt;/th>
 &lt;th>差異重點&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Topic&lt;/td>
 &lt;td>Topic&lt;/td>
 &lt;td>名稱相同但語意不同：Kafka topic 有 partition，Pub/Sub topic 沒有&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Partition&lt;/td>
 &lt;td>無直接對應&lt;/td>
 &lt;td>Pub/Sub 的 ordering 用 ordering key 實現，但 ordering key 不保證全域順序&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Consumer group&lt;/td>
 &lt;td>Subscription&lt;/td>
 &lt;td>每個 subscription 獨立消費 topic 的全部訊息，類似 Kafka 的 consumer group&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Offset&lt;/td>
 &lt;td>無直接對應&lt;/td>
 &lt;td>Pub/Sub 用 ack/nack 而非 offset commit。ack 後訊息不可重讀（除非用 seek）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Offset commit&lt;/td>
 &lt;td>Ack&lt;/td>
 &lt;td>Kafka 可以 commit 到任意 offset（replay）；Pub/Sub ack 是 per-message、seek 可以回到 timestamp&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Retention&lt;/td>
 &lt;td>Message retention&lt;/td>
 &lt;td>Kafka retention 期內可任意 seek；Pub/Sub retention 期內可用 timestamp seek&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Consumer lag&lt;/td>
 &lt;td>Oldest unacked message age&lt;/td>
 &lt;td>觀測指標不同：Kafka 看 offset lag、Pub/Sub 看 oldest_unacked_message_age&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Partition rebalance&lt;/td>
 &lt;td>無（Pub/Sub 自動負載分散）&lt;/td>
 &lt;td>Kafka rebalance 是操作痛點，Pub/Sub 消除了這個概念&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Schema Registry&lt;/td>
 &lt;td>Pub/Sub Schema&lt;/td>
 &lt;td>Pub/Sub 原生支援 Avro/Protobuf schema validation&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Kafka Connect&lt;/td>
 &lt;td>Dataflow / BigQuery subscription&lt;/td>
 &lt;td>下游整合的對應工具不同&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="ordering-語意是最大差異">Ordering 語意是最大差異&lt;/h3>
&lt;p>Kafka 的 ordering 保證是 partition 內全域有序。同一個 partition 的訊息按寫入順序消費，consumer group 內每個 partition 只有一個 consumer。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是跨 vendor migration playbook、cross-link 到 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Apache Kafka</a>（source）跟 <a href="/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Google Cloud Pub/Sub</a>（target）。跑 6 維 diff dimension audit 後判定為 <strong>Type E paradigm shift</strong>：兩者投遞模型本質不同（partition-based log vs topic-subscription pub/sub）。</p></blockquote>
<h2 id="為什麼從-kafka-遷到-pubsub">為什麼從 Kafka 遷到 Pub/Sub</h2>
<p>這個遷移的 driver 通常是平台策略：</p>
<ul>
<li><strong>All-in GCP</strong>：組織決定收斂到 GCP 生態，Kafka 是唯一非 GCP 的 stateful 服務，維運孤島成本高</li>
<li><strong>運維簡化</strong>：自管 Kafka cluster 的 broker、ZooKeeper/KRaft、partition rebalance、retention 管理需要專職團隊；Pub/Sub 是全託管</li>
<li><strong>GCP 整合</strong>：下游是 BigQuery、Dataflow、Cloud Run — Pub/Sub 原生串接，Kafka 要加 connector 層</li>
<li><strong>全球路由</strong>：Pub/Sub topic 是 global（不綁 region），Kafka 需要 MirrorMaker 做跨 region 同步</li>
</ul>
<p>遷移的工作量不在資料搬遷（message queue 通常不搬歷史資料），在 <strong>模型轉換</strong> — Kafka 的 partition ordering、consumer group、offset commit 跟 Pub/Sub 的 topic-subscription、ack deadline、ordering key 是不同抽象。</p>
<h2 id="6-維-diff-dimension-audit">6 維 diff dimension audit</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>評估</th>
          <th>等級</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Schema / API</td>
          <td>Kafka producer/consumer API → Pub/Sub client library，完全不同 API</td>
          <td>High</td>
      </tr>
      <tr>
          <td>Operational model</td>
          <td>自管 broker/ZK/KRaft → 全託管</td>
          <td>High（方向：簡化）</td>
      </tr>
      <tr>
          <td>Abstraction / paradigm</td>
          <td>partition-based log vs topic-subscription pub/sub</td>
          <td>High</td>
      </tr>
      <tr>
          <td>Number of components</td>
          <td>Kafka + Schema Registry + Connect → Pub/Sub + (optional) Dataflow</td>
          <td>Medium</td>
      </tr>
      <tr>
          <td>Application change</td>
          <td>Producer/Consumer 全部改寫</td>
          <td>High</td>
      </tr>
      <tr>
          <td>Data topology</td>
          <td>Partition × offset → Topic × subscription × ack</td>
          <td>High</td>
      </tr>
  </tbody>
</table>
<p>五維 High — <strong>Type E paradigm shift</strong>，是兩套模型的橋接，工程量遠超 drop-in 或翻譯。</p>
<h2 id="模型差異對照">模型差異對照</h2>
<p>遷移前必須理解兩套模型的對應關係。對應不是一對一 — 有些概念在對方沒有直接等價物。</p>
<table>
  <thead>
      <tr>
          <th>Kafka 概念</th>
          <th>Pub/Sub 對應</th>
          <th>差異重點</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Topic</td>
          <td>Topic</td>
          <td>名稱相同但語意不同：Kafka topic 有 partition，Pub/Sub topic 沒有</td>
      </tr>
      <tr>
          <td>Partition</td>
          <td>無直接對應</td>
          <td>Pub/Sub 的 ordering 用 ordering key 實現，但 ordering key 不保證全域順序</td>
      </tr>
      <tr>
          <td>Consumer group</td>
          <td>Subscription</td>
          <td>每個 subscription 獨立消費 topic 的全部訊息，類似 Kafka 的 consumer group</td>
      </tr>
      <tr>
          <td>Offset</td>
          <td>無直接對應</td>
          <td>Pub/Sub 用 ack/nack 而非 offset commit。ack 後訊息不可重讀（除非用 seek）</td>
      </tr>
      <tr>
          <td>Offset commit</td>
          <td>Ack</td>
          <td>Kafka 可以 commit 到任意 offset（replay）；Pub/Sub ack 是 per-message、seek 可以回到 timestamp</td>
      </tr>
      <tr>
          <td>Retention</td>
          <td>Message retention</td>
          <td>Kafka retention 期內可任意 seek；Pub/Sub retention 期內可用 timestamp seek</td>
      </tr>
      <tr>
          <td>Consumer lag</td>
          <td>Oldest unacked message age</td>
          <td>觀測指標不同：Kafka 看 offset lag、Pub/Sub 看 oldest_unacked_message_age</td>
      </tr>
      <tr>
          <td>Partition rebalance</td>
          <td>無（Pub/Sub 自動負載分散）</td>
          <td>Kafka rebalance 是操作痛點，Pub/Sub 消除了這個概念</td>
      </tr>
      <tr>
          <td>Schema Registry</td>
          <td>Pub/Sub Schema</td>
          <td>Pub/Sub 原生支援 Avro/Protobuf schema validation</td>
      </tr>
      <tr>
          <td>Kafka Connect</td>
          <td>Dataflow / BigQuery subscription</td>
          <td>下游整合的對應工具不同</td>
      </tr>
  </tbody>
</table>
<h3 id="ordering-語意是最大差異">Ordering 語意是最大差異</h3>
<p>Kafka 的 ordering 保證是 partition 內全域有序。同一個 partition 的訊息按寫入順序消費，consumer group 內每個 partition 只有一個 consumer。</p>
<p>Pub/Sub 預設不保證 ordering。要 ordering 需開啟 ordering key — 同一 ordering key 的訊息有序，但不同 ordering key 之間無序。ordering key 的並行度由 key 的 cardinality 決定（類似 Kafka 的 partition key）。</p>
<p>遷移時的判斷：</p>
<ul>
<li>若 Kafka 的 ordering 只依賴 partition key（常見），ordering key 直接對應</li>
<li>若依賴 partition 內的全域順序（少見但存在），需要重新設計 — Pub/Sub 沒有 partition 全域順序的概念</li>
<li>若完全不需要 ordering（fan-out 場景），Pub/Sub 預設行為更簡單</li>
</ul>
<h3 id="component-數量轉換">Component 數量轉換</h3>
<p>Kafka 生態的 Schema Registry 在 Pub/Sub 由原生 Schema 功能替代（topic-level schema validation）；Kafka Connect 的 sink connector 由 BigQuery subscription 或 Dataflow job 替代。Dataflow 不是必要 — 簡單的 push/pull consumer 不需要 Dataflow，只有 stream processing（windowed aggregation、join）才需要。</p>
<h2 id="階段一producer-遷移雙寫">階段一：Producer 遷移（雙寫）</h2>
<p>雙寫策略是 paradigm shift 遷移的標準起手。Application 同時把訊息寫入 Kafka 和 Pub/Sub，consumer 仍從 Kafka 消費。</p>
<h3 id="producer-改造">Producer 改造</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 示意：雙寫 wrapper（實際生產用各自語言的 client library）</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="k">def</span> <span class="nf">publish_order_event</span><span class="p">(</span><span class="n">event</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="c1"># 原有 Kafka producer</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="n">kafka_producer</span><span class="o">.</span><span class="n">send</span><span class="p">(</span><span class="s2">&#34;order-events&#34;</span><span class="p">,</span> <span class="n">key</span><span class="o">=</span><span class="n">event</span><span class="o">.</span><span class="n">order_id</span><span class="p">,</span> <span class="n">value</span><span class="o">=</span><span class="n">event</span><span class="o">.</span><span class="n">to_bytes</span><span class="p">())</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="c1"># 新增 Pub/Sub producer</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="n">pubsub_publisher</span><span class="o">.</span><span class="n">publish</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="s2">&#34;projects/my-project/topics/order-events&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="n">data</span><span class="o">=</span><span class="n">event</span><span class="o">.</span><span class="n">to_bytes</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="n">ordering_key</span><span class="o">=</span><span class="n">event</span><span class="o">.</span><span class="n">order_id</span>  <span class="c1"># 對應 Kafka partition key</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="p">)</span></span></span></code></pre></div><h3 id="雙寫驗證">雙寫驗證</h3>
<table>
  <thead>
      <tr>
          <th>驗證項目</th>
          <th>方法</th>
          <th>通過條件</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>訊息數量一致</td>
          <td>比對 Kafka produce count 與 Pub/Sub publish count</td>
          <td>差異 &lt; 0.01%（允許 timing 差異）</td>
      </tr>
      <tr>
          <td>Ordering 一致</td>
          <td>同一 ordering key 的訊息在兩端順序相同</td>
          <td>抽樣驗證 100 個 key</td>
      </tr>
      <tr>
          <td>Latency 影響</td>
          <td>監控 request latency 變化</td>
          <td>p99 增加 &lt; 10ms</td>
      </tr>
      <tr>
          <td>失敗隔離</td>
          <td>Pub/Sub publish 失敗不影響 Kafka publish</td>
          <td>Pub/Sub timeout 時 Kafka 正常</td>
      </tr>
  </tbody>
</table>
<p>雙寫的失敗隔離要嚴格設計。Pub/Sub publish 失敗時，application 應該 log + metric 但不 block request。Kafka 是已驗證的正式路徑，Pub/Sub 在這個階段是 shadow。</p>
<h2 id="階段二consumer-遷移逐-subscription-切換">階段二：Consumer 遷移（逐 subscription 切換）</h2>
<p>Producer 雙寫穩定後，逐一把 consumer 從 Kafka 切到 Pub/Sub subscription。</p>
<h3 id="consumer-改造重點">Consumer 改造重點</h3>
<p><strong>Ack 模型差異</strong>：Kafka consumer 是 poll + commit offset；Pub/Sub 是 pull（或 push）+ per-message ack。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># Kafka consumer pattern</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="k">for</span> <span class="n">msg</span> <span class="ow">in</span> <span class="n">kafka_consumer</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="n">process</span><span class="p">(</span><span class="n">msg</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="n">kafka_consumer</span><span class="o">.</span><span class="n">commit</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"># Pub/Sub pull subscriber pattern</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="k">def</span> <span class="nf">callback</span><span class="p">(</span><span class="n">message</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="n">process</span><span class="p">(</span><span class="n">message</span><span class="o">.</span><span class="n">data</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="n">message</span><span class="o">.</span><span class="n">ack</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">except</span> <span class="ne">Exception</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="n">message</span><span class="o">.</span><span class="n">nack</span><span class="p">()</span>  <span class="c1"># 會被重新投遞</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="n">subscriber</span><span class="o">.</span><span class="n">subscribe</span><span class="p">(</span><span class="s2">&#34;projects/my-project/subscriptions/order-processor&#34;</span><span class="p">,</span> <span class="n">callback</span><span class="o">=</span><span class="n">callback</span><span class="p">)</span></span></span></code></pre></div><p><strong>Idempotency 更重要</strong>：Pub/Sub 的 at-least-once delivery 加上 ack deadline 機制，redelivery 比 Kafka 更容易觸發（ack deadline 內沒 ack 就重投）。Consumer 的 <a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a> 設計要比 Kafka 時更嚴格。</p>
<p><strong>Flow control</strong>：Pub/Sub client library 支援 <code>max_outstanding_messages</code> 和 <code>max_outstanding_bytes</code> 做 <a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a> 控制，對應 Kafka 的 <code>max.poll.records</code>。</p>
<h3 id="切換順序">切換順序</h3>
<p>依 consumer 的重要度和複雜度排序：</p>
<ol>
<li>先切 stateless consumer（log pipeline、metrics aggregation）— 低風險</li>
<li>再切有 side effect 但 idempotent 的 consumer（search index sync、notification）</li>
<li>最後切核心 consumer（payment processing、inventory update）— 需要完整 idempotency 驗證</li>
</ol>
<p>每切一組 consumer：</p>
<ol>
<li>建立對應的 Pub/Sub subscription</li>
<li>部署新 consumer（讀 Pub/Sub）</li>
<li>驗證處理正確性（比對 Kafka consumer 和 Pub/Sub consumer 的輸出）</li>
<li>停止舊 Kafka consumer</li>
<li>觀察 7 天無異常</li>
</ol>
<h2 id="階段三停止雙寫">階段三：停止雙寫</h2>
<p>所有 consumer 切完後：</p>
<ol>
<li>停止 Kafka producer（移除雙寫邏輯）</li>
<li>觀察 Kafka topic 不再有新訊息</li>
<li>等 Kafka retention 過期</li>
<li>下線 Kafka cluster</li>
</ol>
<p>Kafka cluster 不要在 consumer 切完後立即下線。保留 retention period + 7 天作為回退保險。</p>
<h2 id="回退路徑">回退路徑</h2>
<p>Type E 遷移的回退要在每個階段都設計：</p>
<ul>
<li><strong>階段一回退</strong>：移除 Pub/Sub publish 邏輯，Kafka 路徑不受影響</li>
<li><strong>階段二回退</strong>：重啟 Kafka consumer、停止 Pub/Sub subscriber。Kafka 的 offset 要確認是否仍在 retention 內</li>
<li><strong>階段三回退</strong>：如果 Kafka 已下線，需要重新建 cluster 並從 Pub/Sub 反向雙寫回 Kafka — 成本高，所以階段三前要確認穩定</li>
</ul>
<p>回退的關鍵指標：consumer lag（Pub/Sub 的 <code>oldest_unacked_message_age</code>）持續上升、error rate 上升、或 redelivery rate 異常。</p>
<h2 id="遷移後的監控對照">遷移後的監控對照</h2>
<table>
  <thead>
      <tr>
          <th>Kafka 監控指標</th>
          <th>Pub/Sub 對應指標</th>
          <th>來源</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Consumer lag (offset)</td>
          <td><code>subscription/oldest_unacked_message_age</code></td>
          <td>Cloud Monitoring</td>
      </tr>
      <tr>
          <td>Produce rate</td>
          <td><code>topic/send_message_operation_count</code></td>
          <td>Cloud Monitoring</td>
      </tr>
      <tr>
          <td>Consume rate</td>
          <td><code>subscription/pull_message_operation_count</code></td>
          <td>Cloud Monitoring</td>
      </tr>
      <tr>
          <td>Redelivery count</td>
          <td><code>subscription/dead_letter_message_count</code> + nack rate</td>
          <td>Cloud Monitoring</td>
      </tr>
      <tr>
          <td>Broker disk usage</td>
          <td>無需關注（fully managed）</td>
          <td>N/A</td>
      </tr>
      <tr>
          <td>Rebalance events</td>
          <td>無（Pub/Sub 自動分散）</td>
          <td>N/A</td>
      </tr>
  </tbody>
</table>
<h2 id="不適合遷移的場景">不適合遷移的場景</h2>
<p>以下場景 Kafka → Pub/Sub 的 ROI 不成立：</p>
<ul>
<li><strong>需要 exactly-once semantics</strong>：Kafka 的 transactional producer + idempotent producer 提供 exactly-once；Pub/Sub 是 at-least-once，application 層做 dedup</li>
<li><strong>需要長期 replay</strong>：Kafka retention 可設數月甚至永久（tiered storage）；Pub/Sub message retention 最長 31 天（若需超過 31 天的 replay，可用 BigQuery subscription 做長期歸檔，但查詢模式不同於 Kafka 的 offset-based replay）</li>
<li><strong>大量 ordering 依賴</strong>：如果 Kafka topology 重度依賴 partition ordering 且 key cardinality 低，Pub/Sub ordering key 的並行度會比 Kafka 差</li>
<li><strong>使用 Kafka Streams / ksqlDB 做 stateful processing</strong>：stream processing 邏輯跟 Kafka 綁定（state store backed by changelog topic），遷到 Pub/Sub 要同時遷移 processing 框架（→ Dataflow / Beam），工程量額外翻倍且 API 完全不同</li>
<li><strong>多雲 / 非 GCP 環境</strong>：Pub/Sub 是 GCP-only，跨雲場景反而讓 Kafka 更合理</li>
</ul>
<h2 id="交接路由">交接路由</h2>
<ul>
<li>Source vendor overview：<a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Apache Kafka</a></li>
<li>Target vendor overview：<a href="/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Google Cloud Pub/Sub</a></li>
<li>Pub/Sub 操作細節：<a href="/blog/backend/03-message-queue/vendors/google-pubsub/push-pull-ack-flow-control/" data-link-title="Google Pub/Sub push vs pull：不是實作偏好，是下游容量的判讀" data-link-desc="Pub/Sub 的 push 與 pull subscription 常被當成實作偏好二選一，但它其實是一個容量判讀：push 把流量瞬間打到 endpoint，pull 讓 consumer 自己節流。下游有 RPS 限制就只能 pull。本文展開 subscription 模型、ack deadline、flow control 與 dead-letter topic，5 個把 push/pull 與 ack deadline 寫成下游打爆與重投的 production 踩坑">Push / Pull / Ack Flow Control</a>、<a href="/blog/backend/03-message-queue/vendors/google-pubsub/ordering-dlt-schema/" data-link-title="Pub/Sub Ordering Key、Dead-Letter Topic 與 Schema Enforcement：三道交付治理" data-link-desc="Pub/Sub overview 之下的 implementation-layer deep article — 把 ordering key 的有序代價、dead-letter topic 的 poison message 隔離、schema enforcement 的契約守門三件事寫到可操作：subscription 是 first-class、ackDeadline 與 extension、push vs pull vs streaming pull &#43; flow control、Avro / Protobuf schema、Pub/Sub Lite 與標準版差異、BigQuery / Cloud Storage subscription，含 5 個 production 故障演練（ordering 限流 / ack deadline 太短重投 / DLT max delivery attempts / push 500 retry storm / schema 擋下不相容 publish）">Ordering / DLT / Schema</a></li>
<li>Consumer idempotency：<a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 Consumer Design</a>、<a href="/blog/backend/03-message-queue/processing-recovery-semantics/" data-link-title="3.6 Processing Semantics 與 Recovery Semantics" data-link-desc="說明投遞成功、處理成功與恢復成功為何是三個不同判斷。">3.6 Processing Recovery Semantics</a></li>
<li>反向路徑（SQS → Pub/Sub）：<a href="/blog/backend/03-message-queue/vendors/aws-sqs/migrate-to-google-pubsub/" data-link-title="AWS SQS → Google Pub/Sub：queue 模型搬到 topic &#43; subscription 模型的跨雲遷移" data-link-desc="SQS 是單一 region-scoped pull queue、Pub/Sub 是 global topic &#43; first-class subscription 的 pub/sub 模型；這篇跨雲 migration playbook 走 6 維 diff dimension audit（components / data topology 軸 High）、對位 visibility timeout → ack deadline、maxReceiveCount → dead-letter topic、long polling → streaming pull、IAM policy → Service Account、SQS-to-many-consumer 要重設計成 topic fan-out；含 5 個 production 故障演練（fan-out 行為差 / ack deadline 太短重投 / ordering key vs FIFO / 跨雲網路成本 / DLT 設定差）跟 dual-publish 漸進 cutover">AWS SQS → Google Pub/Sub</a></li>
</ul>
]]></content:encoded></item><item><title>Kafka Replication、ISR 與 exactly-once：從 acks 到端到端不重不漏</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/replication-isr-exactly-once/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/replication-isr-exactly-once/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka&lt;/a> overview「Replication 與 exactly-once 升級」段的 implementation-layer deep article。Overview 已給出 partition / replication 的選型定位、本文展開 &lt;em>寫入承諾&lt;/em> 跟 &lt;em>處理語義&lt;/em> 兩條獨立軸線怎麼設、邊界在哪、成本是什麼。對應反例 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9 Queue 語義誤配&lt;/a>。&lt;/p>&lt;/blockquote>
&lt;h2 id="寫入承諾與處理語義是兩條獨立軸線">寫入承諾與處理語義是兩條獨立軸線&lt;/h2>
&lt;p>Kafka 的可靠性拆成兩個彼此正交的問題、混在一起談是多數誤配的起點。第一條軸線是 &lt;em>寫入承諾&lt;/em>：一筆訊息寫進 broker 後、在多少 replica 落地才算「成功」、broker 掛掉時這筆訊息會不會消失。這條軸線由 replication factor、ISR、&lt;code>acks&lt;/code> 與 &lt;code>min.insync.replicas&lt;/code> 共同決定、屬於 broker 端的耐久性保證。第二條軸線是 &lt;em>處理語義&lt;/em>：同一筆訊息在 producer 重送、consumer 重啟、partition rebalance 等情境下、會不會被寫進去兩次或被處理兩次。這條軸線由 producer idempotence、transaction 與 consumer 端的 commit 設計決定、屬於端到端的正確性保證。&lt;/p>
&lt;p>兩條軸線可以獨立調整：可以有「寫入承諾很強但處理語義是 at-least-once」的配置（acks=all + 非冪等 consumer）、也可以有「寫入承諾較弱但已開冪等」的配置。把 exactly-once 當成單一開關去找、是因為沒看出這兩條軸線存在。本文先講第一條（replication / ISR / acks）、再講第二條（idempotence / transaction）、最後談兩者疊起來能達成什麼、達不成什麼。&lt;/p>
&lt;p>這個拆分對映 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/delivery-semantics/" data-link-title="Delivery Semantics" data-link-desc="說明事件投遞語意如何定義遺失、重複、順序與補償策略">delivery semantics&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency&lt;/a> 兩張知識卡：前者描述 broker 承諾的送達次數、後者描述處理端怎麼讓「送達多次」不等於「生效多次」。&lt;/p>
&lt;h2 id="isr誰算跟得上的副本">ISR：誰算「跟得上」的副本&lt;/h2>
&lt;p>ISR（in-sync replica、同步副本集）是一個 partition 當前「跟得上 leader」的 replica 集合、是 Kafka 把 replication factor 這個 &lt;em>靜態配置&lt;/em> 轉成 &lt;em>動態保證&lt;/em> 的關鍵概念。Replication factor = 3 只說明這個 partition 有 3 份 replica；但任一時刻真正跟得上 leader 的可能只有 2 份或 1 份。ISR 就是這個「當前實際同步」的集合、寫入承諾的判斷都基於 ISR、不是基於 replication factor。&lt;/p>
&lt;p>一個 follower 留在 ISR 內的條件是：它在 &lt;code>replica.lag.time.max.ms&lt;/code>（預設 30 秒）內持續向 leader 拉取資料、且追上 leader 的 log end offset。當 follower 因為 broker 慢、網路抖動、GC 停頓或 disk 壓力而落後超過這個時間窗、leader 會把它移出 ISR — 這就是 ISR shrink（收縮）。當它恢復、重新追上、再被加回 ISR — 這是 ISR expand（擴張）。&lt;/p>
&lt;p>ISR 收縮本身不是故障、是 Kafka 對「這個 follower 暫時不可信」的誠實表態。真正的風險在於：ISR 收縮到某個程度後、&lt;code>acks=all&lt;/code> 的寫入承諾會無法滿足 &lt;code>min.insync.replicas&lt;/code> 而開始拒絕寫入。下一段的 acks 取捨直接建立在 ISR 這個概念上。&lt;/p>
&lt;p>實機看 ISR 的方式是 &lt;code>kafka-topics.sh --describe&lt;/code>、Isr 欄位列出當前同步的 broker id：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># RF=3、min.insync.replicas=2 的 topic、三 broker 都同步時&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">kafka-topics.sh --describe --topic repl-demo --bootstrap-server kafka1:9092
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="c1"># Topic: repl-demo PartitionCount: 1 ReplicationFactor: 3 Configs: min.insync.replicas=2&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1"># Topic: repl-demo Partition: 0 Leader: 2 Replicas: 2,3,1 Isr: 2,3,1&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Replicas 欄位是 &lt;em>配置上&lt;/em> 的 3 份副本、Isr 欄位是 &lt;em>當前實際同步&lt;/em> 的集合。兩者一致代表健康；Isr 比 Replicas 短代表有副本落後。日常巡檢用 &lt;code>kafka-topics.sh --describe --under-replicated-partitions&lt;/code> 直接列出 Isr 短於 Replicas 的 partition。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka</a> overview「Replication 與 exactly-once 升級」段的 implementation-layer deep article。Overview 已給出 partition / replication 的選型定位、本文展開 <em>寫入承諾</em> 跟 <em>處理語義</em> 兩條獨立軸線怎麼設、邊界在哪、成本是什麼。對應反例 <a href="/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9 Queue 語義誤配</a>。</p></blockquote>
<h2 id="寫入承諾與處理語義是兩條獨立軸線">寫入承諾與處理語義是兩條獨立軸線</h2>
<p>Kafka 的可靠性拆成兩個彼此正交的問題、混在一起談是多數誤配的起點。第一條軸線是 <em>寫入承諾</em>：一筆訊息寫進 broker 後、在多少 replica 落地才算「成功」、broker 掛掉時這筆訊息會不會消失。這條軸線由 replication factor、ISR、<code>acks</code> 與 <code>min.insync.replicas</code> 共同決定、屬於 broker 端的耐久性保證。第二條軸線是 <em>處理語義</em>：同一筆訊息在 producer 重送、consumer 重啟、partition rebalance 等情境下、會不會被寫進去兩次或被處理兩次。這條軸線由 producer idempotence、transaction 與 consumer 端的 commit 設計決定、屬於端到端的正確性保證。</p>
<p>兩條軸線可以獨立調整：可以有「寫入承諾很強但處理語義是 at-least-once」的配置（acks=all + 非冪等 consumer）、也可以有「寫入承諾較弱但已開冪等」的配置。把 exactly-once 當成單一開關去找、是因為沒看出這兩條軸線存在。本文先講第一條（replication / ISR / acks）、再講第二條（idempotence / transaction）、最後談兩者疊起來能達成什麼、達不成什麼。</p>
<p>這個拆分對映 <a href="/blog/backend/knowledge-cards/delivery-semantics/" data-link-title="Delivery Semantics" data-link-desc="說明事件投遞語意如何定義遺失、重複、順序與補償策略">delivery semantics</a> 與 <a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a> 兩張知識卡：前者描述 broker 承諾的送達次數、後者描述處理端怎麼讓「送達多次」不等於「生效多次」。</p>
<h2 id="isr誰算跟得上的副本">ISR：誰算「跟得上」的副本</h2>
<p>ISR（in-sync replica、同步副本集）是一個 partition 當前「跟得上 leader」的 replica 集合、是 Kafka 把 replication factor 這個 <em>靜態配置</em> 轉成 <em>動態保證</em> 的關鍵概念。Replication factor = 3 只說明這個 partition 有 3 份 replica；但任一時刻真正跟得上 leader 的可能只有 2 份或 1 份。ISR 就是這個「當前實際同步」的集合、寫入承諾的判斷都基於 ISR、不是基於 replication factor。</p>
<p>一個 follower 留在 ISR 內的條件是：它在 <code>replica.lag.time.max.ms</code>（預設 30 秒）內持續向 leader 拉取資料、且追上 leader 的 log end offset。當 follower 因為 broker 慢、網路抖動、GC 停頓或 disk 壓力而落後超過這個時間窗、leader 會把它移出 ISR — 這就是 ISR shrink（收縮）。當它恢復、重新追上、再被加回 ISR — 這是 ISR expand（擴張）。</p>
<p>ISR 收縮本身不是故障、是 Kafka 對「這個 follower 暫時不可信」的誠實表態。真正的風險在於：ISR 收縮到某個程度後、<code>acks=all</code> 的寫入承諾會無法滿足 <code>min.insync.replicas</code> 而開始拒絕寫入。下一段的 acks 取捨直接建立在 ISR 這個概念上。</p>
<p>實機看 ISR 的方式是 <code>kafka-topics.sh --describe</code>、Isr 欄位列出當前同步的 broker id：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># RF=3、min.insync.replicas=2 的 topic、三 broker 都同步時</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">kafka-topics.sh --describe --topic repl-demo --bootstrap-server kafka1:9092
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"># Topic: repl-demo  PartitionCount: 1  ReplicationFactor: 3  Configs: min.insync.replicas=2</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">#   Topic: repl-demo  Partition: 0  Leader: 2  Replicas: 2,3,1  Isr: 2,3,1</span></span></span></code></pre></div><p>Replicas 欄位是 <em>配置上</em> 的 3 份副本、Isr 欄位是 <em>當前實際同步</em> 的集合。兩者一致代表健康；Isr 比 Replicas 短代表有副本落後。日常巡檢用 <code>kafka-topics.sh --describe --under-replicated-partitions</code> 直接列出 Isr 短於 Replicas 的 partition。</p>
<h2 id="acks-與-mininsyncreplicas寫入承諾的兩個旋鈕">acks 與 min.insync.replicas：寫入承諾的兩個旋鈕</h2>
<p>寫入承諾由 producer 端的 <code>acks</code> 跟 broker / topic 端的 <code>min.insync.replicas</code> 共同決定、兩者必須一起設才有意義。<code>acks</code> 決定 producer 在收到「成功」回應前、要等多少 replica 確認；<code>min.insync.replicas</code> 決定 broker 在 ISR 不足時是否拒絕寫入。前者是 producer 的等待策略、後者是 broker 的拒絕底線。</p>
<p><code>acks</code> 三個值對應遞增的耐久性與遞增的延遲成本：</p>
<table>
  <thead>
      <tr>
          <th>acks 值</th>
          <th>承諾</th>
          <th>資料風險</th>
          <th>延遲</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>0</td>
          <td>不等任何確認、送出即視為成功</td>
          <td>leader 沒收到也不知道、broker 掛掉直接丟</td>
          <td>最低</td>
      </tr>
      <tr>
          <td>1</td>
          <td>leader 寫入本地 log 即回成功</td>
          <td>leader 確認後、follower 同步前掛掉、這筆訊息遺失</td>
          <td>中</td>
      </tr>
      <tr>
          <td>all</td>
          <td>ISR 內所有 replica 都確認才回成功</td>
          <td>ISR 內任一存活即不丟；ISR 不足 min.insync 時拒絕寫入</td>
          <td>最高</td>
      </tr>
  </tbody>
</table>
<p><code>acks=0</code> 適用「丟一兩筆無所謂」的場景、例如高頻 metric 上報、log shipping 的非關鍵層。它把網路往返成本壓到最低、代價是 producer 完全不知道 broker 有沒有收到。任何牽涉金流、訂單、狀態變更的訊息都不該用 acks=0。</p>
<p><code>acks=1</code> 是一個容易被誤以為安全的中間值。它只等 leader 寫入本地、不等 follower 同步。多數時候運作正常、但存在一個明確的資料遺失窗口：leader 回了成功、follower 還沒拉到這筆訊息、此時 leader 所在 broker 崩潰、新 leader 從 follower 中選出 — 那筆「已回成功」的訊息在新 leader 上不存在、producer 卻以為寫成功了。這個窗口在正常運行時很窄、但在 broker 滾動重啟、硬體故障、AZ 中斷時會被放大。</p>
<p><code>acks=all</code> 是耐久性配置的正解、但只有搭配 <code>min.insync.replicas ≥ 2</code> 才完整。單獨設 acks=all、若 <code>min.insync.replicas=1</code>、那麼當 ISR 收縮到只剩 leader 一份時、acks=all 等同 acks=1 — 「所有 ISR 確認」這個條件在 ISR 只剩 1 份時形同虛設。<code>min.insync.replicas=2</code> 補上這個漏洞：它要求 ISR 至少有 2 份才接受 acks=all 寫入、否則直接拒絕、把「靜默遺失」轉成「明確拒絕」。</p>
<p><code>min.insync.replicas</code> 是 topic-level 可動態調整的配置、不需重啟 broker：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 動態調整單一 topic 的 min.insync.replicas</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">kafka-configs.sh --alter --topic repl-demo <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --add-config min.insync.replicas<span class="o">=</span><span class="m">2</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --bootstrap-server kafka1:9092
</span></span><span class="line"><span class="ln">5</span><span class="cl">
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"># 查當前值、synonyms 會顯示 topic override 蓋過 broker default</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">kafka-configs.sh --describe --topic repl-demo --bootstrap-server kafka1:9092
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1"># min.insync.replicas=2 synonyms={DYNAMIC_TOPIC_CONFIG:min.insync.replicas=2,</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="c1">#   DYNAMIC_DEFAULT_BROKER_CONFIG:min.insync.replicas=1, DEFAULT_CONFIG:min.insync.replicas=1}</span></span></span></code></pre></div><p>RF=3 + acks=all + min.insync.replicas=2 是業界對「不能丟資料」topic 的標準三件組：3 份副本提供冗餘、acks=all 要求同步確認、min.insync=2 在容忍一台 broker 掛掉的同時仍保證每筆寫入落在至少兩份 replica。容忍度的算術是 <code>RF - min.insync.replicas</code>：3 - 2 = 1、代表可以掉一台 broker 仍正常寫入、掉兩台則寫入被拒（但已寫入的資料不丟）。</p>
<h2 id="producer-idempotence去掉重送造成的重複">Producer idempotence：去掉重送造成的重複</h2>
<p>Producer idempotence（冪等生產者、<code>enable.idempotence=true</code>）解決的是 <em>producer 重送</em> 造成的 broker 端重複。它讓「producer 因為沒收到 ack 而重送同一筆訊息」這件事、在 broker 端被去重、不會寫進兩筆。這是處理語義軸線的第一塊、獨立於前面的寫入承諾。</p>
<p>問題的根源是：producer 送出訊息後、若因網路超時沒收到 broker 的 ack、它無法分辨是「訊息沒送到」還是「訊息送到了但 ack 在回程丟了」。預設行為是重送。在沒有冪等保護時、若實際是後者、broker 就收到兩筆相同訊息、partition 裡出現重複。</p>
<p>冪等機制的做法是給每個 producer 分配一個 producer ID（PID）、並為每個 partition 維護一個遞增的 sequence number。Broker 記住每個 (PID, partition) 已接受的最大 sequence；重送的訊息帶相同 sequence、broker 認出是重複、直接丟棄並回成功。這個保證的範圍是 <em>單一 producer session 內、單一 partition</em> 的精確一次寫入。</p>
<p>開啟方式是 producer 端設 <code>enable.idempotence=true</code>。在較新版 Kafka 這已是預設值、且它會隱含要求 <code>acks=all</code>、<code>retries &gt; 0</code>、<code>max.in.flight.requests.per.connection ≤ 5</code> — 因為冪等去重依賴這些前提。冪等的成本極低（broker 多維護 PID/sequence 的少量 metadata）、幾乎沒有理由關閉。</p>
<p>需要明確的邊界是：冪等只覆蓋 <em>同一個 producer session</em>。Producer 重啟後拿到新的 PID、broker 無法把新舊 session 的訊息關聯起來。跨 session 的去重、以及「寫多個 partition 要嘛全成功要嘛全失敗」的需求、要靠下一段的 transaction。</p>
<h2 id="kafka-transaction-與-read_committed跨-partition-的原子寫入">Kafka transaction 與 read_committed：跨 partition 的原子寫入</h2>
<p>Kafka transaction（交易）解決的是 <em>跨多個 partition 的原子寫入</em> 與 <em>consume-process-produce 的原子提交</em>。它讓一組寫入（可能跨多個 topic / partition）以及對應的 consumer offset commit、要嘛全部對下游可見、要嘛全部不可見。這是處理語義軸線的第二塊、建立在冪等之上。</p>
<p>典型場景是 stream processing 的 consume-process-produce 迴圈：consumer 讀入一批訊息、處理後產出結果寫到另一個 topic、然後 commit 讀取進度。若這三步不是原子的、崩潰時可能出現「結果已產出但 offset 沒 commit」（重啟後重複處理、重複產出）或「offset 已 commit 但結果沒寫成功」（訊息遺失）。Transaction 把「產出結果」跟「commit offset」綁成一個原子操作、消除這個窗口。</p>
<p>啟用 transaction 需要 producer 設一個穩定的 <code>transactional.id</code>、並在程式碼中走完整的 transaction 生命週期：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">producer.initTransactions()      // 向 transaction coordinator 註冊、fence 掉舊 session
</span></span><span class="line"><span class="ln">2</span><span class="cl">producer.beginTransaction()
</span></span><span class="line"><span class="ln">3</span><span class="cl">  producer.send(record1)          // 跨多個 topic/partition 的寫入
</span></span><span class="line"><span class="ln">4</span><span class="cl">  producer.send(record2)
</span></span><span class="line"><span class="ln">5</span><span class="cl">  producer.sendOffsetsToTransaction(offsets, groupMetadata)  // consumer 進度也納入交易
</span></span><span class="line"><span class="ln">6</span><span class="cl">producer.commitTransaction()      // 全部原子提交；失敗則 abortTransaction()</span></span></code></pre></div><p><code>transactional.id</code> 提供跨 session 的 fencing（隔離）：同一個 transactional.id 的新 producer 啟動時、coordinator 會 fence 掉舊的、避免「殭屍 producer」在崩潰後復活還繼續寫。這是冪等的 PID 機制做不到的跨 session 保證。</p>
<blockquote>
<p><strong>實機限制</strong>：<code>kafka-console-producer.sh</code> 帶 <code>--producer-property transactional.id=...</code> 不會自動呼叫 <code>initTransactions()</code>、會直接報 <code>IllegalStateException: Cannot add partition ... before completing a call to initTransactions</code>。完整 transaction 生命週期只能在 client code 中驗證、無法用 console 工具演示。本文的 transaction 行為描述依官方 producer API 語義、生命週期程式碼未經本地 client 實機跑通。</p></blockquote>
<p>Transaction 的另一半在 consumer 端：<code>isolation.level=read_committed</code>。預設的 <code>read_uncommitted</code> 會讀到尚未 commit、甚至最終被 abort 的 transactional 訊息。設成 <code>read_committed</code> 後、consumer 只會看到已 commit 的 transactional 訊息、abort 的訊息對它不可見、未 commit 的訊息會被擋在 last stable offset（LSO）之前等待。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># consumer 以 read_committed 隔離級別讀取、只看已 commit 的 transactional 訊息</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">kafka-console-consumer.sh --topic repl-demo --from-beginning <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --isolation-level read_committed <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --bootstrap-server kafka1:9092</span></span></code></pre></div><p>需要注意：對非 transactional 的普通訊息、read_committed 跟 read_uncommitted 行為相同 — 普通訊息一律可見。隔離級別只對 transactional 訊息產生差異。這也是為什麼若上游沒有任何 transactional producer、把 consumer 改成 read_committed 不會有任何可觀察的效果。</p>
<h2 id="端到端-exactly-once-的邊界與成本">端到端 exactly-once 的邊界與成本</h2>
<p>端到端 exactly-once 的意思是：訊息從 producer 到 consumer 處理結果、整條路徑上「不重不漏」。它由前面所有零件疊出來、但有明確的適用邊界、不是萬用保證。</p>
<p>Kafka 原生能提供 exactly-once 的範圍是 <em>Kafka-to-Kafka 的封閉迴圈</em>：consume from Kafka、process、produce to Kafka、commit offset、整個用 transaction 綁定。Kafka Streams 框架把這套封裝成 <code>processing.guarantee=exactly_once_v2</code> 一個配置、底層就是 transaction + 冪等 + read_committed 的組合。在這個封閉迴圈內、exactly-once 是真實成立的。</p>
<p>邊界出現在 <em>離開 Kafka 的那一刻</em>。當處理結果要寫進外部系統（資料庫、HTTP API、第三方服務、寄信、扣款）、Kafka 的 transaction 管不到外部系統的提交。一筆訊息「已扣款但 offset commit 前崩潰」這種跨系統不一致、Kafka transaction 無法消除 — 它只保證 Kafka 內部的原子性。跨系統的 exactly-once 要靠外部系統自己的冪等鍵（idempotency key）、或 outbox pattern、或兩階段提交、由應用層補上、不是 Kafka 送的。</p>
<p>成本方面、exactly-once 不是免費的耐久性升級：</p>
<table>
  <thead>
      <tr>
          <th>成本維度</th>
          <th>影響</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>吞吐</td>
          <td>transaction 的 begin/commit 與 coordinator 往返增加 per-batch overhead、吞吐下降</td>
      </tr>
      <tr>
          <td>延遲</td>
          <td>read_committed 要等 LSO 推進、consumer 端引入額外延遲</td>
      </tr>
      <tr>
          <td>複雜度</td>
          <td>producer 要管 transaction 生命週期、abort 路徑、fencing；錯誤處理比 fire-forget 重</td>
      </tr>
      <tr>
          <td>coordinator 壓力</td>
          <td>transaction coordinator 與 <code>__transaction_state</code> topic 成為新的關鍵路徑與容量點</td>
      </tr>
  </tbody>
</table>
<p>務實的判斷是：先確認需求真的是 exactly-once、還是「at-least-once + 下游冪等」就夠。多數業務（包括金流）用 at-least-once 送達 + 下游用業務冪等鍵去重、就達到了「效果上不重複」、且吞吐與複雜度成本遠低於完整 transaction exactly-once。完整的 Kafka transaction exactly-once 留給 Kafka-to-Kafka 的 stream processing pipeline、那是它的甜蜜點。這個取捨對映 <a href="/blog/backend/03-message-queue/processing-recovery-semantics/" data-link-title="3.6 Processing Semantics 與 Recovery Semantics" data-link-desc="說明投遞成功、處理成功與恢復成功為何是三個不同判斷。">3.6 processing-recovery-semantics</a> 對「在哪一層放冪等」的判讀。</p>
<h2 id="故障演練">故障演練</h2>
<p>可靠性配置的價值在故障時才顯現。以下演練在 3-broker KRaft 叢集（RF=3、min.insync.replicas=2）上跑、用停 broker 製造 ISR 收縮、觀察各配置的真實行為。</p>
<h3 id="isr-收縮到低於-mininsyncreplicas-時-acksall-被拒">ISR 收縮到低於 min.insync.replicas 時 acks=all 被拒</h3>
<p><strong>演練</strong>：起 3-broker 叢集、建 RF=3 / min.insync.replicas=2 的 topic、初始 ISR = 三台全在。依序停掉兩個 follower broker、觀察 ISR 收縮、再用 acks=all produce。</p>
<p><strong>初始狀態</strong>（ISR 三份全在、acks=all 正常）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">Topic: repl-demo  Partition: 0  Leader: 2  Replicas: 2,3,1  Isr: 2,3,1
</span></span><span class="line"><span class="ln">2</span><span class="cl"># acks=all produce → exit=0</span></span></code></pre></div><p><strong>停一個 follower（broker 3）</strong>、ISR 收縮到 2 份、仍滿足 min.insync=2：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">Topic: repl-demo  Partition: 0  Leader: 2  Replicas: 2,3,1  Isr: 2,1
</span></span><span class="line"><span class="ln">2</span><span class="cl"># acks=all produce → exit=0（ISR=2 仍 &gt;= min.insync=2、寫入接受）</span></span></code></pre></div><p><strong>再停一個 follower（broker 1）</strong>、ISR 收縮到只剩 leader 1 份、低於 min.insync=2：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl"># acks=all produce → broker 拒絕：
</span></span><span class="line"><span class="ln">2</span><span class="cl">[Producer] Got error produce response ... Error: NOT_ENOUGH_REPLICAS, retrying
</span></span><span class="line"><span class="ln">3</span><span class="cl">org.apache.kafka.common.errors.NotEnoughReplicasException:
</span></span><span class="line"><span class="ln">4</span><span class="cl">  Messages are rejected since there are fewer in-sync replicas than required.</span></span></code></pre></div><p><strong>判讀</strong>：這正是 min.insync.replicas 的設計意圖在運作。ISR 不足時、broker 選擇 <em>明確拒絕寫入</em>（NOT_ENOUGH_REPLICAS）、而不是降級成 acks=1 默默接受。對 producer 而言、寫入失敗會觸發 retry、retry 耗盡後拋例外、上游應用感知到「現在寫不進去」、可以 fail-fast 或 backpressure — 而不是寫了一筆只在單一 broker 上、隨時可能隨那台 broker 一起消失的「假成功」訊息。把資料遺失轉成可觀測的寫入拒絕、是這個配置的全部目的。</p>
<p><strong>恢復</strong>：重啟兩個 broker、ISR 自動 expand 回三份、acks=all 恢復接受寫入：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">Topic: repl-demo  Partition: 0  Leader: 2  Replicas: 2,3,1  Isr: 1,2,3</span></span></code></pre></div><blockquote>
<p>附帶觀察：在 KRaft 模式下、controller 也是 quorum（本演練三台都兼任 controller）。同時停掉兩台、controller quorum 失去多數、<code>kafka-topics.sh --describe</code> 對 metadata 的查詢會 timeout（DisconnectException）。production 叢集應把 controller 數量與 broker 故障域分開規劃、避免 broker 故障連帶打垮 metadata 平面。</p></blockquote>
<h3 id="unclean-leader-election-的取捨">Unclean leader election 的取捨</h3>
<p>當一個 partition 的所有 ISR replica 都不可用、只剩一個 <em>曾經落後、已被踢出 ISR</em> 的 replica 還活著、Kafka 面臨一個無法兩全的選擇。<code>unclean.leader.election.enable=false</code>（預設）會選擇 <em>不選 leader</em>：這個 partition 進入不可用狀態、拒絕讀寫、直到某個 ISR replica 恢復。<code>unclean.leader.election.enable=true</code> 會選擇 <em>把那個落後的 replica 提為 leader</em>：partition 立刻恢復可用、代價是那個 replica 上缺失的訊息（leader 掛掉前已 commit 但它還沒同步到的部分）永久遺失。</p>
<p><strong>判讀</strong>：這是一個 <em>可用性 vs 耐久性</em> 的直接取捨、沒有正確答案、只有對映業務的選擇。對金流、訂單、審計這類「丟一筆都不行」的 topic、保持 false、寧可 partition 短暫不可用也不接受靜默資料遺失。對 metric、log、可重算的衍生資料、開 true 換可用性、丟幾筆可接受。預設 false 是合理的安全預設、但要意識到它的代價是「所有 replica 都不在 ISR 時、partition 會卡住不可用」、這在多 broker 同時故障時會發生。</p>
<h3 id="idempotent-producer-對重送去重">Idempotent producer 對重送去重</h3>
<p><strong>演練</strong>：producer 開 <code>enable.idempotence=true</code>、acks=all、模擬 ack 丟失導致的重送。</p>
<p><strong>判讀</strong>：冪等開啟後、producer 因網路超時重送的訊息帶相同 (PID, partition, sequence)、broker 認出 sequence 重複、丟棄重送並回成功、partition 內不出現重複。實機上 <code>enable.idempotence=true</code> 的 produce 寫入正常（exit=0）、消費端讀回的訊息數等於實際送出的邏輯訊息數、重送不放大。要記住的邊界仍是：這只覆蓋單一 producer session；producer 重啟換 PID 後、跨 session 的重複要靠 transaction 或下游冪等鍵處理。</p>
<h3 id="transaction-中途失敗的-read_committed-隔離">Transaction 中途失敗的 read_committed 隔離</h3>
<p><strong>演練</strong>：transactional producer 在 beginTransaction 後寫入若干訊息、然後 abortTransaction（模擬處理中途失敗）；consumer 分別用 read_uncommitted 與 read_committed 讀取。</p>
<p><strong>判讀</strong>：read_committed 的 consumer 看不到被 abort 的訊息 — 中途失敗的 transaction 對它等於沒發生過、不會讀到「處理一半的髒資料」。read_uncommitted 的 consumer 則會讀到這些最終被 abort 的訊息、若據此處理就產生了不該發生的副作用。這是 transaction 隔離的核心價值：把「transaction 失敗」的可見性控制在 commit 邊界內。</p>
<blockquote>
<p>本段的 abort 行為依官方 transaction 語義描述。本地以 <code>kafka-console-consumer.sh --isolation-level read_committed</code> 驗證了隔離級別參數可用、且對已 commit 的普通訊息 read_committed 與 read_uncommitted 輸出一致（普通訊息一律可見、隔離級別只對 transactional 訊息產生差異）；完整的 begin/abort transaction 生命週期需 client code、未用 console 工具跑通。</p></blockquote>
<h2 id="capacity--cost">Capacity / cost</h2>
<p>各配置的容量與成本影響、決定它適用的規模與 topic 類別：</p>
<table>
  <thead>
      <tr>
          <th>配置</th>
          <th>吞吐 / 延遲影響</th>
          <th>適用</th>
          <th>警戒</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>acks=0</td>
          <td>最低延遲、最高吞吐</td>
          <td>可丟的 metric / log shipping</td>
          <td>任何狀態變更類訊息不可用</td>
      </tr>
      <tr>
          <td>acks=1</td>
          <td>中等、單次往返</td>
          <td>容忍極少量遺失的衍生資料</td>
          <td>誤當安全選項、broker 故障窗口會遺失</td>
      </tr>
      <tr>
          <td>acks=all + min.insync=2 + RF=3</td>
          <td>延遲 +1 次跨 broker 往返、吞吐略降</td>
          <td>不能丟的業務訊息</td>
          <td>min.insync 沒設則 acks=all 在 ISR=1 時失效</td>
      </tr>
      <tr>
          <td>enable.idempotence=true</td>
          <td>幾乎無額外成本</td>
          <td>所有 producer 預設開</td>
          <td>只覆蓋單一 session</td>
      </tr>
      <tr>
          <td>transaction + read_committed</td>
          <td>begin/commit overhead、read 端 LSO 等待延遲</td>
          <td>Kafka-to-Kafka stream processing 封閉迴圈</td>
          <td>跨外部系統不成立、coordinator 成新關鍵路徑</td>
      </tr>
  </tbody>
</table>
<p>務實 default：</p>
<ul>
<li>業務 topic 一律 RF=3 + acks=all + min.insync.replicas=2、idempotence 預設開</li>
<li>容忍度算術 <code>RF - min.insync.replicas</code> 要 ≥ 1、否則單台 broker 維護就會中斷寫入</li>
<li>完整 transaction exactly-once 只給 Kafka-to-Kafka pipeline；跨系統用 at-least-once + 下游冪等鍵</li>
<li>unclean.leader.election 保持 false、除非該 topic 明確可丟資料換可用性</li>
</ul>
<h2 id="整合--下一步">整合 / 下一步</h2>
<h3 id="跟-processing-recovery-semantics-對位">跟 processing-recovery-semantics 對位</h3>
<p>寫入承諾保證訊息留在 broker、但 <em>處理</em> 的不重不漏在 consumer 端。<a href="/blog/backend/03-message-queue/processing-recovery-semantics/" data-link-title="3.6 Processing Semantics 與 Recovery Semantics" data-link-desc="說明投遞成功、處理成功與恢復成功為何是三個不同判斷。">3.6 processing-recovery-semantics</a> 展開 consumer 的 commit 時機、崩潰恢復的 replay 範圍、以及「冪等放在哪一層」的判讀 — 跟本文的 transaction exactly-once 邊界互補：本文界定 Kafka 能送什麼、那篇界定處理端怎麼接才不放大重複。</p>
<h3 id="跟-event-contract-replay-boundary-對位">跟 event-contract-replay-boundary 對位</h3>
<p>Exactly-once 的封閉迴圈假設訊息格式穩定、replay 可重現。<a href="/blog/backend/03-message-queue/event-contract-replay-boundary/" data-link-title="3.7 Event Contract 與 Replay Boundary" data-link-desc="說明 event schema、idempotency key、replay window 與補償如何先於 broker 選型。">3.7 event-contract-replay-boundary</a> 展開 schema 演進與 replay 邊界 — 當 transaction 提供的原子性遇上 schema 變更、replay 舊訊息的可重現性會受 contract 影響、是 exactly-once 在時間維度上的延伸限制。</p>
<h3 id="對應反例-3c9">對應反例 3.C9</h3>
<p><a href="/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9 Queue 語義誤配</a> 是本文兩條軸線混淆的真實後果：broker 遷移後「名稱上相近的 delivery semantics」在失敗重播時產生不同結果、出現重複扣款與狀態漏更新。判讀路徑正是本文的拆分 — 先確認是寫入承諾（acks / ISR）還是處理語義（idempotence / commit 時機）出問題、不要用 queue depth 這種寫入承諾層的指標去判斷處理語義層的故障。</p>
<h3 id="對應案例-3c21-goldman-sachs-msk-遷移">對應案例 3.C21 Goldman Sachs MSK 遷移</h3>
<p><a href="/blog/backend/03-message-queue/cases/kafka-goldman-sachs-msk-migration/" data-link-title="3.C21 Goldman Sachs：MSK 遷移 with MirrorMaker 2" data-link-desc="Goldman Sachs Global Investment Research 從 on-prem Kafka 遷到 MSK、用 MM2 同步 topic/ACL/offset、atomic cutover 7 小時完成。">3.C21 Goldman Sachs MSK 遷移</a> 揭露遷移時可靠性配置的細節風險集中在 client 端的 timeout / flush / LB 配置、而非 broker 本身。本文的 acks=all 在 ISR 不足時拒絕寫入、若 client 端的 retry 與 timeout 沒對齊（如 flush timeout 太短）、會把「broker 正常的 backpressure」誤判成「遷移失敗」。可靠性配置與 client 容錯參數要一起驗證。</p>
<h3 id="下一步路由">下一步路由</h3>
<ul>
<li>上游概念：<a href="/blog/backend/knowledge-cards/delivery-semantics/" data-link-title="Delivery Semantics" data-link-desc="說明事件投遞語意如何定義遺失、重複、順序與補償策略">delivery semantics</a>、<a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a> 知識卡</li>
<li>同 vendor：<a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka overview</a> 的 producer / consumer 設計段</li>
<li>下游能力：<a href="/blog/backend/03-message-queue/processing-recovery-semantics/" data-link-title="3.6 Processing Semantics 與 Recovery Semantics" data-link-desc="說明投遞成功、處理成功與恢復成功為何是三個不同判斷。">3.6 processing-recovery-semantics</a>、<a href="/blog/backend/03-message-queue/event-contract-replay-boundary/" data-link-title="3.7 Event Contract 與 Replay Boundary" data-link-desc="說明 event schema、idempotency key、replay window 與補償如何先於 broker 選型。">3.7 event-contract-replay-boundary</a>、<a href="/blog/backend/06-reliability/idempotency-replay/" data-link-title="6.12 Idempotency 與 Replay 驗證" data-link-desc="把重試、重播與冪等性從口頭約定變成可驗證屬性">6.12 idempotency / replay</a></li>
<li>方法論：<a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章的寫作方法論</a></li>
</ul>
]]></content:encoded></item><item><title>Self-managed Kafka → AWS MSK：把 $15K/month operational cost 拆解到 managed</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/migrate-to-msk/</link><pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/migrate-to-msk/</guid><description>&lt;blockquote>
&lt;p>本文是跨 vendor migration playbook、cross-link &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka&lt;/a> 跟 AWS MSK。跑 &lt;a href="https://tarrragon.github.io/blog/posts/migration-playbook-%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84stage-0-variant-%E8%A6%8F%E5%8A%83%E6%8A%8A-collapse-%E7%8E%87%E5%BE%9E-60-%E9%99%8D%E5%88%B0-0/" data-link-title="Migration Playbook 方法論的演化紀錄：Stage 0 variant 規劃把 collapse 率從 60% 降到 0%" data-link-desc="跨 vendor migration playbook 需要獨立寫作方法論的依據，以及這套方法論從三輪 batch dogfood 中演化出來的驗證證據。">migration-playbook-methodology 6 維 audit&lt;/a> 後對映 &lt;em>Operational = High（self-managed → AWS managed）→ Type C operational redesign hybrid&lt;/em>。&lt;/p>&lt;/blockquote>
&lt;h2 id="15kmonth-operational-cost-拆解">$15K/month operational cost 拆解&lt;/h2>
&lt;p>跟 &lt;a href="https://tarrragon.github.io/blog/backend/04-observability/vendors/datadog/migrate-to-grafana-stack/" data-link-title="Datadog → Grafana Stack：把 $50K/month bill 拆解到 self-hosted observability" data-link-desc="Datadog 五層計費（host APM / metric / log ingest / log retention / RUM）拆解、對位 Grafana Stack（Mimir / Loki / Tempo / Grafana / Alloy）的 5 層責任；OTel-based agent migration、5 個 production 踩雷（cardinality 爆 / log volume cost / dashboard 不直接轉 / alert routing 換邏輯 / SLO definition 差異）、cost reality check">Datadog → Grafana Stack&lt;/a>（H cost variant）同 framing — 用 cost 拆解開頭、不是「為什麼遷」driver list：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Self-managed Kafka cost 項&lt;/th>
 &lt;th>中型 (3 broker + 3 ZK + monitoring) / month&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>EC2 (3× r6g.xlarge broker)&lt;/td>
 &lt;td>$660&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>EBS (3× 1TB io2)&lt;/td>
 &lt;td>$1,500&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>EC2 (3× t3.medium ZK / KRaft)&lt;/td>
 &lt;td>$90&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Monitoring (Prometheus + Grafana on EC2)&lt;/td>
 &lt;td>$200&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Backup S3 (1TB)&lt;/td>
 &lt;td>$25&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Cross-AZ traffic&lt;/td>
 &lt;td>$300&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Operational FTE (0.5)&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$5,000-8,000&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Patching window cost&lt;/td>
 &lt;td>$200 (downtime opportunity)&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total infrastructure&lt;/td>
 &lt;td>$7,975-10,975&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total with FTE&lt;/td>
 &lt;td>&lt;strong>$13,000-18,975&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>最大成本塊是 operational FTE、不是 infrastructure&lt;/strong>。MSK 把 50-80% operational 工作轉嫁 AWS、留 application + cost monitoring 給 SRE。&lt;/p>
&lt;p>跑 &lt;a href="https://tarrragon.github.io/blog/posts/migration-playbook-%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84stage-0-variant-%E8%A6%8F%E5%8A%83%E6%8A%8A-collapse-%E7%8E%87%E5%BE%9E-60-%E9%99%8D%E5%88%B0-0/" data-link-title="Migration Playbook 方法論的演化紀錄：Stage 0 variant 規劃把 collapse 率從 60% 降到 0%" data-link-desc="跨 vendor migration playbook 需要獨立寫作方法論的依據，以及這套方法論從三輪 batch dogfood 中演化出來的驗證證據。">6 維 diff dimension audit&lt;/a>：&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是跨 vendor migration playbook、cross-link <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka</a> 跟 AWS MSK。跑 <a href="/blog/posts/migration-playbook-%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84stage-0-variant-%E8%A6%8F%E5%8A%83%E6%8A%8A-collapse-%E7%8E%87%E5%BE%9E-60-%E9%99%8D%E5%88%B0-0/" data-link-title="Migration Playbook 方法論的演化紀錄：Stage 0 variant 規劃把 collapse 率從 60% 降到 0%" data-link-desc="跨 vendor migration playbook 需要獨立寫作方法論的依據，以及這套方法論從三輪 batch dogfood 中演化出來的驗證證據。">migration-playbook-methodology 6 維 audit</a> 後對映 <em>Operational = High（self-managed → AWS managed）→ Type C operational redesign hybrid</em>。</p></blockquote>
<h2 id="15kmonth-operational-cost-拆解">$15K/month operational cost 拆解</h2>
<p>跟 <a href="/blog/backend/04-observability/vendors/datadog/migrate-to-grafana-stack/" data-link-title="Datadog → Grafana Stack：把 $50K/month bill 拆解到 self-hosted observability" data-link-desc="Datadog 五層計費（host APM / metric / log ingest / log retention / RUM）拆解、對位 Grafana Stack（Mimir / Loki / Tempo / Grafana / Alloy）的 5 層責任；OTel-based agent migration、5 個 production 踩雷（cardinality 爆 / log volume cost / dashboard 不直接轉 / alert routing 換邏輯 / SLO definition 差異）、cost reality check">Datadog → Grafana Stack</a>（H cost variant）同 framing — 用 cost 拆解開頭、不是「為什麼遷」driver list：</p>
<table>
  <thead>
      <tr>
          <th>Self-managed Kafka cost 項</th>
          <th>中型 (3 broker + 3 ZK + monitoring) / month</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>EC2 (3× r6g.xlarge broker)</td>
          <td>$660</td>
      </tr>
      <tr>
          <td>EBS (3× 1TB io2)</td>
          <td>$1,500</td>
      </tr>
      <tr>
          <td>EC2 (3× t3.medium ZK / KRaft)</td>
          <td>$90</td>
      </tr>
      <tr>
          <td>Monitoring (Prometheus + Grafana on EC2)</td>
          <td>$200</td>
      </tr>
      <tr>
          <td>Backup S3 (1TB)</td>
          <td>$25</td>
      </tr>
      <tr>
          <td>Cross-AZ traffic</td>
          <td>$300</td>
      </tr>
      <tr>
          <td><strong>Operational FTE (0.5)</strong></td>
          <td><strong>$5,000-8,000</strong></td>
      </tr>
      <tr>
          <td>Patching window cost</td>
          <td>$200 (downtime opportunity)</td>
      </tr>
      <tr>
          <td>Total infrastructure</td>
          <td>$7,975-10,975</td>
      </tr>
      <tr>
          <td>Total with FTE</td>
          <td><strong>$13,000-18,975</strong></td>
      </tr>
  </tbody>
</table>
<p><strong>最大成本塊是 operational FTE、不是 infrastructure</strong>。MSK 把 50-80% operational 工作轉嫁 AWS、留 application + cost monitoring 給 SRE。</p>
<p>跑 <a href="/blog/posts/migration-playbook-%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84stage-0-variant-%E8%A6%8F%E5%8A%83%E6%8A%8A-collapse-%E7%8E%87%E5%BE%9E-60-%E9%99%8D%E5%88%B0-0/" data-link-title="Migration Playbook 方法論的演化紀錄：Stage 0 variant 規劃把 collapse 率從 60% 降到 0%" data-link-desc="跨 vendor migration playbook 需要獨立寫作方法論的依據，以及這套方法論從三輪 batch dogfood 中演化出來的驗證證據。">6 維 diff dimension audit</a>：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>評估</th>
          <th>等級</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Schema / API</td>
          <td>同 Kafka protocol、client SDK 不改</td>
          <td>Low</td>
      </tr>
      <tr>
          <td>Operational model</td>
          <td>Self-managed → AWS managed、HA / patch / backup 全託管</td>
          <td><strong>High</strong></td>
      </tr>
      <tr>
          <td>Paradigm</td>
          <td>同 Kafka log-based</td>
          <td>Low</td>
      </tr>
      <tr>
          <td>Components</td>
          <td>同 1 個 Kafka cluster</td>
          <td>Low</td>
      </tr>
      <tr>
          <td>Application change</td>
          <td>Auth config 改（IAM / SASL）、其他不變</td>
          <td>Low-Medium</td>
      </tr>
      <tr>
          <td>Data topology</td>
          <td>同 broker + partition 配置</td>
          <td>Low</td>
      </tr>
  </tbody>
</table>
<p>Operational = High（其他 Low-Medium）→ <strong>Type C operational redesign hybrid</strong>。</p>
<h2 id="為什麼遷fte--availability--consistency-三條-driver">為什麼遷：FTE / availability / consistency 三條 driver</h2>
<ul>
<li><strong>Operational FTE</strong>：Kafka self-managed + ZooKeeper / KRaft + Prometheus 端到端 ops 是 0.5-1 FTE、MSK 把 patch / HA / backup 全託管</li>
<li><strong>Availability</strong>：MSK 自動 multi-AZ broker + auto-recovery、self-managed 自管 broker 故障 RTO 30 分鐘-2 小時</li>
<li><strong>Consistency with cloud stack</strong>：已 deep on AWS（RDS / S3 / Lambda）、MSK 進 same VPC + IAM auth、降低 cross-vendor 設置成本</li>
</ul>
<p>反向 driver（MSK → self-managed）：</p>
<ul>
<li>Throughput / GB 規模大時 MSK 跨 broker cost 反轉（cost &gt; self-managed）</li>
<li>需要 Kafka 客製化（custom plugin / kraft early adopter / 非 AWS region）</li>
<li>Multi-cloud / hybrid 架構不想 vendor lock</li>
</ul>
<h2 id="operational-redesign-對位">Operational redesign 對位</h2>
<p>跟 <a href="/blog/backend/01-database/vendors/postgresql/migrate-to-aurora/" data-link-title="PostgreSQL → Aurora Migration：protocol 相容、operational 重設計" data-link-desc="Aurora 號稱 PostgreSQL-compatible 但 operational model 不同（storage decouple / cluster endpoint / instance class / 自家備份）；遷移流程是混合（protocol drop-in &#43; operational phased）、5 個 production 踩雷（extension 不支援 / replication slot 不直通 / autovacuum 行為差 / IAM 認證強制 / cost model 換算）、跟 Patroni / read replica / DR 對位">PostgreSQL → Aurora</a> / <a href="/blog/backend/01-database/vendors/mongodb/migrate-to-atlas/" data-link-title="MongoDB → Atlas：Atlas 不是 MongoDB &#43; managed、是另一個 product" data-link-desc="Atlas 號稱「MongoDB managed」但 operational model 完全不同（auto-scaling / VPC peering / IAM-driven access / 內建 backup / billing 模型）；本文採用 Type C operational redesign hybrid 結構、4-phase operational migration &#43; drop-in cutover、5 個 production 踩雷（連線數限制 / IP whitelist / backup retention / IAM token 過期 / billing 暴漲）">MongoDB → Atlas</a> 同 Type C pattern：</p>
<table>
  <thead>
      <tr>
          <th>Operational concept</th>
          <th>Self-managed Kafka</th>
          <th>MSK</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Cluster bootstrap</td>
          <td>手動配置 broker + ZK + brokers.properties</td>
          <td>UI / Terraform 一鍵建</td>
      </tr>
      <tr>
          <td>HA</td>
          <td>自管 replica + ISR + broker placement</td>
          <td>自動 multi-AZ + auto-recovery</td>
      </tr>
      <tr>
          <td>Patching</td>
          <td>Rolling restart 手動 / 工具</td>
          <td>MSK 自動 monthly maintenance window</td>
      </tr>
      <tr>
          <td>Backup</td>
          <td>自管 MirrorMaker / cluster snapshot</td>
          <td>MSK 內建 backup（S3、自動）</td>
      </tr>
      <tr>
          <td>Authentication</td>
          <td>SASL/SCRAM / mTLS 自管</td>
          <td>IAM auth（推薦）/ SASL/SCRAM via Secrets Manager</td>
      </tr>
      <tr>
          <td>Monitoring</td>
          <td>Prometheus + JMX exporter 自建</td>
          <td>CloudWatch + open monitoring + Prometheus</td>
      </tr>
      <tr>
          <td>Sizing</td>
          <td>手動 broker instance class</td>
          <td>MSK broker size（kafka.m5.large+）</td>
      </tr>
      <tr>
          <td>Configuration</td>
          <td>server.properties 全控</td>
          <td>Configuration set（限制可調 parameter）</td>
      </tr>
      <tr>
          <td>Cluster topology</td>
          <td>自管 placement / rack awareness</td>
          <td>MSK 自動 multi-AZ + rack-aware</td>
      </tr>
      <tr>
          <td>Tiered storage</td>
          <td>Kafka 3.6+ 自管</td>
          <td>MSK Tiered Storage（auto-tier 到 S3）</td>
      </tr>
  </tbody>
</table>
<p>每行 operational concept 都需要 migration plan、application code 不變但 <em>運維知識體系全換</em>。</p>
<h2 id="4-phase-migrationtype-c-標準流程">4-phase migration（Type C 標準流程）</h2>
<h3 id="phase-0pre-migration-audit">Phase 0：Pre-migration audit</h3>
<ul>
<li><strong>Workload sizing → MSK broker class</strong>：當前 throughput / partition count / topic count</li>
<li><strong>Application connection pattern audit</strong>：客戶端 producer / consumer 用 SASL / mTLS / plaintext？哪個 application</li>
<li><strong>Topic config audit</strong>：retention / replication factor / cleanup policy</li>
<li><strong>Backup pattern audit</strong>：有 MirrorMaker / cross-cluster mirror 嗎</li>
</ul>
<h3 id="phase-1msk-cluster-建置2-3-週">Phase 1：MSK cluster 建置（2-3 週）</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_msk_cluster&#34; &#34;main&#34;</span> {
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">  cluster_name</span>           <span class="o">=</span> <span class="s2">&#34;production&#34;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">  kafka_version</span>          <span class="o">=</span> <span class="s2">&#34;3.6.0&#34;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">  number_of_broker_nodes</span> <span class="o">=</span> <span class="m">3</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="k">broker_node_group_info</span> {
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="n">    instance_type</span>   <span class="o">=</span> <span class="s2">&#34;kafka.m5.large&#34;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="n">    client_subnets</span>  <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">private_subnets</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="n">    security_groups</span> <span class="o">=</span> <span class="p">[</span><span class="k">aws_security_group</span><span class="p">.</span><span class="k">msk</span><span class="p">.</span><span class="k">id</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">storage_info</span> {
</span></span><span class="line"><span class="ln">11</span><span class="cl">      <span class="k">ebs_storage_info</span> {
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="n">        volume_size</span> <span class="o">=</span> <span class="m">1000</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="k">provisioned_throughput</span> {
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="n">          enabled</span>           <span class="o">=</span> <span class="kt">true</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="n">          volume_throughput</span> <span class="o">=</span> <span class="m">500</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        }
</span></span><span class="line"><span class="ln">17</span><span class="cl">      }
</span></span><span class="line"><span class="ln">18</span><span class="cl">    }
</span></span><span class="line"><span class="ln">19</span><span class="cl">  }
</span></span><span class="line"><span class="ln">20</span><span class="cl">
</span></span><span class="line"><span class="ln">21</span><span class="cl">  <span class="k">client_authentication</span> {
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="k">sasl</span> {
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="n">      iam</span> <span class="o">=</span> <span class="kt">true</span><span class="c1">        # IAM auth (推薦)
</span></span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="c1"></span><span class="n">      scram</span> <span class="o">=</span> <span class="kt">false</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">    }
</span></span><span class="line"><span class="ln">26</span><span class="cl">  }
</span></span><span class="line"><span class="ln">27</span><span class="cl">
</span></span><span class="line"><span class="ln">28</span><span class="cl">  <span class="k">configuration_info</span> {
</span></span><span class="line"><span class="ln">29</span><span class="cl"><span class="n">    arn</span>      <span class="o">=</span> <span class="k">aws_msk_configuration</span><span class="p">.</span><span class="k">main</span><span class="p">.</span><span class="k">arn</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl"><span class="n">    revision</span> <span class="o">=</span> <span class="k">aws_msk_configuration</span><span class="p">.</span><span class="k">main</span><span class="p">.</span><span class="k">latest_revision</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">  }
</span></span><span class="line"><span class="ln">32</span><span class="cl">
</span></span><span class="line"><span class="ln">33</span><span class="cl">  <span class="k">encryption_info</span> {
</span></span><span class="line"><span class="ln">34</span><span class="cl">    <span class="k">encryption_in_transit</span> {
</span></span><span class="line"><span class="ln">35</span><span class="cl"><span class="n">      client_broker</span> <span class="o">=</span> <span class="s2">&#34;TLS&#34;</span>
</span></span><span class="line"><span class="ln">36</span><span class="cl">    }
</span></span><span class="line"><span class="ln">37</span><span class="cl">  }
</span></span><span class="line"><span class="ln">38</span><span class="cl">
</span></span><span class="line"><span class="ln">39</span><span class="cl">  <span class="k">logging_info</span> {
</span></span><span class="line"><span class="ln">40</span><span class="cl">    <span class="k">broker_logs</span> {
</span></span><span class="line"><span class="ln">41</span><span class="cl">      <span class="k">cloudwatch_logs</span> {
</span></span><span class="line"><span class="ln">42</span><span class="cl"><span class="n">        enabled</span>   <span class="o">=</span> <span class="kt">true</span>
</span></span><span class="line"><span class="ln">43</span><span class="cl"><span class="n">        log_group</span> <span class="o">=</span> <span class="k">aws_cloudwatch_log_group</span><span class="p">.</span><span class="k">msk</span><span class="p">.</span><span class="k">name</span>
</span></span><span class="line"><span class="ln">44</span><span class="cl">      }
</span></span><span class="line"><span class="ln">45</span><span class="cl">    }
</span></span><span class="line"><span class="ln">46</span><span class="cl">  }
</span></span><span class="line"><span class="ln">47</span><span class="cl">}</span></span></code></pre></div><h3 id="phase-2data-migrationmirrormaker-20">Phase 2：Data migration（MirrorMaker 2.0）</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">Self-managed Kafka ──(MM2)──→ MSK
</span></span><span class="line"><span class="ln">2</span><span class="cl">                       │
</span></span><span class="line"><span class="ln">3</span><span class="cl">                consumer offset sync
</span></span><span class="line"><span class="ln">4</span><span class="cl">                       │
</span></span><span class="line"><span class="ln">5</span><span class="cl">                topic config sync</span></span></code></pre></div><p>MM2 跑 1-7 天、依 topic 量 + retention 期間；replica.lag 對齊後進 cutover。</p>
<h3 id="phase-3cutover">Phase 3：Cutover</h3>
<ul>
<li>Application 端切 bootstrap.servers 從 self-managed → MSK</li>
<li>Producer 漸進切（10% → 50% → 100%）</li>
<li>Consumer 切換時 offset 從 MM2 sync 過的位置開始</li>
<li>Self-managed cluster read-only standby 2 週</li>
</ul>
<h2 id="production-故障演練">Production 故障演練</h2>
<h3 id="case-1iam-auth-沒設application-連不上">Case 1：IAM auth 沒設、application 連不上</h3>
<p><strong>徵兆</strong>：cutover 後 application 報 <code>SaslAuthenticationException: Access denied</code>；MSK 端 cloudWatch log 顯示 IAM principal 不認。</p>
<p><strong>根因</strong>：MSK IAM auth 要求 client 跑 <em>MSK IAM auth library</em>（Java 用 <code>aws-msk-iam-auth</code>、Python 用 <code>aws-msk-iam-sasl-signer-python</code>）；application 端用 standard Kafka client、不知道怎麼 sign IAM signature。</p>
<p><strong>修法</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># Python kafka-python + IAM auth</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kn">from</span> <span class="nn">aws_msk_iam_sasl_signer</span> <span class="kn">import</span> <span class="n">MSKAuthTokenProvider</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="kn">from</span> <span class="nn">kafka</span> <span class="kn">import</span> <span class="n">KafkaProducer</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="k">class</span> <span class="nc">AwsMskIamProvider</span><span class="p">(</span><span class="n">MSKAuthTokenProvider</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">def</span> <span class="nf">token</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="k">return</span> <span class="bp">self</span><span class="o">.</span><span class="n">generate_auth_token</span><span class="p">(</span><span class="s1">&#39;us-east-1&#39;</span><span class="p">)[</span><span class="mi">0</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="n">producer</span> <span class="o">=</span> <span class="n">KafkaProducer</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="n">bootstrap_servers</span><span class="o">=</span><span class="s1">&#39;b-1.mycluster.kafka.us-east-1.amazonaws.com:9098&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="n">security_protocol</span><span class="o">=</span><span class="s1">&#39;SASL_SSL&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="n">sasl_mechanism</span><span class="o">=</span><span class="s1">&#39;OAUTHBEARER&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="n">sasl_oauth_token_provider</span><span class="o">=</span><span class="n">AwsMskIamProvider</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">)</span></span></span></code></pre></div><p>EKS pod 必須有 IAM role（IRSA）對 MSK cluster <code>kafka-cluster:Connect</code> action。</p>
<h3 id="case-2version-pinning360-跟-self-managed-行為差">Case 2：Version pinning、3.6.0 跟 self-managed 行為差</h3>
<p><strong>徵兆</strong>：cutover 到 MSK 3.6.0 後、某些 consumer 跑舊 client 失敗；新 broker 改 default <code>inter.broker.protocol.version</code> 但 client 不認。</p>
<p><strong>根因</strong>：MSK 升 Kafka version 後 broker config 變動、舊 client（&lt; 2.8）跟新 broker 協議不對；self-managed 端可能用更舊 broker version 跑、看不出問題。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>Pre-migration</strong>：所有 client 升 Kafka client library 2.8+</li>
<li><strong>MSK kafka_version 對齊 self-managed</strong>：先建 MSK 3.0 / 3.5、跟 self-managed 一致、cutover 後再升</li>
<li><strong>Phase rollout</strong>：用 <em>Tiered Storage</em> + retention 策略保留舊資料、新 producer / consumer 用新 version</li>
</ol>
<h3 id="case-3metric-pipeline-失效soc-dashboard-無數據">Case 3：Metric pipeline 失效、SOC dashboard 無數據</h3>
<p><strong>徵兆</strong>：cutover 後 Grafana dashboard 顯示 MSK metric 0；舊 JMX exporter 抓不到 MSK；CloudWatch 有 metric 但 SOC 端不接 CloudWatch。</p>
<p><strong>根因</strong>：MSK 不暴露 JMX、metric 走 CloudWatch / open monitoring (Prometheus + Grafana)、跟自建 JMX-based pipeline 不對等。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>Open monitoring enabled</strong>：MSK config 設 <code>open_monitoring.prometheus.jmx_exporter.enabled = true</code>、跑 Prometheus 對 MSK broker 拉 metric</li>
<li><strong>CloudWatch → Prometheus</strong>：用 <code>cloudwatch-exporter</code> 拉 CloudWatch metric 進 Prometheus</li>
<li><strong>Dashboard refresh</strong>：Grafana dashboard 對 MSK-specific metric name 重寫（<code>kafka_server_*</code> → <code>aws_kafka_*</code> 或統一 alias）</li>
</ol>
<h3 id="case-4cross-cluster-mirrormm2--msk配置複雜">Case 4：Cross-cluster mirror（MM2 → MSK）配置複雜</h3>
<p><strong>徵兆</strong>：MM2 跑了 1 週、self-managed 跟 MSK consumer offset 沒同步；application 切過去後 <em>重新讀整批舊資料</em>、duplicate processing。</p>
<p><strong>根因</strong>：MM2 consumer offset sync 需要 <em>跨 cluster</em> mapping、source 端 offset 跟 target 端 offset 不直通；MM2 預設 offset sync 沒打開。</p>
<p><strong>修法</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-properties" data-lang="properties"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># MM2 config</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="na">source.consumer.bootstrap.servers</span><span class="o">=</span><span class="s">self-managed-kafka:9092</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="na">target.consumer.bootstrap.servers</span><span class="o">=</span><span class="s">msk-cluster:9098</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="na">target.security.protocol</span><span class="o">=</span><span class="s">SASL_SSL</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="na">sync.group.offsets.enabled</span><span class="o">=</span><span class="s">true       # 必須打開</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="na">emit.checkpoints.enabled</span><span class="o">=</span><span class="s">true</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="na">checkpoints.topic.replication.factor</span><span class="o">=</span><span class="s">3</span></span></span></code></pre></div><p><strong>Architecture</strong>：consumer 切換時讀 <em>MM2 checkpoint</em> topic、不直接讀 internal offset；application 端用 <em>idempotent</em> + <em>dedup key</em>、avoid duplicate processing。</p>
<h3 id="case-5msk-billing-暴漲tiered-storage--cross-az-沒控">Case 5：MSK billing 暴漲、Tiered Storage / cross-AZ 沒控</h3>
<p><strong>徵兆</strong>：MSK 第一個月帳單比預估高 50%；breakdown 後發現 cross-AZ traffic（producer/consumer 跨 AZ）+ Tiered Storage 退到 S3 的 hot tier。</p>
<p><strong>根因</strong>：</p>
<ul>
<li>MSK auto multi-AZ deployment 不可避免 cross-AZ traffic、producer 寫 partition leader 可能跨 AZ</li>
<li>Tiered Storage 對 hot data（retention &lt; 24 小時）會多 storage cost；cold data 才 cost-effective</li>
</ul>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>Application AZ-aware routing</strong>：producer 走 same-AZ broker（用 rack-aware producer config）、降 cross-AZ</li>
<li><strong>Retention 對齊 hot tier</strong>：&lt; 24 小時 retention 用 broker local storage、24 小時+ 才走 Tiered Storage</li>
<li><strong>Reserved instance</strong>：MSK 不直接 reserved、但 EBS / data transfer 可預付、降 10-20%</li>
</ol>
<h2 id="capacity--cost">Capacity / cost</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>Self-managed Kafka</th>
          <th>MSK</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Cluster cost (3 broker)</td>
          <td>$660 EC2 + $1500 EBS = $2,160</td>
          <td>$2,500-3,500（含 storage + multi-AZ）</td>
      </tr>
      <tr>
          <td>Operational FTE</td>
          <td>0.5-1 FTE = $5K-10K</td>
          <td>0.1-0.3 FTE = $1K-3K</td>
      </tr>
      <tr>
          <td>Patch / maintenance</td>
          <td>Manual + downtime opportunity</td>
          <td>Auto + maintenance window scheduled</td>
      </tr>
      <tr>
          <td>Backup</td>
          <td>Self-managed MirrorMaker</td>
          <td>Built-in（S3 archive、auto）</td>
      </tr>
      <tr>
          <td>Metric / monitoring</td>
          <td>Prometheus + Grafana self-deploy</td>
          <td>CloudWatch + open monitoring</td>
      </tr>
      <tr>
          <td>Cross-AZ traffic</td>
          <td>Limited by VPC layout</td>
          <td>Auto multi-AZ、cross-AZ traffic cost 注意</td>
      </tr>
      <tr>
          <td>Tiered storage</td>
          <td>Kafka 3.6+ self-managed</td>
          <td>MSK built-in tiered storage</td>
      </tr>
      <tr>
          <td>Total (3 broker, 中型)</td>
          <td>$7K-11K / mo (含 FTE)</td>
          <td>$3.5K-6.5K / mo (含 FTE)</td>
      </tr>
      <tr>
          <td>Migration cost</td>
          <td>-</td>
          <td>1-3 FTE × 1-2 個月</td>
      </tr>
  </tbody>
</table>
<p><strong>判讀</strong>：&lt; 50 broker organization MSK ROI 通常 6-12 月持平、之後省 FTE；50+ broker 大 organization 自管 cost 可能反而低。</p>
<h2 id="整合--下一步">整合 / 下一步</h2>
<h3 id="跟-kafka--nats-migration-對位">跟 <a href="/blog/backend/03-message-queue/vendors/kafka/migrate-from-to-nats/" data-link-title="Kafka ↔ NATS：不是 migration、是 messaging paradigm 重設計" data-link-desc="Kafka 跟 NATS 不是同類產品（log-based event streaming vs subject-based messaging）、&#39;migration&#39; 字面上不成立；本文釐清兩家 paradigm 邊界、什麼情境真的能換、application 模式重設計的 5 個踩雷（consumer offset 觀念差 / retention model / exactly-once 假設 / schema registry 缺位 / fan-out 模式差）、跟 JetStream 對位 &#43; 混合架構">Kafka ↔ NATS migration</a> 對位</h3>
<p>兩條 Kafka 出路：</p>
<ul>
<li>MSK：operational simplification、protocol drop-in、cost 中等漲；適合 <em>繼續用 Kafka paradigm</em> 的 organization</li>
<li>NATS：paradigm shift、application 必須改、適合 <em>單純 messaging 不要 event sourcing</em> 的 use case</li>
</ul>
<p>多數 organization 不需要 paradigm shift、MSK 更合理；真正需要 lightweight messaging 才走 NATS。</p>
<h3 id="跟-confluent-cloud-對位">跟 <a href="https://www.confluent.io/confluent-cloud/">Confluent Cloud</a> 對位</h3>
<p>Confluent Cloud 是另一個 managed Kafka、跨 cloud（AWS / GCP / Azure）；MSK 是 AWS-only、但跟 IAM / VPC 整合更深。Multi-cloud organization 走 Confluent、AWS-deep organization 走 MSK。</p>
<h3 id="跟-iam--secrets-manager-整合">跟 IAM / Secrets Manager 整合</h3>
<p>MSK + IAM auth + Secrets Manager（連 <a href="/blog/backend/07-security-data-protection/vendors/hashicorp-vault/migrate-to-aws-secrets-manager/" data-link-title="Vault → AWS Secrets Manager：「secret」不是「secret」、identity model 才是核心差異" data-link-desc="Vault → AWS Secrets Manager migration 表面是 secret store 替換、實際核心是 identity model 對位（Vault token &#43; policy vs AWS IAM &#43; resource policy）；驗證 [#128](/report/data-topology-as-audit-dimension/) self-aware limitation 提出的 identity axis 候選 — identity 是否獨立 audit 軸；5 個 production 踩雷（IAM principal 對位 / dynamic credential 對等失敗 / lease lifecycle 模型不同 / audit log 結構差 / 計費模型反轉）">Vault → AWS Secrets Manager migration</a>）是 AWS-deep stack 的標準組合；short-lived credential + IRSA 是 production best practice。</p>
<h3 id="反向-migrationmsk--self-managed">反向 migration（MSK → self-managed）</h3>
<p>少見、通常是 <em>cost 反轉</em>（大 scale）或 <em>multi-cloud strategy</em>；流程鏡像對稱、注意 MSK Tiered Storage data 不直接 export、需要 <em>先 disable tiered storage</em> + recall data。</p>
<h3 id="下一步議題">下一步議題</h3>
<ul>
<li><strong>MSK Connect</strong>：managed Kafka Connect、降 connector 運維、但 plugin ecosystem 比 self-managed Connect 少</li>
<li><strong>MSK Serverless</strong>：burst workload 適合、steady workload 反而貴</li>
<li><strong>Cost monitoring playbook</strong>：MSK billing 拆解每月跑一次、catch unexpected egress / tiered storage cost</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li>Source vendor：<a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka</a></li>
<li>平行 migration playbook (Type C)：<a href="/blog/backend/01-database/vendors/postgresql/migrate-to-aurora/" data-link-title="PostgreSQL → Aurora Migration：protocol 相容、operational 重設計" data-link-desc="Aurora 號稱 PostgreSQL-compatible 但 operational model 不同（storage decouple / cluster endpoint / instance class / 自家備份）；遷移流程是混合（protocol drop-in &#43; operational phased）、5 個 production 踩雷（extension 不支援 / replication slot 不直通 / autovacuum 行為差 / IAM 認證強制 / cost model 換算）、跟 Patroni / read replica / DR 對位">PostgreSQL → Aurora</a> / <a href="/blog/backend/01-database/vendors/mongodb/migrate-to-atlas/" data-link-title="MongoDB → Atlas：Atlas 不是 MongoDB &#43; managed、是另一個 product" data-link-desc="Atlas 號稱「MongoDB managed」但 operational model 完全不同（auto-scaling / VPC peering / IAM-driven access / 內建 backup / billing 模型）；本文採用 Type C operational redesign hybrid 結構、4-phase operational migration &#43; drop-in cutover、5 個 production 踩雷（連線數限制 / IP whitelist / backup retention / IAM token 過期 / billing 暴漲）">MongoDB → Atlas</a></li>
<li>平行 H cost variant：<a href="/blog/backend/04-observability/vendors/datadog/migrate-to-grafana-stack/" data-link-title="Datadog → Grafana Stack：把 $50K/month bill 拆解到 self-hosted observability" data-link-desc="Datadog 五層計費（host APM / metric / log ingest / log retention / RUM）拆解、對位 Grafana Stack（Mimir / Loki / Tempo / Grafana / Alloy）的 5 層責任；OTel-based agent migration、5 個 production 踩雷（cardinality 爆 / log volume cost / dashboard 不直接轉 / alert routing 換邏輯 / SLO definition 差異）、cost reality check">Datadog → Grafana Stack</a></li>
<li>平行 paradigm shift：<a href="/blog/backend/03-message-queue/vendors/kafka/migrate-from-to-nats/" data-link-title="Kafka ↔ NATS：不是 migration、是 messaging paradigm 重設計" data-link-desc="Kafka 跟 NATS 不是同類產品（log-based event streaming vs subject-based messaging）、&#39;migration&#39; 字面上不成立；本文釐清兩家 paradigm 邊界、什麼情境真的能換、application 模式重設計的 5 個踩雷（consumer offset 觀念差 / retention model / exactly-once 假設 / schema registry 缺位 / fan-out 模式差）、跟 JetStream 對位 &#43; 混合架構">Kafka ↔ NATS</a></li>
<li>Methodology：<a href="/blog/posts/migration-playbook-%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84stage-0-variant-%E8%A6%8F%E5%8A%83%E6%8A%8A-collapse-%E7%8E%87%E5%BE%9E-60-%E9%99%8D%E5%88%B0-0/" data-link-title="Migration Playbook 方法論的演化紀錄：Stage 0 variant 規劃把 collapse 率從 60% 降到 0%" data-link-desc="跨 vendor migration playbook 需要獨立寫作方法論的依據，以及這套方法論從三輪 batch dogfood 中演化出來的驗證證據。">Migration playbook methodology</a></li>
</ul>
]]></content:encoded></item><item><title>3.C12 Pinterest：Shallow Mirror 優化 MirrorMaker</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-pinterest-shallow-mirror/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-pinterest-shallow-mirror/</guid><description>&lt;p>這個案例的核心責任是說明 cross-region replication 的 CPU/memory 成本是被低估的工程議題。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>Pinterest 三個 AWS region（us-east-1 / us-east-2 / eu-west-1）跑 MirrorMaker v1、原版設計把 record 解壓+重壓、memory 用量 2-10x 於網路 bytes、CPU spike 與 OOM 頻繁。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>Shallow Mirror 在 RecordBatch 層淺迭代 + ByteBuffer pointer 共享、避開 deserialize/re-compress。揭露「跨區同步不是純 I/O 問題、是 CPU + memory + 網路三維壓力」。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>Kafka 進階主題：cross-region MirrorMaker / MirrorMaker 2 配置。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/meta-foqs-global-migration/" data-link-title="3.C1 Meta：FOQS 從區域到全域佇列遷移" data-link-desc="佇列架構如何在不中斷下升級成 disaster-ready 模式。">3.C1 Meta FOQS&lt;/a>。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://medium.com/pinterest-engineering/shallow-mirror-f543b14bb25">Pinterest Shallow Mirror&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 cross-region replication 的 CPU/memory 成本是被低估的工程議題。</p>
<h2 id="觀察">觀察</h2>
<p>Pinterest 三個 AWS region（us-east-1 / us-east-2 / eu-west-1）跑 MirrorMaker v1、原版設計把 record 解壓+重壓、memory 用量 2-10x 於網路 bytes、CPU spike 與 OOM 頻繁。</p>
<h2 id="判讀">判讀</h2>
<p>Shallow Mirror 在 RecordBatch 層淺迭代 + ByteBuffer pointer 共享、避開 deserialize/re-compress。揭露「跨區同步不是純 I/O 問題、是 CPU + memory + 網路三維壓力」。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>Kafka 進階主題：cross-region MirrorMaker / MirrorMaker 2 配置。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/cases/meta-foqs-global-migration/" data-link-title="3.C1 Meta：FOQS 從區域到全域佇列遷移" data-link-desc="佇列架構如何在不中斷下升級成 disaster-ready 模式。">3.C1 Meta FOQS</a>。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://medium.com/pinterest-engineering/shallow-mirror-f543b14bb25">Pinterest Shallow Mirror</a></li>
</ul>
]]></content:encoded></item><item><title>Kafka Retention 與 Tiered Storage：保留策略、log compaction 與冷熱分層</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/retention-tiered-storage/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/retention-tiered-storage/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka&lt;/a> overview 的 implementation-layer deep article、聚焦保留與分層儲存。選型層的「該不該選 Kafka」「跟其他 broker 差在哪」見 overview；本文回答「保留策略怎麼設、log compaction 怎麼運作、冷熱分層怎麼讓容量跟保留期解耦、踩哪些坑」。配置段在 Apache Kafka KRaft 單節點實機驗證；tiered storage 段標註未實機驗證的範圍。&lt;/p>&lt;/blockquote>
&lt;h2 id="retention-是-replay-window-的物理邊界">Retention 是 replay window 的物理邊界&lt;/h2>
&lt;p>Retention 的核心責任是決定「一筆訊息在 broker 上能存活多久」、而這條邊界直接界定 consumer 能往回重播多遠。Kafka 的 log 是 append-only 的事件序列、訊息寫入後不會被原地修改；retention 是唯一會把舊訊息從磁碟移除的機制。設多久、用什麼條件刪、刪掉之後 consumer 還能不能讀到，全由保留策略決定。&lt;/p>
&lt;p>這條邊界之所以重要、是因為 Kafka 的多 consumer 模型讓「重播」變成一級能力。同一個 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic&lt;/a> 可以被多組 consumer 各自從任意 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/offset/" data-link-title="Offset" data-link-desc="說明 consumer 在事件流中的讀取位置與重放基準">offset&lt;/a> 開始讀、每組維護自己的進度；只要訊息還在 retention 範圍內、新加入的 consumer 或出事後要補算的 consumer 都能從頭重讀。一旦訊息超過 retention 被刪、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/replay-window/" data-link-title="Replay Window" data-link-desc="說明事件可重播的時間或 offset 範圍邊界，由 retention 與 checkpoint 決定">replay window&lt;/a> 就到此為止、補償只能改走資料庫或上游來源。&lt;/p>
&lt;p>Kafka 提供兩條獨立的保留軸、可單獨用也可同時用：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>配置&lt;/th>
 &lt;th>觸發條件&lt;/th>
 &lt;th>典型場景&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>retention.ms&lt;/code>&lt;/td>
 &lt;td>訊息寫入時間超過設定值（時間軸）&lt;/td>
 &lt;td>「保留 7 天事件供事故 replay」&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>retention.bytes&lt;/code>&lt;/td>
 &lt;td>該 partition log 總大小超過設定值（容量軸）&lt;/td>
 &lt;td>「每 partition 上限 50 GB、防止磁碟塞爆」&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>兩者同時設&lt;/td>
 &lt;td>任一條件先達到就刪（取交集、誰先到誰生效）&lt;/td>
 &lt;td>「保留 7 天、但單 partition 不超過 50 GB」&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>時間軸對齊的是 replay 需求：把 retention 設成「事故從發生到偵測到修復的最長時間」、確保發現要補算時事件還在。容量軸對齊的是成本與磁碟保護：避免某個突發高流量 topic 把 broker 磁碟寫滿、拖垮同 broker 上其他 partition。兩者同時設時是「誰先觸發誰生效」、所以容量軸常常會在高流量時段提前砍掉本來預期能保留 7 天的事件——這個交互是後面故障演練的重點之一。&lt;/p>
&lt;p>實機建立一個同時設兩軸的 topic、&lt;code>--describe&lt;/code> 會把保留配置直接列在 Configs：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># CLI 在容器內 /opt/kafka/bin/、bootstrap-server 指向 broker&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">kafka-topics.sh --create --topic ret-delete --partitions &lt;span class="m">1&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --config retention.ms&lt;span class="o">=&lt;/span>&lt;span class="m">60000&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --config retention.bytes&lt;span class="o">=&lt;/span>&lt;span class="m">10485760&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --config segment.ms&lt;span class="o">=&lt;/span>&lt;span class="m">10000&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --bootstrap-server localhost:9092
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">kafka-topics.sh --describe --topic ret-delete --bootstrap-server localhost:9092
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">&lt;span class="c1"># Configs: retention.ms=60000,retention.bytes=10485760,segment.ms=10000,...&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>retention 不是寫死在建 topic 當下、線上可以用 &lt;code>kafka-configs.sh --alter&lt;/code> 動態調整、立即生效不需重啟 broker：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">kafka-configs.sh --alter --entity-type topics --entity-name ret-delete &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --add-config retention.ms&lt;span class="o">=&lt;/span>&lt;span class="m">3600000&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --bootstrap-server localhost:9092
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1"># Completed updating config for topic ret-delete.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">kafka-configs.sh --describe --entity-type topics --entity-name ret-delete &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --bootstrap-server localhost:9092
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="c1"># retention.ms=3600000 sensitive=false synonyms={DYNAMIC_TOPIC_CONFIG:retention.ms=3600000}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>動態調整的 retention 屬於 &lt;code>DYNAMIC_TOPIC_CONFIG&lt;/code>、優先於 broker 層的 &lt;code>log.retention.*&lt;/code> 預設值；synonyms 欄位會把覆蓋關係列出來、排查時可確認當前生效的是哪一層。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka</a> overview 的 implementation-layer deep article、聚焦保留與分層儲存。選型層的「該不該選 Kafka」「跟其他 broker 差在哪」見 overview；本文回答「保留策略怎麼設、log compaction 怎麼運作、冷熱分層怎麼讓容量跟保留期解耦、踩哪些坑」。配置段在 Apache Kafka KRaft 單節點實機驗證；tiered storage 段標註未實機驗證的範圍。</p></blockquote>
<h2 id="retention-是-replay-window-的物理邊界">Retention 是 replay window 的物理邊界</h2>
<p>Retention 的核心責任是決定「一筆訊息在 broker 上能存活多久」、而這條邊界直接界定 consumer 能往回重播多遠。Kafka 的 log 是 append-only 的事件序列、訊息寫入後不會被原地修改；retention 是唯一會把舊訊息從磁碟移除的機制。設多久、用什麼條件刪、刪掉之後 consumer 還能不能讀到，全由保留策略決定。</p>
<p>這條邊界之所以重要、是因為 Kafka 的多 consumer 模型讓「重播」變成一級能力。同一個 <a href="/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic</a> 可以被多組 consumer 各自從任意 <a href="/blog/backend/knowledge-cards/offset/" data-link-title="Offset" data-link-desc="說明 consumer 在事件流中的讀取位置與重放基準">offset</a> 開始讀、每組維護自己的進度；只要訊息還在 retention 範圍內、新加入的 consumer 或出事後要補算的 consumer 都能從頭重讀。一旦訊息超過 retention 被刪、<a href="/blog/backend/knowledge-cards/replay-window/" data-link-title="Replay Window" data-link-desc="說明事件可重播的時間或 offset 範圍邊界，由 retention 與 checkpoint 決定">replay window</a> 就到此為止、補償只能改走資料庫或上游來源。</p>
<p>Kafka 提供兩條獨立的保留軸、可單獨用也可同時用：</p>
<table>
  <thead>
      <tr>
          <th>配置</th>
          <th>觸發條件</th>
          <th>典型場景</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>retention.ms</code></td>
          <td>訊息寫入時間超過設定值（時間軸）</td>
          <td>「保留 7 天事件供事故 replay」</td>
      </tr>
      <tr>
          <td><code>retention.bytes</code></td>
          <td>該 partition log 總大小超過設定值（容量軸）</td>
          <td>「每 partition 上限 50 GB、防止磁碟塞爆」</td>
      </tr>
      <tr>
          <td>兩者同時設</td>
          <td>任一條件先達到就刪（取交集、誰先到誰生效）</td>
          <td>「保留 7 天、但單 partition 不超過 50 GB」</td>
      </tr>
  </tbody>
</table>
<p>時間軸對齊的是 replay 需求：把 retention 設成「事故從發生到偵測到修復的最長時間」、確保發現要補算時事件還在。容量軸對齊的是成本與磁碟保護：避免某個突發高流量 topic 把 broker 磁碟寫滿、拖垮同 broker 上其他 partition。兩者同時設時是「誰先觸發誰生效」、所以容量軸常常會在高流量時段提前砍掉本來預期能保留 7 天的事件——這個交互是後面故障演練的重點之一。</p>
<p>實機建立一個同時設兩軸的 topic、<code>--describe</code> 會把保留配置直接列在 Configs：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># CLI 在容器內 /opt/kafka/bin/、bootstrap-server 指向 broker</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">kafka-topics.sh --create --topic ret-delete --partitions <span class="m">1</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --config retention.ms<span class="o">=</span><span class="m">60000</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --config retention.bytes<span class="o">=</span><span class="m">10485760</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  --config segment.ms<span class="o">=</span><span class="m">10000</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="se"></span>  --bootstrap-server localhost:9092
</span></span><span class="line"><span class="ln">7</span><span class="cl">
</span></span><span class="line"><span class="ln">8</span><span class="cl">kafka-topics.sh --describe --topic ret-delete --bootstrap-server localhost:9092
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="c1"># Configs: retention.ms=60000,retention.bytes=10485760,segment.ms=10000,...</span></span></span></code></pre></div><p>retention 不是寫死在建 topic 當下、線上可以用 <code>kafka-configs.sh --alter</code> 動態調整、立即生效不需重啟 broker：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">kafka-configs.sh --alter --entity-type topics --entity-name ret-delete <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --add-config retention.ms<span class="o">=</span><span class="m">3600000</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --bootstrap-server localhost:9092
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># Completed updating config for topic ret-delete.</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">
</span></span><span class="line"><span class="ln">6</span><span class="cl">kafka-configs.sh --describe --entity-type topics --entity-name ret-delete <span class="se">\
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="se"></span>  --bootstrap-server localhost:9092
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1"># retention.ms=3600000 sensitive=false synonyms={DYNAMIC_TOPIC_CONFIG:retention.ms=3600000}</span></span></span></code></pre></div><p>動態調整的 retention 屬於 <code>DYNAMIC_TOPIC_CONFIG</code>、優先於 broker 層的 <code>log.retention.*</code> 預設值；synonyms 欄位會把覆蓋關係列出來、排查時可確認當前生效的是哪一層。</p>
<h2 id="segment-是刪除的最小單位">Segment 是刪除的最小單位</h2>
<p>Retention 刪資料的最小單位是 log segment、不是單筆訊息。理解這一點才能解釋「為什麼設了 retention.ms 之後，過期的訊息有時還在」。每個 partition 的 log 在磁碟上被切成多個 segment 檔、只有 active segment（當前正在寫入的那一個）以外、已經 roll over 的 segment 才會被 retention 檢查並整段刪除。</p>
<p>Segment 何時 roll over 由兩個條件決定：<code>segment.bytes</code>（檔案大到上限、預設 1 GB、最小 1 MB）或 <code>segment.ms</code>（檔案存在時間超過設定）。實機寫入 ~6 MB 資料到一個 <code>segment.bytes=1048576</code>（1 MB）的 topic、磁碟上會看到 6 個 roll 過的 segment：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">00000000000000000000.log   1045229   # 已 roll，可被 retention 刪
</span></span><span class="line"><span class="ln">2</span><span class="cl">00000000000000001024.log   1046336   # 已 roll
</span></span><span class="line"><span class="ln">3</span><span class="cl">00000000000000002048.log   1046336   # 已 roll
</span></span><span class="line"><span class="ln">4</span><span class="cl">00000000000000003072.log   1046336   # 已 roll
</span></span><span class="line"><span class="ln">5</span><span class="cl">00000000000000004096.log   1037748   # 已 roll
</span></span><span class="line"><span class="ln">6</span><span class="cl">00000000000000005112.log    904737   # active segment，不會被刪</span></span></code></pre></div><p>Retention 的實際刪除動作由背景執行緒週期性執行、頻率是 broker 層的 <code>log.retention.check.interval.ms</code>、預設 300000 毫秒（5 分鐘）。這代表「過期」跟「被刪」之間有最長一個檢查週期的延遲：訊息超過 retention.ms 的瞬間不會立刻消失、要等下一次檢查跑到、且該訊息所在的 segment 已經 roll over、整段才會被刪。實機把 retention.bytes 設成 2 MB、寫進 6 MB（6 個 segment）、在 5 分鐘檢查週期內查 earliest offset 仍是 0——超量的 segment 還沒被回收、因為檢查執行緒還沒跑到下一輪。</p>
<p>這個機制有兩個操作後果。其一、磁碟用量會在「超過 retention 上限」到「下一次檢查」之間短暫超標、容量規劃要把這段 overshoot 算進緩衝。其二、把 retention.ms 設得比 segment.ms 還短沒有意義：訊息要等所在 segment roll 才可能被刪、active segment 永遠刪不掉、所以實際最短保留時間是 <code>max(retention.ms, segment 尚未 roll 的時間)</code>。</p>
<h2 id="cleanuppolicydelete-與-compact-是兩種回收語意">cleanup.policy：delete 與 compact 是兩種回收語意</h2>
<p><code>cleanup.policy</code> 決定 retention 用哪種語意回收空間、是保留策略最關鍵的分岔。預設值 <code>delete</code> 是時間或容量到期就整段刪除、適合事件流（event stream）：訊息代表「發生過的事實」、過了 replay window 就沒有保留價值。另一個值 <code>compact</code> 是 log compaction、語意完全不同：它保留每個 key 的最新值、刪除同 key 的歷史版本、適合「狀態快照」型資料。</p>
<p>兩者的判準是這份 log 表達的是「事件序列」還是「最終狀態」。訂單建立、付款完成、商品瀏覽這類事件、每一筆都是獨立事實、用 <code>delete</code>；使用者個人設定、商品庫存當前值、CDC 同步出來的資料表鏡像這類「同一個 key 不斷被覆寫、只關心最新值」的資料、用 <code>compact</code>。Kafka 內部的 <code>__consumer_offsets</code> topic 就是 compact——它只需要每個 consumer group 的最新 offset、不需要歷史 commit 記錄。</p>
<p>兩者可以同時開（<code>cleanup.policy=compact,delete</code>）：先按 key 壓縮保留最新值、同時對壓縮後的結果再套時間 / 容量上限。用 <code>kafka-configs.sh</code> 切換時、逗號分隔的值要用中括號群組、否則會被解析成兩個獨立 config：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">kafka-configs.sh --alter --entity-type topics --entity-name ret-delete <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --add-config <span class="s1">&#39;cleanup.policy=[compact,delete]&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --bootstrap-server localhost:9092
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># Completed updating config for topic ret-delete.</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># describe: cleanup.policy=compact,delete</span></span></span></code></pre></div><h2 id="log-compaction-用最新值取代歷史">Log compaction 用最新值取代歷史</h2>
<p>Log compaction 的核心責任是讓一個 topic 收斂成「每個 key 的最新狀態」、同時保有 Kafka 的 log 重播能力。它的運作方式是背景的 log cleaner 執行緒掃描已 roll 的 segment、對每個 key 只保留 offset 最大的那筆、把同 key 的舊版本標記移除、再把存活的記錄重寫成新 segment。Compaction 後、新加入的 consumer 從頭讀一次、拿到的就是整個 keyspace 的最新快照、而非完整變更歷史。</p>
<p>實機驗證最直接：建一個 compact topic、對 3 個 key 各寫 2 個版本（舊值在前、新值在後）、等 compaction 跑完、從頭消費：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl">kafka-topics.sh --create --topic ret-compact --partitions <span class="m">1</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="se"></span>  --config cleanup.policy<span class="o">=</span>compact <span class="se">\
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="se"></span>  --config min.cleanable.dirty.ratio<span class="o">=</span>0.01 <span class="se">\
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="se"></span>  --config segment.ms<span class="o">=</span><span class="m">5000</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="se"></span>  --config delete.retention.ms<span class="o">=</span><span class="m">100</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="se"></span>  --bootstrap-server localhost:9092
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"># 寫 k1/k2/k3 各舊值一筆、再各新值一筆（key:value 用冒號分隔）</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="nb">printf</span> <span class="s1">&#39;k1:v1-old\nk2:v1-old\nk3:v1-old\nk1:v2-new\nk2:v2-new\nk3:v2-new\n&#39;</span> <span class="p">|</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="se"></span>  kafka-console-producer.sh --topic ret-compact <span class="se">\
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="se"></span>  --property parse.key<span class="o">=</span><span class="nb">true</span> --property key.separator<span class="o">=</span>: <span class="se">\
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="se"></span>  --bootstrap-server localhost:9092
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1"># 等 segment roll + compaction，再從頭消費</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">kafka-console-consumer.sh --topic ret-compact --from-beginning <span class="se">\
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="se"></span>  --property print.key<span class="o">=</span><span class="nb">true</span> --property print.offset<span class="o">=</span><span class="nb">true</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="se"></span>  --timeout-ms <span class="m">6000</span> --bootstrap-server localhost:9092
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="c1"># Offset:3  k1  v2-new</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="c1"># Offset:4  k2  v2-new</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="c1"># Offset:5  k3  v2-new</span></span></span></code></pre></div><p>寫進 6 筆、從頭只讀到 3 筆——k1/k2/k3 的 <code>v1-old</code>（offset 0-2）被壓縮移除、只留每個 key 的 <code>v2-new</code>。關鍵細節：offset 沒有重新編號、留存記錄保留原始 offset（3、4、5）、log 的位置語意不變、其他 consumer 的 offset 進度不會錯位。</p>
<p>Compaction 的觸發不是即時的、由幾個參數共同決定。<code>min.cleanable.dirty.ratio</code> 是「髒比例」門檻、髒記錄（已被新版本取代但還沒清掉的舊版本）佔 log 比例超過這個值、cleaner 才會處理該 partition、預設 0.5（驗證時調成 0.01 加速觸發）。<code>segment.ms</code> 控制 active segment 多久 roll、只有 roll 過的 segment 能被 compact。<code>delete.retention.ms</code> 控制 tombstone（value 為 null 的刪除標記）保留多久——compaction topic 用 null value 表示「這個 key 已刪除」、tombstone 要保留夠久讓所有 consumer 都讀到刪除事件、之後才清掉。</p>
<p>Tombstone 是 compaction 表達「刪除」的方式：寫一筆 key 存在、value 為 null 的記錄、compaction 會把該 key 的所有歷史連同這筆 tombstone 在 <code>delete.retention.ms</code> 之後一起清除。這讓 compact topic 能表達「key 從存在到被刪」的完整生命週期、而不只是「永遠累積最新值」。</p>
<h2 id="tiered-storage-讓容量與保留期解耦">Tiered Storage 讓容量與保留期解耦</h2>
<blockquote>
<p>以下 tiered storage 段落依 Apache Kafka 官方文件（KIP-405）與 Pinterest / LinkedIn 公開案例敘述、未在本文的 KRaft 單節點環境實機驗證。Apache Kafka 的原生 tiered storage（<code>remote.storage.enable</code>）在當前版本屬 early-access、需要額外的 RemoteStorageManager plugin 與 broker 設定；正式採用前以官方文件版本標註為準。</p></blockquote>
<p>Tiered storage 的核心責任是把 broker 的「儲存容量」跟「保留期長度」解耦。傳統 Kafka 的保留期受限於 broker 本機磁碟：想保留 30 天、就得讓每個 broker 的 local disk 容納 30 天的全量資料、retention 拉長等於 broker 數量或單機磁碟線性增長、而 broker 的 CPU / 記憶體 / 網路其實沒用到那麼多。Tiered storage 把 log 分成兩層：熱資料（近期、頻繁讀）留在 broker local disk（local tier）、冷資料（過期門檻之外、偶爾 replay）卸載到遠端物件儲存如 S3（remote tier）。Broker 只需放得下熱資料、保留期可以拉到數月甚至更久、成本變成 S3 的物件儲存費而非 broker 機群。</p>
<p>分層的觸發由 <code>local.retention.ms</code> / <code>local.retention.bytes</code>（本機保留多久 / 多大、超過就卸到 remote）跟整體的 <code>retention.ms</code> / <code>retention.bytes</code>（含 remote 的總保留邊界、超過才真正刪除）共同界定。一筆訊息的生命週期變成：寫入 local tier、超過 local retention 卸到 remote tier、超過整體 retention 從 remote 刪除。Replay window 因此可以遠大於 broker local disk 容量。</p>
<p>讀取路徑分熱冷兩條、效能特性不同。Consumer 讀近期 offset、資料在 local tier、走的是 Kafka 一向的 page cache + 順序讀路徑、低延遲高吞吐。Consumer 讀很舊的 offset（例如出事後從幾週前重播）、資料在 remote tier、broker 要先從 S3 把對應 segment 拉回來才能 serve、第一次讀的延遲明顯高於熱路徑、吞吐受 S3 頻寬與 broker 拉取並行度限制。這個熱冷讀差異是 tiered storage 的核心取捨——也是故障演練要處理的場景。</p>
<p>業界對 tiered storage 有兩條不同的工程路線、對應不同的 broker 角色定位：</p>
<table>
  <thead>
      <tr>
          <th>路線</th>
          <th>broker 角色</th>
          <th>代表案例</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Broker-coupled（KIP-405 原生）</td>
          <td>broker 仍是 remote 讀的熱路徑、代理拉取</td>
          <td>Apache Kafka 原生 tiered storage</td>
      </tr>
      <tr>
          <td>Broker-decoupled</td>
          <td>consumer 直接從 S3 拉、broker 不在熱路徑</td>
          <td><a href="/blog/backend/03-message-queue/cases/kafka-pinterest-tiered-storage/" data-link-title="3.C11 Pinterest：Kafka tiered storage broker-decoupled" data-link-desc="Pinterest 採 broker-decoupled tiered storage、把 ~200 TB/day 熱資料卸到 S3、broker 不再是熱路徑。">3.C11 Pinterest Tiered Storage</a></td>
      </tr>
  </tbody>
</table>
<p><a href="/blog/backend/03-message-queue/cases/kafka-pinterest-tiered-storage/" data-link-title="3.C11 Pinterest：Kafka tiered storage broker-decoupled" data-link-desc="Pinterest 採 broker-decoupled tiered storage、把 ~200 TB/day 熱資料卸到 S3、broker 不再是熱路徑。">Pinterest 的 broker-decoupled 做法</a>把 ~200 TB/day 熱資料卸到 S3、讓 consumer 直接從 S3 拉冷資料、broker 不再是冷讀的熱路徑。它揭露的設計判讀是「broker 運算資源」跟「跨 AZ 網路成本」其實該分開治理、而不是綁在 broker 容量擴張上——保留期變長不該等於 broker 機群變大。</p>
<p><a href="/blog/backend/03-message-queue/cases/linkedin-kafka-tiered-clusters/" data-link-title="3.C4 LinkedIn：Kafka 分層叢集治理" data-link-desc="Kafka 從單叢集走向 tiered clusters 的轉換案例。">LinkedIn 的分層叢集策略</a>是另一個層次的「分層」：把不同業務特性與可靠性需求的 workload 拆到不同叢集（依關鍵程度分群、例如關鍵 / 一般 / 實驗性，分層名稱為示意而非案例原文用詞）、避免混在同一叢集時故障與資源競爭互相放大。這裡的「分層」指叢集隔離、不是儲存的冷熱分層。兩種「分層」常被混談、但解的是不同問題：tiered storage 解單一 topic 的儲存成本、tiered clusters 解多 workload 的隔離治理。</p>
<h2 id="故障演練">故障演練</h2>
<h3 id="retention-太短replay-window-不夠補事故">Retention 太短、replay window 不夠補事故</h3>
<p><strong>徵兆</strong>：下游 consumer 出 bug、產出錯誤的衍生資料、幾天後才被對帳發現；要從原始事件重播修復時、發現最舊的事件已經被刪、replay 從某個時間點之後才有資料、之前的修不回來。</p>
<p><strong>根因</strong>：retention.ms 設得比「事故從發生到偵測到開始修復的最長時間」短。Replay window 由 broker retention 與 consumer checkpoint 共同界定、retention 是其物理上限；偵測延遲一旦超過 retention、要補算時原始事件已過期。常見的隱性誘因是把 retention 按「正常 consumer 跟得上的進度」來設（例如 consumer 通常落後幾分鐘、就設 1 天保險）、卻沒按「最壞情況下多久才會發現問題」來設。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>把 retention.ms 對齊事故偵測到修復的最長時間、而非 consumer 正常落後量；對帳 / 審計類 pipeline 的偵測週期常以天計、retention 要跟著拉到對應天數。</li>
<li>對「偵測延遲可能很長」的關鍵 topic、在下游另留可重算的來源（資料庫快照、上游 source of truth）、不把 Kafka retention 當唯一補償依據。</li>
<li>用 <code>kafka-configs.sh --alter</code> 動態延長 retention 是即時生效的、但只對「還沒被刪」的訊息有用——已刪的救不回來；所以調整要趁事故升級前、發現偵測週期被低估的當下就改、不是等出事才改。</li>
<li>Replay 邊界對齊見 <a href="/blog/backend/03-message-queue/event-contract-replay-boundary/" data-link-title="3.7 Event Contract 與 Replay Boundary" data-link-desc="說明 event schema、idempotency key、replay window 與補償如何先於 broker 選型。">3.7 Event Contract 與 Replay Boundary</a>：replay 要能指定 time range、超出 retention 的 time range 直接無效。</li>
</ol>
<h3 id="compaction-開了磁碟卻沒回收">Compaction 開了、磁碟卻沒回收</h3>
<p><strong>徵兆</strong>：topic 設了 <code>cleanup.policy=compact</code>、預期同 key 舊版本會被清掉、磁碟用量卻持續上漲、<code>--describe</code> 看 partition log 一直變大；從頭消費仍讀到大量同 key 的歷史版本。</p>
<p><strong>根因</strong>：compaction 觸發條件沒滿足。log cleaner 只處理已 roll 的 segment、active segment 永遠不壓縮；<code>min.cleanable.dirty.ratio</code> 預設 0.5、髒比例沒到一半 cleaner 不動手；如果寫入集中在少數 key、active segment 遲遲不 roll（segment.bytes / segment.ms 都沒到）、髒記錄全積在 active segment 裡、compaction 看不到它們。另一個常見原因是 broker 的 log cleaner 執行緒數（<code>log.cleaner.threads</code>）不足以跟上高寫入量、cleaner backlog 累積。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>確認 active segment 會適時 roll：對寫入量不大但需要及時壓縮的 topic、設 <code>segment.ms</code>（例如數小時）強制 roll、讓髒記錄離開 active segment 進入可壓縮範圍。</li>
<li>視壓縮急迫度調 <code>min.cleanable.dirty.ratio</code>：要更積極壓縮就調低（驗證時用 0.01）、但調太低會讓 cleaner 頻繁重寫 segment、增加 I/O——這是壓縮及時性跟 cleaner 開銷的取捨。</li>
<li>監控 cleaner backlog：看 broker 的 <code>log-cleaner</code> 相關 metric、backlog 持續成長代表 cleaner 執行緒不夠、加 <code>log.cleaner.threads</code>。</li>
<li>確認沒有把 compact 用在「其實該 delete」的事件流上——事件流每筆 key 多半唯一、compaction 沒有舊版本可壓、磁碟自然不會降；那種情況該用 <code>delete</code> 加 retention。</li>
</ol>
<h3 id="cold-tier-讀延遲拖垮-replay">Cold tier 讀延遲拖垮 replay</h3>
<p><strong>徵兆</strong>：開了 tiered storage、平時讀近期資料正常、一旦發起從幾週前的舊 offset 大規模 replay、consumer 的吞吐驟降、p99 拉取延遲飆高、broker S3 拉取頻寬打滿、同 broker 上其他正常 consumer 也跟著受影響。</p>
<p><strong>根因</strong>：舊 offset 的資料在 remote tier、每次讀要先從 S3 把 segment 拉回 broker、第一次冷讀延遲遠高於 local tier 的順序讀。大規模 replay 等於一次要從 S3 拉大量冷 segment、S3 頻寬與 broker 拉取並行成為瓶頸；broker-coupled 架構下這些拉取流量全經過 broker、會排擠到熱路徑的正常服務。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>把大規模冷 replay 排到低流量時段、避免跟線上熱路徑爭 broker 資源與 S3 頻寬。</li>
<li>控制 replay 的並行度與範圍：依 <a href="/blog/backend/03-message-queue/event-contract-replay-boundary/" data-link-title="3.7 Event Contract 與 Replay Boundary" data-link-desc="說明 event schema、idempotency key、replay window 與補償如何先於 broker 選型。">replay boundary</a> 指定 time range / tenant / partition、分批拉冷資料、不要一次全量回放整個保留期。</li>
<li>評估 broker-decoupled 架構（如 <a href="/blog/backend/03-message-queue/cases/kafka-pinterest-tiered-storage/" data-link-title="3.C11 Pinterest：Kafka tiered storage broker-decoupled" data-link-desc="Pinterest 採 broker-decoupled tiered storage、把 ~200 TB/day 熱資料卸到 S3、broker 不再是熱路徑。">Pinterest 做法</a>）：consumer 直接從 S3 拉冷資料、把冷讀流量從 broker 熱路徑移開、保護線上服務。</li>
<li>容量規劃把「冷讀延遲」算進 RTO：replay window 拉很長能補很久以前的事故、但補的速度受 cold tier 吞吐限制、事故修復時間估算要把這段拉取時間算進去。</li>
</ol>
<h3 id="retentionbytes-在高流量時段提早刪">retention.bytes 在高流量時段提早刪</h3>
<p><strong>徵兆</strong>：retention.ms 明明設了 7 天、某次流量突增後、consumer 卻發現幾小時前的事件就已經被刪、replay 拿不到本該還在的資料；earliest offset 在沒人預期的時候大幅前移。</p>
<p><strong>根因</strong>：retention.ms 與 retention.bytes 同時設時是「誰先觸發誰生效」。流量突增讓 partition log 在遠不到 7 天時就撞到 retention.bytes 容量上限、容量軸先觸發、舊 segment 被提前刪除——時間軸的 7 天承諾在高流量下失效。常見於「按平均流量估容量上限、卻遇到尖峰流量」、或多個 topic 共享磁碟時為了保護磁碟把每 topic 容量上限壓得偏低。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>釐清這個 topic 的保留承諾是時間還是容量主導：以 replay window 為準的關鍵 topic、容量上限要按「尖峰流量 × 保留天數」估、而非平均流量、否則尖峰時容量軸會偷走時間承諾。</li>
<li>監控 earliest offset 與 log 大小的變化率：earliest offset 在非預期時間前移、就是 retention.bytes 提前觸發的訊號、加進告警。</li>
<li>要硬保證時間保留、就把 retention.bytes 設成 -1（不限容量、純時間軸）、改用獨立的磁碟告警與容量規劃來防磁碟塞爆、而不是用 retention.bytes 兼做兩件事。</li>
<li>評估 tiered storage：把保留壓力從 broker local disk 移到 remote tier、local 只留熱資料、就不必為了保護 broker 磁碟而把 retention.bytes 壓低、時間承諾不再被容量上限侵蝕。</li>
</ol>
<h2 id="容量與成本">容量與成本</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>估算與判讀</th>
          <th>警戒</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Local disk 用量</td>
          <td>partition 數 × 單 partition log 大小 × replication factor</td>
          <td>接近磁碟上限時 retention.bytes 會提前砍時間承諾</td>
      </tr>
      <tr>
          <td>保留期 vs 成本</td>
          <td>純 local 時 retention 線性推高 broker 磁碟成本</td>
          <td>數月保留 + 純 local = broker 機群為冷資料買單</td>
      </tr>
      <tr>
          <td>Tiered remote 成本</td>
          <td>S3 物件儲存費 + 冷讀時的拉取 / egress 流量費</td>
          <td>跨 AZ / 跨 region 冷讀 egress 成本易被低估</td>
      </tr>
      <tr>
          <td>Retention 檢查延遲</td>
          <td>過期到實際刪除最長一個 <code>log.retention.check.interval.ms</code>（預設 5 分）</td>
          <td>容量規劃要預留 overshoot 緩衝</td>
      </tr>
      <tr>
          <td>Compaction 開銷</td>
          <td>cleaner 重寫 segment 的 I/O、隨 dirty.ratio 調低而上升</td>
          <td>dirty.ratio 過低 = cleaner 頻繁重寫、I/O 壓力升</td>
      </tr>
      <tr>
          <td>Cold replay 吞吐</td>
          <td>受 remote tier（S3）頻寬與 broker 拉取並行度限制</td>
          <td>大規模 cold replay 排低流量時段、分批進行</td>
      </tr>
  </tbody>
</table>
<p>實務 default：</p>
<ul>
<li>事件流 topic 用 <code>delete</code>、retention.ms 對齊事故偵測到修復的最長時間、retention.bytes 設 -1 或按尖峰流量估、不讓容量軸偷走時間承諾。</li>
<li>狀態快照 / CDC 鏡像 topic 用 <code>compact</code>、確認 active segment 會適時 roll、監控 cleaner backlog。</li>
<li>需要長保留期（數月以上）且 broker 磁碟成本敏感時、評估 tiered storage、把冷資料移到 S3、broker 只放熱資料。</li>
<li>任何 retention 調整前先確認當前生效層級（<code>kafka-configs.sh --describe</code> 看 synonyms）、避免 broker 預設與 topic 動態配置混淆。</li>
</ul>
<h2 id="整合與下一步">整合與下一步</h2>
<h3 id="跟-replay-邊界對齊">跟 replay 邊界對齊</h3>
<p>Retention 是 <a href="/blog/backend/knowledge-cards/replay-window/" data-link-title="Replay Window" data-link-desc="說明事件可重播的時間或 offset 範圍邊界，由 retention 與 checkpoint 決定">replay window</a> 的物理上限、但 replay 能不能正確執行還要看 <a href="/blog/backend/03-message-queue/event-contract-replay-boundary/" data-link-title="3.7 Event Contract 與 Replay Boundary" data-link-desc="說明 event schema、idempotency key、replay window 與補償如何先於 broker 選型。">event contract</a> 是否齊備（event id / schema version / occurred time / dedup key）。保留策略設計要跟 <a href="/blog/backend/03-message-queue/event-contract-replay-boundary/" data-link-title="3.7 Event Contract 與 Replay Boundary" data-link-desc="說明 event schema、idempotency key、replay window 與補償如何先於 broker 選型。">3.7 Event Contract 與 Replay Boundary</a> 一起看：retention 決定「能不能讀到」、event contract 決定「讀到了能不能正確重播」、兩者缺一 replay 都不成立。相關概念見 <a href="/blog/backend/knowledge-cards/retention/" data-link-title="Retention" data-link-desc="說明資料或事件保留多久，以及保留期限如何影響重放與成本">retention</a> 與 <a href="/blog/backend/knowledge-cards/offset/" data-link-title="Offset" data-link-desc="說明 consumer 在事件流中的讀取位置與重放基準">offset</a> 知識卡。</p>
<h3 id="跟分層叢集治理對位">跟分層叢集治理對位</h3>
<p>本文的 tiered storage 解的是單一 topic 的儲存成本；<a href="/blog/backend/03-message-queue/cases/linkedin-kafka-tiered-clusters/" data-link-title="3.C4 LinkedIn：Kafka 分層叢集治理" data-link-desc="Kafka 從單叢集走向 tiered clusters 的轉換案例。">3.C4 LinkedIn 分層叢集</a>解的是多 workload 的隔離——把不同可靠性需求的 topic 拆到不同叢集、避免資源競爭互相放大。保留策略在分層叢集裡會按層差異化：critical 叢集拉長 retention 保 replay、experimental 叢集縮短 retention 控成本。</p>
<h3 id="跟-broker-decoupled-架構的取捨">跟 broker-decoupled 架構的取捨</h3>
<p><a href="/blog/backend/03-message-queue/cases/kafka-pinterest-tiered-storage/" data-link-title="3.C11 Pinterest：Kafka tiered storage broker-decoupled" data-link-desc="Pinterest 採 broker-decoupled tiered storage、把 ~200 TB/day 熱資料卸到 S3、broker 不再是熱路徑。">3.C11 Pinterest broker-decoupled tiered storage</a> 把冷讀流量從 broker 熱路徑移開、是「cold tier 讀延遲拖垮 replay」故障演練的架構級解法；它跟 <a href="/blog/backend/03-message-queue/cases/kafka-pinterest-shallow-mirror/" data-link-title="3.C12 Pinterest：Shallow Mirror 優化 MirrorMaker" data-link-desc="Pinterest 跨 3 region MirrorMaker、原版解壓&#43;重壓造成 CPU/memory 2-10x spike、改 RecordBatch 層淺迭代。">3.C12 Pinterest Shallow Mirror</a> 揭露的「跨區同步是 CPU + memory + 網路三維壓力」一起、構成 Pinterest 在儲存與複製兩條路徑上的成本治理。</p>
<h3 id="回上游">回上游</h3>
<ul>
<li>上游 vendor 頁：<a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Apache Kafka</a>（「Tiered storage」與「Cross-region 與分層叢集」段）</li>
<li>平行 deep article：<a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">consumer rebalance 與 lag 診斷</a> / <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">replication、ISR 與 exactly-once</a>（同 vendor 其他實作層議題）</li>
<li>下游能力：<a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計</a> / <a href="/blog/backend/06-reliability/idempotency-replay/" data-link-title="6.12 Idempotency 與 Replay 驗證" data-link-desc="把重試、重播與冪等性從口頭約定變成可驗證屬性">6.12 idempotency / replay</a></li>
</ul>
]]></content:encoded></item><item><title>3.C13 Shopify：Debezium CDC over sharded MySQL</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-shopify-debezium-cdc/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-shopify-debezium-cdc/</guid><description>&lt;p>Shopify 的 CDC pipeline 揭露了 sharded monolith 上大規模 log-based CDC 的真實工程壓力。壓力集中在 snapshot 跟 oversized payload，穩態複製本身反而是最穩定的部分。&lt;/p>
&lt;h2 id="業務背景">業務背景&lt;/h2>
&lt;p>Shopify 的核心資料儲存是 100+ 個 MySQL shard，每個 shard 承載不同商家的交易資料。下游系統（搜尋索引、analytics、資料倉儲）需要近即時地取得資料變更。原本用 query-based 方案（內部系統 Longboat）輪詢資料庫，但隨 shard 數量跟資料量成長，輪詢的延遲跟資料庫負載壓力持續惡化。&lt;/p>
&lt;p>遷移到 log-based CDC（Debezium over Kafka Connect）後，pipeline 的穩態規模是 ~150 個 Debezium connector 跑在 12 個 Kubernetes pod、Black Friday peak 100K records/sec、P99 latency &amp;lt; 10s。&lt;/p>
&lt;h2 id="技術挑戰">技術挑戰&lt;/h2>
&lt;h3 id="snapshot-鎖定-read-replica">Snapshot 鎖定 read replica&lt;/h3>
&lt;p>Debezium 在初始同步（snapshot）時需要取得一致性快照。MySQL connector 的預設行為是對 read replica 取 global read lock，鎖住的時間跟表大小成正比。Shopify 的大表 snapshot 可能鎖住 read replica 數小時，影響線上查詢。&lt;/p>
&lt;p>Shopify 工程師直接向 Debezium 上游貢獻了「lock-free snapshot」機制 — 用 MySQL 的 GTID（Global Transaction ID）確保一致性，取代 global read lock。這個改動後來合併進 Debezium 主線，所有使用者都受益。&lt;/p>
&lt;h3 id="oversized-record">Oversized record&lt;/h3>
&lt;p>MySQL 的 blob / text 欄位可能產生超過 1 MB 的 CDC record。Kafka 的 message size limit（預設 1 MB）會讓這些 record 被 producer 拒絕。調大 &lt;code>max.message.bytes&lt;/code> 是一個選項，但會影響 broker 的記憶體跟 replication 效率。&lt;/p>
&lt;p>Shopify 的解法是把 oversized payload 寫到 GCS（Google Cloud Storage），CDC record 只帶 GCS pointer。Consumer 端在需要完整資料時再從 GCS 取。這個 pattern 把 Kafka 維持在「傳遞事件 metadata」的定位，大型 payload 走 object storage。&lt;/p>
&lt;h3 id="connector-故障隔離">Connector 故障隔離&lt;/h3>
&lt;p>150 個 connector 跑在 12 個 pod 上，一個 connector 的 failure（例如某個 shard 的 MySQL 做了 schema change、binlog 格式不相容）可能影響同 pod 上的其他 connector。Shopify 用 Kafka Connect 的 distributed mode + task rebalance 做故障隔離，但 rebalance 本身在 connector 數量多時有延遲。&lt;/p>
&lt;h2 id="解法與取捨">解法與取捨&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>挑戰&lt;/th>
 &lt;th>解法&lt;/th>
 &lt;th>取捨&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Snapshot 鎖定&lt;/td>
 &lt;td>Lock-free snapshot（GTID）&lt;/td>
 &lt;td>需要 MySQL 啟用 GTID、upstream contribution 維護成本&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Oversized record&lt;/td>
 &lt;td>GCS pointer 替代 inline data&lt;/td>
 &lt;td>Consumer 端要多一步 GCS 讀取、增加端到端延遲&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Connector 隔離&lt;/td>
 &lt;td>Distributed mode + rebalance&lt;/td>
 &lt;td>Rebalance storm 在大量 connector 時可能造成全域暫停&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>高峰流量&lt;/td>
 &lt;td>12 pod K8s 部署、水平擴展&lt;/td>
 &lt;td>Pod 數量增加讓 Kafka Connect worker 的 rebalance 更複雜&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="回寫教材的連結">回寫教材的連結&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/outbox-pattern/" data-link-title="3.3 outbox pattern 與發佈一致性" data-link-desc="把 transaction 與 event publish 分離">3.3 outbox pattern&lt;/a>：CDC 是 outbox pattern 的 log-based 替代方案。Shopify 的 case 揭露 CDC 的工程成本集中在 snapshot 跟 schema evolution，outbox 的成本集中在應用層 dual-write。&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka vendor 頁&lt;/a>：Kafka Connect / CDC 的進階主題。&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/broker-basics/" data-link-title="3.1 broker 基礎與投遞模型" data-link-desc="先理解 broker、queue、consumer 與 delivery semantics">3.1 broker basics&lt;/a>：message size limit 跟 broker 資源的關係。&lt;/li>
&lt;/ul>
&lt;h2 id="判讀徵兆">判讀徵兆&lt;/h2>
&lt;p>讀者在自己的系統看到以下訊號時，應該回讀本案例：&lt;/p></description><content:encoded><![CDATA[<p>Shopify 的 CDC pipeline 揭露了 sharded monolith 上大規模 log-based CDC 的真實工程壓力。壓力集中在 snapshot 跟 oversized payload，穩態複製本身反而是最穩定的部分。</p>
<h2 id="業務背景">業務背景</h2>
<p>Shopify 的核心資料儲存是 100+ 個 MySQL shard，每個 shard 承載不同商家的交易資料。下游系統（搜尋索引、analytics、資料倉儲）需要近即時地取得資料變更。原本用 query-based 方案（內部系統 Longboat）輪詢資料庫，但隨 shard 數量跟資料量成長，輪詢的延遲跟資料庫負載壓力持續惡化。</p>
<p>遷移到 log-based CDC（Debezium over Kafka Connect）後，pipeline 的穩態規模是 ~150 個 Debezium connector 跑在 12 個 Kubernetes pod、Black Friday peak 100K records/sec、P99 latency &lt; 10s。</p>
<h2 id="技術挑戰">技術挑戰</h2>
<h3 id="snapshot-鎖定-read-replica">Snapshot 鎖定 read replica</h3>
<p>Debezium 在初始同步（snapshot）時需要取得一致性快照。MySQL connector 的預設行為是對 read replica 取 global read lock，鎖住的時間跟表大小成正比。Shopify 的大表 snapshot 可能鎖住 read replica 數小時，影響線上查詢。</p>
<p>Shopify 工程師直接向 Debezium 上游貢獻了「lock-free snapshot」機制 — 用 MySQL 的 GTID（Global Transaction ID）確保一致性，取代 global read lock。這個改動後來合併進 Debezium 主線，所有使用者都受益。</p>
<h3 id="oversized-record">Oversized record</h3>
<p>MySQL 的 blob / text 欄位可能產生超過 1 MB 的 CDC record。Kafka 的 message size limit（預設 1 MB）會讓這些 record 被 producer 拒絕。調大 <code>max.message.bytes</code> 是一個選項，但會影響 broker 的記憶體跟 replication 效率。</p>
<p>Shopify 的解法是把 oversized payload 寫到 GCS（Google Cloud Storage），CDC record 只帶 GCS pointer。Consumer 端在需要完整資料時再從 GCS 取。這個 pattern 把 Kafka 維持在「傳遞事件 metadata」的定位，大型 payload 走 object storage。</p>
<h3 id="connector-故障隔離">Connector 故障隔離</h3>
<p>150 個 connector 跑在 12 個 pod 上，一個 connector 的 failure（例如某個 shard 的 MySQL 做了 schema change、binlog 格式不相容）可能影響同 pod 上的其他 connector。Shopify 用 Kafka Connect 的 distributed mode + task rebalance 做故障隔離，但 rebalance 本身在 connector 數量多時有延遲。</p>
<h2 id="解法與取捨">解法與取捨</h2>
<table>
  <thead>
      <tr>
          <th>挑戰</th>
          <th>解法</th>
          <th>取捨</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Snapshot 鎖定</td>
          <td>Lock-free snapshot（GTID）</td>
          <td>需要 MySQL 啟用 GTID、upstream contribution 維護成本</td>
      </tr>
      <tr>
          <td>Oversized record</td>
          <td>GCS pointer 替代 inline data</td>
          <td>Consumer 端要多一步 GCS 讀取、增加端到端延遲</td>
      </tr>
      <tr>
          <td>Connector 隔離</td>
          <td>Distributed mode + rebalance</td>
          <td>Rebalance storm 在大量 connector 時可能造成全域暫停</td>
      </tr>
      <tr>
          <td>高峰流量</td>
          <td>12 pod K8s 部署、水平擴展</td>
          <td>Pod 數量增加讓 Kafka Connect worker 的 rebalance 更複雜</td>
      </tr>
  </tbody>
</table>
<h2 id="回寫教材的連結">回寫教材的連結</h2>
<ul>
<li><a href="/blog/backend/03-message-queue/outbox-pattern/" data-link-title="3.3 outbox pattern 與發佈一致性" data-link-desc="把 transaction 與 event publish 分離">3.3 outbox pattern</a>：CDC 是 outbox pattern 的 log-based 替代方案。Shopify 的 case 揭露 CDC 的工程成本集中在 snapshot 跟 schema evolution，outbox 的成本集中在應用層 dual-write。</li>
<li><a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka vendor 頁</a>：Kafka Connect / CDC 的進階主題。</li>
<li><a href="/blog/backend/03-message-queue/broker-basics/" data-link-title="3.1 broker 基礎與投遞模型" data-link-desc="先理解 broker、queue、consumer 與 delivery semantics">3.1 broker basics</a>：message size limit 跟 broker 資源的關係。</li>
</ul>
<h2 id="判讀徵兆">判讀徵兆</h2>
<p>讀者在自己的系統看到以下訊號時，應該回讀本案例：</p>
<ul>
<li>CDC snapshot 過程持續數小時、鎖住 read replica 影響線上查詢</li>
<li>CDC record size 頻繁超過 Kafka 的 message size limit</li>
<li>Kafka Connect connector 數量超過 50 個、rebalance 時間開始明顯增長</li>
<li>從 query-based 同步（輪詢）切換到 log-based CDC 的評估階段</li>
</ul>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://shopify.engineering/capturing-every-change-shopify-sharded-monolith">Capturing Every Change From Shopify&rsquo;s Sharded Monolith</a></li>
</ul>
]]></content:encoded></item><item><title>Kafka Schema Registry 與 schema 演進：wire format、compatibility level 與安全演進規則</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/schema-registry-evolution/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/schema-registry-evolution/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Apache Kafka&lt;/a> overview「KRaft 與 Schema Registry」段的 implementation-layer deep article。Overview 已交代 Schema Registry 在事件總線中的定位；本文聚焦 &lt;em>怎麼設 compatibility、wire format 長什麼樣、schema 怎麼安全演進、演進設錯會打掛什麼&lt;/em>。對應 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/event-schema-compatibility/" data-link-title="Event Schema Compatibility" data-link-desc="說明 event schema 演進時，新舊 producer 與 consumer 能否互通的相容性等級">Event Schema Compatibility&lt;/a> 知識卡的 implementation 展開。&lt;/p>&lt;/blockquote>
&lt;h2 id="為什麼事件總線需要一個獨立的-schema-治理元件">為什麼事件總線需要一個獨立的 schema 治理元件&lt;/h2>
&lt;p>Schema Registry 是把「event 的結構契約」從 producer 與 consumer 的程式碼裡抽出來、集中存放並強制版本相容性的元件。它承擔的責任是讓不同 service、不同部署節奏的 producer 與 consumer 在 schema 改版時仍能互通，而不需要全體同時上線。Kafka broker 本身只存 bytes、不理解 payload 結構；一旦多個團隊往同一個 topic 寫事件、又各自獨立發版，schema 漂移就會在 consumer 端炸開。&lt;/p>
&lt;p>這個責任在單一 service 內部不存在。一個 service 自己 produce、自己 consume，schema 改版同一個 deploy 就同步了，序列化用什麼格式都行。Schema Registry 解的是 &lt;em>跨 service、跨團隊、跨部署時間&lt;/em> 的契約問題：A 團隊升級了訂單事件加一個欄位，B 團隊的對帳服務還跑舊版 consumer，C 團隊的風控服務跑更舊版——三方不同步演進，靠的就是 registry 在 producer 註冊新 schema 時先擋下破壞相容性的改動。&lt;/p>
&lt;p>Yelp 的 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-yelp-schematizer/" data-link-title="3.C14 Yelp：Schematizer 自建 Schema Registry" data-link-desc="Yelp data pipeline 強制所有 message 走 Avro、自建 Schematizer 做 schema evolution 與 topic 自動分配。">Schematizer 案例&lt;/a> 把這個責任拉到極端：一天數十億訊息、數百個 service、數千個 schema，自建 registry 強制所有 message 走 Avro、訊息只帶 schema ID。它揭露 schema 治理是 data pipeline 的核心責任、不是 add-on——當規模到了數千 schema，沒有集中強制的相容性檢查，跨服務事件契約會在某次發版後悄悄斷掉，而 broker 不會報任何錯。&lt;/p>
&lt;p>Confluent Schema Registry 是業界事實標準的實作；Apicurio 是 CNCF 生態的開源替代，額外支援 OpenAPI / AsyncAPI artifact、且提供 Confluent-compatible API endpoint，遷移成本低。兩者都把 schema 存進一個 Kafka topic（Confluent 用 &lt;code>_schemas&lt;/code>，single-partition、compacted），registry 自己是無狀態的，掛掉重啟後從該 topic rebuild。&lt;/p>
&lt;h2 id="schema-id-嵌進訊息的-wire-format">Schema ID 嵌進訊息的 wire format&lt;/h2>
&lt;p>Confluent wire format 在每筆訊息的 value（或 key）前面加 5 個 byte：1 個 magic byte（固定 &lt;code>0x00&lt;/code>）加 4 個 big-endian byte 的 schema ID，後面才接序列化後的 payload。Consumer 拿到訊息先讀這 5 個 byte，用 schema ID 去 registry 查對應 schema，再用該 schema 反序列化。這是「訊息只帶 schema ID、不帶 schema 本體」的機制——schema 本體只在 registry 存一份，訊息裡放的是指標。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Apache Kafka</a> overview「KRaft 與 Schema Registry」段的 implementation-layer deep article。Overview 已交代 Schema Registry 在事件總線中的定位；本文聚焦 <em>怎麼設 compatibility、wire format 長什麼樣、schema 怎麼安全演進、演進設錯會打掛什麼</em>。對應 <a href="/blog/backend/knowledge-cards/event-schema-compatibility/" data-link-title="Event Schema Compatibility" data-link-desc="說明 event schema 演進時，新舊 producer 與 consumer 能否互通的相容性等級">Event Schema Compatibility</a> 知識卡的 implementation 展開。</p></blockquote>
<h2 id="為什麼事件總線需要一個獨立的-schema-治理元件">為什麼事件總線需要一個獨立的 schema 治理元件</h2>
<p>Schema Registry 是把「event 的結構契約」從 producer 與 consumer 的程式碼裡抽出來、集中存放並強制版本相容性的元件。它承擔的責任是讓不同 service、不同部署節奏的 producer 與 consumer 在 schema 改版時仍能互通，而不需要全體同時上線。Kafka broker 本身只存 bytes、不理解 payload 結構；一旦多個團隊往同一個 topic 寫事件、又各自獨立發版，schema 漂移就會在 consumer 端炸開。</p>
<p>這個責任在單一 service 內部不存在。一個 service 自己 produce、自己 consume，schema 改版同一個 deploy 就同步了，序列化用什麼格式都行。Schema Registry 解的是 <em>跨 service、跨團隊、跨部署時間</em> 的契約問題：A 團隊升級了訂單事件加一個欄位，B 團隊的對帳服務還跑舊版 consumer，C 團隊的風控服務跑更舊版——三方不同步演進，靠的就是 registry 在 producer 註冊新 schema 時先擋下破壞相容性的改動。</p>
<p>Yelp 的 <a href="/blog/backend/03-message-queue/cases/kafka-yelp-schematizer/" data-link-title="3.C14 Yelp：Schematizer 自建 Schema Registry" data-link-desc="Yelp data pipeline 強制所有 message 走 Avro、自建 Schematizer 做 schema evolution 與 topic 自動分配。">Schematizer 案例</a> 把這個責任拉到極端：一天數十億訊息、數百個 service、數千個 schema，自建 registry 強制所有 message 走 Avro、訊息只帶 schema ID。它揭露 schema 治理是 data pipeline 的核心責任、不是 add-on——當規模到了數千 schema，沒有集中強制的相容性檢查，跨服務事件契約會在某次發版後悄悄斷掉，而 broker 不會報任何錯。</p>
<p>Confluent Schema Registry 是業界事實標準的實作；Apicurio 是 CNCF 生態的開源替代，額外支援 OpenAPI / AsyncAPI artifact、且提供 Confluent-compatible API endpoint，遷移成本低。兩者都把 schema 存進一個 Kafka topic（Confluent 用 <code>_schemas</code>，single-partition、compacted），registry 自己是無狀態的，掛掉重啟後從該 topic rebuild。</p>
<h2 id="schema-id-嵌進訊息的-wire-format">Schema ID 嵌進訊息的 wire format</h2>
<p>Confluent wire format 在每筆訊息的 value（或 key）前面加 5 個 byte：1 個 magic byte（固定 <code>0x00</code>）加 4 個 big-endian byte 的 schema ID，後面才接序列化後的 payload。Consumer 拿到訊息先讀這 5 個 byte，用 schema ID 去 registry 查對應 schema，再用該 schema 反序列化。這是「訊息只帶 schema ID、不帶 schema 本體」的機制——schema 本體只在 registry 存一份，訊息裡放的是指標。</p>
<p>本文用 OrbStack 起 <code>confluentinc/cp-kafka</code> + <code>confluentinc/cp-schema-registry</code>，用 Avro console producer 寫一筆 <code>{&quot;id&quot;:1,&quot;name&quot;:&quot;alice&quot;}</code>，再 dump 出 raw bytes 驗證 wire format：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">000000 00 00 00 00 01 02 0a 61 6c 69 63 65 0a   &gt;.......alice.&lt;</span></span></code></pre></div><p>逐 byte 拆解：</p>
<ul>
<li><code>00</code>：magic byte，標識這是 Confluent wire format</li>
<li><code>00 00 00 01</code>：4-byte big-endian schema ID = 1，consumer 拿這個去 registry 查 schema</li>
<li><code>02</code>：Avro 把 <code>id</code>（long）以 zigzag varint 編碼，<code>1</code> 編成 <code>0x02</code></li>
<li><code>0a 61 6c 69 63 65</code>：<code>name</code>（string）長度 5（zigzag <code>0x0a</code>）加 UTF-8 的 <code>alice</code></li>
</ul>
<p>這個格式有兩個工程後果。第一，consumer 反序列化任何訊息前都要能連到 registry——registry 掛掉，已 cache schema ID 的 consumer 還能跑，但遇到沒見過的 schema ID 就卡住。第二，schema ID 是全域單調遞增的整數、跨 subject 共用：同一份 schema 被多個 topic 註冊只會有一個 ID。實機驗證可以看到，先註冊到 <code>user-value</code> 的 schema 拿到 <code>id:1</code>，之後用同樣結構寫 <code>users-demo</code> topic 時，registry 認出是同一份 schema、複用 <code>id:1</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">{</span><span class="nt">&#34;subject&#34;</span><span class="p">:</span><span class="s2">&#34;users-demo-value&#34;</span><span class="p">,</span><span class="nt">&#34;version&#34;</span><span class="p">:</span><span class="mi">1</span><span class="p">,</span><span class="nt">&#34;id&#34;</span><span class="p">:</span><span class="mi">1</span><span class="p">,</span><span class="nt">&#34;schemaType&#34;</span><span class="p">:</span><span class="s2">&#34;AVRO&#34;</span><span class="p">,</span> <span class="err">...</span><span class="p">}</span></span></span></code></pre></div><p><code>version</code> 是 subject 內的序號（每個 subject 從 1 開始）、<code>id</code> 是全域的。除錯時看到某筆訊息反序列化失敗，第一步就是讀那 4-byte schema ID、去 registry 撈出它指向哪個 schema、跟 consumer 預期的對不對。</p>
<h2 id="序列化格式取捨avroprotobufjson-schema">序列化格式取捨：Avro、Protobuf、JSON Schema</h2>
<p>Schema Registry 支援三種格式，差異不只是語法、而是演進規則與生態的取捨。</p>
<table>
  <thead>
      <tr>
          <th>格式</th>
          <th>演進機制</th>
          <th>適合場景</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Avro</td>
          <td>reader / writer schema resolution</td>
          <td>data pipeline、強 schema 演進需求、JVM 生態</td>
      </tr>
      <tr>
          <td>Protobuf</td>
          <td>field number 標記</td>
          <td>已用 gRPC、跨語言 RPC + 事件共用 schema</td>
      </tr>
      <tr>
          <td>JSON Schema</td>
          <td>結構 + validation keyword</td>
          <td>已大量 JSON、要人類可讀、容忍較弱的型別保證</td>
      </tr>
  </tbody>
</table>
<p>Avro 的演進靠 <em>reader schema 與 writer schema 分離</em>：訊息用 writer schema（寫入時的版本）序列化，consumer 用自己的 reader schema（讀取時的版本）反序列化，registry 提供兩者做 schema resolution。這是 Avro 在 data pipeline 場景的核心優勢——欄位帶 default 時，舊資料用新 schema 讀會自動填 default，新資料用舊 schema 讀會自動忽略多出來的欄位。Yelp、多數 Kafka-native data platform 都選 Avro，正是因為它的演進語意最完整。</p>
<p>Protobuf 用 field number 而非欄位名做 wire 識別：欄位改名不破壞相容性（number 沒變即可），刪欄位要 reserve 掉 number 避免重用。已經用 gRPC 的團隊讓 RPC 與事件共用同一份 <code>.proto</code>，省一套 schema 維護。代價是 Protobuf 的 default 語意較弱（proto3 沒有 explicit presence 的 scalar 一律有 zero value），某些演進判斷不如 Avro 直觀。</p>
<p>JSON Schema 適合既有系統已經大量用 JSON、且看重人類可讀與 validation keyword（<code>required</code>、<code>minimum</code>、<code>pattern</code>）的場景。代價是 payload 較大（欄位名重複出現在每筆訊息）、型別保證弱於前兩者。當吞吐量大、payload size 敏感時，JSON Schema 的頻寬成本會顯著高於 Avro 的 binary 編碼。</p>
<p>選型判準：data pipeline 為主、重演進安全 → Avro；已有 gRPC、RPC 與事件共用 → Protobuf；既有 JSON 生態、重可讀性而吞吐量不極端 → JSON Schema。三者可在同一個 registry 並存（每個 subject 各自標 schemaType），但同一個 subject 內不能混用格式。</p>
<h2 id="subject-naming-strategy-決定相容性檢查的邊界">Subject naming strategy 決定相容性檢查的邊界</h2>
<p>Subject 是 registry 裡做版本管理與相容性檢查的基本單位；naming strategy 決定「哪些 schema 被歸進同一個 subject、因而要互相相容」。選錯 strategy 會讓相容性檢查管太寬或太窄，是後面故障演練的根源之一。</p>
<table>
  <thead>
      <tr>
          <th>Strategy</th>
          <th>Subject 名</th>
          <th>相容性檢查邊界</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>TopicNameStrategy</td>
          <td><code>&lt;topic&gt;-value</code> / <code>&lt;topic&gt;-key</code></td>
          <td>整個 topic 只能有一種 value schema 演進</td>
      </tr>
      <tr>
          <td>RecordNameStrategy</td>
          <td><code>&lt;record 全名&gt;</code></td>
          <td>同名 record 跨所有 topic 一起演進</td>
      </tr>
      <tr>
          <td>TopicRecordNameStrategy</td>
          <td><code>&lt;topic&gt;-&lt;record 全名&gt;</code></td>
          <td>同 topic 內可放多種 record、各自演進</td>
      </tr>
  </tbody>
</table>
<p>TopicNameStrategy 是預設，subject 名就是 <code>&lt;topic&gt;-value</code>。實機驗證可以看到，用 Avro producer 寫 <code>users-demo</code> topic 時，registry 自動建立 <code>users-demo-value</code> subject：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">[</span><span class="s2">&#34;user-value&#34;</span><span class="p">,</span><span class="s2">&#34;users-demo-value&#34;</span><span class="p">]</span></span></span></code></pre></div><p>預設策略的隱含假設是「一個 topic 只承載一種事件型別」。這對多數 topic 成立，但當業務要把多種相關事件（例如 <code>OrderCreated</code> 與 <code>OrderCancelled</code>）放進同一個 topic 以保證跨事件 ordering 時，TopicNameStrategy 會把兩種 record 當成同一個 subject 的版本演進、互相做相容性檢查——這幾乎一定失敗，因為兩種事件結構本來就不同。</p>
<p>這時要改 RecordNameStrategy（subject = record 全名，跨 topic 同名 record 共用一份演進歷史）或 TopicRecordNameStrategy（subject = topic + record 名，同 topic 多型別各自獨立演進）。判準：一個 topic 一種事件 → 預設即可；一個 topic 多種事件且要保 ordering → TopicRecordNameStrategy；同一種 record 散在多個 topic 要強制全域一致 → RecordNameStrategy。Producer 與 consumer 必須設成同一個 strategy，否則 consumer 會用錯 subject 去查 schema。</p>
<h2 id="compatibility-level四種基礎--transitive">Compatibility level：四種基礎 × transitive</h2>
<p>Compatibility level 是 registry 在 producer 註冊新 schema 時套用的相容性規則，決定哪些 schema 改動會被擋下。它回答的問題是「新 schema 跟既有 schema 比，誰應該能讀誰寫的資料」。設定可以是全域預設、也可以 per-subject 覆寫。</p>
<table>
  <thead>
      <tr>
          <th>Level</th>
          <th>規則</th>
          <th>保護對象</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>BACKWARD</td>
          <td>新 schema 能讀舊 schema 寫的資料</td>
          <td>consumer 先升級、producer 後升級</td>
      </tr>
      <tr>
          <td>FORWARD</td>
          <td>舊 schema 能讀新 schema 寫的資料</td>
          <td>producer 先升級、consumer 後升級</td>
      </tr>
      <tr>
          <td>FULL</td>
          <td>同時滿足 BACKWARD 與 FORWARD</td>
          <td>雙向都能不同步演進</td>
      </tr>
      <tr>
          <td>NONE</td>
          <td>不檢查</td>
          <td>不保護（演進風險全交給人）</td>
      </tr>
  </tbody>
</table>
<p>BACKWARD 是 Confluent 預設，實機驗證可以確認：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">{</span><span class="nt">&#34;compatibilityLevel&#34;</span><span class="p">:</span><span class="s2">&#34;BACKWARD&#34;</span><span class="p">}</span></span></span></code></pre></div><p>BACKWARD 保護的是「consumer 先升級」的演進順序——新版 consumer 必須能讀舊版 producer 還在寫的舊資料。它允許的安全改動是「加帶 default 的欄位」與「刪欄位」：新 schema 讀舊資料時，舊資料缺的新欄位用 default 補；新 schema 不要的欄位讀舊資料時忽略。它擋下的是「加沒有 default 的必填欄位」——舊資料沒這欄位、新 consumer 又要求它存在，就讀不出來。</p>
<p>FORWARD 反過來保護「producer 先升級」：舊版 consumer 要能讀新版 producer 寫的資料。它允許「刪帶 default 的欄位」與「加欄位」。當演進順序是 producer 先上、consumer 慢慢跟（例如先讓 producer 開始寫新欄位、consumer 之後才用）時選 FORWARD。</p>
<p>FULL 同時滿足兩者，代價是只能做「加帶 default 的欄位」與「刪帶 default 的欄位」這類雙向安全的改動，演進自由度最低但最安全。當 producer 與 consumer 的升級順序無法協調（大型組織、多團隊各自排程）時，FULL 把演進約束到怎麼改都不會斷。</p>
<p>四種各有一個 transitive 變體（<code>BACKWARD_TRANSITIVE</code> 等）。非 transitive 只檢查新 schema 對 <em>最近一版</em>；transitive 檢查新 schema 對 <em>該 subject 所有歷史版本</em>。差別在這個場景：v1 → v2 相容、v2 → v3 相容，但 v3 對 v1 不相容。非 transitive 會放行 v3（因為只比 v2）；transitive 會擋下。當 consumer 可能 replay 很舊的歷史資料（Kafka 的長期保留 + replay 正是常態），transitive 才能保證任何歷史版本都讀得出來。<a href="/blog/backend/03-message-queue/event-contract-replay-boundary/" data-link-title="3.7 Event Contract 與 Replay Boundary" data-link-desc="說明 event schema、idempotency key、replay window 與補償如何先於 broker 選型。">3.7 event contract / replay boundary</a> 講的 replay 邊界，在 schema 層的對應就是 transitive compatibility。</p>
<h2 id="安全演進規則實機驗證註冊與拒絕">安全演進規則：實機驗證註冊與拒絕</h2>
<p>把上面的規則落到實際操作。在預設 BACKWARD 下，註冊 v1（<code>id</code> + <code>name</code>）後，加一個帶 default 的 <code>email</code> 欄位是安全的，registry 接受並記為 v2：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">{</span><span class="nt">&#34;id&#34;</span><span class="p">:</span><span class="mi">2</span><span class="p">,</span><span class="nt">&#34;version&#34;</span><span class="p">:</span><span class="mi">2</span><span class="p">,</span><span class="nt">&#34;schemaType&#34;</span><span class="p">:</span><span class="s2">&#34;AVRO&#34;</span><span class="p">,</span> <span class="err">...</span><span class="p">}</span></span></span></code></pre></div><p><code>user-value</code> 的版本列表確認累積成兩版：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">[</span><span class="mi">1</span><span class="p">,</span><span class="mi">2</span><span class="p">]</span></span></span></code></pre></div><p>接著嘗試加一個 <em>沒有 default</em> 的 <code>age</code>（int）必填欄位——這破壞 BACKWARD，因為新 consumer 讀舊資料時 <code>age</code> 沒值也沒 default。registry 回 HTTP 409 並指出確切原因：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">{</span><span class="nt">&#34;error_code&#34;</span><span class="p">:</span><span class="mi">40901</span><span class="p">,</span><span class="nt">&#34;message&#34;</span><span class="p">:</span><span class="s2">&#34;Schema being registered is incompatible with an earlier schema for subject \&#34;user-value\&#34;</span><span class="p">,</span> <span class="err">details:</span> <span class="err">[{errorType:&#39;READER_FIELD_MISSING_DEFAULT_VALUE&#39;,</span> <span class="err">description:&#39;The</span> <span class="err">field</span> <span class="err">&#39;age&#39;</span> <span class="err">at</span> <span class="err">path</span> <span class="err">&#39;/fields/3&#39;</span> <span class="err">in</span> <span class="err">the</span> <span class="err">new</span> <span class="err">schema</span> <span class="err">has</span> <span class="err">no</span> <span class="err">default</span> <span class="err">value</span> <span class="err">and</span> <span class="err">is</span> <span class="err">missing</span> <span class="err">in</span> <span class="err">the</span> <span class="err">old</span> <span class="err">schema&#39;,</span> <span class="err">...</span><span class="p">}</span><span class="err">],</span> <span class="err">compatibility:</span> <span class="err">&#39;BACKWARD&#39;}</span></span></span></code></pre></div><p><code>READER_FIELD_MISSING_DEFAULT_VALUE</code> 精確命中規則：reader（新 schema）多了一個舊資料沒有、又無 default 的欄位。registry 另外提供 compatibility check API，可以在不真正註冊的前提下先問「相不相容」，給 CI pipeline 在 PR 階段擋下破壞性改動：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">{</span><span class="nt">&#34;is_compatible&#34;</span><span class="p">:</span><span class="kc">false</span><span class="p">}</span></span></span></code></pre></div><p>由此導出兩條安全演進的操作規則。<strong>加欄位</strong>：一律帶 default（BACKWARD / FULL 都要），舊資料才能用新 schema 讀出。沒有合理 default 的「必填新欄位」不能直接加——要嘛在 producer 端先全部開始寫該欄位、確認資料齊全後再 promote，要嘛走新 topic / 新 record 而非原地演進。<strong>刪欄位</strong>：分步做。先讓所有 consumer 停止依賴該欄位（部署一輪），確認沒人讀之後，下一輪才從 schema 拿掉。一步到位刪掉還在被讀的欄位，會在 FORWARD / FULL 下被擋、在 BACKWARD 下放行但打掛還沒升級的 consumer。</p>
<h2 id="production-故障演練">Production 故障演練</h2>
<h3 id="case-1producer-加必填欄位無-default打掛舊-consumer">Case 1：producer 加必填欄位無 default，打掛舊 consumer</h3>
<p><strong>徵兆</strong>：某團隊 producer 發版後，另一團隊的舊 consumer 開始大量反序列化失敗、<code>SerializationException</code> 或 <code>AvroTypeException: Found X, expecting Y</code>，consumer lag 暴衝、訊息卡在 poll 階段。producer 端與 broker 端完全沒報錯——訊息照寫成功。</p>
<p><strong>根因</strong>：subject 的 compatibility level 被設成 NONE（或該欄位走了 FORWARD 不檢查 reader 缺欄位的路徑）。producer 加了一個沒有 default 的必填欄位、registry 沒擋，新訊息帶新 schema ID 寫進 topic。舊 consumer 用自己的舊 reader schema 去反序列化新 writer schema 的資料，遇到自己不認識又無從補值的結構就炸。問題不在 producer 也不在 broker，在 <em>registry 沒在註冊時擋下這次演進</em>。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>把 compatibility level 改回至少 BACKWARD</strong>：實機驗證過 NONE 會直接放行破壞性 schema——把 <code>compatibility</code> 設成 NONE 後，前面被 409 拒絕的破壞性 schema 立刻被接受成 v3。NONE 等於把演進安全完全交給人，多團隊場景幾乎一定出事。</li>
<li><strong>回退 producer</strong>：先讓 producer 退回舊 schema 止血，恢復舊 consumer 可讀。</li>
<li><strong>重新演進</strong>：欄位帶 default 重發，或若該欄位語意上必填、走「先讓 producer 寫、consumer 升級、再 promote」的分步路徑。</li>
<li><strong>CI 防線</strong>：把 compatibility check API（<code>/compatibility/subjects/&lt;s&gt;/versions/latest</code>）接進 producer repo 的 CI，PR 階段就用 <code>is_compatible:false</code> 擋掉，不等到 production 註冊時才發現。</li>
</ol>
<h3 id="case-2compatibility-level-設錯放行破壞性變更">Case 2：compatibility level 設錯，放行破壞性變更</h3>
<p><strong>徵兆</strong>：team 以為有 registry 把關所以放心演進，某次刪掉一個還在被下游讀的欄位、registry 接受了，下游服務隔天開始拿到 null / 缺欄位、business logic 走錯分支，但沒有任何 exception——資料「看起來正常」只是少了東西。</p>
<p><strong>根因</strong>：compatibility level 設成了 FORWARD 而需求其實是 BACKWARD，或設成 NONE。實機驗證可以看到 per-subject 覆寫的行為——對 <code>user-value</code> 單獨 PUT <code>FORWARD</code> 後查 config 回 <code>{&quot;compatibilityLevel&quot;:&quot;FORWARD&quot;}</code>，這個 subject 的檢查方向就跟全域預設不同了。FORWARD 允許刪帶 default 的欄位（保護 producer 先升級的順序），但團隊實際的演進順序是 consumer 後升級——方向錯配，registry 放行的正是會打掛 consumer 的那類改動。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>依演進順序選 level，不是隨手設</strong>：consumer 先升級選 BACKWARD；producer 先升級選 FORWARD；順序無法協調選 FULL。把這個決策寫進 topic ownership 文件、不是留給註冊當下的人臨時判斷。</li>
<li><strong>可能 replay 歷史就用 transitive</strong>：Kafka 長期保留 + replay 是常態，非 transitive 只擋最近一版、replay 舊資料時舊 schema 仍可能讀不出。長期保留的 topic 預設用 <code>*_TRANSITIVE</code>。</li>
<li><strong>per-subject 覆寫要留審計</strong>：全域預設外的每一個 per-subject 覆寫都是一個風險點，要能查出「誰、何時、為什麼把這個 subject 改成跟預設不同」。</li>
</ol>
<h3 id="case-3schema-id-對不上consumer-反序列化失敗">Case 3：schema ID 對不上，consumer 反序列化失敗</h3>
<p><strong>徵兆</strong>：consumer 報 <code>Schema not found; error code: 40403</code> 或反序列化拿到亂碼、欄位錯位。某些訊息正常、某些失敗，跟特定 producer 或特定時間段相關。</p>
<p><strong>根因</strong>有幾種，靠讀訊息前 5 byte 的 schema ID 定位：</p>
<ul>
<li><strong>registry 換過、ID 不一致</strong>：跨環境（dev / staging / prod）各自一套 registry，schema ID 全域遞增的順序不同，同一份 schema 在不同環境是不同 ID。如果有人把 prod 的訊息 mirror 到 staging 而沒搬 schema，staging consumer 拿 prod 的 schema ID 去 staging registry 查就 404。</li>
<li><strong>訊息根本不是 Confluent wire format</strong>：有 producer 沒走 schema-aware serializer、直接寫 raw bytes，前 5 byte 不是 magic + ID。consumer 把第一個 byte 當 magic、後 4 byte 當 ID 去查，撈到不存在或錯誤的 schema。</li>
<li><strong>registry 不可達或 cache 失效</strong>：consumer 端 schema cache 沒命中、又連不上 registry。</li>
</ul>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>讀 wire format 確認</strong>：dump 訊息 raw bytes，確認第一個 byte 是 <code>00</code>、接下來 4 byte 解出來的 ID 在目標 registry 查得到。本文驗證過 <code>00 00 00 00 01</code> 對應 schema id 1，這是除錯的第一手證據。</li>
<li><strong>跨環境 schema 搬遷</strong>：mirror 訊息時用 registry 的 import / export，或 MirrorMaker 搭配 schema 同步，不要只搬資料不搬 schema。</li>
<li><strong>隔離非 schema-aware producer</strong>：用 ACL 或 topic 命名規範強制所有 producer 走 schema serializer，避免 raw bytes 混進 schema-managed topic。</li>
</ol>
<h3 id="case-4subject-naming-strategy-衝突">Case 4：subject naming strategy 衝突</h3>
<p><strong>徵兆</strong>：把第二種事件型別寫進既有 topic 時，producer 直接註冊失敗報 incompatible，或多 producer 寫同 topic 互相把對方的 schema 判成不相容、彼此發版互相擋。</p>
<p><strong>根因</strong>：用 TopicNameStrategy（預設）卻往同一個 topic 放多種 record。subject 是 <code>&lt;topic&gt;-value</code>、整個 topic 共用一條演進線，registry 拿 <code>OrderCancelled</code> 去跟既有的 <code>OrderCreated</code> 做相容性檢查——兩種結構不同的事件當然不相容。strategy 的隱含假設（一 topic 一事件型別）跟實際用法（一 topic 多事件保 ordering）衝突。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>改 strategy 配合用法</strong>：一 topic 多事件 → TopicRecordNameStrategy，subject 變成 <code>&lt;topic&gt;-&lt;record 全名&gt;</code>，每種 record 各自一條演進線、不互相檢查。</li>
<li><strong>producer 與 consumer 設同一個 strategy</strong>：strategy 不一致時 consumer 會用錯 subject 查 schema，拿到 null 或錯 schema。這是部署層的硬約束，要在共用 config 統一。</li>
<li><strong>若只是不小心寫錯 topic</strong>：那不是 strategy 問題、是路由問題，修 producer 的 topic 選擇邏輯，別為了繞過檢查改成 RecordNameStrategy。</li>
</ol>
<h2 id="容量與運維邊界">容量與運維邊界</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>估算 / 邊界</th>
          <th>警戒</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Schema 數量</td>
          <td>數千 schema registry 仍可運作（Yelp 等級）</td>
          <td><code>_schemas</code> topic 是 single-partition</td>
      </tr>
      <tr>
          <td>Wire format overhead</td>
          <td>每筆訊息固定 +5 byte</td>
          <td>高頻小訊息時相對 overhead 不可忽略</td>
      </tr>
      <tr>
          <td>Registry 可用性</td>
          <td>consumer cache 命中時可短暫容忍 registry 不可達</td>
          <td>冷 consumer / 新 schema ID 時硬依賴</td>
      </tr>
      <tr>
          <td>Compatibility 檢查</td>
          <td>註冊時做、非 hot path</td>
          <td>transitive 對長歷史 subject 檢查較慢</td>
      </tr>
      <tr>
          <td>環境隔離</td>
          <td>每環境一套 registry、schema ID 不跨環境一致</td>
          <td>跨環境 mirror 要同步搬 schema</td>
      </tr>
  </tbody>
</table>
<p>實務 default：data pipeline 場景選 Avro + 至少 BACKWARD；長期保留 + replay 的 topic 用 transitive；compatibility check 接進 CI 在 PR 階段擋破壞性改動，不依賴註冊當下把關；一 topic 一事件型別當預設、要多型別才動 naming strategy。Schema Registry 自己也是個要 HA 的元件——production 跑多副本、<code>_schemas</code> topic 的 replication factor 拉高，registry 是事件總線的單點時要當關鍵基礎設施對待。</p>
<h2 id="整合與下一步">整合與下一步</h2>
<h3 id="跟-cdc-pipeline-的銜接">跟 CDC pipeline 的銜接</h3>
<p><a href="/blog/backend/03-message-queue/cases/kafka-shopify-debezium-cdc/" data-link-title="3.C13 Shopify：Debezium CDC over sharded MySQL" data-link-desc="Shopify 100&#43; MySQL shard、150 Debezium connector、Black Friday 100K records/sec P99 &lt; 10s。">Shopify Debezium CDC 案例</a> 跑在 100+ MySQL shard、150 個 Debezium connector 的規模（該案例記載的重點是 lock-free snapshot 與 oversized record 處理）。CDC pipeline 有一個一般性的 schema 演進壓力，以下依 CDC 機制推導、非該案例的結論：上游 DDL 一改，Debezium 產生的 Kafka record schema 跟著變，下游 consumer 受影響。Schema Registry 的 compatibility 檢查就是把這道衝擊在進 Kafka 時攔下的關卡——選錯 compatibility level，一次 ALTER TABLE 就可能透過 CDC 打穿整條 pipeline。Debezium 與 Kafka Connect 原生整合 Schema Registry，connector 設定裡指定 registry URL 與 naming strategy。</p>
<h3 id="跟-replay-邊界與事件契約">跟 replay 邊界與事件契約</h3>
<p><a href="/blog/backend/03-message-queue/event-contract-replay-boundary/" data-link-title="3.7 Event Contract 與 Replay Boundary" data-link-desc="說明 event schema、idempotency key、replay window 與補償如何先於 broker 選型。">3.7 event contract / replay boundary</a> 講的是事件契約能 replay 多遠；schema 層的對應就是本文的 transitive compatibility。Replay 跨越多個 schema 版本時，只有 transitive 能保證任何歷史版本都讀得出來。兩者一起界定「這條事件流的契約能安全回放到多久以前」。</p>
<h3 id="下游能力">下游能力</h3>
<ul>
<li>概念索引：<a href="/blog/backend/knowledge-cards/event-schema-compatibility/" data-link-title="Event Schema Compatibility" data-link-desc="說明 event schema 演進時，新舊 producer 與 consumer 能否互通的相容性等級">Event Schema Compatibility</a> 知識卡（本文的 implementation 來源）</li>
<li>上游 vendor 頁：<a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Apache Kafka</a>（KRaft 與 Schema Registry 段）</li>
<li>對應案例：<a href="/blog/backend/03-message-queue/cases/kafka-yelp-schematizer/" data-link-title="3.C14 Yelp：Schematizer 自建 Schema Registry" data-link-desc="Yelp data pipeline 強制所有 message 走 Avro、自建 Schematizer 做 schema evolution 與 topic 自動分配。">3.C14 Yelp Schematizer</a>（schema 治理拉到平台層）、<a href="/blog/backend/03-message-queue/cases/kafka-shopify-debezium-cdc/" data-link-title="3.C13 Shopify：Debezium CDC over sharded MySQL" data-link-desc="Shopify 100&#43; MySQL shard、150 Debezium connector、Black Friday 100K records/sec P99 &lt; 10s。">3.C13 Shopify Debezium CDC</a>（CDC 場景的 schema evolution）</li>
<li>方法論：<a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章的寫作方法論</a></li>
</ul>
]]></content:encoded></item><item><title>3.C14 Yelp：Schematizer 自建 Schema Registry</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-yelp-schematizer/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-yelp-schematizer/</guid><description>&lt;p>這個案例的核心責任是說明 schema 治理是 data pipeline 的核心責任、不是 add-on。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>Yelp data pipeline 一天數十億訊息、跨數百個 service、數千 schema、用自建 Schematizer 強制所有 message 走 Avro schema、訊息只帶 schema ID。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>Schematizer 不只是 schema store、還做 schema evolution compatibility 與 topic 自動分配（不相容 schema 強制新 topic）。揭露 producer / consumer schema 治理要拉到平台層、靠工具強制、不靠人約定。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>Kafka 進階主題：Schema Registry / Schema evolution。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/event-contract-replay-boundary/" data-link-title="3.7 Event Contract 與 Replay Boundary" data-link-desc="說明 event schema、idempotency key、replay window 與補償如何先於 broker 選型。">3.7 event contract / replay boundary&lt;/a>。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://engineeringblog.yelp.com/2016/08/more-than-just-a-schema-store.html">Yelp Schematizer: More than just a schema store&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 schema 治理是 data pipeline 的核心責任、不是 add-on。</p>
<h2 id="觀察">觀察</h2>
<p>Yelp data pipeline 一天數十億訊息、跨數百個 service、數千 schema、用自建 Schematizer 強制所有 message 走 Avro schema、訊息只帶 schema ID。</p>
<h2 id="判讀">判讀</h2>
<p>Schematizer 不只是 schema store、還做 schema evolution compatibility 與 topic 自動分配（不相容 schema 強制新 topic）。揭露 producer / consumer schema 治理要拉到平台層、靠工具強制、不靠人約定。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>Kafka 進階主題：Schema Registry / Schema evolution。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/event-contract-replay-boundary/" data-link-title="3.7 Event Contract 與 Replay Boundary" data-link-desc="說明 event schema、idempotency key、replay window 與補償如何先於 broker 選型。">3.7 event contract / replay boundary</a>。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://engineeringblog.yelp.com/2016/08/more-than-just-a-schema-store.html">Yelp Schematizer: More than just a schema store</a></li>
</ul>
]]></content:encoded></item><item><title>Kafka Multi-tenant 治理：quota 限流、ACL 授權與 topic 生命週期</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/multi-tenant-quota-acl/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/multi-tenant-quota-acl/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Apache Kafka&lt;/a> overview「Multi-tenant 與配額治理」「Topic 生命週期治理」兩段的 implementation-layer deep article。Overview 說明這些議題對應哪些案例跟子議題、本文展開具體的 quota / ACL 配置、授權模型推導、故障徵兆與修法。&lt;/p>&lt;/blockquote>
&lt;h2 id="共享叢集的治理問題一個叢集多個互不信任的租戶">共享叢集的治理問題：一個叢集、多個互不信任的租戶&lt;/h2>
&lt;p>Multi-tenant Kafka 的核心問題是把一個物理叢集切成多個彼此隔離的邏輯空間、讓每個團隊用同一組 broker 卻不互相干擾。當 Kafka 從單一團隊的工具長成全公司的事件總線、叢集承載的不再是一條 pipeline、而是數十到數百個團隊的 producer 跟 consumer。這時叢集的瓶頸從「broker 夠不夠快」轉成「怎麼防止某個團隊的流量、權限、或 topic 失控波及其他所有人」。&lt;/p>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/uber-kafka-infrastructure-evolution/" data-link-title="3.C6 Uber：Kafka 事件平台演進" data-link-desc="事件平台從團隊自管走向多租戶共享基礎設施。">Uber 的 Kafka 平台演進&lt;/a>把這個轉換描述為「從單隊列問題提升到平台治理問題」。當事件平台服務眾多團隊、重點是配額、隔離、觀測與運維標準化、而非只擴 broker。擴 broker 解決的是總容量、解決不了「單一租戶吃光共享資源」這類隔離問題。&lt;/p>
&lt;p>共享叢集的治理分三個獨立的軸、各自處理不同的失控來源：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>治理軸&lt;/th>
 &lt;th>防的是什麼&lt;/th>
 &lt;th>工具&lt;/th>
 &lt;th>失控後果&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Quota（資源配額）&lt;/td>
 &lt;td>單租戶吃滿頻寬 / request 容量、餓死其他租戶&lt;/td>
 &lt;td>&lt;code>kafka-configs.sh&lt;/code> 設 byte rate&lt;/td>
 &lt;td>鄰居 producer 寫入卡死、consumer lag&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>ACL（存取授權）&lt;/td>
 &lt;td>租戶讀寫不屬於自己的 topic、或被未授權方寫入&lt;/td>
 &lt;td>&lt;code>kafka-acls.sh&lt;/code> + broker authorizer&lt;/td>
 &lt;td>資料外洩、跨租戶污染、誤刪 topic&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>生命週期（治理）&lt;/td>
 &lt;td>死 topic 累積、partition 數爆炸壓垮 metadata 面&lt;/td>
 &lt;td>命名規範 + 活躍判準 + 自動回收&lt;/td>
 &lt;td>controller 變慢、rebalance 風暴&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>三軸正交：quota 設好不代表權限對、ACL 鎖好不代表 topic 不會爆炸。下面逐軸展開、每軸都對應 production 踩過的失控場景。本文 quota 與 ACL 操作以 Kafka 4.2.0（KRaft 模式、&lt;code>apache/kafka:latest&lt;/code>）實機驗證。&lt;/p>
&lt;h2 id="quota把頻寬與-request-容量切給租戶">Quota：把頻寬與 request 容量切給租戶&lt;/h2>
&lt;p>Quota 是 broker 端對 client 的流量上限、由 broker 在超限時主動 throttle（延遲回應）而非拒絕、讓單一租戶無法把共享頻寬吃光。Kafka 的 quota 是 broker-side 強制、不依賴 client 自律 —— 即使 client 不配合、broker 也會在回應裡插入 throttle 延遲、把該 client 的有效吞吐壓回配額內。&lt;/p>
&lt;h3 id="三類-quota-度量">三類 quota 度量&lt;/h3>
&lt;p>Kafka quota 度量三種資源、對應三類飽和：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Quota 鍵&lt;/th>
 &lt;th>單位&lt;/th>
 &lt;th>限制對象&lt;/th>
 &lt;th>飽和訊號&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>producer_byte_rate&lt;/code>&lt;/td>
 &lt;td>bytes/sec&lt;/td>
 &lt;td>單一 client 每秒寫入 broker 的 bytes&lt;/td>
 &lt;td>寫入端 network / disk I/O 飽和&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>consumer_byte_rate&lt;/code>&lt;/td>
 &lt;td>bytes/sec&lt;/td>
 &lt;td>單一 client 每秒從 broker 讀取的 bytes&lt;/td>
 &lt;td>讀取端 network 飽和、fan-out 過大&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>request_percentage&lt;/code>&lt;/td>
 &lt;td>百分比&lt;/td>
 &lt;td>單一 client 佔用 broker request handler 的 CPU 時間&lt;/td>
 &lt;td>broker CPU 飽和、小訊息高頻請求&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>前兩個 byte rate 防的是頻寬類飽和、適合「大訊息、穩定流量」的租戶。&lt;code>request_percentage&lt;/code> 防的是另一種失控 —— 某租戶送大量極小的 request（例如每筆一個 byte、每秒幾萬筆）、byte rate 看起來很低、卻把 broker 的 request handler thread 佔滿。這種「請求數爆炸但流量不大」的攻擊型 pattern 只有 &lt;code>request_percentage&lt;/code> 抓得到。一個 broker 預設有 N 個 request handler thread、&lt;code>request_percentage=200&lt;/code> 代表允許該 client 用掉 2 條 thread 的時間（100% = 1 條）。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Apache Kafka</a> overview「Multi-tenant 與配額治理」「Topic 生命週期治理」兩段的 implementation-layer deep article。Overview 說明這些議題對應哪些案例跟子議題、本文展開具體的 quota / ACL 配置、授權模型推導、故障徵兆與修法。</p></blockquote>
<h2 id="共享叢集的治理問題一個叢集多個互不信任的租戶">共享叢集的治理問題：一個叢集、多個互不信任的租戶</h2>
<p>Multi-tenant Kafka 的核心問題是把一個物理叢集切成多個彼此隔離的邏輯空間、讓每個團隊用同一組 broker 卻不互相干擾。當 Kafka 從單一團隊的工具長成全公司的事件總線、叢集承載的不再是一條 pipeline、而是數十到數百個團隊的 producer 跟 consumer。這時叢集的瓶頸從「broker 夠不夠快」轉成「怎麼防止某個團隊的流量、權限、或 topic 失控波及其他所有人」。</p>
<p><a href="/blog/backend/03-message-queue/cases/uber-kafka-infrastructure-evolution/" data-link-title="3.C6 Uber：Kafka 事件平台演進" data-link-desc="事件平台從團隊自管走向多租戶共享基礎設施。">Uber 的 Kafka 平台演進</a>把這個轉換描述為「從單隊列問題提升到平台治理問題」。當事件平台服務眾多團隊、重點是配額、隔離、觀測與運維標準化、而非只擴 broker。擴 broker 解決的是總容量、解決不了「單一租戶吃光共享資源」這類隔離問題。</p>
<p>共享叢集的治理分三個獨立的軸、各自處理不同的失控來源：</p>
<table>
  <thead>
      <tr>
          <th>治理軸</th>
          <th>防的是什麼</th>
          <th>工具</th>
          <th>失控後果</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Quota（資源配額）</td>
          <td>單租戶吃滿頻寬 / request 容量、餓死其他租戶</td>
          <td><code>kafka-configs.sh</code> 設 byte rate</td>
          <td>鄰居 producer 寫入卡死、consumer lag</td>
      </tr>
      <tr>
          <td>ACL（存取授權）</td>
          <td>租戶讀寫不屬於自己的 topic、或被未授權方寫入</td>
          <td><code>kafka-acls.sh</code> + broker authorizer</td>
          <td>資料外洩、跨租戶污染、誤刪 topic</td>
      </tr>
      <tr>
          <td>生命週期（治理）</td>
          <td>死 topic 累積、partition 數爆炸壓垮 metadata 面</td>
          <td>命名規範 + 活躍判準 + 自動回收</td>
          <td>controller 變慢、rebalance 風暴</td>
      </tr>
  </tbody>
</table>
<p>三軸正交：quota 設好不代表權限對、ACL 鎖好不代表 topic 不會爆炸。下面逐軸展開、每軸都對應 production 踩過的失控場景。本文 quota 與 ACL 操作以 Kafka 4.2.0（KRaft 模式、<code>apache/kafka:latest</code>）實機驗證。</p>
<h2 id="quota把頻寬與-request-容量切給租戶">Quota：把頻寬與 request 容量切給租戶</h2>
<p>Quota 是 broker 端對 client 的流量上限、由 broker 在超限時主動 throttle（延遲回應）而非拒絕、讓單一租戶無法把共享頻寬吃光。Kafka 的 quota 是 broker-side 強制、不依賴 client 自律 —— 即使 client 不配合、broker 也會在回應裡插入 throttle 延遲、把該 client 的有效吞吐壓回配額內。</p>
<h3 id="三類-quota-度量">三類 quota 度量</h3>
<p>Kafka quota 度量三種資源、對應三類飽和：</p>
<table>
  <thead>
      <tr>
          <th>Quota 鍵</th>
          <th>單位</th>
          <th>限制對象</th>
          <th>飽和訊號</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>producer_byte_rate</code></td>
          <td>bytes/sec</td>
          <td>單一 client 每秒寫入 broker 的 bytes</td>
          <td>寫入端 network / disk I/O 飽和</td>
      </tr>
      <tr>
          <td><code>consumer_byte_rate</code></td>
          <td>bytes/sec</td>
          <td>單一 client 每秒從 broker 讀取的 bytes</td>
          <td>讀取端 network 飽和、fan-out 過大</td>
      </tr>
      <tr>
          <td><code>request_percentage</code></td>
          <td>百分比</td>
          <td>單一 client 佔用 broker request handler 的 CPU 時間</td>
          <td>broker CPU 飽和、小訊息高頻請求</td>
      </tr>
  </tbody>
</table>
<p>前兩個 byte rate 防的是頻寬類飽和、適合「大訊息、穩定流量」的租戶。<code>request_percentage</code> 防的是另一種失控 —— 某租戶送大量極小的 request（例如每筆一個 byte、每秒幾萬筆）、byte rate 看起來很低、卻把 broker 的 request handler thread 佔滿。這種「請求數爆炸但流量不大」的攻擊型 pattern 只有 <code>request_percentage</code> 抓得到。一個 broker 預設有 N 個 request handler thread、<code>request_percentage=200</code> 代表允許該 client 用掉 2 條 thread 的時間（100% = 1 條）。</p>
<h3 id="三種套用層級">三種套用層級</h3>
<p>Quota 可以套在三種 entity 上、精度遞增：</p>
<table>
  <thead>
      <tr>
          <th>套用層級</th>
          <th>entity 指定</th>
          <th>適用情境</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>client-id</td>
          <td><code>--entity-type clients --entity-name &lt;id&gt;</code></td>
          <td>沒有認證、用 client.id 區分服務</td>
      </tr>
      <tr>
          <td>user</td>
          <td><code>--entity-type users --entity-name &lt;user&gt;</code></td>
          <td>有 SASL 認證、整個租戶共用一個 quota</td>
      </tr>
      <tr>
          <td>user + client-id</td>
          <td>兩個 entity 同時指定</td>
          <td>同租戶內不同服務分別配額（最細）</td>
      </tr>
  </tbody>
</table>
<p>層級的選擇取決於認證模型。沒開認證的叢集只能用 client-id —— 但 client.id 由 client 自行宣告、可偽造、只適合內部信任環境的粗略區分。開了 SASL 認證後、user 才是可信的租戶邊界、quota 綁 user 才有隔離意義。最細的 user + client-id 組合用在「同一個租戶內、batch 匯入服務跟即時 API 服務要分開限流」這種情境：整個 billing 租戶有一個總配額、但裡面的 <code>batch-importer</code> 再單獨壓低、避免夜間批次把租戶配額吃光、害同租戶的即時服務沒頻寬。</p>
<h3 id="設定與查詢實機驗證">設定與查詢（實機驗證）</h3>
<p>設 client-id 層級、同時給 producer 跟 consumer byte rate：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">kafka-configs.sh --bootstrap-server localhost:9092 --alter <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --add-config <span class="s1">&#39;producer_byte_rate=1048576,consumer_byte_rate=2097152&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --entity-type clients --entity-name svc-orders
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># Completed updating config for client svc-orders.</span></span></span></code></pre></div><p>設 user 層級、含 <code>request_percentage</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">kafka-configs.sh --bootstrap-server localhost:9092 --alter <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --add-config <span class="s1">&#39;producer_byte_rate=5242880,consumer_byte_rate=10485760,request_percentage=200&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --entity-type users --entity-name tenant-billing
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># Completed updating config for user tenant-billing.</span></span></span></code></pre></div><p>設 user + client-id 組合層級（同租戶內單獨壓低 batch 服務）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">kafka-configs.sh --bootstrap-server localhost:9092 --alter <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --add-config <span class="s1">&#39;producer_byte_rate=524288&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --entity-type users --entity-name tenant-billing <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --entity-type clients --entity-name batch-importer
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># Completed updating config for user tenant-billing.</span></span></span></code></pre></div><p>查詢時 entity 指定要對齊設定時的層級。查 user 層級：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">kafka-configs.sh --bootstrap-server localhost:9092 --describe <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --entity-type users --entity-name tenant-billing
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"># Quota configs for user-principal &#39;tenant-billing&#39; are</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">#   consumer_byte_rate=1.048576E7, request_percentage=200.0, producer_byte_rate=5242880.0</span></span></span></code></pre></div><p>組合層級要兩個 entity 都帶、否則查不到：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">kafka-configs.sh --bootstrap-server localhost:9092 --describe <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --entity-type users --entity-name tenant-billing <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --entity-type clients --entity-name batch-importer
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># Quota configs for user-principal &#39;tenant-billing&#39;, client-id &#39;batch-importer&#39; are</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1">#   producer_byte_rate=524288.0</span></span></span></code></pre></div><p>不帶 <code>--entity-name</code> 而只給 <code>--entity-type clients</code> 會列出所有 client-id 層級的 quota、適合稽核整個叢集的 quota 分布。</p>
<h2 id="acl把存取權限綁到-principal">ACL：把存取權限綁到 principal</h2>
<p>ACL 是 broker 對每個操作的授權檢查、把「誰（principal）能對什麼資源（resource）做什麼操作（operation）從哪裡來（host）」綁成一條規則、broker 在每次 produce / fetch / admin 操作前比對。Quota 管的是「用多少」、ACL 管的是「能不能用」—— 兩者正交、quota 不限制權限、ACL 不限制流量。</p>
<h3 id="授權模型四要素">授權模型四要素</h3>
<p>一條 ACL 由四個維度構成、四個維度交集才決定一次操作是否放行：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>含義</th>
          <th>範例值</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>principal</td>
          <td>操作的發起身分</td>
          <td><code>User:svc-orders</code></td>
      </tr>
      <tr>
          <td>resource</td>
          <td>被操作的對象（type + name + pattern）</td>
          <td>topic <code>orders.events</code>、group <code>fulfillment-workers</code></td>
      </tr>
      <tr>
          <td>operation</td>
          <td>動作</td>
          <td><code>Write</code> / <code>Read</code> / <code>Describe</code> / <code>All</code></td>
      </tr>
      <tr>
          <td>host</td>
          <td>來源 IP（<code>*</code> 為不限）</td>
          <td><code>10.0.3.21</code></td>
      </tr>
  </tbody>
</table>
<p>resource 的 pattern type 是隔離設計的關鍵：<code>LITERAL</code> 精確匹配單一資源名、<code>PREFIXED</code> 匹配整個前綴。多租戶的 topic 隔離靠 prefixed ACL 加命名規範 —— 給 <code>tenant-billing</code> 一條 <code>billing.</code> 前綴的 <code>All</code> 權限、它就能自由管理所有 <code>billing.</code> 開頭的 topic、卻碰不到 <code>orders.</code> 或別租戶的命名空間。命名規範在這裡不只是整潔、是授權邊界本身。</p>
<p>operation 的選擇要對齊角色。一個 producer 需要 topic 的 <code>Write</code> 跟 <code>Describe</code>（描述 partition metadata）；一個 consumer 需要 topic 的 <code>Read</code> <code>Describe</code> 加上 consumer group 的 <code>Read</code> <code>Describe</code>（commit offset 要對 group 有權）。漏掉 group 的 ACL 是常見錯誤：consumer 能讀到訊息、卻 commit 不了 offset、表現成不斷重複消費。</p>
<h3 id="kraft-的-standardauthorizer">KRaft 的 StandardAuthorizer</h3>
<p>ACL 的儲存與判定由 broker 的 authorizer 負責。KRaft 模式用 <code>org.apache.kafka.metadata.authorizer.StandardAuthorizer</code>、ACL 存在 metadata log（取代 ZooKeeper 時代的 <code>AclAuthorizer</code> 把 ACL 存在 ZK）。預設的 <code>apache/kafka</code> 容器不開 authorizer —— 不開時所有操作放行、ACL 指令也無從生效。啟用需要在 broker 設三項：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-properties" data-lang="properties"><span class="line"><span class="ln">1</span><span class="cl"><span class="na">authorizer.class.name</span><span class="o">=</span><span class="s">org.apache.kafka.metadata.authorizer.StandardAuthorizer</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="na">super.users</span><span class="o">=</span><span class="s">User:admin</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="na">allow.everyone.if.no.acl.found</span><span class="o">=</span><span class="s">false</span></span></span></code></pre></div><p><code>super.users</code> 列出繞過所有 ACL 檢查的管理身分、用來開機跟救援；少了它、開 authorizer 後第一個操作就會把自己鎖在外面。<code>allow.everyone.if.no.acl.found=false</code> 是隔離的前提 —— 設 <code>true</code> 時「沒有任何 ACL 的資源對所有人開放」、等於 deny-list 模式、漏設一個 topic 就全公司可讀。多租戶必須走 <code>false</code> 的 allow-list 模式：預設拒絕、明確授權才放行。</p>
<blockquote>
<p>本文 ACL 操作以實機驗證：用上述三項 env（<code>KAFKA_AUTHORIZER_CLASS_NAME</code> / <code>KAFKA_SUPER_USERS='User:ANONYMOUS'</code> / <code>KAFKA_ALLOW_EVERYONE_IF_NO_ACL_FOUND=false</code>）配完整 KRaft single-node 設定起容器、PLAINTEXT 連線的 principal 為 <code>User:ANONYMOUS</code>、設為 super user 後即可用 <code>kafka-acls.sh</code> 操作。</p></blockquote>
<h3 id="acl-配置實機驗證">ACL 配置（實機驗證）</h3>
<p>給 producer 對單一 topic 的 write + describe：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">kafka-acls.sh --bootstrap-server localhost:9092 --add <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --allow-principal User:svc-orders <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --operation Write --operation Describe <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --topic orders.events</span></span></code></pre></div><p>給 consumer topic 的 read + describe、外加 consumer group 的權限（一條指令同時建兩個 resource 的 ACL）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">kafka-acls.sh --bootstrap-server localhost:9092 --add <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --allow-principal User:svc-fulfillment <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --operation Read --operation Describe <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --topic orders.events <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  --group fulfillment-workers</span></span></code></pre></div><p>prefixed ACL 把整個命名空間授權給一個租戶：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">kafka-acls.sh --bootstrap-server localhost:9092 --add <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --allow-principal User:tenant-billing <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --operation All <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --resource-pattern-type prefixed <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  --topic billing.
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"># Adding ACLs for resource</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1">#   `ResourcePattern(resourceType=TOPIC, name=billing., patternType=PREFIXED)`</span></span></span></code></pre></div><p>host 限制把同一 principal 的權限綁到特定來源 IP：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">kafka-acls.sh --bootstrap-server localhost:9092 --add <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --allow-principal User:svc-orders <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --allow-host 10.0.3.21 <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --operation Write <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  --topic orders.events</span></span></code></pre></div><p>deny 規則的優先序高於 allow —— 同一 principal 即使有 allow、命中 deny 就拒絕。用來在大範圍 allow（如 prefixed <code>All</code>）之上挖一個例外：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">kafka-acls.sh --bootstrap-server localhost:9092 --add <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --deny-principal User:svc-orders <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --deny-host 10.0.9.99 <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --operation Write <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  --topic orders.events</span></span></code></pre></div><p>列出特定 topic 的全部 ACL、用於稽核：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">kafka-acls.sh --bootstrap-server localhost:9092 --list --topic orders.events</span></span></code></pre></div><h2 id="topic-生命週期治理命名ownership-與回收">Topic 生命週期治理：命名、ownership 與回收</h2>
<p>Topic 生命週期治理把「topic 的建立、歸屬、淘汰」變成有規則的流程、避免死 topic 累積與 partition 數爆炸壓垮叢集的 metadata 面。Kafka 的每個 partition 都是 controller 要追蹤的 metadata 單位；topic 只增不減時、partition 總數隨團隊數線性成長、最終 controller 的 metadata 處理、broker 的 leader election、client 的 metadata fetch 都跟著變慢。</p>
<h3 id="命名規範劃出-ownership">命名規範劃出 ownership</h3>
<p>Topic 命名規範把 ownership 跟隔離邊界編碼進名字本身。一個可治理的命名規範通常含三段：租戶 / 領域前綴、語意名、版本。例如 <code>billing.invoices.v1</code> —— <code>billing.</code> 前綴對齊 prefixed ACL 的隔離邊界跟 quota 的租戶歸屬、<code>invoices</code> 是語意、<code>v1</code> 給 schema 演進留出平行存在的空間。命名規範在多租戶不是風格問題、是三個治理軸的共同錨點：ACL 靠前綴授權、quota 靠前綴歸屬、回收靠前綴找 owner。</p>
<p>實機建 topic 時 Kafka 4.2.0 對 <code>.</code> 跟 <code>_</code> 混用會出 metric 名稱碰撞警告：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">WARNING: Due to limitations in metric names, topics with a period (&#39;.&#39;)
</span></span><span class="line"><span class="ln">2</span><span class="cl">or underscore (&#39;_&#39;) could collide. To avoid issues it is best to use
</span></span><span class="line"><span class="ln">3</span><span class="cl">either, but not both.</span></span></code></pre></div><p>成因是 metric 名把 topic 名裡的 <code>.</code> 跟 <code>_</code> 都正規化掉、<code>billing.invoices</code> 跟 <code>billing_invoices</code> 可能對映到同一條 metric。命名規範應在 <code>.</code> 跟 <code>_</code> 之間選一個當分隔符、全叢集一致、避免監控數據互相污染。</p>
<h3 id="活躍判準與自動回收">活躍判準與自動回收</h3>
<p>死 topic 的回收靠可量化的活躍判準。<a href="/blog/backend/03-message-queue/cases/linkedin-topicgc-kafka-governance/" data-link-title="3.C3 LinkedIn：TopicGC 與 Kafka 治理轉換" data-link-desc="Kafka topic 從手動治理轉自動治理對叢集的影響。">LinkedIn 的 TopicGC</a>以自動治理取代手動清理未使用 topic、降低 metadata 壓力並改善 produce / consume 效能。它的判讀是：當 queue 規模擴大、僅靠容量擴充不夠、topic 生命週期與治理自動化會成為可靠性關鍵。</p>
<p>TopicGC 是 LinkedIn 的內部系統、不是 Kafka 內建指令；它揭示的是一套可借鏡的回收流程結構：</p>
<ol>
<li>定義活躍判準：以 last produce / last consume timestamp 判斷 topic 是否仍在使用、設一段觀察窗（例如 N 天無寫入且無讀取）。</li>
<li>分級回收：先標記（soft）、進入待回收狀態並通知 owner、保留一段 grace period、無人認領才真正刪除（hard）。兩段式避免誤刪仍有低頻流量的 topic。</li>
<li>保留稽核：每次標記與刪除留紀錄、回收前後比對 controller log、partition 數量、produce / consume 效能指標、確認治理有效且無誤傷。</li>
</ol>
<p>回收條件的設定要對齊業務節奏。純看 produce timestamp 會誤判「低頻但關鍵」的 topic（如月結批次）；活躍判準要同時看 produce 跟 consume、且觀察窗要長於最長的合法閒置週期。</p>
<h2 id="production-故障演練">Production 故障演練</h2>
<h3 id="case-1單一租戶暴衝吃滿頻寬quota-缺位">Case 1：單一租戶暴衝吃滿頻寬（quota 缺位）</h3>
<p><strong>徵兆</strong>：某團隊上線一支新 backfill job、開始全速寫入；同叢集其他租戶的 producer 端 <code>request-latency</code> p99 從個位數 ms 跳到數百 ms、consumer lag 全面上升；broker network out 打到網卡上限、但 CPU 不高。受害的不是暴衝者自己、是所有共用 broker 的鄰居。</p>
<p><strong>根因</strong>：叢集沒設任何 producer quota、或只對部分租戶設了 quota。沒有 broker-side throttle 時、單一 client 能用滿 broker 的 network / disk I/O、把共享頻寬擠光。byte rate 飽和的特徵是 network 打滿但 CPU 不高 —— 區別於 <code>request_percentage</code> 缺位導致的 CPU 飽和。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>立即對暴衝 client 設 <code>producer_byte_rate</code>、broker 即時 throttle、無需重啟。</li>
<li>建立 quota 預設值：對所有 client-id（或 user）設一個保守的 default byte rate、新租戶上線自動受限、避免「漏設就無限」。</li>
<li>區分 byte rate 與 request_percentage 飽和：network 打滿設 byte rate、CPU 打滿（高頻小訊息）補 <code>request_percentage</code>。</li>
<li>容量規劃：把各租戶 quota 總和對齊 broker 的 network / disk 容量、留 headroom、避免「每個 quota 都合理但加總超過物理上限」。</li>
</ol>
<h3 id="case-2acl-設太鬆或太緊">Case 2：ACL 設太鬆或太緊</h3>
<p><strong>徵兆（太鬆）</strong>：稽核發現某 consumer 服務能讀到不屬於它的租戶 topic；或某 topic 被預期外的 principal 寫入、資料被污染。最壞情況是 <code>allow.everyone.if.no.acl.found=true</code> 下漏設 ACL 的 topic 對全叢集可讀寫。</p>
<p><strong>徵兆（太緊）</strong>：consumer 能讀訊息卻不斷重複消費、log 顯示 commit offset 被拒；或 producer 報 <code>TOPIC_AUTHORIZATION_FAILED</code>、明明該有權限。</p>
<p><strong>根因</strong>：太鬆來自 deny-list 心態 —— <code>allow.everyone.if.no.acl.found=true</code> 把「沒設 ACL」當成「開放」、漏設就外洩。太緊通常是漏掉 operation 或 resource：consumer 只給了 topic 的 <code>Read</code>、漏給 consumer group 的 <code>Read</code> <code>Describe</code>、於是讀得到但 commit 不了、表現成重複消費；producer 漏給 <code>Describe</code>、拿不到 partition metadata。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>走 allow-list：<code>allow.everyone.if.no.acl.found=false</code>、預設拒絕、明確授權才放行。</li>
<li>ACL 對齊角色模板：producer = topic Write + Describe；consumer = topic Read + Describe 加 group Read + Describe；漏 group ACL 是重複消費的常見根因。</li>
<li>用 prefixed ACL 而非逐 topic 設、把授權邊界對齊命名規範前綴、減少漏設。</li>
<li>稽核流程：定期 <code>kafka-acls.sh --list</code> 比對預期授權矩陣、把 ACL 納入版本控制與 review、而非手動逐條加。</li>
</ol>
<h3 id="case-3topic-數量爆炸壓垮-metadata-面">Case 3：Topic 數量爆炸壓垮 metadata 面</h3>
<p><strong>徵兆</strong>：叢集 topic / partition 總數隨團隊增長爬到數萬以上；controller failover 時間從秒級拉長到分鐘級；broker 啟動載入 metadata 變慢；client 的 metadata fetch 變大變慢、rebalance 期間出現連鎖延遲。容量沒滿、但整個叢集的 control plane 變鈍。</p>
<p><strong>根因</strong>：partition 是 controller 要追蹤的 metadata 單位、數量只增不減。每個團隊隨手建 topic、每個 topic 又開高 partition 數、總 partition 數線性甚至超線性成長、壓垮 metadata 處理。KRaft 相比 ZooKeeper 提高了 metadata 上限、但上限仍存在、不是無限。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>Partition 數規劃納入 topic 建立流程：partition 數對應並行度上限、不是越多越好；多餘 partition 是純 metadata 成本。詳見 <a href="/blog/backend/knowledge-cards/partition/" data-link-title="Partition" data-link-desc="說明事件流如何切分成多個可並行處理的有序片段">Partition</a> 卡。</li>
<li>回收死 topic 釋放 partition slot：見 Case 4 與生命週期治理段。</li>
<li>監控 metadata 壓力訊號：controller log、partition 總數、controller failover 時間設告警、在壓垮前介入。</li>
<li>規模化路徑：單叢集 metadata 逼近上限時、評估分群（依關鍵程度分多叢集）、見 overview 的 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Cross-region 與分層叢集</a>段與 <a href="/blog/backend/03-message-queue/cases/linkedin-kafka-tiered-clusters/" data-link-title="3.C4 LinkedIn：Kafka 分層叢集治理" data-link-desc="Kafka 從單叢集走向 tiered clusters 的轉換案例。">LinkedIn Tiered Clusters</a>案例。</li>
</ol>
<h3 id="case-4unused-topic-未回收">Case 4：Unused topic 未回收</h3>
<p><strong>徵兆</strong>：叢集裡大量 topic 數月無 produce 也無 consume、卻持續佔 partition slot 跟 metadata；沒人記得某些 topic 屬於哪個團隊、不敢刪；新 topic 想建時撞到 partition 上限、被迫先擴叢集而非先回收。</p>
<p><strong>根因</strong>：沒有活躍判準與回收流程、topic 只建不刪。歸屬資訊沒編碼進命名、回收時找不到 owner、於是「不敢刪」成為預設、死 topic 無限累積。這是 Case 3（metadata 爆炸）的慢性來源。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>建立活躍判準：以 last produce / last consume timestamp 加觀察窗判定死 topic、觀察窗長於最長合法閒置週期（避免誤刪月結類低頻 topic）。</li>
<li>兩段式回收：先 soft 標記並通知 owner、grace period 內無人認領才 hard 刪除、避免誤刪。</li>
<li>命名規範補 ownership：前綴對齊團隊、回收時能直接找到 owner、消除「不敢刪」。</li>
<li>自動化加稽核：參考 <a href="/blog/backend/03-message-queue/cases/linkedin-topicgc-kafka-governance/" data-link-title="3.C3 LinkedIn：TopicGC 與 Kafka 治理轉換" data-link-desc="Kafka topic 從手動治理轉自動治理對叢集的影響。">TopicGC</a>的流程結構、回收前後比對 metadata 與效能指標、留稽核紀錄。</li>
</ol>
<h2 id="容量與規模邊界">容量與規模邊界</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>估算 / 訊號</th>
          <th>警戒與下一步</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Quota 總和 vs 物理容量</td>
          <td>各租戶 byte rate 加總對 broker network / disk 容量</td>
          <td>加總逼近物理上限要重新切分、留 headroom</td>
      </tr>
      <tr>
          <td>ACL 條目數</td>
          <td>逐 topic 設會隨 topic 數線性成長</td>
          <td>改 prefixed ACL 對齊命名規範、降條目數與漏設風險</td>
      </tr>
      <tr>
          <td>Partition 總數</td>
          <td>controller failover 時間、metadata fetch 延遲</td>
          <td>逼近上限先回收死 topic、再評估分群</td>
      </tr>
      <tr>
          <td>Topic 活躍率</td>
          <td>有 produce / consume 的 topic 佔比</td>
          <td>死 topic 比例高代表缺回收流程、補活躍判準</td>
      </tr>
  </tbody>
</table>
<p>Quota 與 ACL 是 broker-side 即時生效、不需重啟、可隨租戶調整、運維成本低。生命週期治理是持續流程、不是一次性操作 —— 死 topic 會持續產生、回收要常態化。三軸的共同前提是命名規範：沒有可治理的命名、quota 找不到歸屬、ACL 邊界對不齊、回收找不到 owner。多租戶治理的第一步是先把命名規範立起來、再談 quota 與 ACL。</p>
<h2 id="整合與下一步">整合與下一步</h2>
<h3 id="跟-overview-與案例的對位">跟 overview 與案例的對位</h3>
<ul>
<li>上游 vendor 頁：<a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Apache Kafka</a> —— 本文展開其「Multi-tenant 與配額治理」「Topic 生命週期治理」兩段</li>
<li>平台治理案例：<a href="/blog/backend/03-message-queue/cases/uber-kafka-infrastructure-evolution/" data-link-title="3.C6 Uber：Kafka 事件平台演進" data-link-desc="事件平台從團隊自管走向多租戶共享基礎設施。">3.C6 Uber Kafka 事件平台</a> —— 單隊列問題提升到平台治理</li>
<li>生命週期案例：<a href="/blog/backend/03-message-queue/cases/linkedin-topicgc-kafka-governance/" data-link-title="3.C3 LinkedIn：TopicGC 與 Kafka 治理轉換" data-link-desc="Kafka topic 從手動治理轉自動治理對叢集的影響。">3.C3 LinkedIn TopicGC</a> —— 自動回收與 metadata 壓力</li>
<li>規模化分群：<a href="/blog/backend/03-message-queue/cases/linkedin-kafka-tiered-clusters/" data-link-title="3.C4 LinkedIn：Kafka 分層叢集治理" data-link-desc="Kafka 從單叢集走向 tiered clusters 的轉換案例。">3.C4 LinkedIn Tiered Clusters</a> —— metadata 逼近上限時的多叢集路徑</li>
<li>自管轉 managed 的 ACL cutover：<a href="/blog/backend/03-message-queue/cases/vmware-kafka-to-msk/" data-link-title="3.C2 VMware Tanzu CloudHealth：Kafka 轉 Amazon MSK" data-link-desc="自管 Kafka 遷移到託管平台時的治理重點。">3.C2 VMware → MSK</a></li>
</ul>
<h3 id="跟安全模組對位">跟安全模組對位</h3>
<p>ACL 是 Kafka 內建的授權層、處理 broker 級的 principal × resource 授權。完整的 secret 管理（SASL 認證憑證怎麼發、輪替、撤銷）屬於 <a href="/blog/backend/07-security-data-protection/" data-link-title="模組七：資安與資料保護" data-link-desc="以問題驅動方式擴充資安知識網：先定義服務環節問題，再以案例作為觸發式參考">07 資料保護與安全模組</a>的範疇 —— ACL 綁的 principal 從哪來、由認證層決定、ACL 只負責「這個 principal 能做什麼」。多租戶的完整信任鏈是「認證確認身分（07）→ ACL 授權操作（本文）→ quota 限制用量（本文）」三層。</p>
<h3 id="下一步議題">下一步議題</h3>
<ul>
<li>Schema 治理：跨租戶共用 topic 時、schema compatibility 是另一層契約治理、見 overview 的 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">KRaft 與 Schema Registry</a>段</li>
<li>Consumer group ACL 細節：跟 <a href="/blog/backend/knowledge-cards/consumer-group/" data-link-title="Consumer Group" data-link-desc="說明一組 consumer 如何共同分攤 stream 或 topic 的處理責任">Consumer group</a> rebalance 的互動</li>
<li>Quota 與 <a href="/blog/backend/knowledge-cards/delivery-semantics/" data-link-title="Delivery Semantics" data-link-desc="說明事件投遞語意如何定義遺失、重複、順序與補償策略">delivery semantics</a>：throttle 延遲對 producer timeout / retry 的影響</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li>上游 vendor 頁：<a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Apache Kafka</a></li>
<li>對位 deep article（同模組）：本模組其他 Kafka deep article 見 vendor 頁進階主題段</li>
<li>跨模組授權鏈：<a href="/blog/backend/07-security-data-protection/" data-link-title="模組七：資安與資料保護" data-link-desc="以問題驅動方式擴充資安知識網：先定義服務環節問題，再以案例作為觸發式參考">07 資料保護與安全模組</a></li>
<li>方法論：<a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章的寫作方法論</a></li>
<li>知識卡：<a href="/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">Topic</a>、<a href="/blog/backend/knowledge-cards/partition/" data-link-title="Partition" data-link-desc="說明事件流如何切分成多個可並行處理的有序片段">Partition</a>、<a href="/blog/backend/knowledge-cards/consumer-group/" data-link-title="Consumer Group" data-link-desc="說明一組 consumer 如何共同分攤 stream 或 topic 的處理責任">Consumer group</a></li>
</ul>
]]></content:encoded></item><item><title>3.C15 Airbnb：Spark Streaming Kafka reader rebalance</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-airbnb-spark-streaming-rebalance/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-airbnb-spark-streaming-rebalance/</guid><description>&lt;p>這個案例的核心責任是說明 stream processor 與 Kafka partition 數的緊耦合是 production scaling 瓶頸。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>Airbnb logging pipeline 跨多個 topic、event size 從幾百 bytes 到幾百 KB、QPS 跨數個量級差異、Spark 一個 partition 對一個 task 造成 data skew、catch-up 一個 4 小時 lag 要再花 4 小時。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>自建 balanced Spark Kafka reader、把 parallelism 從 partition 數解耦、按 event volume × size 重新分派 work。揭露 partition 數不該等同 consumer parallelism、要看 event 形狀。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>Kafka 進階主題：Consumer 設計 / consumer lag / rebalance / partition + consumer group。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計&lt;/a>。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://medium.com/airbnb-engineering/scaling-spark-streaming-for-logging-event-ingestion-4a03141d135d">Scaling Spark Streaming for Logging Event Ingestion&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 stream processor 與 Kafka partition 數的緊耦合是 production scaling 瓶頸。</p>
<h2 id="觀察">觀察</h2>
<p>Airbnb logging pipeline 跨多個 topic、event size 從幾百 bytes 到幾百 KB、QPS 跨數個量級差異、Spark 一個 partition 對一個 task 造成 data skew、catch-up 一個 4 小時 lag 要再花 4 小時。</p>
<h2 id="判讀">判讀</h2>
<p>自建 balanced Spark Kafka reader、把 parallelism 從 partition 數解耦、按 event volume × size 重新分派 work。揭露 partition 數不該等同 consumer parallelism、要看 event 形狀。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>Kafka 進階主題：Consumer 設計 / consumer lag / rebalance / partition + consumer group。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計</a>。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://medium.com/airbnb-engineering/scaling-spark-streaming-for-logging-event-ingestion-4a03141d135d">Scaling Spark Streaming for Logging Event Ingestion</a></li>
</ul>
]]></content:encoded></item><item><title>3.C16 Robinhood：Faust Python stream processing</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-robinhood-faust-python-streaming/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-robinhood-faust-python-streaming/</guid><description>&lt;p>這個案例的核心責任是說明語言生態與 stream framework 的選型張力。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>Robinhood 每天處理 billions of events / TB 資料、用於 risk signal、order quality、market data、fraud detection；team 多為 Python、不想用 JVM 生態。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>把 Kafka Streams 的 stateful streaming 模式（topology、tables、windowing）移植到 Python library 形式、不需要 Yarn / Mesos resource manager。揭露 stream processing framework 選型常被語言生態主導、不是技術 feature。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>Kafka 進階主題：跨語言 client / Streams framework / stream processing on Kafka。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計&lt;/a>。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://medium.com/robinhood-engineering/faust-stream-processing-for-python-a66d3a51212d">Faust: Stream Processing for Python&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明語言生態與 stream framework 的選型張力。</p>
<h2 id="觀察">觀察</h2>
<p>Robinhood 每天處理 billions of events / TB 資料、用於 risk signal、order quality、market data、fraud detection；team 多為 Python、不想用 JVM 生態。</p>
<h2 id="判讀">判讀</h2>
<p>把 Kafka Streams 的 stateful streaming 模式（topology、tables、windowing）移植到 Python library 形式、不需要 Yarn / Mesos resource manager。揭露 stream processing framework 選型常被語言生態主導、不是技術 feature。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>Kafka 進階主題：跨語言 client / Streams framework / stream processing on Kafka。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計</a>。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://medium.com/robinhood-engineering/faust-stream-processing-for-python-a66d3a51212d">Faust: Stream Processing for Python</a></li>
</ul>
]]></content:encoded></item><item><title>3.C17 Walmart：Messaging Proxy Service 解 rebalance storm</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-walmart-mps-rebalance/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-walmart-mps-rebalance/</guid><description>&lt;p>這個案例的核心責任是說明 partition-consumer 1:1 模型在大規模 K8s 環境的擴張極限。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>Walmart 每天 trillions of message、25K+ Kafka consumer 跑在 WCNP Kubernetes 多雲環境；最大痛點是 pod scaling / deploy / heartbeat fail 觸發 consumer rebalance、lag spike。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>自建 Messaging Proxy Service（MPS、Kafka Connect sink connector）、把 consumer 從 partition-bound 解耦成 stateless REST service、可獨立 auto-scale、不用增 partition；內建 DLQ 處理 poison pill。揭露「consumer 該跟 partition 數綁定」這個假設在 K8s 規模化下不再成立。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>Kafka 進階主題：rebalance storm / consumer lag / multi-tenant 配額。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計&lt;/a>。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://medium.com/walmartglobaltech/reliably-processing-trillions-of-kafka-messages-per-day-23494f553ef9">Reliably Processing Trillions of Kafka Messages Per Day&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 partition-consumer 1:1 模型在大規模 K8s 環境的擴張極限。</p>
<h2 id="觀察">觀察</h2>
<p>Walmart 每天 trillions of message、25K+ Kafka consumer 跑在 WCNP Kubernetes 多雲環境；最大痛點是 pod scaling / deploy / heartbeat fail 觸發 consumer rebalance、lag spike。</p>
<h2 id="判讀">判讀</h2>
<p>自建 Messaging Proxy Service（MPS、Kafka Connect sink connector）、把 consumer 從 partition-bound 解耦成 stateless REST service、可獨立 auto-scale、不用增 partition；內建 DLQ 處理 poison pill。揭露「consumer 該跟 partition 數綁定」這個假設在 K8s 規模化下不再成立。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>Kafka 進階主題：rebalance storm / consumer lag / multi-tenant 配額。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計</a>。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://medium.com/walmartglobaltech/reliably-processing-trillions-of-kafka-messages-per-day-23494f553ef9">Reliably Processing Trillions of Kafka Messages Per Day</a></li>
</ul>
]]></content:encoded></item><item><title>3.C18 Wix：Greyhound TLLSR 解 consumer 卡住</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-wix-greyhound-troubleshooting/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-wix-greyhound-troubleshooting/</guid><description>&lt;p>這個案例的核心責任是說明大規模 multi-tenant Kafka 的營運可視性需求遠超原生 metric。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>Wix 2000+ microservice、每天 66 billion Kafka 訊息、用自建 Greyhound（JVM library + polyglot sidecar）抽象 Kafka；troubleshooting 痛點是「卡住的 consumer 看不到原因、只能寫 DB 修復腳本」。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>TLLSR 框架（Trace / Lookup / Longest-running / Skip-replay / Redistribute）解 single-partition lag、單筆 poison pill、handler 卡住等情境；consumer lag alert &amp;gt; 30 分鐘觸發。揭露原生 lag metric 無法定位「卡在哪」、需要 message-level trace + 操作介面。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>Kafka 進階主題：consumer lag / observability / multi-tenant / poison message。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/red-team-delivery-layer/" data-link-title="3.5 攻擊者視角（紅隊）：傳遞層弱點判讀" data-link-desc="從重複投遞、重放濫用、毒訊息與容量壓力，盤點 message delivery 的主要弱點">3.5 紅隊章&lt;/a>。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://medium.com/wix-engineering/troubleshooting-kafka-for-2000-microservices-at-wix-986ee382fd1e">Troubleshooting Kafka for 2000 Microservices at Wix&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明大規模 multi-tenant Kafka 的營運可視性需求遠超原生 metric。</p>
<h2 id="觀察">觀察</h2>
<p>Wix 2000+ microservice、每天 66 billion Kafka 訊息、用自建 Greyhound（JVM library + polyglot sidecar）抽象 Kafka；troubleshooting 痛點是「卡住的 consumer 看不到原因、只能寫 DB 修復腳本」。</p>
<h2 id="判讀">判讀</h2>
<p>TLLSR 框架（Trace / Lookup / Longest-running / Skip-replay / Redistribute）解 single-partition lag、單筆 poison pill、handler 卡住等情境；consumer lag alert &gt; 30 分鐘觸發。揭露原生 lag metric 無法定位「卡在哪」、需要 message-level trace + 操作介面。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>Kafka 進階主題：consumer lag / observability / multi-tenant / poison message。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/red-team-delivery-layer/" data-link-title="3.5 攻擊者視角（紅隊）：傳遞層弱點判讀" data-link-desc="從重複投遞、重放濫用、毒訊息與容量壓力，盤點 message delivery 的主要弱點">3.5 紅隊章</a>。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://medium.com/wix-engineering/troubleshooting-kafka-for-2000-microservices-at-wix-986ee382fd1e">Troubleshooting Kafka for 2000 Microservices at Wix</a></li>
</ul>
]]></content:encoded></item><item><title>3.C19 Wix：Multi-cluster Kafka zero-downtime 遷移</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-wix-multi-cluster-migration/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-wix-multi-cluster-migration/</guid><description>&lt;p>這個案例的核心責任是說明 single mega-cluster 的 metadata scaling ceiling 與分群策略。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>Wix cluster metadata 從 2019 年 5K topic / 45K partition 漲到 20K topic / 200K partition、每日 record 從 450M 漲到 2.5B、controller startup 與 broker stability 受 metadata 量壓垮。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>不用 MirrorMaker、自建 Replicator service + Migration Orchestrator、用 Kafka topic 當控制平面協調 consumer 切換 + offset mapping；按 SLA 切多 cluster。揭露「topic / partition 數量」是 broker 級別的物理上限、不能無限擴張。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>Kafka 進階主題：cross-region MirrorMaker / topic 生命週期 / 分層叢集策略。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/linkedin-topicgc-kafka-governance/" data-link-title="3.C3 LinkedIn：TopicGC 與 Kafka 治理轉換" data-link-desc="Kafka topic 從手動治理轉自動治理對叢集的影響。">3.C3 LinkedIn TopicGC&lt;/a>。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://medium.com/wix-engineering/migrating-to-a-multi-cluster-managed-kafka-with-0-downtime-b936655f888e">Migrating to a Multi-Cluster Managed Kafka with 0 Downtime&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 single mega-cluster 的 metadata scaling ceiling 與分群策略。</p>
<h2 id="觀察">觀察</h2>
<p>Wix cluster metadata 從 2019 年 5K topic / 45K partition 漲到 20K topic / 200K partition、每日 record 從 450M 漲到 2.5B、controller startup 與 broker stability 受 metadata 量壓垮。</p>
<h2 id="判讀">判讀</h2>
<p>不用 MirrorMaker、自建 Replicator service + Migration Orchestrator、用 Kafka topic 當控制平面協調 consumer 切換 + offset mapping；按 SLA 切多 cluster。揭露「topic / partition 數量」是 broker 級別的物理上限、不能無限擴張。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>Kafka 進階主題：cross-region MirrorMaker / topic 生命週期 / 分層叢集策略。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/cases/linkedin-topicgc-kafka-governance/" data-link-title="3.C3 LinkedIn：TopicGC 與 Kafka 治理轉換" data-link-desc="Kafka topic 從手動治理轉自動治理對叢集的影響。">3.C3 LinkedIn TopicGC</a>。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://medium.com/wix-engineering/migrating-to-a-multi-cluster-managed-kafka-with-0-downtime-b936655f888e">Migrating to a Multi-Cluster Managed Kafka with 0 Downtime</a></li>
</ul>
]]></content:encoded></item><item><title>3.C20 Spotify：Event Delivery 從 Kafka 遷出（反例）</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-spotify-event-delivery-exodus/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-spotify-event-delivery-exodus/</guid><description>&lt;p>Spotify 從 Kafka 遷出到 GCP Pub/Sub 的決策揭露了兩件事：broker 的可靠性保證是版本特性而非 Kafka 的不變量；以及「升級到新版」跟「換到另一個系統」之間的決策判準。&lt;/p>
&lt;h2 id="業務背景">業務背景&lt;/h2>
&lt;p>Spotify 的事件傳遞系統（Event Delivery）負責把使用者行為事件（播放、搜尋、推薦互動）從客戶端送到資料管線。系統跨 5 個 datacenter 運行 Kafka 0.7，production peak 700K events/sec、pressure test 達到 2M events/sec。事件資料是推薦系統、analytics 跟廣告計費的輸入，遺失事件直接影響商業決策的準確性。&lt;/p>
&lt;p>2016 年，Spotify 決定把 Event Delivery 從 Kafka 遷移到 GCP Pub/Sub，而非升級到當時已發布的 Kafka 0.8+。&lt;/p>
&lt;h2 id="技術挑戰">技術挑戰&lt;/h2>
&lt;h3 id="mirrormaker-的-best-effort-語意">MirrorMaker 的 best-effort 語意&lt;/h3>
&lt;p>Kafka 0.7 的跨 datacenter replication 工具 MirrorMaker 在 best-effort mode 下會丟失資料但向 producer 回報成功。對 Spotify 的場景，producer 端認為事件已送達，但跨 datacenter 的 mirror 實際上丟了一部分。丟失比例在正常情況下很低，但在 broker restart 或網路抖動時可以升高到影響 analytics 準確性的程度。&lt;/p>
&lt;p>這個問題的根源是 Kafka 0.7 的 producer 沒有 idempotent 保證，MirrorMaker 的 consumer offset commit 跟 producer ack 之間有 gap。&lt;/p>
&lt;h3 id="broker-restart-後-producer-無法自動恢復">Broker restart 後 producer 無法自動恢復&lt;/h3>
&lt;p>Kafka 0.7 的 producer 在 broker restart 後可能進入無法自動恢復的狀態 — 需要人工重啟 producer process。在 5 個 datacenter、數百個 producer instance 的規模下，每次 broker 維護操作都需要人工介入恢復 producer，運維成本跟 broker 數量成正比。&lt;/p>
&lt;h3 id="為什麼不升級到-kafka-08">為什麼不升級到 Kafka 0.8+&lt;/h3>
&lt;p>Kafka 0.8 引入了 replication、新的 consumer API 跟更可靠的 producer。但 Spotify 評估後認為升級的成本接近重新部署：&lt;/p>
&lt;ul>
&lt;li>Kafka 0.7 到 0.8 的 wire protocol 不相容，需要全量遷移而非滾動升級&lt;/li>
&lt;li>所有 producer / consumer 的 client library 都要更換&lt;/li>
&lt;li>Spotify 同時在向 GCP 遷移基礎設施，Kafka 的自管運維模式跟 GCP 的託管方向不一致&lt;/li>
&lt;/ul>
&lt;p>相比之下，GCP Pub/Sub 提供了託管的 exactly-once 語意、跨 region replication、零運維。遷移成本跟升級 Kafka 版本的成本相當，但遷移後的長期運維成本低得多。&lt;/p>
&lt;h2 id="解法與取捨">解法與取捨&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>面向&lt;/th>
 &lt;th>留在 Kafka（升級 0.8+）&lt;/th>
 &lt;th>遷到 GCP Pub/Sub&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>一次性遷移成本&lt;/td>
 &lt;td>中（全量遷移、不可滾動升級）&lt;/td>
 &lt;td>中（同樣需要改所有 client）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>長期運維成本&lt;/td>
 &lt;td>高（自管 broker × 5 DC）&lt;/td>
 &lt;td>低（託管、零 broker 維護）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>可靠性保證&lt;/td>
 &lt;td>0.8+ 有 replication、改善大&lt;/td>
 &lt;td>Pub/Sub 原生 exactly-once&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>跨 region replication&lt;/td>
 &lt;td>需要自建 MirrorMaker 2.0&lt;/td>
 &lt;td>原生支援&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>生態鎖定&lt;/td>
 &lt;td>Kafka 生態成熟&lt;/td>
 &lt;td>GCP 鎖定、跨雲成本高&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Spotify 的判斷是：在同時進行 GCP 遷移的背景下，維護自管 Kafka 的投資回報比不上切換到託管方案。這個判斷跟 Kafka 本身的能力無關 — Kafka 0.8+ 的可靠性已經解決了 0.7 的問題。決策的關鍵變數是「組織正在往哪走」，不只是「技術上哪個更好」。&lt;/p></description><content:encoded><![CDATA[<p>Spotify 從 Kafka 遷出到 GCP Pub/Sub 的決策揭露了兩件事：broker 的可靠性保證是版本特性而非 Kafka 的不變量；以及「升級到新版」跟「換到另一個系統」之間的決策判準。</p>
<h2 id="業務背景">業務背景</h2>
<p>Spotify 的事件傳遞系統（Event Delivery）負責把使用者行為事件（播放、搜尋、推薦互動）從客戶端送到資料管線。系統跨 5 個 datacenter 運行 Kafka 0.7，production peak 700K events/sec、pressure test 達到 2M events/sec。事件資料是推薦系統、analytics 跟廣告計費的輸入，遺失事件直接影響商業決策的準確性。</p>
<p>2016 年，Spotify 決定把 Event Delivery 從 Kafka 遷移到 GCP Pub/Sub，而非升級到當時已發布的 Kafka 0.8+。</p>
<h2 id="技術挑戰">技術挑戰</h2>
<h3 id="mirrormaker-的-best-effort-語意">MirrorMaker 的 best-effort 語意</h3>
<p>Kafka 0.7 的跨 datacenter replication 工具 MirrorMaker 在 best-effort mode 下會丟失資料但向 producer 回報成功。對 Spotify 的場景，producer 端認為事件已送達，但跨 datacenter 的 mirror 實際上丟了一部分。丟失比例在正常情況下很低，但在 broker restart 或網路抖動時可以升高到影響 analytics 準確性的程度。</p>
<p>這個問題的根源是 Kafka 0.7 的 producer 沒有 idempotent 保證，MirrorMaker 的 consumer offset commit 跟 producer ack 之間有 gap。</p>
<h3 id="broker-restart-後-producer-無法自動恢復">Broker restart 後 producer 無法自動恢復</h3>
<p>Kafka 0.7 的 producer 在 broker restart 後可能進入無法自動恢復的狀態 — 需要人工重啟 producer process。在 5 個 datacenter、數百個 producer instance 的規模下，每次 broker 維護操作都需要人工介入恢復 producer，運維成本跟 broker 數量成正比。</p>
<h3 id="為什麼不升級到-kafka-08">為什麼不升級到 Kafka 0.8+</h3>
<p>Kafka 0.8 引入了 replication、新的 consumer API 跟更可靠的 producer。但 Spotify 評估後認為升級的成本接近重新部署：</p>
<ul>
<li>Kafka 0.7 到 0.8 的 wire protocol 不相容，需要全量遷移而非滾動升級</li>
<li>所有 producer / consumer 的 client library 都要更換</li>
<li>Spotify 同時在向 GCP 遷移基礎設施，Kafka 的自管運維模式跟 GCP 的託管方向不一致</li>
</ul>
<p>相比之下，GCP Pub/Sub 提供了託管的 exactly-once 語意、跨 region replication、零運維。遷移成本跟升級 Kafka 版本的成本相當，但遷移後的長期運維成本低得多。</p>
<h2 id="解法與取捨">解法與取捨</h2>
<table>
  <thead>
      <tr>
          <th>面向</th>
          <th>留在 Kafka（升級 0.8+）</th>
          <th>遷到 GCP Pub/Sub</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>一次性遷移成本</td>
          <td>中（全量遷移、不可滾動升級）</td>
          <td>中（同樣需要改所有 client）</td>
      </tr>
      <tr>
          <td>長期運維成本</td>
          <td>高（自管 broker × 5 DC）</td>
          <td>低（託管、零 broker 維護）</td>
      </tr>
      <tr>
          <td>可靠性保證</td>
          <td>0.8+ 有 replication、改善大</td>
          <td>Pub/Sub 原生 exactly-once</td>
      </tr>
      <tr>
          <td>跨 region replication</td>
          <td>需要自建 MirrorMaker 2.0</td>
          <td>原生支援</td>
      </tr>
      <tr>
          <td>生態鎖定</td>
          <td>Kafka 生態成熟</td>
          <td>GCP 鎖定、跨雲成本高</td>
      </tr>
  </tbody>
</table>
<p>Spotify 的判斷是：在同時進行 GCP 遷移的背景下，維護自管 Kafka 的投資回報比不上切換到託管方案。這個判斷跟 Kafka 本身的能力無關 — Kafka 0.8+ 的可靠性已經解決了 0.7 的問題。決策的關鍵變數是「組織正在往哪走」，不只是「技術上哪個更好」。</p>
<h2 id="回寫教材的連結">回寫教材的連結</h2>
<ul>
<li><a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka vendor 頁</a>：cross-region replication 跟 MirrorMaker 的進階主題。Spotify 的案例是「早期版本限制」的歷史教訓，Kafka 3.x 的 KRaft + idempotent producer 已解決這些問題。</li>
<li><a href="/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Pub/Sub vendor 頁</a>：託管 MQ 的定位跟適用場景。</li>
<li><a href="/blog/backend/03-message-queue/processing-recovery-semantics/" data-link-title="3.6 Processing Semantics 與 Recovery Semantics" data-link-desc="說明投遞成功、處理成功與恢復成功為何是三個不同判斷。">3.6 processing recovery semantics</a>：exactly-once 語意的工程實踐。Spotify 案例揭露 exactly-once 在早期 Kafka 版本不成立。</li>
<li><a href="/blog/backend/03-message-queue/broker-basics/" data-link-title="3.1 broker 基礎與投遞模型" data-link-desc="先理解 broker、queue、consumer 與 delivery semantics">3.1 broker basics</a>：broker 版本跟可靠性保證的關係。</li>
</ul>
<h2 id="判讀徵兆">判讀徵兆</h2>
<p>讀者在自己的系統看到以下訊號時，應該回讀本案例：</p>
<ul>
<li>使用舊版 Kafka（&lt; 2.0）且跨 region replication 的資料完整性無法驗證</li>
<li>Broker restart 後需要人工重啟 producer、運維成本跟 broker 數量成正比</li>
<li>組織正在做基礎設施遷移（on-prem → cloud），考慮是否同步切換 MQ</li>
<li>評估「升級現有系統 vs 遷移到新系統」的決策框架</li>
</ul>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://engineering.atspotify.com/2017/03/spotifys-event-delivery-the-road-to-the-cloud-part-ii">Spotify&rsquo;s Event Delivery — The Road to the Cloud (Part II)</a></li>
</ul>
]]></content:encoded></item><item><title>3.C21 Goldman Sachs：MSK 遷移 with MirrorMaker 2</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-goldman-sachs-msk-migration/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-goldman-sachs-msk-migration/</guid><description>&lt;p>這個案例的核心責任是說明 MM2 在 production cutover 的真實 tuning 與 LB 整合 pitfall。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>Global Investment Research 把 ~12 microservice / 30 instance 從 on-prem Kafka 遷到 MSK；用 MM2 同步 topic / ACL / consumer group / offset、選擇 atomic cutover、整體耗時 ~7 小時。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>把 MM2 預設的 prefixed topic 改成 identical name；遇到 flush timeout（5s → 30s）、request size、NLB idle timeout 350s vs client 540s 衝突。揭露 managed 服務遷移的細節風險集中在「LB / timeout / topic naming」這些 client 端配置、不在 broker 本身。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>Kafka 進階主題：cross-region MirrorMaker / managed broker 遷移 / ACL 設計。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/vmware-kafka-to-msk/" data-link-title="3.C2 VMware Tanzu CloudHealth：Kafka 轉 Amazon MSK" data-link-desc="自管 Kafka 遷移到託管平台時的治理重點。">3.C2 VMware → MSK&lt;/a>。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://aws.amazon.com/blogs/big-data/how-goldman-sachs-migrated-from-their-on-premises-apache-kafka-cluster-to-amazon-msk/">How Goldman Sachs Migrated from On-Premises Apache Kafka to Amazon MSK&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 MM2 在 production cutover 的真實 tuning 與 LB 整合 pitfall。</p>
<h2 id="觀察">觀察</h2>
<p>Global Investment Research 把 ~12 microservice / 30 instance 從 on-prem Kafka 遷到 MSK；用 MM2 同步 topic / ACL / consumer group / offset、選擇 atomic cutover、整體耗時 ~7 小時。</p>
<h2 id="判讀">判讀</h2>
<p>把 MM2 預設的 prefixed topic 改成 identical name；遇到 flush timeout（5s → 30s）、request size、NLB idle timeout 350s vs client 540s 衝突。揭露 managed 服務遷移的細節風險集中在「LB / timeout / topic naming」這些 client 端配置、不在 broker 本身。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>Kafka 進階主題：cross-region MirrorMaker / managed broker 遷移 / ACL 設計。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/cases/vmware-kafka-to-msk/" data-link-title="3.C2 VMware Tanzu CloudHealth：Kafka 轉 Amazon MSK" data-link-desc="自管 Kafka 遷移到託管平台時的治理重點。">3.C2 VMware → MSK</a>。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://aws.amazon.com/blogs/big-data/how-goldman-sachs-migrated-from-their-on-premises-apache-kafka-cluster-to-amazon-msk/">How Goldman Sachs Migrated from On-Premises Apache Kafka to Amazon MSK</a></li>
</ul>
]]></content:encoded></item><item><title>3.C22 Trivago：KEDA scale-to-zero by Kafka lag</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-trivago-keda-scale-to-zero/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-trivago-keda-scale-to-zero/</guid><description>&lt;p>這個案例的核心責任是說明 event-driven workload 該按 backlog 而非 resource usage scale 的設計判準。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>Trivago 跨 3 個 region 跑 50+ Kafka sink service、每個 always-on 用 1 CPU + 1 GB；CPU/mem-based autoscaling 無效（sink 多為 I/O bottleneck、CPU 平坦）。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>KEDA 以 consumer lag 為 scaling signal、minReplicaCount=0 達到 scale-to-zero、daily replica-hour 從 50 降到 1-2。揭露「resource usage 不等於工作量」、event-driven 場景該看 backlog signal。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>Kafka 進階主題：consumer lag / autoscaling / multi-tenant 配額。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計&lt;/a>。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tech.trivago.com/post/2026-02-18-from-always-on-to-on-demand-scaling-kafka-sinks-with-keda">From Always-On to On-Demand: Scaling Kafka Sinks with KEDA&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 event-driven workload 該按 backlog 而非 resource usage scale 的設計判準。</p>
<h2 id="觀察">觀察</h2>
<p>Trivago 跨 3 個 region 跑 50+ Kafka sink service、每個 always-on 用 1 CPU + 1 GB；CPU/mem-based autoscaling 無效（sink 多為 I/O bottleneck、CPU 平坦）。</p>
<h2 id="判讀">判讀</h2>
<p>KEDA 以 consumer lag 為 scaling signal、minReplicaCount=0 達到 scale-to-zero、daily replica-hour 從 50 降到 1-2。揭露「resource usage 不等於工作量」、event-driven 場景該看 backlog signal。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>Kafka 進階主題：consumer lag / autoscaling / multi-tenant 配額。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計</a>。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://tech.trivago.com/post/2026-02-18-from-always-on-to-on-demand-scaling-kafka-sinks-with-keda">From Always-On to On-Demand: Scaling Kafka Sinks with KEDA</a></li>
</ul>
]]></content:encoded></item><item><title>MongoDB Change Streams + Kafka 整合：resume token、scope 選擇與 connector 治理</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/mongodb/change-streams-kafka/</link><pubDate>Wed, 27 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/mongodb/change-streams-kafka/</guid><description>&lt;p>MongoDB change streams 是 3.6+ 原生 CDC 介面、本質上是 oplog tail 包裝成 cursor API。Application 從 dual-write 模式（自己寫 MongoDB 又寫 Elasticsearch / Redis / data warehouse）換成 change stream → Kafka → downstream sink 後、有了第一版 CDC pipeline、但連續工作幾週後出現「downstream 漏 event」或「duplicate event」；最痛的是 connector restart 後 resume token 過期（oplog 已滾掉）、整個 collection 必須重灌。本文把 change stream 機制、Kafka Connector 配置、resume token 治理、sharded cluster scope 選擇講清楚。&lt;/p>
&lt;p>本文不重複 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/mongodb/" data-link-title="MongoDB" data-link-desc="Document database 代表、Atlas managed、跨雲可用、許多大規模平台從 MongoDB 起家">MongoDB vendor overview&lt;/a> 已寫過的 change streams 簡介 — 而是 production CDC pipeline 部署 + 失敗修復的實作層教學。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>MongoDB 適用度前置判讀&lt;/strong>：進到 CDC pipeline 設計前先確認 workload 在 MongoDB 適用區（document shape 主導 / contract layer 該放哪 / 跨雲 hedging 是否需要）— 詳見 &lt;a href="../schema-design-pattern/#%e5%95%8f%e9%a1%8c%e6%83%85%e5%a2%83document-%e8%87%aa%e7%94%b1%e7%9a%84%e5%be%8c%e5%ba%a7%e5%8a%9b">schema-design-pattern 開頭 3 軸前置判讀&lt;/a>、本篇不重複展開。Change streams 是 &lt;em>已選 MongoDB 後&lt;/em> 的 event-driven 整合議題。&lt;/p>&lt;/blockquote>
&lt;h2 id="問題情境第一版-cdc-pipeline-跑幾週的踩雷">問題情境：第一版 CDC pipeline 跑幾週的踩雷&lt;/h2>
&lt;p>典型觸發場景：application 寫 MongoDB 後還要 dual-write Elasticsearch / Redis / data warehouse、application code 越塞越多 hook、寫入失敗的補償邏輯散落各處。改用 change stream → Kafka → downstream sink 後、有了第一版 CDC pipeline、但連續工作幾週後出現：&lt;/p>
&lt;ul>
&lt;li>Downstream 漏 event 或 duplicate event&lt;/li>
&lt;li>Connector restart 後 resume token 過期（oplog 已滾掉）、整個 collection 必須重灌&lt;/li>
&lt;li>Sharded cluster 上 collection-level change stream 跟 cluster-wide change stream 行為不同、application 連 mongos 跟連 single shard 拿到不同 event&lt;/li>
&lt;/ul>
&lt;p>讀者徵兆：&lt;/p>
&lt;ul>
&lt;li>MongoDB Kafka Connector log &lt;code>ChangeStreamHistoryLost&lt;/code> 或 &lt;code>ResumeTokenChanged&lt;/code>&lt;/li>
&lt;li>Downstream Kafka topic event count vs source collection write count 不平&lt;/li>
&lt;li>Replication oplog 跟 change stream consumer 的 lag 同時升&lt;/li>
&lt;/ul>
&lt;p>Case anchor：CDC pipeline resume token 過期導致全量重灌的具體 incident 細節需未來 case 補完、本文以「常見 failure pattern」+ 容量公式處理、不憑空編造 incident 數字。側面引用 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/spotify-kafka-to-pubsub-migration-gcp/" data-link-title="9.C9 Spotify：從自管 Kafka 遷移到 GCP Pub/Sub 的事件交付系統" data-link-desc="Spotify 把自管 Kafka 事件系統遷移到 Google Cloud Pub/Sub、避免自管 broker 的容量規劃成本">Spotify Kafka → PubSub migration&lt;/a>（pipeline-level migration 經驗對照）。&lt;/p></description><content:encoded><![CDATA[<p>MongoDB change streams 是 3.6+ 原生 CDC 介面、本質上是 oplog tail 包裝成 cursor API。Application 從 dual-write 模式（自己寫 MongoDB 又寫 Elasticsearch / Redis / data warehouse）換成 change stream → Kafka → downstream sink 後、有了第一版 CDC pipeline、但連續工作幾週後出現「downstream 漏 event」或「duplicate event」；最痛的是 connector restart 後 resume token 過期（oplog 已滾掉）、整個 collection 必須重灌。本文把 change stream 機制、Kafka Connector 配置、resume token 治理、sharded cluster scope 選擇講清楚。</p>
<p>本文不重複 <a href="/blog/backend/01-database/vendors/mongodb/" data-link-title="MongoDB" data-link-desc="Document database 代表、Atlas managed、跨雲可用、許多大規模平台從 MongoDB 起家">MongoDB vendor overview</a> 已寫過的 change streams 簡介 — 而是 production CDC pipeline 部署 + 失敗修復的實作層教學。</p>
<blockquote>
<p><strong>MongoDB 適用度前置判讀</strong>：進到 CDC pipeline 設計前先確認 workload 在 MongoDB 適用區（document shape 主導 / contract layer 該放哪 / 跨雲 hedging 是否需要）— 詳見 <a href="../schema-design-pattern/#%e5%95%8f%e9%a1%8c%e6%83%85%e5%a2%83document-%e8%87%aa%e7%94%b1%e7%9a%84%e5%be%8c%e5%ba%a7%e5%8a%9b">schema-design-pattern 開頭 3 軸前置判讀</a>、本篇不重複展開。Change streams 是 <em>已選 MongoDB 後</em> 的 event-driven 整合議題。</p></blockquote>
<h2 id="問題情境第一版-cdc-pipeline-跑幾週的踩雷">問題情境：第一版 CDC pipeline 跑幾週的踩雷</h2>
<p>典型觸發場景：application 寫 MongoDB 後還要 dual-write Elasticsearch / Redis / data warehouse、application code 越塞越多 hook、寫入失敗的補償邏輯散落各處。改用 change stream → Kafka → downstream sink 後、有了第一版 CDC pipeline、但連續工作幾週後出現：</p>
<ul>
<li>Downstream 漏 event 或 duplicate event</li>
<li>Connector restart 後 resume token 過期（oplog 已滾掉）、整個 collection 必須重灌</li>
<li>Sharded cluster 上 collection-level change stream 跟 cluster-wide change stream 行為不同、application 連 mongos 跟連 single shard 拿到不同 event</li>
</ul>
<p>讀者徵兆：</p>
<ul>
<li>MongoDB Kafka Connector log <code>ChangeStreamHistoryLost</code> 或 <code>ResumeTokenChanged</code></li>
<li>Downstream Kafka topic event count vs source collection write count 不平</li>
<li>Replication oplog 跟 change stream consumer 的 lag 同時升</li>
</ul>
<p>Case anchor：CDC pipeline resume token 過期導致全量重灌的具體 incident 細節需未來 case 補完、本文以「常見 failure pattern」+ 容量公式處理、不憑空編造 incident 數字。側面引用 <a href="/blog/backend/09-performance-capacity/cases/spotify-kafka-to-pubsub-migration-gcp/" data-link-title="9.C9 Spotify：從自管 Kafka 遷移到 GCP Pub/Sub 的事件交付系統" data-link-desc="Spotify 把自管 Kafka 事件系統遷移到 Google Cloud Pub/Sub、避免自管 broker 的容量規劃成本">Spotify Kafka → PubSub migration</a>（pipeline-level migration 經驗對照）。</p>
<h2 id="核心機制">核心機制</h2>
<p>Change stream 是 MongoDB 3.6+ 原生 CDC、本質上是 oplog tail 包裝成 cursor API。可以從 collection / database / cluster 三個 scope 開：</p>
<ul>
<li><strong>Collection-level</strong>：監看單一 collection 的變更</li>
<li><strong>Database-level</strong>：監看整個 database 的所有 collection</li>
<li><strong>Cluster-wide</strong>：監看整個 cluster 的所有 database</li>
</ul>
<p>Oplog 是 capped collection、預設 size = disk 5% 或 50GB（取較小）。Resume token 對應 oplog entry 的 timestamp + UUID + documentKey。Token 必須對應仍在 oplog 內的 entry — oplog 滾掉就拿不到 token 對應的位置、<code>ChangeStreamHistoryLost</code>。</p>
<p><strong>Resume token 兩種用法</strong>：</p>
<ul>
<li><code>_id</code>：每個 event 都帶、application 自己存</li>
<li><code>startAfter</code> / <code>resumeAfter</code> parameter：重啟 cursor 時帶上</li>
</ul>
<p><strong><code>fullDocument: &quot;updateLookup&quot;</code></strong>：update event 預設只給 delta、加這個 option 會額外 query 一次 primary 拿完整 doc；高頻 update 下成本顯著（primary 負擔翻倍）。</p>
<p><strong>Pre-image / post-image（6.0+）</strong>：可以拿到 update 前的 doc 狀態、需 collection-level option <code>changeStreamPreAndPostImages: true</code>。</p>
<p><strong>Cluster-wide vs collection-level change stream</strong>：</p>
<ul>
<li>Cluster-wide 必須打 mongos、event ordering 是 global</li>
<li>Collection-level 可直接打單 shard、ordering 只在該 shard 內</li>
<li>Sharded cluster 上 cluster-wide stream 容易把 mongos 變單點瓶頸（所有 shard 的 event 都收斂到 mongos）</li>
</ul>
<p><strong>MongoDB Kafka Connector</strong>（Confluent / MongoDB 官方）：</p>
<ul>
<li>Source connector：把 change stream → Kafka topic</li>
<li>Sink connector：把 Kafka topic → MongoDB</li>
<li>At-least-once 語義、需 application 處理 idempotency</li>
</ul>
<p>對應 knowledge card：<a href="/blog/backend/knowledge-cards/change-data-capture/" data-link-title="Change Data Capture" data-link-desc="說明資料變更如何被捕捉並傳送到其他系統">change-data-capture</a>、<a href="/blog/backend/knowledge-cards/replication-channel/" data-link-title="Replication Channel" data-link-desc="說明多來源複製中，每個來源對應的獨立複製通道如何成為隔離單位">replication-channel</a>、<a href="/blog/backend/knowledge-cards/replication-slot/" data-link-title="Replication Slot" data-link-desc="說明邏輯複製如何用 slot 追蹤消費進度，並對來源端造成保留壓力">replication-slot</a>（MongoDB 沒 slot、概念對照）。</p>
<h2 id="操作流程">操作流程</h2>
<p><strong>Step 1：scope 決策樹</strong>。</p>
<table>
  <thead>
      <tr>
          <th>Scope</th>
          <th>適用條件</th>
          <th>代價</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Collection-level</td>
          <td>單一 collection 的下游 sink、ordering 需求單一</td>
          <td>多 collection 要多 connector</td>
      </tr>
      <tr>
          <td>Database-level</td>
          <td>多 collection 共享 sink、ordering 跨 collection</td>
          <td>filter cost 在 connector 端</td>
      </tr>
      <tr>
          <td>Cluster-wide</td>
          <td>整個 cluster 統一 audit / replay</td>
          <td>mongos 單點瓶頸風險、event 量大</td>
      </tr>
  </tbody>
</table>
<p><strong>Step 2：oplog sizing</strong>。容量公式：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">oplog size &gt;= peak write rate × max acceptable consumer downtime</span></span></code></pre></div><p>典型設 24-72 小時可恢復窗口。例：peak 5K WPS、想容忍 48 小時 connector down、oplog 至少 5K × 86400 × 2 ÷ docs_per_GB ≈ 看實際 doc size 決定。在 Atlas 上 oplog size 可直接調、自管 cluster 改 <code>replSetResizeOplog</code>。</p>
<p><strong>Step 3：Kafka Connector 配置</strong>。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="nt">&#34;connector.class&#34;</span><span class="p">:</span> <span class="s2">&#34;com.mongodb.kafka.connect.MongoSourceConnector&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="nt">&#34;connection.uri&#34;</span><span class="p">:</span> <span class="s2">&#34;mongodb://...&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="nt">&#34;database&#34;</span><span class="p">:</span> <span class="s2">&#34;shop&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="nt">&#34;collection&#34;</span><span class="p">:</span> <span class="s2">&#34;orders&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="nt">&#34;publish.full.document.only&#34;</span><span class="p">:</span> <span class="s2">&#34;true&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="nt">&#34;change.stream.full.document&#34;</span><span class="p">:</span> <span class="s2">&#34;updateLookup&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="nt">&#34;copy.existing&#34;</span><span class="p">:</span> <span class="s2">&#34;true&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="nt">&#34;copy.existing.namespace.regex&#34;</span><span class="p">:</span> <span class="s2">&#34;shop\\.orders&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="nt">&#34;errors.tolerance&#34;</span><span class="p">:</span> <span class="s2">&#34;none&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="nt">&#34;offset.flush.interval.ms&#34;</span><span class="p">:</span> <span class="s2">&#34;10000&#34;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>關鍵欄位：</p>
<ul>
<li><code>change.stream.full.document: &quot;updateLookup&quot;</code>：每 update 額外 query primary 拿完整 doc（成本意識）</li>
<li><code>copy.existing: &quot;true&quot;</code>：connector 啟動時先把現有 collection 全量複製、再切到 change stream — 適合初次部署</li>
<li><code>errors.tolerance: &quot;none&quot;</code>：sink 失敗時 batch 停在 dead-letter queue、不 silently drop</li>
</ul>
<p><strong>Step 4：resume token persistence</strong>。Connector 把 token 寫 Kafka <code>__consumer_offsets</code> 或外部 store；application 自管 change stream 時要寫到 durable store（不是 in-memory）。</p>
<p><strong>Step 5：filter pipeline</strong>。Change stream 支援 aggregation pipeline 把過濾下推到 MongoDB：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">const</span> <span class="nx">pipeline</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="p">{</span> <span class="nx">$match</span><span class="o">:</span> <span class="p">{</span> <span class="s2">&#34;operationType&#34;</span><span class="o">:</span> <span class="p">{</span> <span class="nx">$in</span><span class="o">:</span> <span class="p">[</span><span class="s2">&#34;insert&#34;</span><span class="p">,</span> <span class="s2">&#34;update&#34;</span><span class="p">,</span> <span class="s2">&#34;delete&#34;</span><span class="p">]</span> <span class="p">}</span> <span class="p">}</span> <span class="p">},</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="p">{</span> <span class="nx">$match</span><span class="o">:</span> <span class="p">{</span> <span class="s2">&#34;fullDocument.region&#34;</span><span class="o">:</span> <span class="s2">&#34;ap-tokyo&#34;</span> <span class="p">}</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">]</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="kr">const</span> <span class="nx">changeStream</span> <span class="o">=</span> <span class="nx">db</span><span class="p">.</span><span class="nx">orders</span><span class="p">.</span><span class="nx">watch</span><span class="p">(</span><span class="nx">pipeline</span><span class="p">)</span></span></span></code></pre></div><p>把過濾下推減少 connector 處理量、特別是高頻 collection 上。</p>
<p><strong>Step 6：downstream idempotency</strong>。Sink 收 Kafka event 時用 <code>documentKey._id + clusterTime</code> 做 dedup key — at-least-once 語義意味著 connector restart 後幾分鐘 event 會重發。</p>
<p>驗證點：</p>
<ul>
<li>Source collection write count vs Kafka topic event count 差異 &lt; 0.1%</li>
<li>Resume token age &lt; oplog retention 的 50%（健康狀態）</li>
<li>Connector restart drill 能 5 分鐘內接回</li>
</ul>
<p>Rollback boundary：source connector 是 read-only 對 MongoDB 無傷；sink connector 要備份 target 才能還原；resume token 寫錯 → 從 <code>startAtOperationTime</code> 回退到時間點重跑。</p>
<h2 id="失敗模式">失敗模式</h2>
<p><strong>Resume token 過期（oplog 滾掉）</strong>：connector down 太久、oplog 已超出 retention、<code>ChangeStreamHistoryLost</code> → 必須 <code>copy.existing</code> 全量重灌、期間 downstream 看不到新資料。預防是 oplog sizing 留 buffer + connector lag alarm + token age 監控（age &gt; oplog retention 的 50% 預警）。</p>
<p><strong>updateLookup 在高頻 update 下打爆 primary</strong>：每筆 update event 都觸發一次 primary query、primary 負擔翻倍。修法是改 collection-level pre/post image（6.0+）、由 MongoDB 自己在寫入時記錄、或在 application 補完整 doc 後再寫 Kafka、不用 updateLookup。</p>
<p><strong>Sharded cluster cluster-wide stream 打爆 mongos</strong>：所有 shard 的 event 都收斂到 mongos、mongos 變單點瓶頸。修法是改 collection-level stream 多 connector 並行、每 connector 連 mongos 但只訂單一 collection。</p>
<p><strong>At-least-once 變 duplicate flood</strong>：connector restart 點之後幾分鐘 event 重發、downstream 沒做 idempotency → 重複 side effect（重複發 email、重複扣款）。修法是 sink 端強制 idempotency（dedup key 寫 Redis / DB）、不能假設「我用 at-least-once 但實際不會 duplicate」。</p>
<p><strong>Schema drift 突然 break sink</strong>：MongoDB 寫了新欄位 / 改型別、sink connector 的 JSON schema 不認、batch 停在 dead-letter queue。修法是 schema 變動有 validation gate（見 <a href="../schema-design-pattern/">schema design pattern</a>）、sink schema 設 <code>lenient</code> 模式吃 unknown field、或加 schema registry 統一版本。</p>
<p><strong>Backup / DDL 期間 change stream 異常</strong>：<code>reIndex</code> / <code>compact</code> / <code>dropCollection</code> 觸發特殊 event、connector 沒處理 → consumer 停。修法是 connector 處理特殊 event 邏輯要明確、不認得的 operation type 至少 log warning 而不是 silently stuck。</p>
<p>Anti-recommendation：</p>
<ul>
<li>簡單的 <a href="/blog/backend/knowledge-cards/outbox-pattern/" data-link-title="Outbox Pattern" data-link-desc="說明資料庫狀態變更與事件發布如何透過 outbox 維持一致">outbox pattern</a> + application transactional write 對於低吞吐 / 單 sink 的場景比 change stream + Kafka 簡單；不是所有「需要 event 通知」的場景都要 CDC pipeline</li>
<li>若 downstream 只是同一 region 同團隊的 Elasticsearch index、<code>$merge</code> 寫進中介 collection 或 application 雙寫 + 對賬可能成本更低</li>
<li>Resume token 過期是這條路徑最痛的事故、oplog sizing 是 <em>投資而不是成本</em> — 不要為了省 storage 把 oplog 設太小</li>
</ul>
<h2 id="容量與觀測">容量與觀測</h2>
<p>關鍵 metric：</p>
<ul>
<li><strong>Oplog 健康</strong>：oplog 寫入速率與保留時間</li>
<li><strong>Change stream 健康</strong>：cursor age、resume token 距 oplog 頭尾的距離</li>
<li><strong>Connector 健康</strong>：connector lag（Kafka offset 對比 source write）</li>
<li><strong>下游健康</strong>：event count diff（source write count vs sink apply count）、event time → arrival time lag 分布</li>
</ul>
<p>Mongo command：</p>
<ul>
<li><code>db.getReplicationInfo()</code>：oplog 大小 / 時間範圍</li>
<li><code>db.printReplicationInfo()</code>：oplog 摘要</li>
<li><code>db.currentOp({ &quot;op&quot;: &quot;getmore&quot;, &quot;ns&quot;: &quot;local.oplog.rs&quot; })</code>：看 change stream consumer 連線</li>
</ul>
<p>Connector metric（Kafka Connect JMX）：<code>source-record-poll-rate</code>、<code>source-record-write-rate</code>、<code>offset-commit-success-rate</code>。</p>
<p>回到 <a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">4.20 observability evidence</a>：oplog retention + connector lag + dedup rate 是 CDC pipeline 健康狀態 evidence 三件套。</p>
<p>回到 <a href="/blog/backend/09-performance-capacity/bottleneck-localization/" data-link-title="9.5 瓶頸定位流程" data-link-desc="從 app 到 DB / cache / broker / 第三方 quota 的逐層瓶頸定位">9.5 bottleneck localization</a>：CDC lag 升高時區分 (a) source oplog 寫太快 (b) connector 處理慢 (c) downstream sink 慢。</p>
<h2 id="邊界與整合">邊界與整合</h2>
<p>Sibling deep articles：</p>
<ul>
<li><a href="../shard-key-selection/">shard key selection</a> — cluster-wide vs collection-level change stream 在 sharded cluster 的選擇</li>
<li><a href="../replica-set-read-preference/">replica set read preference</a> — change stream 對 primary load 的影響、能否走 secondary</li>
<li><a href="../schema-design-pattern/">schema design pattern</a> — schema validator 對下游 sink 的契約意義</li>
<li><a href="../connection-management-and-cache-layer/">connection management and cache layer</a> — CDC sink 在 production 跨層架構裡的角色（cache invalidation / federated DB 同步）</li>
</ul>
<p>Migration playbook：</p>
<ul>
<li>MongoDB → 其他 sink 的 bulk migration 走 <a href="/blog/backend/01-database/vendors/mongodb/migrate-to-atlas/" data-link-title="MongoDB → Atlas：Atlas 不是 MongoDB &#43; managed、是另一個 product" data-link-desc="Atlas 號稱「MongoDB managed」但 operational model 完全不同（auto-scaling / VPC peering / IAM-driven access / 內建 backup / billing 模型）；本文採用 Type C operational redesign hybrid 結構、4-phase operational migration &#43; drop-in cutover、5 個 production 踩雷（連線數限制 / IP whitelist / backup retention / IAM token 過期 / billing 暴漲）">→ Atlas Migration Service</a></li>
<li>遷出 MongoDB 時 change stream 是 catch-up 機制（先 bulk export、再 change stream 補增量）</li>
</ul>
<p>跟 1.x 互引：<a href="/blog/backend/01-database/schema-migration-rollout-evidence/" data-link-title="1.7 Schema Migration Rollout 證據（Schema Migration Rollout Evidence）實作示範" data-link-desc="以訂單付款狀態欄位演進示範 schema migration 如何產出 evidence、release gate 與 incident decision log。">1.7 schema migration rollout evidence</a> 處理 schema drift 時 CDC pipeline 的對賬；<a href="/blog/backend/01-database/reconciliation-data-repair/" data-link-title="1.9 Reconciliation 與 Data Repair" data-link-desc="資料不一致的分類、偵測模式、修復策略、audit trail、跟 backup / PITR 整合">1.9 reconciliation data repair</a> 處理 CDC 失準後的對賬流程。</p>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/mongodb/" data-link-title="MongoDB" data-link-desc="Document database 代表、Atlas managed、跨雲可用、許多大規模平台從 MongoDB 起家">MongoDB vendor overview</a> — 本文是該頁尾「change streams + Kafka」backlog 的深度展開</li>
<li><a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章方法論</a></li>
<li>官方：<a href="https://www.mongodb.com/docs/manual/changeStreams/">Change Streams</a>、<a href="https://www.mongodb.com/docs/kafka-connector/current/">MongoDB Kafka Connector</a>、<a href="https://www.mongodb.com/docs/manual/core/replica-set-oplog/">Oplog</a></li>
</ul>
]]></content:encoded></item><item><title>終端機訊息佇列客戶端：Kafka 的 kaskade/yozefu/ktea 與 Redis 的 iredis</title><link>https://tarrragon.github.io/blog/linux/tools/cli/message-queue-tui-clients/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/linux/tools/cli/message-queue-tui-clients/</guid><description>&lt;p>終端機訊息佇列客戶端把 broker 的 topic、partition、consumer group 與訊息內容做成可導航的文字介面，讓遠端只有終端機時也能瀏覽訊息流、消費單一 topic、看消費進度，取代把連線資訊餵給桌面工具（Kafka 的 Conduktor、Redis 的 RedisInsight）的需求。它跟 broker 自帶的純指令工具（&lt;code>kafka-topics.sh&lt;/code>、&lt;code>rabbitmqctl&lt;/code>、&lt;code>redis-cli&lt;/code>）互補：指令工具適合腳本與一次性查詢，TUI 適合「邊看 topic 清單邊翻訊息內容」這種互動探索。&lt;/p>
&lt;p>本文承接 &lt;a href="https://tarrragon.github.io/blog/linux/tools/cli/cli-graphical-tools-overview/" data-link-title="終端機圖形化工具總覽：遠端操作下的 TUI、文字圖表與多工器" data-link-desc="在純文字終端機裡用 ASCII 與製圖字元做出監控儀表板、資料圖表與多視窗操作的工具總覽，並針對 SSH 伺服器、手機平板、低頻寬三種遠端情境給出選型判讀。">終端機圖形化工具總覽&lt;/a> 的訊息佇列客戶端分類。broker 端的純指令操作與 vendor 選型見 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/redis-streams/" data-link-title="Redis Streams" data-link-desc="Redis 生態內的 streams、append-only log &amp;#43; consumer group">Redis Streams&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/rabbitmq/" data-link-title="RabbitMQ" data-link-desc="Classic message broker、AMQP routing 為主">RabbitMQ&lt;/a> 服務頁。&lt;/p>
&lt;h2 id="跟-sql-客戶端最大的不同多半綁單一-broker-協議">跟 SQL 客戶端最大的不同：多半綁單一 broker 協議&lt;/h2>
&lt;p>訊息佇列 TUI 幾乎都綁定單一 broker 協議，這是選型要先認清的一點，也跟 SQL 客戶端剛好相反。&lt;a href="https://tarrragon.github.io/blog/linux/tools/cli/sql-database-clients/" data-link-title="終端機 SQL 客戶端：harlequin、lazysql 與 pgcli/litecli 的選型" data-link-desc="在純文字終端機連資料庫、跑查詢、看結果的客戶端：全螢幕 TUI（harlequin IDE 風、lazysql 瀏覽器風）與增強型 REPL（pgcli/litecli）兩種範式，以及遠端連線的 SSL driver gotcha。">SQL 客戶端&lt;/a> 一個工具靠 adapter 連 Postgres、MySQL、SQLite 多種資料庫；訊息佇列這邊，Kafka 的 TUI 說的是 Kafka protocol、不認 AMQP，RabbitMQ 的 TUI 走 management API、也不讀 Kafka topic。能同時連多種 broker 的工具是少數例外（見後文 queuepeek）。&lt;/p>
&lt;p>所以選型順序是先定 broker、再挑該 broker 生態的工具。實機盤點下來，Kafka 的 TUI 生態最成熟（多個活躍專案、安裝管道齊全），Redis 有強的增強型 REPL，RabbitMQ 與跨 broker 工具仍在早期。&lt;/p>
&lt;h2 id="兩種範式全螢幕-tui-與增強型-repl">兩種範式：全螢幕 TUI 與增強型 REPL&lt;/h2>
&lt;p>訊息佇列客戶端沿用跟 SQL 客戶端同一組範式區分。全螢幕 TUI（&lt;code>kaskade&lt;/code> / &lt;code>yozefu&lt;/code> / &lt;code>ktea&lt;/code>）把 topic 清單、訊息內容、consumer 狀態排進多個面板，鍵盤導航瀏覽；增強型 REPL（&lt;code>iredis&lt;/code>）仍是一行行打指令，但加上補全、語法高亮與型別感知輸出，是原生 client 的升級版。&lt;/p>
&lt;p>選哪種看工作型態：要在多個 topic 間翻訊息、看 partition 與 consumer group 全貌，用全螢幕 TUI；要快速接上跑幾條指令、或塞進腳本，用增強型 REPL。&lt;/p>
&lt;h2 id="kafka-全螢幕-tuikaskadeyozefuktea">Kafka 全螢幕 TUI：kaskade、yozefu、ktea&lt;/h2>
&lt;p>Kafka 有三個定位不同的全螢幕 TUI，互動模型與連線設定各異。&lt;/p>
&lt;p>&lt;code>kaskade&lt;/code>（Python、Textual 寫，實測 4.0.7）分 admin 與 consumer 兩個子命令，連線參數走 &lt;code>-b&lt;/code>。&lt;code>kaskade admin -b localhost:9092&lt;/code> 進管理模式，實測連上 broker 後渲染出 topics 面板，欄位是 name、partitions、replicas、in sync、groups、members、records，一頁看完叢集的 topic 全貌。&lt;code>kaskade consumer -b localhost:9092 -t orders --from-beginning&lt;/code> 進消費模式翻單一 topic 的訊息，&lt;code>-v json&lt;/code> 與 &lt;code>-v registry&lt;/code> 切 payload 解碼方式，後者配 &lt;code>--registry url=http://localhost:8081&lt;/code> 接 Schema Registry。SSL / SASL 不走 &lt;code>-b&lt;/code>，要用 &lt;code>--config security.protocol=SSL&lt;/code> 逐項帶或 &lt;code>--config-file kafka.properties&lt;/code> 餵設定檔。&lt;/p>
&lt;p>&lt;code>yozefu&lt;/code>（Rust 寫、binary 名是 &lt;code>yozf&lt;/code>，MAIF 維護）主打跨 topic 的搜尋查詢，把找特定 record 當成核心場景。它的查詢語言是 SQL 風的，預設 &lt;code>initial_query&lt;/code> 是 &lt;code>from end - 10&lt;/code>（從尾端往回取 10 筆），search filter 還能用 WebAssembly 自訂（&lt;code>create-filter&lt;/code> / &lt;code>import-filter&lt;/code> 子命令）。連線走 config 模型而非純 flag：&lt;code>yozf config&lt;/code> 會印出設定（檔案在 &lt;code>~/Library/Application Support/io.maif.yozefu/config.json&lt;/code>），每個 cluster 在裡面定義 &lt;code>bootstrap.servers&lt;/code>、&lt;code>security.protocol&lt;/code> 與 schema registry，再用 &lt;code>yozf -c &amp;lt;cluster&amp;gt; -t &amp;lt;topics&amp;gt;&lt;/code> 指定要連哪個。&lt;/p></description><content:encoded><![CDATA[<p>終端機訊息佇列客戶端把 broker 的 topic、partition、consumer group 與訊息內容做成可導航的文字介面，讓遠端只有終端機時也能瀏覽訊息流、消費單一 topic、看消費進度，取代把連線資訊餵給桌面工具（Kafka 的 Conduktor、Redis 的 RedisInsight）的需求。它跟 broker 自帶的純指令工具（<code>kafka-topics.sh</code>、<code>rabbitmqctl</code>、<code>redis-cli</code>）互補：指令工具適合腳本與一次性查詢，TUI 適合「邊看 topic 清單邊翻訊息內容」這種互動探索。</p>
<p>本文承接 <a href="/blog/linux/tools/cli/cli-graphical-tools-overview/" data-link-title="終端機圖形化工具總覽：遠端操作下的 TUI、文字圖表與多工器" data-link-desc="在純文字終端機裡用 ASCII 與製圖字元做出監控儀表板、資料圖表與多視窗操作的工具總覽，並針對 SSH 伺服器、手機平板、低頻寬三種遠端情境給出選型判讀。">終端機圖形化工具總覽</a> 的訊息佇列客戶端分類。broker 端的純指令操作與 vendor 選型見 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka</a>、<a href="/blog/backend/03-message-queue/vendors/redis-streams/" data-link-title="Redis Streams" data-link-desc="Redis 生態內的 streams、append-only log &#43; consumer group">Redis Streams</a>、<a href="/blog/backend/03-message-queue/vendors/rabbitmq/" data-link-title="RabbitMQ" data-link-desc="Classic message broker、AMQP routing 為主">RabbitMQ</a> 服務頁。</p>
<h2 id="跟-sql-客戶端最大的不同多半綁單一-broker-協議">跟 SQL 客戶端最大的不同：多半綁單一 broker 協議</h2>
<p>訊息佇列 TUI 幾乎都綁定單一 broker 協議，這是選型要先認清的一點，也跟 SQL 客戶端剛好相反。<a href="/blog/linux/tools/cli/sql-database-clients/" data-link-title="終端機 SQL 客戶端：harlequin、lazysql 與 pgcli/litecli 的選型" data-link-desc="在純文字終端機連資料庫、跑查詢、看結果的客戶端：全螢幕 TUI（harlequin IDE 風、lazysql 瀏覽器風）與增強型 REPL（pgcli/litecli）兩種範式，以及遠端連線的 SSL driver gotcha。">SQL 客戶端</a> 一個工具靠 adapter 連 Postgres、MySQL、SQLite 多種資料庫；訊息佇列這邊，Kafka 的 TUI 說的是 Kafka protocol、不認 AMQP，RabbitMQ 的 TUI 走 management API、也不讀 Kafka topic。能同時連多種 broker 的工具是少數例外（見後文 queuepeek）。</p>
<p>所以選型順序是先定 broker、再挑該 broker 生態的工具。實機盤點下來，Kafka 的 TUI 生態最成熟（多個活躍專案、安裝管道齊全），Redis 有強的增強型 REPL，RabbitMQ 與跨 broker 工具仍在早期。</p>
<h2 id="兩種範式全螢幕-tui-與增強型-repl">兩種範式：全螢幕 TUI 與增強型 REPL</h2>
<p>訊息佇列客戶端沿用跟 SQL 客戶端同一組範式區分。全螢幕 TUI（<code>kaskade</code> / <code>yozefu</code> / <code>ktea</code>）把 topic 清單、訊息內容、consumer 狀態排進多個面板，鍵盤導航瀏覽；增強型 REPL（<code>iredis</code>）仍是一行行打指令，但加上補全、語法高亮與型別感知輸出，是原生 client 的升級版。</p>
<p>選哪種看工作型態：要在多個 topic 間翻訊息、看 partition 與 consumer group 全貌，用全螢幕 TUI；要快速接上跑幾條指令、或塞進腳本，用增強型 REPL。</p>
<h2 id="kafka-全螢幕-tuikaskadeyozefuktea">Kafka 全螢幕 TUI：kaskade、yozefu、ktea</h2>
<p>Kafka 有三個定位不同的全螢幕 TUI，互動模型與連線設定各異。</p>
<p><code>kaskade</code>（Python、Textual 寫，實測 4.0.7）分 admin 與 consumer 兩個子命令，連線參數走 <code>-b</code>。<code>kaskade admin -b localhost:9092</code> 進管理模式，實測連上 broker 後渲染出 topics 面板，欄位是 name、partitions、replicas、in sync、groups、members、records，一頁看完叢集的 topic 全貌。<code>kaskade consumer -b localhost:9092 -t orders --from-beginning</code> 進消費模式翻單一 topic 的訊息，<code>-v json</code> 與 <code>-v registry</code> 切 payload 解碼方式，後者配 <code>--registry url=http://localhost:8081</code> 接 Schema Registry。SSL / SASL 不走 <code>-b</code>，要用 <code>--config security.protocol=SSL</code> 逐項帶或 <code>--config-file kafka.properties</code> 餵設定檔。</p>
<p><code>yozefu</code>（Rust 寫、binary 名是 <code>yozf</code>，MAIF 維護）主打跨 topic 的搜尋查詢，把找特定 record 當成核心場景。它的查詢語言是 SQL 風的，預設 <code>initial_query</code> 是 <code>from end - 10</code>（從尾端往回取 10 筆），search filter 還能用 WebAssembly 自訂（<code>create-filter</code> / <code>import-filter</code> 子命令）。連線走 config 模型而非純 flag：<code>yozf config</code> 會印出設定（檔案在 <code>~/Library/Application Support/io.maif.yozefu/config.json</code>），每個 cluster 在裡面定義 <code>bootstrap.servers</code>、<code>security.protocol</code> 與 schema registry，再用 <code>yozf -c &lt;cluster&gt; -t &lt;topics&gt;</code> 指定要連哪個。</p>
<p><code>ktea</code>（Go 寫，Homebrew 0.8.0）同樣是 config-based，cluster 連線設定走首次啟動的互動流程而非命令列旗標。啟動旗標有 <code>-debug</code> 與 <code>-plain-fonts</code>，後者在終端機沒裝 NerdFonts、圖示顯示成亂碼時關掉圖示。本機裝起來、啟動旗標確認過，cluster 連線與深層瀏覽走互動設定流程、未逐步驗證。</p>
<p>判讀：要一頁看完 topic / consumer group 狀態、或邊看邊消費，選 <code>kaskade</code>；要在大量 topic 裡用查詢撈特定 record，選 <code>yozefu</code> 的搜尋模型；<code>ktea</code> 是另一個 Go 單 binary 選擇、偏好互動式設定 cluster 的可評估。</p>
<h2 id="增強型-repliredisredis-與-redis-streams">增強型 REPL：iredis（Redis 與 Redis Streams）</h2>
<p><code>iredis</code>（Python 寫，實測 1.16.1）是 <code>redis-cli</code> 的增強版，補上指令補全、語法高亮與型別感知輸出，手感仍是 REPL。它跟 dbcli 家族的 <code>pgcli</code> / <code>litecli</code> 同一類定位。實測非互動可跑，把指令用管線餵進去就回結果：<code>echo &quot;DBSIZE&quot; | iredis -h localhost -p 6390</code>，適合塞腳本。</p>
<p>它對 Redis Streams（<a href="/blog/backend/03-message-queue/vendors/redis-streams/" data-link-title="Redis Streams" data-link-desc="Redis 生態內的 streams、append-only log &#43; consumer group">03 的 vendor 之一</a>）的檢視特別省事。<code>peek &lt;key&gt;</code> 會先看型別再自動取值，string 顯示 strlen 與內容、stream 走 <code>XINFO</code>；實測對一個 stream 跑 <code>XINFO STREAM</code> 直接回 length、last-generated-id 等欄位，不必先 <code>TYPE</code> 再決定下哪個讀取指令。它是通用 Redis client、不是 stream 專用工具，但 Redis Streams 的 consumer group 操作（<code>XPENDING</code>、<code>XCLAIM</code>、<code>XINFO GROUPS</code>）都在這套指令補全範圍內。</p>
<h2 id="rabbitmq-與跨-broker生態仍在早期">RabbitMQ 與跨 broker：生態仍在早期</h2>
<p>RabbitMQ 與「一個工具連多種 broker」這兩塊目前缺乏可直接安裝驗證的成熟工具，列出供參考、本機未實機驗證。</p>
<blockquote>
<p>RabbitMQ 的 TUI 候選有 <code>rabbitui</code>（走 RabbitMQ management API）與 <code>rabbithole</code>（帶 exchange / binding 的 topology browser、支援 Protobuf 解碼）。兩者都不在 Homebrew 與 crates.io 的發佈管道，本機未安裝驗證。在缺 TUI 的情況下，RabbitMQ 的互動瀏覽仍以內建的 Management UI（web，預設 15672 埠）為主，純終端機則回到 <code>rabbitmqctl</code> 與 <code>rabbitmqadmin</code>。</p></blockquote>
<blockquote>
<p>跨 broker 的 <code>queuepeek</code>（Rust 寫，宣稱同時連 RabbitMQ、Kafka、MQTT）對應 SQL 類裡 <code>usql</code> 的「一個工具連多種後端」定位。本機 <code>cargo install queuepeek</code> 在編譯 <code>rdkafka-sys</code>（綁定原生 librdkafka）階段失敗、未能驗證。</p></blockquote>
<h2 id="gotcha實測">gotcha（實測）</h2>
<ul>
<li><code>yozefu</code> 預設帶一個名為 <code>localhost</code> 的 cluster、指向 <code>localhost:9092</code>。連非預設 port（例如本機測試的 9093）要先 <code>yozf configure</code> 改掉 <code>bootstrap.servers</code>，直接用 flag 覆寫不會生效。</li>
<li><code>kaskade</code> 的 <code>-b</code> 只接 bootstrap server；SSL / SASL 等安全設定一律走 <code>--config key=value</code> 或 <code>--config-file</code>，混在 <code>-b</code> 裡會被當成 broker 位址。</li>
<li><code>ktea</code> 的 <code>-plain-fonts</code>：終端機沒裝 NerdFonts 時圖示會顯示成亂碼方塊，加這個旗標關掉圖示就恢復可讀。</li>
</ul>
<h2 id="同類其他選擇">同類其他選擇</h2>
<p>Redis 的全螢幕 TUI（如 <code>redis-tui</code>）與其他 Kafka TUI（如 <code>kafka-tui</code>）未在本輪實機驗證、列出供參考。Kafka TUI 這塊專案數量較多，挑選時以發佈管道（Homebrew / pip / crates.io 直接可裝）與維護活躍度篩選，不追求窮舉。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>broker 端純指令工具與 vendor 選型：<a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka</a>、<a href="/blog/backend/03-message-queue/vendors/redis-streams/" data-link-title="Redis Streams" data-link-desc="Redis 生態內的 streams、append-only log &#43; consumer group">Redis Streams</a>、<a href="/blog/backend/03-message-queue/vendors/rabbitmq/" data-link-title="RabbitMQ" data-link-desc="Classic message broker、AMQP routing 為主">RabbitMQ</a> 服務頁。</li>
<li>同範式的資料庫客戶端對照：<a href="/blog/linux/tools/cli/sql-database-clients/" data-link-title="終端機 SQL 客戶端：harlequin、lazysql 與 pgcli/litecli 的選型" data-link-desc="在純文字終端機連資料庫、跑查詢、看結果的客戶端：全螢幕 TUI（harlequin IDE 風、lazysql 瀏覽器風）與增強型 REPL（pgcli/litecli）兩種範式，以及遠端連線的 SSL driver gotcha。">終端機 SQL 客戶端</a>。</li>
<li>把客戶端擺進可持久化的多工器 pane：<a href="/blog/linux/tools/cli/tmux-persistence-and-basics/" data-link-title="tmux 基礎：遠端 session 持久化與基本操作" data-link-desc="tmux 終端機多工器的遠端使用核心：detach/reattach 讓 session 脫離連線生命週期、prefix key 與 window/pane 操作、手機友善的快捷鍵調校，以及 tmux 與 zellij 的選型對照。">tmux 基礎</a>。</li>
<li>訊息佇列客戶端在遠端工具分類中的定位：<a href="/blog/linux/tools/cli/cli-graphical-tools-overview/" data-link-title="終端機圖形化工具總覽：遠端操作下的 TUI、文字圖表與多工器" data-link-desc="在純文字終端機裡用 ASCII 與製圖字元做出監控儀表板、資料圖表與多視窗操作的工具總覽，並針對 SSH 伺服器、手機平板、低頻寬三種遠端情境給出選型判讀。">終端機圖形化工具總覽</a>。</li>
</ul>
]]></content:encoded></item></channel></rss>