<?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>Jetstream on Tarragon</title><link>https://tarrragon.github.io/blog/tags/jetstream/</link><description>Recent content in Jetstream 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/jetstream/index.xml" rel="self" type="application/rss+xml"/><item><title>NATS core 到 JetStream：fire-and-forget 在哪裡不夠、跨過去要付什麼</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/nats/jetstream-durability-consumer/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/nats/jetstream-durability-consumer/</guid><description>&lt;blockquote>
&lt;p>本文是 &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> overview 的 implementation-layer deep article、定位在「要不要從 core NATS 跨進 JetStream」的決策入口。選型層（NATS vs Kafka / RabbitMQ）見 overview；本文只處理 core 與 JetStream 的邊界與基本 consumer 設定。決定採用 JetStream 後的完整實作（stream / consumer 每個旋鈕、跨區拓樸、多租戶）見 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/nats/jetstream-supercluster-design/" data-link-title="NATS JetStream 設計與 supercluster / leaf node：stream、consumer、跨區拓樸與多租戶" data-link-desc="NATS JetStream 的 implementation-layer deep article：stream 設計（storage / retention / discard / 容量上限）、consumer 設計（pull/push、explicit ack、AckWait、MaxDeliver、replay）、Cluster Raft / Supercluster gateway / Leaf node edge 三層拓樸、subject-based ACL 多租戶；含 4 個 production 故障演練（AckWait 太短重投、discard policy 選錯丟訊息、leaf node 斷線重連、stream replica 失去 quorum）。">JetStream 設計與 supercluster / leaf node&lt;/a>。JetStream 實機驗證於 nats:latest（-js）、最後檢查日 2026-06-16；機制以 &lt;a href="https://docs.nats.io/nats-concepts/jetstream">NATS JetStream 官方文件&lt;/a> 為準。&lt;/p>&lt;/blockquote>
&lt;h2 id="fire-and-forget-在-rolling-deploy-那一刻掉訊息">fire-and-forget 在 rolling deploy 那一刻掉訊息&lt;/h2>
&lt;p>Core NATS 的低延遲來自它什麼都不記——一則訊息發布出去，當下有訂閱者就送達、沒有就丟棄。沒有儲存、沒有 ack、沒有重送。這適合「即時但可丟」的場景（metrics、presence、即時通知）：訂閱者暫時離線錯過幾則無所謂，下一則馬上來。&lt;/p>
&lt;p>但這個設計有一條清楚的邊界。&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/nats-clarifai-async-task-queue/" data-link-title="3.C38 Clarifai：NATS Streaming ML 平台非同步任務" data-link-desc="Clarifai custom model 訓練、rolling deploy 掉訊息、改 NATS Streaming queue group、3 週遷移 1 服務、5 月 5 服務、每日 100k&amp;#43; 訊息 100% uptime。">Clarifai 用 NATS 跑 ML 模型訓練的非同步任務&lt;/a>，任務從幾秒到幾分鐘，原本同步呼叫——結果每次 rolling deployment（pod 輪流重啟）就掉訊息：訊息發布的瞬間目標 worker 正在重啟，core NATS 找不到訂閱者就丟了。他們的解法是改用 NATS（當時是 NATS Streaming、JetStream 的前身）的 &lt;strong>at-least-once delivery + redelivery + queue group&lt;/strong>，每日 100k+ 訊息、達成 100% uptime。這個案例揭露的邊界是——&lt;strong>ML 長尾任務不能容忍 rolling deploy 掉訊息，core NATS 的 fire-and-forget 到此為止，要跨進 JetStream。&lt;/strong>&lt;/p>
&lt;p>JetStream 在 core NATS 之上加了一層持久化的 stream + 可重送的 consumer。本文處理這條邊界：什麼時候 core 夠用、什麼時候要 JetStream、跨過去的 consumer 模型怎麼設才不會丟訊息或重投風暴。&lt;/p>
&lt;h2 id="核心概念stream-與-consumer-的求值模型">核心概念：stream 與 consumer 的求值模型&lt;/h2>
&lt;p>JetStream 把「訊息儲存」跟「消費進度」拆成兩個獨立物件——stream（存什麼、留多久）跟 consumer（誰讀、怎麼 ack）。理解 JetStream 就是理解這兩者。&lt;/p>
&lt;p>&lt;strong>stream 決定訊息怎麼被儲存與保留&lt;/strong>。一個 stream 綁定一組 subject、把符合的訊息持久化。三個關鍵維度：storage（&lt;code>file&lt;/code> 持久 / &lt;code>memory&lt;/code> 重啟即失）、retention（&lt;code>limits&lt;/code> 依大小/時間/數量保留、&lt;code>workqueue&lt;/code> 消費後即刪、&lt;code>interest&lt;/code> 有訂閱者才留）、limits（max-msgs / max-bytes / max-age）。retention 選錯是常見陷阱——&lt;code>workqueue&lt;/code> 是「每則訊息只被一個 consumer 消費一次就刪」，&lt;code>limits&lt;/code> 是「保留著、多個 consumer 各自讀」。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/03-message-queue/vendors/nats/" data-link-title="NATS" data-link-desc="Lightweight messaging、JetStream 加持久化與 streams">NATS</a> overview 的 implementation-layer deep article、定位在「要不要從 core NATS 跨進 JetStream」的決策入口。選型層（NATS vs Kafka / RabbitMQ）見 overview；本文只處理 core 與 JetStream 的邊界與基本 consumer 設定。決定採用 JetStream 後的完整實作（stream / consumer 每個旋鈕、跨區拓樸、多租戶）見 <a href="/blog/backend/03-message-queue/vendors/nats/jetstream-supercluster-design/" data-link-title="NATS JetStream 設計與 supercluster / leaf node：stream、consumer、跨區拓樸與多租戶" data-link-desc="NATS JetStream 的 implementation-layer deep article：stream 設計（storage / retention / discard / 容量上限）、consumer 設計（pull/push、explicit ack、AckWait、MaxDeliver、replay）、Cluster Raft / Supercluster gateway / Leaf node edge 三層拓樸、subject-based ACL 多租戶；含 4 個 production 故障演練（AckWait 太短重投、discard policy 選錯丟訊息、leaf node 斷線重連、stream replica 失去 quorum）。">JetStream 設計與 supercluster / leaf node</a>。JetStream 實機驗證於 nats:latest（-js）、最後檢查日 2026-06-16；機制以 <a href="https://docs.nats.io/nats-concepts/jetstream">NATS JetStream 官方文件</a> 為準。</p></blockquote>
<h2 id="fire-and-forget-在-rolling-deploy-那一刻掉訊息">fire-and-forget 在 rolling deploy 那一刻掉訊息</h2>
<p>Core NATS 的低延遲來自它什麼都不記——一則訊息發布出去，當下有訂閱者就送達、沒有就丟棄。沒有儲存、沒有 ack、沒有重送。這適合「即時但可丟」的場景（metrics、presence、即時通知）：訂閱者暫時離線錯過幾則無所謂，下一則馬上來。</p>
<p>但這個設計有一條清楚的邊界。<a href="/blog/backend/03-message-queue/cases/nats-clarifai-async-task-queue/" data-link-title="3.C38 Clarifai：NATS Streaming ML 平台非同步任務" data-link-desc="Clarifai custom model 訓練、rolling deploy 掉訊息、改 NATS Streaming queue group、3 週遷移 1 服務、5 月 5 服務、每日 100k&#43; 訊息 100% uptime。">Clarifai 用 NATS 跑 ML 模型訓練的非同步任務</a>，任務從幾秒到幾分鐘，原本同步呼叫——結果每次 rolling deployment（pod 輪流重啟）就掉訊息：訊息發布的瞬間目標 worker 正在重啟，core NATS 找不到訂閱者就丟了。他們的解法是改用 NATS（當時是 NATS Streaming、JetStream 的前身）的 <strong>at-least-once delivery + redelivery + queue group</strong>，每日 100k+ 訊息、達成 100% uptime。這個案例揭露的邊界是——<strong>ML 長尾任務不能容忍 rolling deploy 掉訊息，core NATS 的 fire-and-forget 到此為止，要跨進 JetStream。</strong></p>
<p>JetStream 在 core NATS 之上加了一層持久化的 stream + 可重送的 consumer。本文處理這條邊界：什麼時候 core 夠用、什麼時候要 JetStream、跨過去的 consumer 模型怎麼設才不會丟訊息或重投風暴。</p>
<h2 id="核心概念stream-與-consumer-的求值模型">核心概念：stream 與 consumer 的求值模型</h2>
<p>JetStream 把「訊息儲存」跟「消費進度」拆成兩個獨立物件——stream（存什麼、留多久）跟 consumer（誰讀、怎麼 ack）。理解 JetStream 就是理解這兩者。</p>
<p><strong>stream 決定訊息怎麼被儲存與保留</strong>。一個 stream 綁定一組 subject、把符合的訊息持久化。三個關鍵維度：storage（<code>file</code> 持久 / <code>memory</code> 重啟即失）、retention（<code>limits</code> 依大小/時間/數量保留、<code>workqueue</code> 消費後即刪、<code>interest</code> 有訂閱者才留）、limits（max-msgs / max-bytes / max-age）。retention 選錯是常見陷阱——<code>workqueue</code> 是「每則訊息只被一個 consumer 消費一次就刪」，<code>limits</code> 是「保留著、多個 consumer 各自讀」。</p>
<p><strong>consumer 是 stream 上的一個可重播視圖</strong>。同一個 stream 可以有多個 consumer，各自維護自己的消費位置。consumer 的關鍵屬性：</p>
<ul>
<li>push vs pull：push 由 server 主動推給訂閱者；pull 由 client 主動拉（<code>consumer next</code>），pull 對流量控制與 worker pool 更可控</li>
<li>durable vs ephemeral：durable consumer 的進度持久（重啟後從上次位置續讀），ephemeral 在 client 斷線後消失（進度丟失）</li>
<li>ack policy：<code>explicit</code>（每則都要 ack、at-least-once 的基礎）/ <code>all</code>（ack 一則等於 ack 之前所有）/ <code>none</code>（不需 ack、近似 fire-and-forget）</li>
<li>max_deliver + ack_wait：沒 ack 的訊息在 <code>ack_wait</code> 後重送，最多 <code>max_deliver</code> 次</li>
</ul>
<p><strong>at-least-once 來自「explicit ack + redelivery」</strong>。consumer 取出訊息、處理、明確 ack；沒 ack（處理失敗或 crash）的訊息在 ack_wait 逾時後重送。這就是 Clarifai 要的「rolling deploy 不丟訊息」——worker 重啟時沒 ack 的任務會被重送給其他 worker。</p>
<h2 id="配置durable-pull-consumer實機驗證">配置：durable pull consumer（實機驗證）</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 啟動 JetStream（server 加 -js）</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"># docker run -d --name nats nats:latest -js</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="c1"># 1. 建 stream：file storage、limits retention</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">nats stream add ORDERS --subjects <span class="s2">&#34;orders.&gt;&#34;</span> --storage file --defaults
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1">#   Subjects: orders.&gt;   Storage: File   Retention: Limits   Replicas: 1</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="c1"># 2. publish</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">nats pub orders.new <span class="s2">&#34;order-1&#34;</span>   <span class="c1"># Published 7 bytes to &#34;orders.new&#34;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"># 3. stream info 確認持久化</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">nats stream info ORDERS
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1">#   Storage: File   Messages: 3   Bytes: 141 B   ← 訊息已落盤、consumer 重啟不丟</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="c1"># 4. durable pull consumer（explicit ack、可重送）</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">nats consumer add ORDERS workers --pull --ack explicit --deliver all --defaults
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="c1">#   Pull Mode: true   Ack Policy: Explicit</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="c1"># 5. 拉取消費（worker pool 多個實例共用同一 durable consumer = queue group 語意）</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">nats consumer next ORDERS workers --count <span class="m">3</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="c1">#   order-1  order-2  order-3</span></span></span></code></pre></div><p>實機驗證於 nats:latest（最後檢查日 2026-06-16）：file storage 的 stream 把訊息落盤（Messages: 3）、durable pull consumer 用 explicit ack 消費。多個 worker 連到同一個 durable pull consumer 形成 worker pool（訊息分給其中一個），這正是 Clarifai 的 queue group 模式。</p>
<p>判讀：</p>
<ul>
<li>worker pool 用同一個 durable pull consumer（共享進度、訊息分流），不是每個 worker 一個 consumer</li>
<li><code>--ack explicit</code> 是 at-least-once 的前提；處理成功才 ack</li>
<li>pull 模式比 push 對 worker pool 更可控（worker 按自己能力拉、不會被 push 淹）</li>
</ul>
<h2 id="production-故障演練">Production 故障演練</h2>
<h3 id="case-1用-core-nats-跑該持久的任務rolling-deploy-掉訊息">Case 1：用 core NATS 跑該持久的任務、rolling deploy 掉訊息</h3>
<p><strong>徵兆</strong>：平時正常，但每次部署（pod 輪流重啟）就有一批任務消失、沒有錯誤。</p>
<p><strong>根因</strong>：用 core NATS（fire-and-forget）跑需要可靠處理的任務。發布瞬間目標訂閱者正在重啟，core NATS 找不到訂閱者就丟棄——這是 core 的設計，不是故障。正是 <a href="/blog/backend/03-message-queue/cases/nats-clarifai-async-task-queue/" data-link-title="3.C38 Clarifai：NATS Streaming ML 平台非同步任務" data-link-desc="Clarifai custom model 訓練、rolling deploy 掉訊息、改 NATS Streaming queue group、3 週遷移 1 服務、5 月 5 服務、每日 100k&#43; 訊息 100% uptime。">Clarifai 的原始問題</a>。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>需要不丟的任務用 JetStream（持久 stream + durable consumer + explicit ack）</li>
<li>訊息落盤後 consumer 重啟從上次位置續讀，rolling deploy 不丟</li>
<li>釐清邊界：可丟的即時資料（metrics / presence）留 core NATS、不可丟的跨 JetStream</li>
<li>不要用 core NATS 當任務隊列——它沒有持久化與重送</li>
</ol>
<h3 id="case-2ephemeral-consumer-斷線消費進度全丟">Case 2：ephemeral consumer 斷線、消費進度全丟</h3>
<p><strong>徵兆</strong>：consumer 重連後從頭重讀整個 stream、或漏掉斷線期間的訊息，進度不連續。</p>
<p><strong>根因</strong>：用了 ephemeral consumer——它的進度不持久，client 斷線後 consumer 本身消失。重連是建一個全新 consumer，從 <code>deliver</code> policy 的起點開始（all 從頭、new 只看新的），不接續之前的進度。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>需要跨重啟接續的用 durable consumer（具名、進度持久）</li>
<li>ephemeral 只適合臨時、一次性的讀取（debug、一次性掃描）</li>
<li>worker pool 一定用 durable（多 worker 共享持久進度）</li>
<li>確認 <code>deliver</code> policy（all / new / last）符合預期的起讀位置</li>
</ol>
<h3 id="case-3ack_wait-太短處理還沒完就重送風暴">Case 3：ack_wait 太短、處理還沒完就重送風暴</h3>
<p><strong>徵兆</strong>：長任務還在處理中就被重送給另一個 worker，同一任務被多個 worker 重複執行，負載放大。</p>
<p><strong>根因</strong>：<code>ack_wait</code>（等 ack 的逾時）設得比任務處理時間短。JetStream 以為訊息處理失敗（沒在 ack_wait 內 ack），重送給別人——但其實第一個 worker 還在跑。ML 長尾任務（幾秒到幾分鐘）特別容易踩。</p>
<p><strong>修法（本文層級的判讀）</strong>：ack_wait 必須涵蓋任務的 p99 處理時間，否則長任務會在處理中被重送。設值方法（量測 p99、長任務用 in-progress ack 延長 deadline、消費端冪等兜底）與實機重現（AckWait 設 1s 觀察 tries 1→2、Redelivered 計數）在 <a href="/blog/backend/03-message-queue/vendors/nats/jetstream-supercluster-design/" data-link-title="NATS JetStream 設計與 supercluster / leaf node：stream、consumer、跨區拓樸與多租戶" data-link-desc="NATS JetStream 的 implementation-layer deep article：stream 設計（storage / retention / discard / 容量上限）、consumer 設計（pull/push、explicit ack、AckWait、MaxDeliver、replay）、Cluster Raft / Supercluster gateway / Leaf node edge 三層拓樸、subject-based ACL 多租戶；含 4 個 production 故障演練（AckWait 太短重投、discard policy 選錯丟訊息、leaf node 斷線重連、stream replica 失去 quorum）。">JetStream 設計與 supercluster/leaf node</a> 的故障演練有完整步驟，採用 JetStream 後依該篇落地。</p>
<h3 id="case-4retention-選-workqueue-但想多-consumer-fanout">Case 4：retention 選 workqueue 但想多 consumer fanout</h3>
<p><strong>徵兆</strong>：想讓多個獨立服務各自消費同一 stream，但發現訊息被一個消費掉就消失、其他服務讀不到。</p>
<p><strong>根因</strong>：stream retention 設成 <code>workqueue</code>——每則訊息只被消費一次就從 stream 刪除（隊列語意）。它不適合 fanout（多個 consumer 各自要完整一份）。fanout 要 <code>limits</code> 或 <code>interest</code> retention。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>fanout（多服務各讀一份）用 <code>limits</code> retention（訊息保留、多 consumer 各自 offset）</li>
<li>單一 worker pool 競爭消費用 <code>workqueue</code>（消費即刪、省空間）</li>
<li>釐清需求：競爭消費（worker pool）vs 廣播消費（fanout）對應不同 retention</li>
<li>Clarifai 用「3 個獨立 NATS 實例做 fanout 隔離」是另一種 fanout 做法，按隔離需求選</li>
</ol>
<h3 id="case-5memory-storage-的-stream-重啟全失">Case 5：memory storage 的 stream 重啟全失</h3>
<p><strong>徵兆</strong>：broker 重啟後 stream 裡的訊息全沒了，consumer 從空的開始。</p>
<p><strong>根因</strong>：stream storage 設成 <code>memory</code>——快但不持久，broker 重啟即失。誤把它當持久 stream 用。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>需要持久的 stream 用 <code>file</code> storage（落盤、重啟不丟，實機驗證過）</li>
<li><code>memory</code> 只適合「快取式、可重建」的 stream（如即時聚合的中間狀態）</li>
<li>要更高可靠性加 <code>replicas</code>（JetStream 用 Raft 跨節點複製 stream）</li>
<li>容量規劃時 file storage 的磁碟與 memory 的 RAM 是不同維度</li>
</ol>
<h2 id="capacity--cost-邊界">Capacity / cost 邊界</h2>
<p>JetStream 的容量判讀：</p>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>健康區間</th>
          <th>警戒與動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>stream storage 用量</td>
          <td>在 max-bytes / max-age 內</td>
          <td>接近上限 → 訊息被 discard、調 limits 或加容量</td>
      </tr>
      <tr>
          <td>redelivery 次數</td>
          <td>低（多數一次 ack 成功）</td>
          <td>高 → ack_wait 太短或處理卡住</td>
      </tr>
      <tr>
          <td>consumer pending</td>
          <td>可消化</td>
          <td>持續堆高 → consumer 跟不上 producer</td>
      </tr>
      <tr>
          <td>ack_wait vs 處理時間</td>
          <td>ack_wait &gt; p99 處理時間</td>
          <td>反了 → 重送風暴</td>
      </tr>
      <tr>
          <td>storage 型別</td>
          <td>持久需求用 file</td>
          <td>誤用 memory → 重啟丟訊息</td>
      </tr>
  </tbody>
</table>
<p>撞牆後的路由判斷：</p>
<ul>
<li><strong>可丟的即時資料</strong>：不需要 JetStream 的持久化開銷，用 core NATS（更快更輕）。</li>
<li><strong>超大吞吐 + 長期保留 + 複雜 replay</strong>：JetStream 適合中等規模可靠 messaging；超大規模 event streaming + 長期保留走 <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>（log-based、生態成熟）。</li>
<li><strong>複雜 routing / 任務隊列語意</strong>：JetStream 的 subject 是樹狀，複雜 routing + DLQ 拓樸用 <a href="/blog/backend/03-message-queue/vendors/rabbitmq/" data-link-title="RabbitMQ" data-link-desc="Classic message broker、AMQP routing 為主">RabbitMQ</a> 更直接。</li>
<li><strong>不想自管</strong>：NATS 的 managed 選項（Synadia Cloud）或其他 managed broker。</li>
</ul>
<h2 id="整合--下一步">整合 / 下一步</h2>
<p>JetStream 的邊界判斷是 NATS 使用的核心，它跟其他議題交織：</p>
<ul>
<li><strong>跟 <a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer design</a></strong>：push/pull、durable/ephemeral、ack policy 是 consumer 設計的具體選項。</li>
<li><strong>跟 <a href="/blog/backend/03-message-queue/durable-queue/" data-link-title="3.2 durable queue 與重試策略" data-link-desc="整理持久化佇列、DLQ 與重試流程">3.2 durable queue</a></strong>：JetStream 的 file storage stream 是 NATS 的 durable queue 實現。</li>
<li><strong>跟 <a href="/blog/backend/06-reliability/idempotency-replay/" data-link-title="6.12 Idempotency 與 Replay 驗證" data-link-desc="把重試、重播與冪等性從口頭約定變成可驗證屬性">6.12 idempotency / replay</a></strong>：at-least-once + redelivery 要求消費冪等，否則重送造成重複副作用。</li>
<li><strong>跟 <a href="/blog/backend/03-message-queue/vendors/rabbitmq/dlq-retry-escalation/" data-link-title="RabbitMQ DLQ 與分層 retry：別把失敗訊息 requeue 回隊首" data-link-desc="RabbitMQ 處理失敗訊息最常見的錯是直接 requeue 回原隊列——它回到隊首、反覆失敗、把後面的訊息全卡住（head-of-line blocking）。正解是用 dead-letter exchange &#43; TTL 組出 work → delay → DLQ 的分層 escalation。本文展開 DLX 求值模型、實機驗證的三層拓樸、5 個把 retry 寫成無限迴圈與隊列阻塞的 production 踩坑，以及 retry 拓樸的容量邊界">RabbitMQ DLQ deep article</a></strong>：max_deliver 達上限後的處理對應 RabbitMQ 的 DLQ，兩者都是「重試上限後往哪去」的問題。</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li>上游 vendor 頁：<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/dlq-retry-escalation/" data-link-title="RabbitMQ DLQ 與分層 retry：別把失敗訊息 requeue 回隊首" data-link-desc="RabbitMQ 處理失敗訊息最常見的錯是直接 requeue 回原隊列——它回到隊首、反覆失敗、把後面的訊息全卡住（head-of-line blocking）。正解是用 dead-letter exchange &#43; TTL 組出 work → delay → DLQ 的分層 escalation。本文展開 DLX 求值模型、實機驗證的三層拓樸、5 個把 retry 寫成無限迴圈與隊列阻塞的 production 踩坑，以及 retry 拓樸的容量邊界">RabbitMQ DLQ 與分層 retry</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/nats-clarifai-async-task-queue/" data-link-title="3.C38 Clarifai：NATS Streaming ML 平台非同步任務" data-link-desc="Clarifai custom model 訓練、rolling deploy 掉訊息、改 NATS Streaming queue group、3 週遷移 1 服務、5 月 5 服務、每日 100k&#43; 訊息 100% uptime。">3.C38 Clarifai NATS ML 非同步任務</a></li>
<li>上游概念：<a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer design</a>、<a href="/blog/backend/03-message-queue/durable-queue/" data-link-title="3.2 durable queue 與重試策略" data-link-desc="整理持久化佇列、DLQ 與重試流程">3.2 durable queue</a></li>
</ul>
]]></content:encoded></item><item><title>Kafka ↔ NATS：不是 migration、是 messaging paradigm 重設計</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/migrate-from-to-nats/</link><pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/migrate-from-to-nats/</guid><description>&lt;blockquote>
&lt;p>本文是跨 vendor migration playbook、cross-link 到 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka&lt;/a> 跟 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/nats/" data-link-title="NATS" data-link-desc="Lightweight messaging、JetStream 加持久化與 streams">NATS&lt;/a>。跟前四篇 migration（schema 差 / drop-in / operational redesign / multi-tool 拆分）對照、本篇是 &lt;em>paradigm shift&lt;/em> — 兩端不是「同類產品的不同實作」、是 &lt;em>不同抽象層的 messaging system&lt;/em>。&lt;/p>&lt;/blockquote>
&lt;h2 id="kafka--nats-migration字面上不成立">「Kafka → NATS migration」字面上不成立&lt;/h2>
&lt;p>前面四篇 migration 都隱含一個前提：source 跟 target 是 &lt;em>同類產品&lt;/em>、只是不同實作或 deployment 模型。「Kafka → NATS」字面上看起來也是 &lt;em>messaging migration&lt;/em>、但實際上：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>維度&lt;/th>
 &lt;th>Kafka&lt;/th>
 &lt;th>NATS Core&lt;/th>
 &lt;th>NATS JetStream&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Core abstraction&lt;/td>
 &lt;td>Distributed log（partition + offset）&lt;/td>
 &lt;td>Pub/Sub subject（fire-and-forget）&lt;/td>
 &lt;td>Stream（subject group + retention）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Message persistence&lt;/td>
 &lt;td>Default persistent（log retention）&lt;/td>
 &lt;td>&lt;strong>不持久化&lt;/strong>（subscriber 缺席 = lost）&lt;/td>
 &lt;td>持久化（K/V backend / file）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Delivery semantic&lt;/td>
 &lt;td>At-least-once / exactly-once（事務）&lt;/td>
 &lt;td>At-most-once&lt;/td>
 &lt;td>At-least-once / exactly-once&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Consumer model&lt;/td>
 &lt;td>Consumer group + offset&lt;/td>
 &lt;td>Subscriber + subject pattern&lt;/td>
 &lt;td>Durable consumer + pull / push&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Ordering&lt;/td>
 &lt;td>Per partition strict&lt;/td>
 &lt;td>無 ordering guarantee&lt;/td>
 &lt;td>Per stream / per consumer&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Replay&lt;/td>
 &lt;td>隨意 from offset&lt;/td>
 &lt;td>&lt;strong>無&lt;/strong>&lt;/td>
 &lt;td>from sequence number&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Throughput&lt;/td>
 &lt;td>高（M msg/s）&lt;/td>
 &lt;td>極高（10M+ msg/s）&lt;/td>
 &lt;td>中（100K-1M msg/s）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Latency&lt;/td>
 &lt;td>5-50ms&lt;/td>
 &lt;td>&amp;lt; 1ms&lt;/td>
 &lt;td>5-20ms&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Kafka 跟 NATS Core 是 &lt;em>不同類產品&lt;/em> — 一個是 durable event log、一個是 transient pub/sub。「migration」需要先決定 &lt;em>target 是 NATS Core 還是 JetStream&lt;/em>、然後判斷 &lt;em>application 模式能否重設計&lt;/em> 對應。&lt;/p>
&lt;h2 id="什麼情境真的能換什麼不能">什麼情境真的能換、什麼不能&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Application 模式&lt;/th>
 &lt;th>Kafka 適配度&lt;/th>
 &lt;th>NATS Core 適配&lt;/th>
 &lt;th>NATS JetStream 適配&lt;/th>
 &lt;th>「migration」可行性&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Event sourcing（replay 過去事件）&lt;/td>
 &lt;td>強&lt;/td>
 &lt;td>不可（無 replay）&lt;/td>
 &lt;td>中（JetStream replay）&lt;/td>
 &lt;td>部分（移到 JetStream）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Microservice async messaging&lt;/td>
 &lt;td>強&lt;/td>
 &lt;td>強&lt;/td>
 &lt;td>強&lt;/td>
 &lt;td>高&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Real-time pub/sub（低延遲、可丟）&lt;/td>
 &lt;td>中&lt;/td>
 &lt;td>強&lt;/td>
 &lt;td>中&lt;/td>
 &lt;td>高（移到 Core）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>跨 service 命令 / RPC&lt;/td>
 &lt;td>弱（不適合）&lt;/td>
 &lt;td>強（request-reply）&lt;/td>
 &lt;td>弱&lt;/td>
 &lt;td>不需要遷&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>大量 log / metric / event collection&lt;/td>
 &lt;td>強&lt;/td>
 &lt;td>弱&lt;/td>
 &lt;td>中&lt;/td>
 &lt;td>低（保留 Kafka）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Multi-tenant message bus&lt;/td>
 &lt;td>中&lt;/td>
 &lt;td>強&lt;/td>
 &lt;td>強&lt;/td>
 &lt;td>高&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Strict ordering + transactional&lt;/td>
 &lt;td>強&lt;/td>
 &lt;td>不可&lt;/td>
 &lt;td>中（per stream）&lt;/td>
 &lt;td>部分（部分功能犧牲）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>5+ 年歷史 retention&lt;/td>
 &lt;td>強&lt;/td>
 &lt;td>不可&lt;/td>
 &lt;td>中（retention 設長）&lt;/td>
 &lt;td>部分&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>判讀&lt;/strong>：&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是跨 vendor migration playbook、cross-link 到 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka</a> 跟 <a href="/blog/backend/03-message-queue/vendors/nats/" data-link-title="NATS" data-link-desc="Lightweight messaging、JetStream 加持久化與 streams">NATS</a>。跟前四篇 migration（schema 差 / drop-in / operational redesign / multi-tool 拆分）對照、本篇是 <em>paradigm shift</em> — 兩端不是「同類產品的不同實作」、是 <em>不同抽象層的 messaging system</em>。</p></blockquote>
<h2 id="kafka--nats-migration字面上不成立">「Kafka → NATS migration」字面上不成立</h2>
<p>前面四篇 migration 都隱含一個前提：source 跟 target 是 <em>同類產品</em>、只是不同實作或 deployment 模型。「Kafka → NATS」字面上看起來也是 <em>messaging migration</em>、但實際上：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>Kafka</th>
          <th>NATS Core</th>
          <th>NATS JetStream</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Core abstraction</td>
          <td>Distributed log（partition + offset）</td>
          <td>Pub/Sub subject（fire-and-forget）</td>
          <td>Stream（subject group + retention）</td>
      </tr>
      <tr>
          <td>Message persistence</td>
          <td>Default persistent（log retention）</td>
          <td><strong>不持久化</strong>（subscriber 缺席 = lost）</td>
          <td>持久化（K/V backend / file）</td>
      </tr>
      <tr>
          <td>Delivery semantic</td>
          <td>At-least-once / exactly-once（事務）</td>
          <td>At-most-once</td>
          <td>At-least-once / exactly-once</td>
      </tr>
      <tr>
          <td>Consumer model</td>
          <td>Consumer group + offset</td>
          <td>Subscriber + subject pattern</td>
          <td>Durable consumer + pull / push</td>
      </tr>
      <tr>
          <td>Ordering</td>
          <td>Per partition strict</td>
          <td>無 ordering guarantee</td>
          <td>Per stream / per consumer</td>
      </tr>
      <tr>
          <td>Replay</td>
          <td>隨意 from offset</td>
          <td><strong>無</strong></td>
          <td>from sequence number</td>
      </tr>
      <tr>
          <td>Throughput</td>
          <td>高（M msg/s）</td>
          <td>極高（10M+ msg/s）</td>
          <td>中（100K-1M msg/s）</td>
      </tr>
      <tr>
          <td>Latency</td>
          <td>5-50ms</td>
          <td>&lt; 1ms</td>
          <td>5-20ms</td>
      </tr>
  </tbody>
</table>
<p>Kafka 跟 NATS Core 是 <em>不同類產品</em> — 一個是 durable event log、一個是 transient pub/sub。「migration」需要先決定 <em>target 是 NATS Core 還是 JetStream</em>、然後判斷 <em>application 模式能否重設計</em> 對應。</p>
<h2 id="什麼情境真的能換什麼不能">什麼情境真的能換、什麼不能</h2>
<table>
  <thead>
      <tr>
          <th>Application 模式</th>
          <th>Kafka 適配度</th>
          <th>NATS Core 適配</th>
          <th>NATS JetStream 適配</th>
          <th>「migration」可行性</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Event sourcing（replay 過去事件）</td>
          <td>強</td>
          <td>不可（無 replay）</td>
          <td>中（JetStream replay）</td>
          <td>部分（移到 JetStream）</td>
      </tr>
      <tr>
          <td>Microservice async messaging</td>
          <td>強</td>
          <td>強</td>
          <td>強</td>
          <td>高</td>
      </tr>
      <tr>
          <td>Real-time pub/sub（低延遲、可丟）</td>
          <td>中</td>
          <td>強</td>
          <td>中</td>
          <td>高（移到 Core）</td>
      </tr>
      <tr>
          <td>跨 service 命令 / RPC</td>
          <td>弱（不適合）</td>
          <td>強（request-reply）</td>
          <td>弱</td>
          <td>不需要遷</td>
      </tr>
      <tr>
          <td>大量 log / metric / event collection</td>
          <td>強</td>
          <td>弱</td>
          <td>中</td>
          <td>低（保留 Kafka）</td>
      </tr>
      <tr>
          <td>Multi-tenant message bus</td>
          <td>中</td>
          <td>強</td>
          <td>強</td>
          <td>高</td>
      </tr>
      <tr>
          <td>Strict ordering + transactional</td>
          <td>強</td>
          <td>不可</td>
          <td>中（per stream）</td>
          <td>部分（部分功能犧牲）</td>
      </tr>
      <tr>
          <td>5+ 年歷史 retention</td>
          <td>強</td>
          <td>不可</td>
          <td>中（retention 設長）</td>
          <td>部分</td>
      </tr>
  </tbody>
</table>
<p><strong>判讀</strong>：</p>
<ul>
<li><em>Microservice async messaging + 低延遲需求</em> → NATS Core 更合適、是 <em>真正的 migration</em></li>
<li><em>Event sourcing + replay</em> → JetStream 部分對等、但 partition / offset 觀念變了</li>
<li><em>Log collection / event streaming</em> → 不該遷、保留 Kafka</li>
</ul>
<h2 id="為什麼會考慮這個-paradigm-shift">為什麼會考慮這個 paradigm shift</h2>
<p>實務上觸發評估 NATS 通常三條 driver：</p>
<ol>
<li><strong>Cost + operational complexity</strong>：Kafka cluster + ZooKeeper（或 KRaft）+ Schema Registry + Connect 是重資產、3-5 broker + ops 1+ FTE；NATS 單 binary、無依賴、輕量</li>
<li><strong>Latency 要求 &lt; 1ms</strong>：Kafka 對單 message latency 不是 SLA、NATS Core 是</li>
<li><strong>Multi-tenant / multi-region 簡化</strong>：NATS 內建 <em>account</em> + <em>leaf node</em> 拓樸、跨 region 是 first-class</li>
</ol>
<p>但這三條 driver 都 <em>只在特定 application 模式有效</em>。不是普世 better、是 <em>某類 workload 適合</em>。</p>
<h2 id="migration-結構application-重設計--部分-stream-cutover">Migration 結構：application 重設計 + 部分 stream cutover</h2>
<p>跟前面四篇 migration 結構都不同、Kafka ↔ NATS 是 <em>混合</em>：</p>
<ol>
<li><strong>Phase 0：scope 判讀</strong> — 列 application、區分「適合 NATS」vs「保留 Kafka」</li>
<li><strong>Phase 1：application code 重設計</strong> — 不是 SDK 換、是 <em>messaging pattern 改</em>（event sourcing → message bus / consumer group → durable consumer）</li>
<li><strong>Phase 2：部分 stream parallel run</strong> — 新 application 走 NATS、舊 application 持續 Kafka</li>
<li><strong>Phase 3：cutover 適合的 stream</strong></li>
<li><strong>Phase 4：長期混合架構</strong> — Kafka 跟 NATS <em>共存</em>、不消滅一邊</li>
</ol>
<p>整體不是 <em>一次 migration</em>、是 <em>漸進拆分</em>。多數 production 環境 <em>永遠</em> 是混合架構。</p>
<h2 id="application-重設計範例consumer-group--durable-consumer">Application 重設計範例：consumer group → durable consumer</h2>





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




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





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">[event sourcing / log collection]        [microservice async messaging]
</span></span><span class="line"><span class="ln">2</span><span class="cl">         Kafka                                       NATS
</span></span><span class="line"><span class="ln">3</span><span class="cl">         │                                            │
</span></span><span class="line"><span class="ln">4</span><span class="cl">         └──────── Bridge (Connect / Custom) ────────┘</span></span></code></pre></div><p>NATS 跑微服務間 messaging、Kafka 跑 event log / analytics pipeline；中間用 Kafka Connect NATS connector 或自寫 bridge 同步必要 stream。</p>
<h3 id="跟-logical-replication--debezium-對位">跟 <a href="/blog/backend/01-database/vendors/postgresql/logical-replication-debezium/" data-link-title="PostgreSQL Logical Replication &#43; Debezium CDC：replication slot × failure × recovery 對照" data-link-desc="PostgreSQL logical replication slot 跟 Debezium CDC 的失效模式對照表：slot lag 撐爆 primary disk / schema change 斷流 / 初始 COPY 鎖表 / zombie slot 不釋放 / replay storm 後 offset reset；publication / subscription / pgoutput 配置、跟 Kafka outbox pattern 整合">Logical Replication + Debezium</a> 對位</h3>
<p>CDC pipeline 設計：</p>
<ul>
<li>DB → Debezium → Kafka topic（event sourcing 主軸）</li>
<li>Kafka → NATS bridge → microservice fan-out</li>
<li>不直接 DB → Debezium → NATS（Debezium 不原生支援 NATS sink）</li>
</ul>
<h3 id="跟前-4-篇-migration-的結構對照">跟前 4 篇 migration 的結構對照</h3>
<table>
  <thead>
      <tr>
          <th>篇</th>
          <th>Schema 差</th>
          <th>Operational 差</th>
          <th>Paradigm 差</th>
          <th>結構</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Splunk → Elastic</td>
          <td>高</td>
          <td>中</td>
          <td>低</td>
          <td>6-phase</td>
      </tr>
      <tr>
          <td>Redis → DragonflyDB</td>
          <td>無</td>
          <td>低</td>
          <td>低</td>
          <td>6-section + audit</td>
      </tr>
      <tr>
          <td>PostgreSQL → Aurora</td>
          <td>無</td>
          <td>高</td>
          <td>低</td>
          <td>hybrid</td>
      </tr>
      <tr>
          <td>Datadog → Grafana Stack</td>
          <td>中</td>
          <td>中</td>
          <td>低</td>
          <td>parallel streams</td>
      </tr>
      <tr>
          <td>Kafka ↔ NATS（本篇）</td>
          <td>中</td>
          <td>中</td>
          <td><strong>高</strong></td>
          <td>partial + 混合</td>
      </tr>
  </tbody>
</table>
<p><strong>結論</strong>：migration 結構由 <em>最大差異維度</em> 決定、不是 universal phased playbook。</p>
<h2 id="相關連結">相關連結</h2>
<ul>
<li>Source / target vendor：<a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka</a> / <a href="/blog/backend/03-message-queue/vendors/nats/" data-link-title="NATS" data-link-desc="Lightweight messaging、JetStream 加持久化與 streams">NATS</a></li>
<li>平行 vendor：<a href="/blog/backend/03-message-queue/vendors/rabbitmq/" data-link-title="RabbitMQ" data-link-desc="Classic message broker、AMQP routing 為主">RabbitMQ</a> / <a href="/blog/backend/03-message-queue/vendors/redis-streams/" data-link-title="Redis Streams" data-link-desc="Redis 生態內的 streams、append-only log &#43; consumer group">Redis Streams</a></li>
<li>平行 migration playbook：<a href="/blog/backend/07-security-data-protection/vendors/splunk/migrate-to-elastic-security/" data-link-title="Splunk → Elastic Security Detection Rule Migration：6 段 phased playbook 跟 5 大踩雷" data-link-desc="從 Splunk Enterprise Security 遷到 Elastic Security 的 detection rule translation playbook：SPL ↔ KQL/ES|QL schema 對位、AI-assisted translation pipeline、parallel run 比對、cutover routing、5 個 production 踩雷（macro 沒對應 / time zone 差異 / summary index 不對位 / alert dedup key 衝突 / 過早 decommission）、capacity / cost 對照">Splunk → Elastic Security</a> / <a href="/blog/backend/02-cache-redis/vendors/redis/migrate-to-dragonflydb/" data-link-title="Redis → DragonflyDB：drop-in 相容下的容量躍升 &#43; 5 個踩雷" data-link-desc="DragonflyDB 號稱 Redis drop-in 替代、單機 throughput 25x、記憶體效率 30% 提升；遷移流程簡單但有 5 個 production 踩雷（RDB 版本差 / Lua 腳本不全支援 / Pub-Sub fanout 行為差異 / Cluster mode 兼容度 / Modules 不支援）、跟 Sentinel / Cluster 模式對位">Redis → DragonflyDB</a> / <a href="/blog/backend/01-database/vendors/postgresql/migrate-to-aurora/" data-link-title="PostgreSQL → Aurora Migration：protocol 相容、operational 重設計" data-link-desc="Aurora 號稱 PostgreSQL-compatible 但 operational model 不同（storage decouple / cluster endpoint / instance class / 自家備份）；遷移流程是混合（protocol drop-in &#43; operational phased）、5 個 production 踩雷（extension 不支援 / replication slot 不直通 / autovacuum 行為差 / IAM 認證強制 / cost model 換算）、跟 Patroni / read replica / DR 對位">PostgreSQL → Aurora</a> / <a href="/blog/backend/04-observability/vendors/datadog/migrate-to-grafana-stack/" data-link-title="Datadog → Grafana Stack：把 $50K/month bill 拆解到 self-hosted observability" data-link-desc="Datadog 五層計費（host APM / metric / log ingest / log retention / RUM）拆解、對位 Grafana Stack（Mimir / Loki / Tempo / Grafana / Alloy）的 5 層責任；OTel-based agent migration、5 個 production 踩雷（cardinality 爆 / log volume cost / dashboard 不直接轉 / alert routing 換邏輯 / SLO definition 差異）、cost reality check">Datadog → Grafana Stack</a></li>
<li>Methodology：<a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章的寫作方法論</a></li>
</ul>
]]></content:encoded></item><item><title>NATS JetStream 設計與 supercluster / leaf node：stream、consumer、跨區拓樸與多租戶</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/nats/jetstream-supercluster-design/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/nats/jetstream-supercluster-design/</guid><description>&lt;blockquote>
&lt;p>本文是 &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> overview 的 implementation-layer deep article。Overview 回答「NATS 該不該選、Core NATS vs JetStream 怎麼分」；要不要從 core NATS 跨進 JetStream 的決策入口見 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/nats/jetstream-durability-consumer/" data-link-title="NATS core 到 JetStream：fire-and-forget 在哪裡不夠、跨過去要付什麼" data-link-desc="Core NATS 的 fire-and-forget 在 consumer 重啟或 rolling deploy 時掉訊息——這不是 bug、是設計。需要訊息不丟就跨進 JetStream（persistence &amp;#43; at-least-once &amp;#43; redelivery）。本文展開 core 與 JetStream 的邊界、stream 與 consumer 的求值模型、實機驗證的 durable pull consumer、5 個把 JetStream consumer 寫成丟訊息與重投風暴的 production 踩坑">core 到 JetStream 的邊界&lt;/a>；本文回答「JetStream stream / consumer 的每個旋鈕怎麼設、設錯踩什麼坑、跨區拓樸怎麼鋪、多租戶怎麼隔離」。寫作結構依 &lt;a href="https://tarrragon.github.io/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 深度技術文章的寫作方法論&lt;/a> 的 6 段框架。&lt;/p>&lt;/blockquote>
&lt;h2 id="jetstream-把-fire-and-forget-升級成-durable-log">JetStream 把 fire-and-forget 升級成 durable log&lt;/h2>
&lt;p>JetStream 是 NATS 內建的持久化層、責任是把 Core NATS 的 fire-and-forget subject 轉成 append-only 的 durable stream、並讓 consumer 能 ack、重投、replay。Core NATS 的訊息一旦沒有 active subscriber 就消失；JetStream 把符合特定 subject 的訊息攔截下來寫進 stream、即使沒有任何 consumer 在線也會留存到 retention 上限。&lt;/p>
&lt;p>兩個概念要先分清楚、後面所有配置都掛在這個分界上。Stream 是 &lt;em>儲存&lt;/em> 責任：定義「哪些 subject 的訊息要存、存多久、存多少、存哪裡」。Consumer 是 &lt;em>投遞&lt;/em> 責任：定義「從 stream 的哪個位置開始讀、怎麼 ack、ack 不回來要不要重投、重投幾次」。同一個 stream 可以掛多個 consumer、各自有獨立的讀取游標跟重投狀態、互不影響。這個 stream / consumer 二分是 JetStream 跟 Kafka（topic / consumer group）對應、但跟 RabbitMQ（queue 本身就綁消費）不同的核心模型差異。&lt;/p>
&lt;p>本文用一個訂單事件流當主線：subject 設計成 &lt;code>orders.created.&amp;lt;region&amp;gt;&lt;/code>、stream 名 &lt;code>orders&lt;/code>、subject filter &lt;code>orders.&amp;gt;&lt;/code>。實機環境用單機 NATS server 加 &lt;code>-js&lt;/code>、CLI 用 &lt;code>natsio/nats-box&lt;/code> 容器；跨節點的 Cluster / quorum 段用 3 節點 docker compose 驗證、Supercluster / Leaf node 因拓樸複雜以 case 敘述加官方文件 caveat 標註。&lt;/p>
&lt;h2 id="stream-設計storageretentiondiscard容量上限">Stream 設計：storage、retention、discard、容量上限&lt;/h2>
&lt;p>Stream 的設計責任是回答四個彼此獨立的問題：訊息存在哪種介質、用什麼規則決定保留、超過上限時丟哪一端、上限本身設多大。這四個旋鈕組合錯了不會在建立時報錯、而是在 production 流量打進來才以丟訊息或塞爆 disk 的形式爆出來。&lt;/p>
&lt;h3 id="storagefile-vs-memory">Storage：file vs memory&lt;/h3>
&lt;p>Storage type 決定訊息寫在 disk 還是 RAM。&lt;code>file&lt;/code> storage 把 stream 寫進 disk、server 重啟後資料還在、是需要 durability 的事件流預設選擇；&lt;code>memory&lt;/code> storage 把 stream 放 RAM、吞吐跟延遲更好但 server 重啟即全失、適合短期 fan-out 或可重建的快取型資料。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/03-message-queue/vendors/nats/" data-link-title="NATS" data-link-desc="Lightweight messaging、JetStream 加持久化與 streams">NATS</a> overview 的 implementation-layer deep article。Overview 回答「NATS 該不該選、Core NATS vs JetStream 怎麼分」；要不要從 core NATS 跨進 JetStream 的決策入口見 <a href="/blog/backend/03-message-queue/vendors/nats/jetstream-durability-consumer/" data-link-title="NATS core 到 JetStream：fire-and-forget 在哪裡不夠、跨過去要付什麼" data-link-desc="Core NATS 的 fire-and-forget 在 consumer 重啟或 rolling deploy 時掉訊息——這不是 bug、是設計。需要訊息不丟就跨進 JetStream（persistence &#43; at-least-once &#43; redelivery）。本文展開 core 與 JetStream 的邊界、stream 與 consumer 的求值模型、實機驗證的 durable pull consumer、5 個把 JetStream consumer 寫成丟訊息與重投風暴的 production 踩坑">core 到 JetStream 的邊界</a>；本文回答「JetStream stream / consumer 的每個旋鈕怎麼設、設錯踩什麼坑、跨區拓樸怎麼鋪、多租戶怎麼隔離」。寫作結構依 <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> 的 6 段框架。</p></blockquote>
<h2 id="jetstream-把-fire-and-forget-升級成-durable-log">JetStream 把 fire-and-forget 升級成 durable log</h2>
<p>JetStream 是 NATS 內建的持久化層、責任是把 Core NATS 的 fire-and-forget subject 轉成 append-only 的 durable stream、並讓 consumer 能 ack、重投、replay。Core NATS 的訊息一旦沒有 active subscriber 就消失；JetStream 把符合特定 subject 的訊息攔截下來寫進 stream、即使沒有任何 consumer 在線也會留存到 retention 上限。</p>
<p>兩個概念要先分清楚、後面所有配置都掛在這個分界上。Stream 是 <em>儲存</em> 責任：定義「哪些 subject 的訊息要存、存多久、存多少、存哪裡」。Consumer 是 <em>投遞</em> 責任：定義「從 stream 的哪個位置開始讀、怎麼 ack、ack 不回來要不要重投、重投幾次」。同一個 stream 可以掛多個 consumer、各自有獨立的讀取游標跟重投狀態、互不影響。這個 stream / consumer 二分是 JetStream 跟 Kafka（topic / consumer group）對應、但跟 RabbitMQ（queue 本身就綁消費）不同的核心模型差異。</p>
<p>本文用一個訂單事件流當主線：subject 設計成 <code>orders.created.&lt;region&gt;</code>、stream 名 <code>orders</code>、subject filter <code>orders.&gt;</code>。實機環境用單機 NATS server 加 <code>-js</code>、CLI 用 <code>natsio/nats-box</code> 容器；跨節點的 Cluster / quorum 段用 3 節點 docker compose 驗證、Supercluster / Leaf node 因拓樸複雜以 case 敘述加官方文件 caveat 標註。</p>
<h2 id="stream-設計storageretentiondiscard容量上限">Stream 設計：storage、retention、discard、容量上限</h2>
<p>Stream 的設計責任是回答四個彼此獨立的問題：訊息存在哪種介質、用什麼規則決定保留、超過上限時丟哪一端、上限本身設多大。這四個旋鈕組合錯了不會在建立時報錯、而是在 production 流量打進來才以丟訊息或塞爆 disk 的形式爆出來。</p>
<h3 id="storagefile-vs-memory">Storage：file vs memory</h3>
<p>Storage type 決定訊息寫在 disk 還是 RAM。<code>file</code> storage 把 stream 寫進 disk、server 重啟後資料還在、是需要 durability 的事件流預設選擇；<code>memory</code> storage 把 stream 放 RAM、吞吐跟延遲更好但 server 重啟即全失、適合短期 fan-out 或可重建的快取型資料。</p>
<p>實機建一個 file storage、limits retention、discard old 的 stream：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl">nats --server nats://localhost:4232 stream add orders <span class="se">\
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="se"></span>  --subjects <span class="s1">&#39;orders.&gt;&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="se"></span>  --storage file <span class="se">\
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="se"></span>  --retention limits <span class="se">\
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="se"></span>  --discard old <span class="se">\
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="se"></span>  --max-msgs <span class="m">1000</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="se"></span>  --max-bytes 10MB <span class="se">\
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="se"></span>  --max-age 1h <span class="se">\
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="se"></span>  --replicas <span class="m">1</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="se"></span>  --defaults</span></span></code></pre></div><p><code>nats stream info orders</code> 回報的配置確認旋鈕都生效：</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">                     Subjects: orders.&gt;
</span></span><span class="line"><span class="ln">2</span><span class="cl">                      Storage: File
</span></span><span class="line"><span class="ln">3</span><span class="cl">                    Retention: Limits
</span></span><span class="line"><span class="ln">4</span><span class="cl">               Discard Policy: Old
</span></span><span class="line"><span class="ln">5</span><span class="cl">             Maximum Messages: 1,000
</span></span><span class="line"><span class="ln">6</span><span class="cl">                Maximum Bytes: 10 MiB
</span></span><span class="line"><span class="ln">7</span><span class="cl">                  Maximum Age: 1h0m0s</span></span></code></pre></div><p>選 memory 的判讀訊號：訊息可從上游重建（例如 metrics 採樣、可重抓的 snapshot）、或 consumer 一定在線且消費速度跟得上、且單 stream 資料量遠小於可用 RAM。一旦這三條有一條不成立、預設回到 file storage。</p>
<h3 id="retentionlimits-vs-interest-vs-workqueue">Retention：limits vs interest vs workqueue</h3>
<p>Retention policy 決定「訊息什麼時候從 stream 移除」、是 stream 三種使用形態的分水嶺。</p>
<p><code>limits</code> retention 是時間 / 容量驅動：訊息留到撞上 MaxMsgs / MaxBytes / MaxAge 任一上限才移除、跟有沒有人消費無關。這是「事件 log」形態、適合需要 replay、多個獨立 consumer 各讀各的場景。訂單事件流用 limits、因為審計、對帳、即時處理可能是三個獨立 consumer、訊息不能因為某個 consumer ack 了就消失。</p>
<p><code>interest</code> retention 是訂閱驅動：當 stream 上 <em>所有</em> 已註冊的 consumer 都 ack 了某筆訊息、該訊息立刻移除。它介於 limits 跟 workqueue 之間、適合「只要所有關心的 consumer 都收到就不必再留」的扇出場景。</p>
<p><code>workqueue</code> retention 是任務佇列形態：每筆訊息只會被 <em>一個</em> consumer 成功 ack、ack 後立刻刪除。它把 stream 當成工作分派佇列、語意接近 RabbitMQ 的 work queue。實機驗證 workqueue 的 retention 在 info 反映：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">nats --server nats://localhost:4232 stream add wq <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --subjects <span class="s1">&#39;wq.&gt;&#39;</span> --storage memory --retention work <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --max-msgs <span class="m">100</span> --replicas <span class="m">1</span> --defaults
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># nats stream info wq → Retention: WorkQueue</span></span></span></code></pre></div><p>判讀路由：需要多 consumer 各自 replay → limits；需要扇出且所有訂閱者收齊就清 → interest；需要競爭式單次消費的任務派工 → workqueue。選 workqueue 卻又掛兩個 filter 重疊的 consumer 會在建 consumer 時被拒、因為 workqueue 不允許同一筆訊息被兩個 consumer 認領。</p>
<h3 id="discardold-vs-new">Discard：old vs new</h3>
<p>Discard policy 決定 stream <em>撞上 MaxMsgs / MaxBytes 上限後</em> 丟哪一端。這個旋鈕的選擇直接對應業務對「舊資料」跟「新資料」誰更重要的判斷、選錯會靜默丟訊息。</p>
<p><code>discard old</code> 在達上限時丟掉最舊的訊息、騰空間給新訊息。實機驗證：max-msgs 設 3、連發 5 筆、stream 留下最後 3 筆：</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">discard old, max-msgs 3, published 5:
</span></span><span class="line"><span class="ln">2</span><span class="cl">                     Messages: 3
</span></span><span class="line"><span class="ln">3</span><span class="cl">               First Sequence: 3
</span></span><span class="line"><span class="ln">4</span><span class="cl">                Last Sequence: 5</span></span></code></pre></div><p>最舊的 seq 1、2 被丟、保留 seq 3-5。這對應「新資料比舊資料重要」的場景：即時儀表板、最新狀態快照、寧可丟歷史也要保住最新。</p>
<p><code>discard new</code> 在達上限時拒絕新訊息、保住已存的舊訊息。同樣 max-msgs 3、連發 5 筆：</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">discard new, max-msgs 3, published 5:
</span></span><span class="line"><span class="ln">2</span><span class="cl">                     Messages: 3
</span></span><span class="line"><span class="ln">3</span><span class="cl">               First Sequence: 1
</span></span><span class="line"><span class="ln">4</span><span class="cl">                Last Sequence: 3</span></span></code></pre></div><p>保留 seq 1-3、後到的 seq 4、5 進不來。這對應「舊資料是已承諾的工作、不能丟」的場景：任務佇列在塞滿時應拒收新任務（並對上游施加 backpressure）、而不是把排隊中的任務擠掉。</p>
<p>discard new 有個容易踩的投遞行為差異、見故障演練 Case 2。</p>
<h3 id="容量上限maxmsgs--maxbytes--maxage">容量上限：MaxMsgs / MaxBytes / MaxAge</h3>
<p>三個上限是 OR 關係：任一撞到就觸發 discard / 移除。MaxMsgs 限筆數、MaxBytes 限總位元組、MaxAge 限訊息存活時間。實務上三者搭配使用：MaxAge 防止無限累積（例如事件流只保留 7 天）、MaxBytes 是 disk 的硬護欄（防單 stream 撐爆 volume）、MaxMsgs 在訊息大小均勻時當作粗略筆數控制。</p>
<p>容量規劃的判讀順序是先定 MaxAge（業務需要 replay 多久）、再用「平均訊息大小 × 預估 throughput × MaxAge」反推 MaxBytes 是否在 disk 預算內、超出就縮短 MaxAge 或拆 stream。把 MaxBytes 設成 unlimited 而只靠 MaxMsgs 是常見的容量事故來源：訊息大小一旦變大（例如 payload 夾帶了 base64 附件）、筆數沒到上限但 disk 已滿。</p>
<h2 id="consumer-設計pullpushackackwaitmaxdeliverreplay">Consumer 設計：pull/push、ack、AckWait、MaxDeliver、replay</h2>
<p>Consumer 的設計責任是控制「訊息怎麼從 stream 送到處理端、處理端怎麼確認、確認不回來怎麼辦」。它的每個旋鈕都圍繞同一個核心張力：在 at-least-once 投遞下、如何在「不漏處理」跟「不過度重投」之間取得平衡。對應的概念基礎見 <a href="/blog/backend/knowledge-cards/delivery-semantics/" data-link-title="Delivery Semantics" data-link-desc="說明事件投遞語意如何定義遺失、重複、順序與補償策略">Delivery Semantics</a> 與 <a href="/blog/backend/knowledge-cards/processing-semantics/" data-link-title="Processing Semantics" data-link-desc="說明 consumer 處理事件後業務結果是否正確，與投遞成功分屬不同責任">Processing Semantics</a> 知識卡。</p>
<h3 id="pull-vs-push">Pull vs push</h3>
<p>Pull consumer 由處理端主動拉：consumer 發 pull request 帶 batch size、server 才送對應數量的訊息。流量控制天然落在消費端、消費端有多少處理能力就拉多少、是現代 JetStream 應用的預設模式。Push consumer 由 server 主動推到一個 delivery subject、處理端訂閱那個 subject、適合需要 server 端 flow control 或既有 Core NATS 訂閱模型遷移的場景。</p>
<p>實機建一個 pull consumer、explicit ack、AckWait 30s、MaxDeliver 5、replay instant：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">nats --server nats://localhost:4232 consumer add orders worker <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --pull <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --deliver all <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --ack explicit <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  --wait 30s <span class="se">\
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="se"></span>  --max-deliver <span class="m">5</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="se"></span>  --replay instant <span class="se">\
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="se"></span>  --filter <span class="s1">&#39;orders.&gt;&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="se"></span>  --defaults</span></span></code></pre></div><p><code>nats consumer info orders worker</code> 確認配置：</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">                    Name: worker
</span></span><span class="line"><span class="ln">2</span><span class="cl">               Pull Mode: true
</span></span><span class="line"><span class="ln">3</span><span class="cl">          Deliver Policy: All
</span></span><span class="line"><span class="ln">4</span><span class="cl">              Ack Policy: Explicit
</span></span><span class="line"><span class="ln">5</span><span class="cl">                Ack Wait: 30.00s
</span></span><span class="line"><span class="ln">6</span><span class="cl">           Replay Policy: Instant
</span></span><span class="line"><span class="ln">7</span><span class="cl">      Maximum Deliveries: 5</span></span></code></pre></div><p>push consumer 改用 <code>--target &lt;subject&gt;</code> 取代 <code>--pull</code>、info 會回報 <code>Delivery Subject:</code> 而非 Pull Mode。</p>
<h3 id="ackpolicyexplicit-是預設選擇">AckPolicy：explicit 是預設選擇</h3>
<p>Ack policy 決定 consumer 怎麼確認訊息已處理。<code>explicit</code> 要求對每一筆訊息單獨 ack、是 at-least-once 處理的基礎、production 預設選擇。<code>all</code> 用累積 ack：ack 第 N 筆等於 ack 了第 N 筆以前全部、吞吐高但一筆處理失敗會讓整段重投。<code>none</code> 完全不 ack、投遞即視為完成、語意退化成接近 fire-and-forget、只適合可容忍丟失的場景。</p>
<p>explicit ack 之所以是預設、是因為它讓每筆訊息的處理結果獨立可追蹤：哪筆 ack 了、哪筆還 outstanding、哪筆重投超限、都能在 consumer info 看到。實機發 3 筆訊息後、consumer info 的 <code>Unprocessed Messages</code> 反映 stream 中尚未投遞的 backlog：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">nats --server nats://localhost:4232 pub orders.created.us-1 <span class="s2">&#34;order-1&#34;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># 發 3 筆後：</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"># nats consumer info orders worker →</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">#     Unprocessed Messages: 3</span></span></span></code></pre></div><p>拉出訊息但不 ack、consumer info 的 <code>Outstanding Acks</code> 反映已投遞但未確認的數量：</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">        Outstanding Acks: 3 out of maximum 1,000</span></span></code></pre></div><p>這兩個數字是診斷 consumer 健康的第一手訊號：<code>Unprocessed</code> 高代表 consumer 拉得太慢或停了（stream backlog）；<code>Outstanding Acks</code> 持續高代表訊息拉出去了但處理端沒 ack（處理慢或卡住）。這個區分對應 overview 排錯段的「pending 是 ack-pending 還是 stream backlog」判讀。</p>
<h3 id="ackwait--maxdeliver重投的兩個邊界">AckWait + MaxDeliver：重投的兩個邊界</h3>
<p>AckWait 是 server 等待 ack 的時間窗：訊息投遞後、若 AckWait 內沒收到 ack、server 視為投遞失敗、重新投遞。MaxDeliver 是同一筆訊息的投遞次數上限：達到後不再重投、訊息進入 terminal 狀態（可導向 advisory / DLQ 機制）。</p>
<p>這兩個旋鈕共同定義重投行為。AckWait 要設成 <em>略大於 consumer 處理一筆訊息的 p99 時間</em>：太短會在 consumer 還在正常處理時就誤判失敗重投、造成重複處理（見故障演練 Case 1）；太長會讓真正卡死的訊息遲遲不重投、拖慢 recovery。MaxDeliver 是 poison message 的護欄：一筆訊息若處理永遠失敗（例如 payload 格式壞）、沒有 MaxDeliver 它會無限重投佔住 consumer。對應 <a href="/blog/backend/knowledge-cards/redelivery-loop/" data-link-title="Redelivery Loop" data-link-desc="說明同一訊息反覆投遞失敗如何消耗 consumer 容量">Redelivery Loop</a> 知識卡描述的失控重投。</p>
<h3 id="replayinstant-vs-original">Replay：instant vs original</h3>
<p>Replay policy 只在 consumer 從歷史位置讀（例如 <code>--deliver all</code> 重讀整個 stream）時生效、決定投遞節奏。<code>instant</code> 以 server 最快速度投遞、是處理 backlog 或重建狀態的預設。<code>original</code> 按訊息 <em>原始寫入的時間間隔</em> 重放：若原始訊息間隔 1 秒寫入、replay 也間隔 1 秒投遞、用於需要重現時序的測試或模擬。實機兩種都可建：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">nats consumer add orders replayorig ... --replay original  <span class="c1"># Replay Policy: Original</span></span></span></code></pre></div><h2 id="cluster--supercluster--leaf-node三層拓樸">Cluster / Supercluster / Leaf node：三層拓樸</h2>
<p>NATS 的拓樸分三層、各解一個不同尺度的問題：Cluster 解單區內的高可用、Supercluster 解跨區的延展、Leaf node 解邊緣到中心的連接。三者可組合、但職責不重疊。</p>
<h3 id="cluster單區-raft-高可用">Cluster：單區 Raft 高可用</h3>
<p>Cluster 是同一 region 內多個 NATS server 用 full mesh route 互連、JetStream 的 stream 透過 Raft 在多個 replica 間複製。Replica 數（R1 / R3 / R5）決定容錯：R3 容忍 1 節點失效、R5 容忍 2 節點。Raft 要求多數派（quorum）才能寫入、所以 R3 需要至少 2 節點健康。</p>
<p>實機用 3 節點 docker compose 起 cluster、建 R3 stream、stream info 顯示 Raft group 與 replica 狀態：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">nats --server nats://n1:4222 stream add rep3 <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --subjects <span class="s1">&#39;rep3.&gt;&#39;</span> --storage file --retention limits <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --discard old --max-msgs <span class="m">1000</span> --replicas <span class="m">3</span> --defaults</span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">                     Replicas: 3
</span></span><span class="line"><span class="ln">2</span><span class="cl">Cluster Information:
</span></span><span class="line"><span class="ln">3</span><span class="cl">                Cluster Group: S-R3F-unEqlH8C
</span></span><span class="line"><span class="ln">4</span><span class="cl">                       Leader: n2 (222ms)
</span></span><span class="line"><span class="ln">5</span><span class="cl">                      Replica: n1, current, seen 217ms ago
</span></span><span class="line"><span class="ln">6</span><span class="cl">                      Replica: n3, current, seen 219ms ago</span></span></code></pre></div><p>Leader 是 Raft 選出的寫入協調者、其餘 replica 跟隨。<code>current</code> 代表該 replica 與 leader 同步；落後會顯示 <code>outdated</code> 加落後的 operation 數。失去 quorum 的行為見故障演練 Case 4。</p>
<h3 id="supercluster跨區-gateway-延展">Supercluster：跨區 gateway 延展</h3>
<p>Supercluster 用 gateway 連接多個 Cluster、形成跨 region / 跨雲的單一 NATS 邏輯網路。Gateway 之間是按需轉發、不是 full mesh：訊息只在有訂閱者的 region 之間流動、避免跨區頻寬被無謂的全量複製吃掉。Supercluster 讓 publisher 在任一 region 發訊息、訂閱者在另一 region 收到、同時讓每個 Cluster 維持自己的 JetStream Raft 群組與本地高可用。</p>
<blockquote>
<p>以下 Supercluster 行為依 <a href="https://docs.nats.io/running-a-nats-service/configuration/gateways">NATS 官方文件</a> 描述、未在本文實機環境驗證（gateway 多區拓樸需要跨 region 部署）。</p></blockquote>
<p><a href="/blog/backend/03-message-queue/cases/nats-form3-multi-cloud-payments/" data-link-title="3.C35 Form3：NATS JetStream 多雲低延遲支付" data-link-desc="Form3 服務 Tier-1 銀行、500ms SLA、SNS/SQS 吃 300ms 預算、改 NATS&#43;JetStream 跨雲 6x 延遲改善。">3.C35 Form3</a> 是 Leaf node 跨雲橋接的代表案例（Supercluster 為相應的一般拓樸選項、case 本身明確點到的是 Leaf node）：服務 Tier-1 銀行、要求 500ms 端到端 SLA、AWS SNS/SQS 約 300ms 延遲吃掉預算。Form3 用 JetStream 跨雲橋接、達到約 6× 延遲改善、並做到「AWS 整個 region 掛掉時不喪失處理能力」。這個案例揭露的判讀是：金融支付的硬 latency 預算逼出特定拓樸選型、不是把 Kafka / SQS 通用化套上去。</p>
<h3 id="leaf-node邊緣連中心">Leaf node：邊緣連中心</h3>
<p>Leaf node 是輕量 NATS server、跑在邊緣（工廠、店面、IoT gateway）、透過單一 leaf connection 連回中心 hub。它在邊緣本地提供完整的 NATS / JetStream 能力（本地 publish / subscribe / 本地持久化）、同時把需要的 subject 透過 leaf connection 雙向橋接到 hub。Leaf node 的價值在於：邊緣到中心的網路斷線時、邊緣端的本地 JetStream 持續收訊息、連線恢復後再同步、不丟資料。</p>
<blockquote>
<p>以下 Leaf node 行為依 <a href="https://docs.nats.io/running-a-nats-service/configuration/leafnodes">NATS 官方文件</a> 與下列 case 描述、未在本文實機環境驗證（leaf 拓樸需要 hub + edge 雙端部署）。</p></blockquote>
<p><a href="/blog/backend/03-message-queue/cases/nats-machinemetrics-edge-to-cloud/" data-link-title="3.C37 MachineMetrics：邊緣到雲端工廠資料管線" data-link-desc="MachineMetrics 跨數百工廠、數千機台、1000Hz 採樣、Kinesis 無法跑在 edge、改 NATS Leaf Node &#43; JetStream &#43; KV &#43; Object Store。">3.C37 MachineMetrics</a> 是 Leaf node 邊緣到雲端的完整案例：跨數百客戶廠區、數千機台、單機最高 1000Hz 採樣、工廠網路斷斷續續、Kinesis 等 cloud-only 工具無法跑在資源受限 edge。MachineMetrics 用 Leaf node 做 hub-and-spoke、edge 端用 JetStream 做本地持久化抵抗斷線。這個案例揭露的判讀是：broker 的功能集合（messaging + 本地持久化 + KV + Object Store + auth）決定它能不能取代邊緣的多套工具。</p>
<p><a href="/blog/backend/03-message-queue/cases/nats-iflow-ot-it-integration/" data-link-title="3.C41 i-flow：NATS 做 OT/IT 跨層整合 bus" data-link-desc="i-flow 每日 4 億筆 data operation、200&#43; OT/IT connector、客戶含 Bosch / Sto / Lenze、NATS 當邊緣到 central 整合 bus。">3.C41 i-flow</a> 是多工廠 leaf node 拓樸的另一證據：每日 4 億筆 data operation、200+ OT/IT connector、用 leaf node hub-and-spoke 把多工廠接到 central、而不是每工廠自管一套 cluster。判讀：多工廠場景的運維成本由「每個邊緣點是不是要獨立維運一套 cluster」決定、leaf node 把邊緣端壓到單一 server。</p>
<h2 id="subject-based-acl-與多租戶">Subject-based ACL 與多租戶</h2>
<p>NATS 多租戶的主機制是 account：account 是完全隔離的 subject 命名空間、不同 account 之間預設互不可見、即使 subject 名稱相同也不會互通。Account 之內再用 subject-level permission 控制每個 user 能 publish / subscribe 哪些 subject。這兩層組合起來：account 給租戶硬隔離、subject permission 給租戶內的角色細分權限。</p>
<p>跨 account 的受控互通用 import / export：一個 account 把特定 subject export 出來、另一個 account 顯式 import、才會打通那條 subject。預設不通、互通是顯式授權的結果、這讓多租戶的資料流動可審計。對應 MachineMetrics 案例用 decentralized auth 隔離不同客戶廠區的設計：每個客戶是一個 account、廠區設備在 account 內用 subject permission 限定只能發自己廠區的 subject。</p>
<p>多租戶設計的判讀訊號：租戶之間要完全隔離、用 account；同租戶內的不同服務 / 角色要限權、用 subject permission；少數需要跨租戶共享的 subject（例如全域控制信號）、用 import / export 顯式打通、不要為了方便把不同租戶塞進同 account。</p>
<h2 id="production-故障演練">Production 故障演練</h2>
<p>deep article 的差異化價值在故障演練。以下四個都是 JetStream stream / consumer / 拓樸層的典型事故、前兩個有本文實機驗證、後兩個結合實機（quorum）與 case 敘述。</p>
<h3 id="case-1ackwait-太短造成重複處理">Case 1：AckWait 太短造成重複處理</h3>
<p><strong>徵兆</strong>：consumer 正常運行、處理邏輯沒報錯、但下游出現大量重複副作用（重複扣款、重複寄信、重複寫入）。consumer info 的 <code>Redelivered Messages</code> 持續上升、即使處理端沒有任何 exception。</p>
<p><strong>根因</strong>：AckWait 設得比 consumer 處理一筆訊息的實際耗時短。訊息投遞後 consumer 還在處理、AckWait 就到期、server 判定投遞失敗、把同一筆訊息重投給（可能是另一個）consumer 實例、於是同一筆訊息被處理兩次。實機重現：建一個 AckWait 1s 的 consumer、拉出訊息不 ack、過 1s 後再拉、<code>tries</code> 從 1 變 2：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">第一次拉：subj: orders.created.us-1 / tries: 1 / str seq: 1
</span></span><span class="line"><span class="ln">2</span><span class="cl">過 1s 後：subj: orders.created.us-1 / tries: 2 / str seq: 1
</span></span><span class="line"><span class="ln">3</span><span class="cl">consumer info → Redelivered Messages: 3</span></span></code></pre></div><p><strong>修法</strong>：</p>
<ol>
<li><strong>量測再設值</strong>：AckWait 設成 consumer 處理 p99 時間的 2-3 倍、而不是拍腦袋設 30s。處理一筆要 5s 的 worker 配 AckWait 30s、處理一筆要 45s 的 worker 配 AckWait 30s 就會持續誤判重投。</li>
<li><strong>長任務用 in-progress ack</strong>：處理時間本就偏長且方差大的任務、處理端在處理中定期送 <code>AckProgress</code>（working ack）延長 AckWait、而不是把 AckWait 設成一個無法涵蓋最壞情況的固定大值。</li>
<li><strong>處理端做冪等</strong>：at-least-once 投遞下重複是常態而非異常、副作用以業務 key 去重（對應 <a href="/blog/backend/knowledge-cards/processing-semantics/" data-link-title="Processing Semantics" data-link-desc="說明 consumer 處理事件後業務結果是否正確，與投遞成功分屬不同責任">Processing Semantics</a> 的冪等要求）。AckWait 只能降低重複頻率、不能消除重複。</li>
</ol>
<h3 id="case-2discard-policy-選錯靜默丟訊息">Case 2：discard policy 選錯靜默丟訊息</h3>
<p><strong>徵兆</strong>：上游 publisher 一切正常、沒收到任何 error、但下游 consumer 發現訊息有缺口（seq 跳號）、或最舊的歷史訊息神祕消失。對帳時帳目對不上、但日誌裡找不到任何失敗紀錄。</p>
<p><strong>根因</strong>：兩種情況。其一、stream 用 <code>discard old</code>、流量超過 MaxMsgs / MaxBytes、最舊的訊息被靜默丟棄騰空間——這在「事件 log 需要完整 replay」的場景是資料遺失。其二、stream 用 <code>discard new</code>、滿了之後新訊息被拒、但 publisher 用的是 <em>Core NATS publish</em>（不等 stream ack）、所以 publisher 端看到「發送成功」、訊息其實沒進 stream。實機重現後者的危險：對一個 discard new 已滿的 stream 用 Core pub 與 JetStream-aware pub、結果完全不同：</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">Core pub（不等 ack）：    Published 8 bytes to &#34;dnew.x&#34;        ← 看似成功、實際丟失
</span></span><span class="line"><span class="ln">2</span><span class="cl">JetStream pub（等 ack）： nats: error: maximum messages exceeded (10077)  ← 正確報錯</span></span></code></pre></div><p><strong>修法</strong>：</p>
<ol>
<li><strong>publisher 一律用 JetStream-aware publish</strong>：等 stream 的 PubAck 回來才算發送成功、才能在 stream 滿、quorum 失效、subject 不匹配時收到明確 error。用 Core pub 發進 JetStream subject 等於放棄所有投遞保證。</li>
<li><strong>discard policy 對齊業務語意</strong>：事件 log（需要完整歷史）配 limits + 充足 MaxAge、絕不靠 discard old 當容量控制；任務佇列配 discard new + 上游 backpressure、滿了就讓 producer 慢下來而不是擠掉排隊任務。</li>
<li><strong>監控 discard 計數</strong>：stream 的 discard 不是錯誤狀態、不會觸發 alert。要主動監控訊息 seq 連續性與 stream 的訊息移除速率、把「非預期的 discard」變成可觀測訊號。</li>
</ol>
<h3 id="case-3leaf-node-斷線重連">Case 3：Leaf node 斷線重連</h3>
<p><strong>徵兆</strong>：邊緣端（工廠 / 店面）到中心 hub 的網路抖動、leaf connection 反覆斷開重連、hub 端看到某些 subject 的訊息延遲尖刺、邊緣端 reconnect 計數持續累加。網路恢復後、邊緣累積的訊息一次湧入 hub、造成 hub 端短暫的處理尖峰。</p>
<p><strong>根因</strong>：邊緣到中心是廣域網、品質不如資料中心內網。Leaf connection 斷線期間、邊緣端的本地 JetStream 持續收訊息並本地持久化（這正是 leaf node 的設計目的）；連線恢復後、累積的 backlog 一次同步到 hub、形成尖峰。若邊緣端沒有本地 JetStream、斷線期間的訊息直接丟失。</p>
<blockquote>
<p>以下根因與修法依 NATS 官方 leaf node 文件與 <a href="/blog/backend/03-message-queue/cases/nats-machinemetrics-edge-to-cloud/" data-link-title="3.C37 MachineMetrics：邊緣到雲端工廠資料管線" data-link-desc="MachineMetrics 跨數百工廠、數千機台、1000Hz 採樣、Kinesis 無法跑在 edge、改 NATS Leaf Node &#43; JetStream &#43; KV &#43; Object Store。">MachineMetrics</a> / <a href="/blog/backend/03-message-queue/cases/nats-iflow-ot-it-integration/" data-link-title="3.C41 i-flow：NATS 做 OT/IT 跨層整合 bus" data-link-desc="i-flow 每日 4 億筆 data operation、200&#43; OT/IT connector、客戶含 Bosch / Sto / Lenze、NATS 當邊緣到 central 整合 bus。">i-flow</a> case 描述、未在本文實機環境驗證。</p></blockquote>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>邊緣端必開本地 JetStream</strong>：把斷線容忍從「依賴網路不斷」改成「斷線期間本地持久化、恢復後同步」。這是 MachineMetrics 用 edge JetStream 取代 SQLite 的核心理由——工廠網路斷斷續續是常態、不是異常。</li>
<li><strong>hub 端對同步尖峰做 flow control</strong>：恢復連線後的 backlog 同步用 consumer 端的 pull batch 限速、避免邊緣 backlog 一次打爆 hub 的處理能力。</li>
<li><strong>監控 reconnect 與 latency</strong>：leaf 連線的 reconnect 次數與 subject mapping latency 是邊緣網路品質的直接訊號（對應 overview 排錯段「leaf node 連線不穩」）。reconnect 頻繁代表網路或 hub 容量要處理、不是調 leaf 參數能解。</li>
</ol>
<h3 id="case-4stream-replica-失去-quorum">Case 4：Stream replica 失去 quorum</h3>
<p><strong>徵兆</strong>：R3 stream 突然無法寫入、publisher 的 JetStream publish 卡住後回 <code>no responders available</code>；stream info 顯示 <code>Leader:</code> 欄位空白、多數 replica 標 OFFLINE。讀取可能還能從存活節點拿到舊資料、但寫入完全停擺。</p>
<p><strong>根因</strong>：JetStream 的 stream 用 Raft 複製、寫入需要多數派確認。R3 stream 需要至少 2 節點健康才有 quorum；同時失去 2 節點就只剩 1 節點、達不到多數、Raft 無法選出 leader、stream 變成無法寫入。實機重現：3 節點 cluster 的 R3 stream、停掉 2 個節點、stream info 顯示無 leader、JetStream publish 報錯：</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">停 2 節點後 stream info：
</span></span><span class="line"><span class="ln">2</span><span class="cl">                       Leader:
</span></span><span class="line"><span class="ln">3</span><span class="cl">                      Replica: n1, current, seen 3.35s ago
</span></span><span class="line"><span class="ln">4</span><span class="cl">                      Replica: n2, outdated, OFFLINE, not seen
</span></span><span class="line"><span class="ln">5</span><span class="cl">                      Replica: n3, outdated, OFFLINE, not seen
</span></span><span class="line"><span class="ln">6</span><span class="cl">
</span></span><span class="line"><span class="ln">7</span><span class="cl">此時 JetStream publish：
</span></span><span class="line"><span class="ln">8</span><span class="cl">                      nats: error: nats: no responders available for request</span></span></code></pre></div><p>恢復 1 個節點（回到 2/3 多數）後、Raft 立即重選 leader、stream 恢復可寫：</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">啟動 n2 後：
</span></span><span class="line"><span class="ln">2</span><span class="cl">                       Leader: n1 (506ms)
</span></span><span class="line"><span class="ln">3</span><span class="cl">                      Replica: n2, current, seen 499ms ago
</span></span><span class="line"><span class="ln">4</span><span class="cl">                      Replica: n3, outdated, OFFLINE, not seen, 4 operations behind</span></span></code></pre></div><p><strong>修法</strong>：</p>
<ol>
<li><strong>replica 數對齊容錯目標</strong>：要容忍 1 節點失效用 R3、容忍 2 節點用 R5；不要為了省資源把關鍵 stream 設 R1（單點、節點掛了 stream 直接不可用）。</li>
<li><strong>replica 跨 failure domain 散開</strong>：R3 的 3 個 replica 要落在不同 availability zone / rack、避免單一 AZ 故障同時帶走 2 個 replica 直接失去 quorum。</li>
<li><strong>監控 replica 健康而非只看 leader</strong>：stream info 的每個 replica 的 <code>current</code> / <code>outdated</code> / <code>OFFLINE</code> 狀態是 quorum 餘裕的直接訊號。R3 已經有 1 個 replica OFFLINE 時 quorum 餘裕只剩 0、要當成 P1 處理、不能等到第 2 個也掛才反應（對應 overview 排錯段「JetStream raft 不一致」）。</li>
</ol>
<h2 id="容量與規模判讀">容量與規模判讀</h2>
<p>JetStream 的配置在不同規模下適用性不同、超出範圍要換拓樸而非調參數。</p>
<table>
  <thead>
      <tr>
          <th>規模訊號</th>
          <th>適用拓樸</th>
          <th>換檔訊號</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>單區、中等吞吐、需要 HA</td>
          <td>單 Cluster R3</td>
          <td>單區頻寬 / 節點數撐不住 → 加節點 reshard 或拆 stream</td>
      </tr>
      <tr>
          <td>跨 region / 跨雲、訂閱者分散各區</td>
          <td>Supercluster（多 Cluster + gateway）</td>
          <td>需要邊緣本地持久化 → 疊加 Leaf node</td>
      </tr>
      <tr>
          <td>大量邊緣點、網路不穩、邊緣要本地能力</td>
          <td>Leaf node hub-and-spoke</td>
          <td>邊緣點 &gt; 數百、每點要獨立運維 → 評估 managed（Synadia）</td>
      </tr>
  </tbody>
</table>
<p><strong>單 Cluster R3</strong> 是多數中等規模服務的起點：單區內高可用、JetStream Raft 處理節點故障、運維只有一套 cluster。撞到天花板的訊號是單區頻寬或單節點 disk / CPU 到上限、此時先評估加節點重分配或把熱 stream 拆出去、而不是急著上 supercluster。</p>
<p><strong>Supercluster</strong> 在訂閱者地理分散、或要求單區整個掛掉仍能服務時才值得引入。它的成本是跨區 gateway 的運維複雜度與跨區頻寬、不該為了「以後可能要跨區」提前鋪。Form3 的判讀是硬 SLA（500ms、region 全掛仍可用）逼出來的、不是預設架構。</p>
<p><strong>Leaf node hub-and-spoke</strong> 在邊緣點多、邊緣網路不穩、邊緣要本地持久化 / KV / 計算能力時適用。當邊緣點數量大到每點獨立運維成本不可接受、評估走 managed NATS（Synadia Cloud）把運維外包、而不是自建更大的 hub。</p>
<h2 id="整合與下一步">整合與下一步</h2>
<p>本文聚焦 JetStream stream / consumer / 拓樸的 implementation；以下是往上下游的銜接。</p>
<h3 id="回-vendor-overview-與相鄰章節">回 vendor overview 與相鄰章節</h3>
<ul>
<li>上游 vendor 頁：<a href="/blog/backend/03-message-queue/vendors/nats/" data-link-title="NATS" data-link-desc="Lightweight messaging、JetStream 加持久化與 streams">NATS overview</a>——Core NATS vs JetStream 的選型判讀、排錯快速判讀、何時改走其他 broker</li>
<li>跨 vendor consumer 設計：<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>——本文的 pull/push、ack、重投放回語言無關的 consumer 設計框架</li>
<li>投遞與處理語意基礎：<a href="/blog/backend/knowledge-cards/delivery-semantics/" data-link-title="Delivery Semantics" data-link-desc="說明事件投遞語意如何定義遺失、重複、順序與補償策略">Delivery Semantics</a> / <a href="/blog/backend/knowledge-cards/processing-semantics/" data-link-title="Processing Semantics" data-link-desc="說明 consumer 處理事件後業務結果是否正確，與投遞成功分屬不同責任">Processing Semantics</a> / <a href="/blog/backend/knowledge-cards/redelivery-loop/" data-link-title="Redelivery Loop" data-link-desc="說明同一訊息反覆投遞失敗如何消耗 consumer 容量">Redelivery Loop</a> 知識卡</li>
</ul>
<h3 id="對應-case">對應 case</h3>
<ul>
<li><a href="/blog/backend/03-message-queue/cases/nats-form3-multi-cloud-payments/" data-link-title="3.C35 Form3：NATS JetStream 多雲低延遲支付" data-link-desc="Form3 服務 Tier-1 銀行、500ms SLA、SNS/SQS 吃 300ms 預算、改 NATS&#43;JetStream 跨雲 6x 延遲改善。">3.C35 Form3</a>——Supercluster + Leaf node 跨雲低延遲支付、硬 SLA 驅動拓樸</li>
<li><a href="/blog/backend/03-message-queue/cases/nats-machinemetrics-edge-to-cloud/" data-link-title="3.C37 MachineMetrics：邊緣到雲端工廠資料管線" data-link-desc="MachineMetrics 跨數百工廠、數千機台、1000Hz 採樣、Kinesis 無法跑在 edge、改 NATS Leaf Node &#43; JetStream &#43; KV &#43; Object Store。">3.C37 MachineMetrics</a>——Leaf node + edge JetStream + KV + Object Store + 多租戶 auth 的完整邊緣案例</li>
<li><a href="/blog/backend/03-message-queue/cases/nats-iflow-ot-it-integration/" data-link-title="3.C41 i-flow：NATS 做 OT/IT 跨層整合 bus" data-link-desc="i-flow 每日 4 億筆 data operation、200&#43; OT/IT connector、客戶含 Bosch / Sto / Lenze、NATS 當邊緣到 central 整合 bus。">3.C41 i-flow</a>——多工廠 leaf node hub-and-spoke、運維成本驅動拓樸選型</li>
</ul>
<h3 id="後續可深入的議題">後續可深入的議題</h3>
<ul>
<li><strong>JetStream KV / Object Store</strong>：基於 stream 的 key-value 與 blob 儲存、何時用 NATS KV vs 真的 KV 服務（Redis / etcd）、見 overview 進階主題段</li>
<li><strong>Leaf node 多節點實機驗證</strong>：本文 Supercluster / Leaf node 段以 case + 官方文件敘述；補一篇 hub + edge 雙端 compose 的實機演練（含斷線注入、backlog 同步觀測）是自然延伸</li>
<li><strong>Subject mapping 與 transform</strong>：leaf node 跨層的 subject 重映射、跨 account import / export 的細部配置</li>
</ul>
]]></content:encoded></item></channel></rss>