<?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>Paradigm on Tarragon</title><link>https://tarrragon.github.io/blog/tags/paradigm/</link><description>Recent content in Paradigm on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Tue, 16 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/paradigm/index.xml" rel="self" type="application/rss+xml"/><item><title>Deterministic vs Fuzzy engineering</title><link>https://tarrragon.github.io/blog/llm/knowledge-cards/deterministic-vs-fuzzy/</link><pubDate>Thu, 14 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/llm/knowledge-cards/deterministic-vs-fuzzy/</guid><description>&lt;p>Deterministic vs Fuzzy engineering 的核心概念是「&lt;strong>LLM 軟體跟傳統軟體在設計典範上的根本差異&lt;/strong>」。Deterministic 軟體建立在「同 input → 同 output」假設、fuzzy 軟體建立在「同 input → 分佈」假設。兩者在資料、邏輯、行為一致性、實驗成本四維度都不同、設計直覺要分開。實務上一個 LLM 應用是兩者混合、guardrail 設計是把 fuzzy 邊界包進 deterministic 約束。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>四維對照：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>維度&lt;/th>
 &lt;th>Deterministic 軟體&lt;/th>
 &lt;th>Fuzzy 軟體&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>資料形狀&lt;/td>
 &lt;td>結構化（JSON、DB row）&lt;/td>
 &lt;td>半結構化 / 非結構化&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>邏輯來源&lt;/td>
 &lt;td>人類寫死規則&lt;/td>
 &lt;td>模型推論、依 prompt + context 浮動&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>行為一致性&lt;/td>
 &lt;td>同 input → 同 output&lt;/td>
 &lt;td>同 input → 分佈&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>分解原則&lt;/td>
 &lt;td>按職責 / 模組&lt;/td>
 &lt;td>按角色 / agent&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>測試方式&lt;/td>
 &lt;td>unit test、覆蓋率&lt;/td>
 &lt;td>eval、judge、distribution metric&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>實驗成本&lt;/td>
 &lt;td>高&lt;/td>
 &lt;td>低（改 prompt 即可）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>典型 LLM 應用的混合：&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">User input
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> ↓ Fuzzy（LLM 理解意圖）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> ↓ Deterministic（DB / API / policy）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> ↓ Fuzzy（LLM 寫回應）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> ↓ Deterministic（發送 / 寫入）&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>讀 LLM 應用設計文章或開始設計 production AI 系統時、這個 framing 決定每個 step 的工具選擇。實作判讀：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>哪段該 deterministic / 哪段該 fuzzy&lt;/strong>：規則可窮舉、失敗代價高、需要解釋、需要 byte-exact 重現的 → deterministic；自由文字輸入、生成有風格的輸出、邊界模糊的 → fuzzy。&lt;/li>
&lt;li>&lt;strong>典範用錯的反模式&lt;/strong>：deterministic 需求硬用 fuzzy（用 LLM 算稅金）、fuzzy 需求硬用 deterministic（regex 解析自由文字）、邊界混（prompt 內塞算術 / code 內塞意圖分類）。&lt;/li>
&lt;li>&lt;strong>Fuzzy 邊界的四種 guardrail&lt;/strong>：schema validation、output validator、action gating、distribution monitoring。混用、不同 risk class 分擔不同層。&lt;/li>
&lt;li>&lt;strong>跟 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/human-in-the-loop/" data-link-title="Human-in-the-loop（HITL）" data-link-desc="人類介入 LLM 工作流的設計：三種觸發時機（pre-act / mid-stream / post-hoc）、避免橡皮圖章化的四條件">HITL&lt;/a> 的關係&lt;/strong>：HITL 是 deterministic guardrail 的一種——把人類判斷當 deterministic check 包 fuzzy LLM 行為。&lt;/li>
&lt;li>&lt;strong>失敗的歸因分層&lt;/strong>：壞掉時要問「是 prompt / model / context / tool / 還是 deterministic glue 的 bug」。deterministic 軟體歸因單一、fuzzy 軟體要分這幾層查。&lt;/li>
&lt;/ol>
&lt;p>完整典範討論見 &lt;a href="https://tarrragon.github.io/blog/llm/00-foundations/deterministic-vs-fuzzy-engineering/" data-link-title="0.8 Deterministic vs Fuzzy Engineering：軟體設計典範的位移" data-link-desc="傳統 deterministic 軟體跟 fuzzy LLM 軟體在資料、邏輯、分解、實驗成本四個維度的根本差異、以及哪段該 deterministic、哪段該 fuzzy 的決策框架">0.8 Deterministic vs Fuzzy Engineering&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>Deterministic vs Fuzzy engineering 的核心概念是「<strong>LLM 軟體跟傳統軟體在設計典範上的根本差異</strong>」。Deterministic 軟體建立在「同 input → 同 output」假設、fuzzy 軟體建立在「同 input → 分佈」假設。兩者在資料、邏輯、行為一致性、實驗成本四維度都不同、設計直覺要分開。實務上一個 LLM 應用是兩者混合、guardrail 設計是把 fuzzy 邊界包進 deterministic 約束。</p>
<h2 id="概念位置">概念位置</h2>
<p>四維對照：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>Deterministic 軟體</th>
          <th>Fuzzy 軟體</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>資料形狀</td>
          <td>結構化（JSON、DB row）</td>
          <td>半結構化 / 非結構化</td>
      </tr>
      <tr>
          <td>邏輯來源</td>
          <td>人類寫死規則</td>
          <td>模型推論、依 prompt + context 浮動</td>
      </tr>
      <tr>
          <td>行為一致性</td>
          <td>同 input → 同 output</td>
          <td>同 input → 分佈</td>
      </tr>
      <tr>
          <td>分解原則</td>
          <td>按職責 / 模組</td>
          <td>按角色 / agent</td>
      </tr>
      <tr>
          <td>測試方式</td>
          <td>unit test、覆蓋率</td>
          <td>eval、judge、distribution metric</td>
      </tr>
      <tr>
          <td>實驗成本</td>
          <td>高</td>
          <td>低（改 prompt 即可）</td>
      </tr>
  </tbody>
</table>
<p>典型 LLM 應用的混合：</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">User input
</span></span><span class="line"><span class="ln">2</span><span class="cl">   ↓ Fuzzy（LLM 理解意圖）
</span></span><span class="line"><span class="ln">3</span><span class="cl">   ↓ Deterministic（DB / API / policy）
</span></span><span class="line"><span class="ln">4</span><span class="cl">   ↓ Fuzzy（LLM 寫回應）
</span></span><span class="line"><span class="ln">5</span><span class="cl">   ↓ Deterministic（發送 / 寫入）</span></span></code></pre></div><h2 id="設計責任">設計責任</h2>
<p>讀 LLM 應用設計文章或開始設計 production AI 系統時、這個 framing 決定每個 step 的工具選擇。實作判讀：</p>
<ol>
<li><strong>哪段該 deterministic / 哪段該 fuzzy</strong>：規則可窮舉、失敗代價高、需要解釋、需要 byte-exact 重現的 → deterministic；自由文字輸入、生成有風格的輸出、邊界模糊的 → fuzzy。</li>
<li><strong>典範用錯的反模式</strong>：deterministic 需求硬用 fuzzy（用 LLM 算稅金）、fuzzy 需求硬用 deterministic（regex 解析自由文字）、邊界混（prompt 內塞算術 / code 內塞意圖分類）。</li>
<li><strong>Fuzzy 邊界的四種 guardrail</strong>：schema validation、output validator、action gating、distribution monitoring。混用、不同 risk class 分擔不同層。</li>
<li><strong>跟 <a href="/blog/llm/knowledge-cards/human-in-the-loop/" data-link-title="Human-in-the-loop（HITL）" data-link-desc="人類介入 LLM 工作流的設計：三種觸發時機（pre-act / mid-stream / post-hoc）、避免橡皮圖章化的四條件">HITL</a> 的關係</strong>：HITL 是 deterministic guardrail 的一種——把人類判斷當 deterministic check 包 fuzzy LLM 行為。</li>
<li><strong>失敗的歸因分層</strong>：壞掉時要問「是 prompt / model / context / tool / 還是 deterministic glue 的 bug」。deterministic 軟體歸因單一、fuzzy 軟體要分這幾層查。</li>
</ol>
<p>完整典範討論見 <a href="/blog/llm/00-foundations/deterministic-vs-fuzzy-engineering/" data-link-title="0.8 Deterministic vs Fuzzy Engineering：軟體設計典範的位移" data-link-desc="傳統 deterministic 軟體跟 fuzzy LLM 軟體在資料、邏輯、分解、實驗成本四個維度的根本差異、以及哪段該 deterministic、哪段該 fuzzy 的決策框架">0.8 Deterministic vs Fuzzy Engineering</a>。</p>
]]></content:encoded></item><item><title>0.8 Deterministic vs Fuzzy Engineering：軟體設計典範的位移</title><link>https://tarrragon.github.io/blog/llm/00-foundations/deterministic-vs-fuzzy-engineering/</link><pubDate>Thu, 14 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/llm/00-foundations/deterministic-vs-fuzzy-engineering/</guid><description>&lt;p>LLM 進到軟體工程的最大影響、不是「多了一個 API 可以呼叫」、而是軟體設計典範本身的位移（見 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/deterministic-vs-fuzzy/" data-link-title="Deterministic vs Fuzzy engineering" data-link-desc="LLM 軟體 vs 傳統軟體在資料 / 邏輯 / 行為一致性 / 實驗成本四維度的典範差異、決定哪段該包 guardrail">deterministic-vs-fuzzy&lt;/a> 卡）。傳統軟體建立在 deterministic 假設上——同樣的 input 永遠對應同樣的 output、邏輯靠人類寫定、行為可以靠 test 鎖住。LLM 軟體則建立在 fuzzy 假設上——同樣的 input 在不同溫度、不同 sampling 下會給不同 output、邏輯是模型自己推、行為只能用統計方式驗證。&lt;/p>
&lt;p>這個位移影響的不只是「在某段程式裡呼叫 LLM」、而是整套設計思維：怎麼處理資料、怎麼定義「正確」、怎麼分解任務、怎麼版本控制、怎麼測試、怎麼除錯。本章把這個典範位移寫成跨應用都成立的心智模型、讓你在後續模組（特別是 &lt;a href="https://tarrragon.github.io/blog/llm/04-applications/" data-link-title="模組四：LLM 應用層原理" data-link-desc="Prompt 技術光譜、RAG、tool use、agent、應用層協議、人機協作、multi-agent、workflow 編排、eval 設計：跨工具不變的概念地圖">模組四 LLM 應用層&lt;/a>）讀到 RAG、agent、workflow pattern 時、知道自己在跟哪個典範打交道、該套哪一邊的設計直覺。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>讀完本章後你能：&lt;/p>
&lt;ol>
&lt;li>區分一段程式碼是 deterministic 還是 fuzzy。&lt;/li>
&lt;li>列出兩個典範在四個維度（資料、邏輯、分解、實驗成本）的差異。&lt;/li>
&lt;li>判斷一個系統的哪段該 deterministic、哪段該 fuzzy。&lt;/li>
&lt;li>設計 fuzzy 邊界的 guardrail（schema / validator / HITL）。&lt;/li>
&lt;li>看到一個失敗案例、能定位是「典範用錯」還是「實作問題」。&lt;/li>
&lt;/ol>
&lt;h2 id="兩個典範的對照">兩個典範的對照&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>維度&lt;/th>
 &lt;th>Deterministic 軟體&lt;/th>
 &lt;th>Fuzzy 軟體&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>資料形狀&lt;/td>
 &lt;td>結構化（JSON、DB row、form 欄位）&lt;/td>
 &lt;td>半結構化 / 非結構化（自由文字、圖像、音訊）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>邏輯來源&lt;/td>
 &lt;td>人類寫死規則&lt;/td>
 &lt;td>模型推論、依 prompt + context 浮動&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>行為一致性&lt;/td>
 &lt;td>同 input → 同 output&lt;/td>
 &lt;td>同 input → 分佈、需 sample 多次才看見平均行為&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>分解原則&lt;/td>
 &lt;td>按職責 / 模組（monolith / microservice）&lt;/td>
 &lt;td>按角色 / agent（manager 思維：誰負責什麼任務）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>測試方式&lt;/td>
 &lt;td>unit test、integration test、覆蓋率&lt;/td>
 &lt;td>eval、judge、distribution-level metric&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>除錯&lt;/td>
 &lt;td>step debugger、log、stack trace&lt;/td>
 &lt;td>trace、prompt diff、token-level inspection&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>版本控制&lt;/td>
 &lt;td>code diff 是行為差異的完整來源&lt;/td>
 &lt;td>code diff + prompt diff + model version 三者&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>實驗成本&lt;/td>
 &lt;td>高（改 code 要 review、可能影響穩定性）&lt;/td>
 &lt;td>低（改 prompt 即可、推翻重做便宜）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>失敗模式&lt;/td>
 &lt;td>crash、wrong value、type error&lt;/td>
 &lt;td>hallucination、tone drift、partial completion&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這張表是後續所有判讀的骨架。看到一段程式時、用這幾個維度自問「這段在哪個典範」、設計直覺自然分開。&lt;/p>
&lt;h2 id="為什麼這個位移是典範級不是只是換工具">為什麼這個位移是典範級、不是只是換工具&lt;/h2>
&lt;p>很多人把 LLM 當「多了一個 API」、結果是把 LLM 塞進 deterministic 設計框架裡、然後因為它「不夠 deterministic」而 frustrated。這個 framing 錯了。LLM 不是 deterministic 工具的下一代、是另一條工具線、需要另一套設計直覺。&lt;/p>
&lt;p>幾個容易踩的混淆：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>把 LLM 行為當 bug 修&lt;/strong>：模型輸出不穩定、想用更多 &lt;code>if&lt;/code> 把它「夾」回固定行為。這條路會走到死巷——當 prompt 越夾越窄、模型反而開始失去原有能力。正確方向是讓邊界本身可以容忍變化（schema validation + retry、distribution metric、HITL）。&lt;/li>
&lt;li>&lt;strong>用 deterministic 的 test 思維測 LLM&lt;/strong>：寫了一個「input X 應該得到 output Y」的單元測試、期望 byte-exact match。LLM 行為是分佈、即使 temperature=0、prompt brittleness 也讓單次測試結果不穩。Fuzzy 系統的測試是「在 N 次採樣中、output 落在期望範圍內的比例」、或「分佈級別 metric」、不是「精確等於某 string」。&lt;/li>
&lt;li>&lt;strong>用 deterministic 的 code review 審 LLM-generated code&lt;/strong>：要求 generated code 完全符合 style guide、結果耗在 nitpick 而不是行為正確性。LLM 生成是 fuzzy 過程、review 焦點該是「功能對 + 安全 + 可讀」、style 交給 linter / formatter 後處理。&lt;/li>
&lt;/ul>
&lt;p>典範位移的真正意涵：&lt;strong>設計時就承認 fuzziness 存在、並圍繞它設計&lt;/strong>、不是假裝它不存在。&lt;/p></description><content:encoded><![CDATA[<p>LLM 進到軟體工程的最大影響、不是「多了一個 API 可以呼叫」、而是軟體設計典範本身的位移（見 <a href="/blog/llm/knowledge-cards/deterministic-vs-fuzzy/" data-link-title="Deterministic vs Fuzzy engineering" data-link-desc="LLM 軟體 vs 傳統軟體在資料 / 邏輯 / 行為一致性 / 實驗成本四維度的典範差異、決定哪段該包 guardrail">deterministic-vs-fuzzy</a> 卡）。傳統軟體建立在 deterministic 假設上——同樣的 input 永遠對應同樣的 output、邏輯靠人類寫定、行為可以靠 test 鎖住。LLM 軟體則建立在 fuzzy 假設上——同樣的 input 在不同溫度、不同 sampling 下會給不同 output、邏輯是模型自己推、行為只能用統計方式驗證。</p>
<p>這個位移影響的不只是「在某段程式裡呼叫 LLM」、而是整套設計思維：怎麼處理資料、怎麼定義「正確」、怎麼分解任務、怎麼版本控制、怎麼測試、怎麼除錯。本章把這個典範位移寫成跨應用都成立的心智模型、讓你在後續模組（特別是 <a href="/blog/llm/04-applications/" data-link-title="模組四：LLM 應用層原理" data-link-desc="Prompt 技術光譜、RAG、tool use、agent、應用層協議、人機協作、multi-agent、workflow 編排、eval 設計：跨工具不變的概念地圖">模組四 LLM 應用層</a>）讀到 RAG、agent、workflow pattern 時、知道自己在跟哪個典範打交道、該套哪一邊的設計直覺。</p>
<h2 id="本章目標">本章目標</h2>
<p>讀完本章後你能：</p>
<ol>
<li>區分一段程式碼是 deterministic 還是 fuzzy。</li>
<li>列出兩個典範在四個維度（資料、邏輯、分解、實驗成本）的差異。</li>
<li>判斷一個系統的哪段該 deterministic、哪段該 fuzzy。</li>
<li>設計 fuzzy 邊界的 guardrail（schema / validator / HITL）。</li>
<li>看到一個失敗案例、能定位是「典範用錯」還是「實作問題」。</li>
</ol>
<h2 id="兩個典範的對照">兩個典範的對照</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>Deterministic 軟體</th>
          <th>Fuzzy 軟體</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>資料形狀</td>
          <td>結構化（JSON、DB row、form 欄位）</td>
          <td>半結構化 / 非結構化（自由文字、圖像、音訊）</td>
      </tr>
      <tr>
          <td>邏輯來源</td>
          <td>人類寫死規則</td>
          <td>模型推論、依 prompt + context 浮動</td>
      </tr>
      <tr>
          <td>行為一致性</td>
          <td>同 input → 同 output</td>
          <td>同 input → 分佈、需 sample 多次才看見平均行為</td>
      </tr>
      <tr>
          <td>分解原則</td>
          <td>按職責 / 模組（monolith / microservice）</td>
          <td>按角色 / agent（manager 思維：誰負責什麼任務）</td>
      </tr>
      <tr>
          <td>測試方式</td>
          <td>unit test、integration test、覆蓋率</td>
          <td>eval、judge、distribution-level metric</td>
      </tr>
      <tr>
          <td>除錯</td>
          <td>step debugger、log、stack trace</td>
          <td>trace、prompt diff、token-level inspection</td>
      </tr>
      <tr>
          <td>版本控制</td>
          <td>code diff 是行為差異的完整來源</td>
          <td>code diff + prompt diff + model version 三者</td>
      </tr>
      <tr>
          <td>實驗成本</td>
          <td>高（改 code 要 review、可能影響穩定性）</td>
          <td>低（改 prompt 即可、推翻重做便宜）</td>
      </tr>
      <tr>
          <td>失敗模式</td>
          <td>crash、wrong value、type error</td>
          <td>hallucination、tone drift、partial completion</td>
      </tr>
  </tbody>
</table>
<p>這張表是後續所有判讀的骨架。看到一段程式時、用這幾個維度自問「這段在哪個典範」、設計直覺自然分開。</p>
<h2 id="為什麼這個位移是典範級不是只是換工具">為什麼這個位移是典範級、不是只是換工具</h2>
<p>很多人把 LLM 當「多了一個 API」、結果是把 LLM 塞進 deterministic 設計框架裡、然後因為它「不夠 deterministic」而 frustrated。這個 framing 錯了。LLM 不是 deterministic 工具的下一代、是另一條工具線、需要另一套設計直覺。</p>
<p>幾個容易踩的混淆：</p>
<ul>
<li><strong>把 LLM 行為當 bug 修</strong>：模型輸出不穩定、想用更多 <code>if</code> 把它「夾」回固定行為。這條路會走到死巷——當 prompt 越夾越窄、模型反而開始失去原有能力。正確方向是讓邊界本身可以容忍變化（schema validation + retry、distribution metric、HITL）。</li>
<li><strong>用 deterministic 的 test 思維測 LLM</strong>：寫了一個「input X 應該得到 output Y」的單元測試、期望 byte-exact match。LLM 行為是分佈、即使 temperature=0、prompt brittleness 也讓單次測試結果不穩。Fuzzy 系統的測試是「在 N 次採樣中、output 落在期望範圍內的比例」、或「分佈級別 metric」、不是「精確等於某 string」。</li>
<li><strong>用 deterministic 的 code review 審 LLM-generated code</strong>：要求 generated code 完全符合 style guide、結果耗在 nitpick 而不是行為正確性。LLM 生成是 fuzzy 過程、review 焦點該是「功能對 + 安全 + 可讀」、style 交給 linter / formatter 後處理。</li>
</ul>
<p>典範位移的真正意涵：<strong>設計時就承認 fuzziness 存在、並圍繞它設計</strong>、不是假裝它不存在。</p>
<h2 id="哪段該-deterministic哪段該-fuzzy">哪段該 Deterministic、哪段該 Fuzzy</h2>
<p>一個系統幾乎不會「全 deterministic」或「全 fuzzy」、實務上是混合。判讀「哪段該哪個」的決策框架：</p>
<table>
  <thead>
      <tr>
          <th>屬性</th>
          <th>偏 deterministic</th>
          <th>偏 fuzzy</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>行為定義</td>
          <td>規則可窮舉</td>
          <td>規則太多 / 邊界模糊</td>
      </tr>
      <tr>
          <td>失敗代價</td>
          <td>高（金錢、安全、不可逆）</td>
          <td>低（可 retry、可 fallback）</td>
      </tr>
      <tr>
          <td>解釋需求</td>
          <td>必須能解釋為什麼做這個決定</td>
          <td>解釋是 nice-to-have</td>
      </tr>
      <tr>
          <td>一致性需求</td>
          <td>必須 byte-exact 重現（auditing、test）</td>
          <td>統計上一致即可</td>
      </tr>
      <tr>
          <td>資料形狀</td>
          <td>結構化</td>
          <td>自由文字 / 多模態</td>
      </tr>
      <tr>
          <td>變化頻率</td>
          <td>規則穩定、長期不變</td>
          <td>需求 / 領域知識 / 用戶輸入快速變化</td>
      </tr>
      <tr>
          <td>邊界條件</td>
          <td>邊界清楚（valid / invalid 兩段式）</td>
          <td>邊界連續（差不多好 / 還行 / 不夠好）</td>
      </tr>
  </tbody>
</table>
<p>實務上一個 production LLM 應用的常見組合：</p>
<ul>
<li><strong>使用者輸入解析</strong>：偏 fuzzy（LLM 解意圖、parse 自由文字）。</li>
<li><strong>資料庫查詢 / 更新</strong>：偏 deterministic（SQL、API、schema validation）。</li>
<li><strong>業務規則檢查</strong>（如「能否退款」「能否變更地址」）：偏 deterministic（policy as code）。</li>
<li><strong>回應草稿生成</strong>：偏 fuzzy（LLM 寫 email、考慮語氣）。</li>
<li><strong>發送 / 寫入動作</strong>：偏 deterministic（API call、template render）。</li>
</ul>
<p>這個混合不是隨機、是按上述決策框架推出來的。LLM 強在「理解模糊輸入」跟「生成有風格的輸出」、其餘部分能 deterministic 就 deterministic。</p>
<h3 id="反模式典範用錯的訊號">反模式：典範用錯的訊號</h3>
<ul>
<li><strong>Deterministic 的需求硬用 fuzzy 解</strong>：例如用 LLM 算稅金、然後用 retry + LLM judge 校驗。這條路的成本跟錯誤率都遠高於直接寫 deterministic 規則。判讀訊號：能用 30 行 code 寫死的規則、不要 LLM。</li>
<li><strong>Fuzzy 的需求硬用 deterministic 解</strong>：例如用 regex 解析自由文字客服訊息、然後維護一個越來越長的 case list。判讀訊號：規則 list 每週都在加新 case、加完還是漏、就該換 fuzzy。</li>
<li><strong>邊界用錯</strong>：把 deterministic 的部分塞進 prompt（如「請計算 9.32 × 47 並退款」）、或把 fuzzy 的部分塞進 code（如 <code>if user_intent == &quot;refund&quot;</code>）。前者讓 LLM 出算術錯、後者讓 code 漏 case。判讀訊號：prompt 在做算術 / 字串解析、或 code 在做意圖分類、就該重切。</li>
</ul>
<h2 id="fuzzy-邊界的-guardrail-設計">Fuzzy 邊界的 Guardrail 設計</h2>
<p>承認 fuzziness 存在後、設計重點轉成「邊界要怎麼包」。Guardrail 是 deterministic 包 fuzzy 的設計模式、防止 fuzzy 行為溢出到不該影響的地方。</p>
<p>四種常見 guardrail：</p>
<h3 id="schema-validation">Schema validation</h3>
<p>LLM 輸出被強制符合某個 schema（JSON schema、Pydantic model、TypeScript type）。不符合就 retry 或 fallback。</p>
<p>適用：LLM 結果要直接餵給下游 deterministic 系統（API、DB、UI）。</p>
<p>實作位置：LLM call 之後、下游 system 之前。</p>
<p>失敗模式：schema 對了但語意錯（structurally valid、semantically wrong）——這層 guardrail 接不住、要加 semantic check。</p>
<h3 id="output-validator">Output validator</h3>
<p>對 LLM 輸出跑語意驗證、不是只看 schema。例：生成的 email 不能包含未經授權的折扣承諾、生成的 code 不能呼叫 deprecated API。</p>
<p>適用：LLM 輸出有「該做 / 不該做」的清單。</p>
<p>實作位置：LLM call 之後、deliver 之前。可以是 deterministic check（regex、AST 分析）、可以是另一個 LLM judge（見 <a href="/blog/llm/04-applications/llm-as-judge/" data-link-title="4.21 LLM-as-Judge 評估方法" data-link-desc="LLM 評估 LLM 的 production eval 方法：rubric design、pairwise / direct scoring、三大 bias 緩解、跟 trace 串接的閉環、calibration">4.21 LLM-as-Judge</a>）。</p>
<p>失敗模式：validator 自己 hallucinate（如果是 LLM judge）、或漏 case（如果是 deterministic check）。混用兩種比較穩。</p>
<h3 id="action-gating">Action gating</h3>
<p>LLM 想做高代價動作前、強制走人類確認或外部驗證。例：寫 production DB 前要 human approval、發 email 前要 dry-run 給內部 review、執行 shell 前要看到 diff。</p>
<p>適用：副作用範圍大、失敗不可逆。對應 <a href="/blog/llm/04-applications/agent-architecture/" data-link-title="4.4 Agent 架構原理" data-link-desc="Agent loop 結構、失敗模式、什麼任務適合 vs 不適合、跟人類審查的協作模型">4.4 agent 架構</a> 的 step-by-step approval / HITL 協作模型。</p>
<p>實作位置：tool layer、不是 prompt layer。Prompt 「請小心」是不夠的、靠 tool 本身不執行才有保證。</p>
<p>失敗模式：人類疲勞（rubber-stamp approval）、確認流程變橡皮圖章。設計時要讓 high-risk 跟 low-risk 動作走不同 gate、不要全部要人類確認、否則人類會關掉腦袋。</p>
<h3 id="distribution-monitoring">Distribution monitoring</h3>
<p>不在 single call 層擋、而是看 LLM 行為的分佈。例：每天客服回應的「拒絕率」「退款承諾率」、跑 alert；新 prompt 上線後追 token 用量、語氣 polarity、user satisfaction 的 baseline 漂移。</p>
<p>適用：行為層面的 silent drift（個別 call 看不出問題、加總起來偏掉）。</p>
<p>實作位置：production observability、trace pipeline（見 <a href="/blog/llm/04-applications/llm-tracing-and-observability/" data-link-title="4.20 LLM tracing 與 observability" data-link-desc="OpenTelemetry GenAI semantic conventions、結構化 span 設計、cost / latency 監控、failure debug 流程、跟 LLM-as-judge eval 的串接">4.20 LLM tracing</a>）。</p>
<p>失敗模式：baseline 沒先建、新 prompt 上線後不知道「正常範圍」是什麼、alert 無基準。</p>
<h3 id="四種-guardrail-怎麼選">四種 guardrail 怎麼選</h3>
<p>順序通常是：schema validation 最便宜先上、output validator 看內容風險再加、action gating 看不可逆性決定、distribution monitoring 是長期經營必備。</p>
<p>混用比例：一個成熟的 production LLM 應用通常四種都有、但分擔不同 risk class。輕量 query 只走 schema、會寫資料的走 schema + validator + gating、會影響多人的走全套加 monitoring。</p>
<h2 id="實驗成本的位移">實驗成本的位移</h2>
<p>Deterministic 軟體的實驗成本高、改 code 要 PR review、要跑 CI、要考慮回退、所以團隊文化是「想清楚再寫」。Fuzzy 軟體的實驗成本低——改 prompt 一行、跑兩個 case、就能看新行為——所以更接近「快速試、不行就丟」。</p>
<p>這個位移對工程師的工作方式有實質影響：</p>
<ul>
<li><strong>Throw-away code 更可接受</strong>：原本「寫了就要維護」、現在「先試、不行就重來」。</li>
<li><strong>Prompt 是 source、但生命週期不一樣</strong>：跟 code 一樣 version control（見 <a href="/blog/llm/04-applications/artifact-management/" data-link-title="4.10 衍生產物管理原理：什麼進 git、什麼不該" data-link-desc="LLM 應用的 source / derived / external 三類產物對應 git / build cache / registry、與 production 部署的 reproducibility / cost / share 取捨">4.10 衍生產物管理</a>）、但 iteration 速度比 code 快一個量級。</li>
<li><strong>Eval 比 unit test 重要</strong>：unit test 鎖行為、但 fuzzy 行為本來就會變、eval 看「行為分佈是否在期望範圍」才是有用的測試。</li>
<li><strong>失敗的歸因分層</strong>：壞掉時要問「是 prompt 問題、model 問題、context 問題、tool 問題、還是 deterministic glue 的 bug」——deterministic 軟體的歸因比較單一、fuzzy 軟體要分這幾層查。</li>
</ul>
<p>這個位移是雙面刃。便宜實驗讓 iteration 快、但也讓 prompt / config / 行為快速分裂、production 跑著的東西跟 git 上看到的東西可能不一致。Mitigation 是 prompt template 上 version control、prompt diff 進 CI、production behavior 進 distribution monitoring。</p>
<h2 id="跟-agent--workflow-設計的關係">跟 Agent / Workflow 設計的關係</h2>
<p>Agent 跟 multi-call workflow 是「fuzzy 軟體」最複雜的型態。<a href="/blog/llm/04-applications/agent-architecture/" data-link-title="4.4 Agent 架構原理" data-link-desc="Agent loop 結構、失敗模式、什麼任務適合 vs 不適合、跟人類審查的協作模型">4.4 agent 架構</a> 列出 agent 的三大失敗模式（context drift / goal drift / tool misread）、本質上都是 fuzzy 行為在多步累積後溢出 guardrail。</p>
<p>這個 framing 對 agent 設計的啟示：</p>
<ul>
<li><strong>Loop 的每一步都是一個 fuzzy 邊界</strong>：每步都要決定 schema / validator / gating / monitoring 的組合。</li>
<li><strong>越多步累積、越需要 deterministic checkpoint</strong>：「跑 10 步 fuzzy 推理、最後一步寫 DB」是高風險、要在中間插 deterministic verification。</li>
<li><strong>Termination 是 deterministic 邊界</strong>：靠模型自己說「完成了」是純 fuzzy、容易失控（見 <a href="/blog/llm/04-applications/agent-architecture/" data-link-title="4.4 Agent 架構原理" data-link-desc="Agent loop 結構、失敗模式、什麼任務適合 vs 不適合、跟人類審查的協作模型">4.4 termination 條件</a>）。混用 step cap、cost cap、external validation 是 deterministic guardrail 包 fuzzy loop 的標準做法。</li>
</ul>
<h2 id="何時過時--何時不過時">何時過時 / 何時不過時</h2>
<p><strong>不會過時的部分</strong>：</p>
<ul>
<li>兩個典範的四維對照（資料、邏輯、行為一致性、實驗成本）。</li>
<li>「哪段該 deterministic / 哪段該 fuzzy」的決策框架。</li>
<li>四種 guardrail 的分類跟組合原則。</li>
<li>Fuzzy 邊界要包 deterministic、不是反過來的設計直覺。</li>
</ul>
<p><strong>會變的部分</strong>：</p>
<ul>
<li>具體 schema 工具（Pydantic、Zod、各家 framework 的 typed output API）。</li>
<li>具體 LLM-as-judge 平台跟方法（見 <a href="/blog/llm/04-applications/llm-as-judge/" data-link-title="4.21 LLM-as-Judge 評估方法" data-link-desc="LLM 評估 LLM 的 production eval 方法：rubric design、pairwise / direct scoring、三大 bias 緩解、跟 trace 串接的閉環、calibration">4.21</a>）。</li>
<li>各家 framework 的 guardrail SDK（隨工具世代換）。</li>
<li>Fuzzy / deterministic 的邊界位置會隨模型能力移動——模型越強、能 fuzzy 處理的範圍越大、但「該包 guardrail」的原則不變。</li>
</ul>
<h2 id="下一章">下一章</h2>
<p>下一章：<a href="/blog/llm/01-local-llm-services/" data-link-title="模組一：本地 LLM 服務的安裝與應用" data-link-desc="Ollama、LM Studio、llama.cpp 的安裝與差異、VS Code &#43; Continue.dev 整合、模型選型與期望管理">模組一 本地 LLM 服務</a> 進入工具層、或跳到 <a href="/blog/llm/04-applications/" data-link-title="模組四：LLM 應用層原理" data-link-desc="Prompt 技術光譜、RAG、tool use、agent、應用層協議、人機協作、multi-agent、workflow 編排、eval 設計：跨工具不變的概念地圖">模組四 LLM 應用層</a> 看這個典範怎麼落到 RAG / agent / workflow 設計。Agent 設計怎麼把 fuzzy / deterministic 邊界體現在 loop 結構上見 <a href="/blog/llm/04-applications/agent-architecture/" data-link-title="4.4 Agent 架構原理" data-link-desc="Agent loop 結構、失敗模式、什麼任務適合 vs 不適合、跟人類審查的協作模型">4.4 agent 架構</a>、人類介入點的設計選擇見 <a href="/blog/llm/04-applications/human-ai-collaboration/" data-link-title="4.5 人機協作拓樸：何時人介入、怎麼介入" data-link-desc="Centaur vs Cyborg 工作模式、jagged frontier、HITL 三種觸發時機（pre-act / mid-stream / post-hoc）、確認流程的設計避免橡皮圖章化">4.5 人機協作拓樸</a>、跨多 call workflow 的 fuzzy 邊界設計見 <a href="/blog/llm/04-applications/workflow-patterns/" data-link-title="4.7 Workflow 編排模式" data-link-desc="Pipeline / router / parallel / reflection：多 LLM call 組合的四種基本模式與退化條件">4.7 workflow 編排模式</a>。</p>
]]></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></channel></rss>