<?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>Message-Queue on Tarragon</title><link>https://tarrragon.github.io/blog/tags/message-queue/</link><description>Recent content in Message-Queue on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Mon, 22 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/message-queue/index.xml" rel="self" type="application/rss+xml"/><item><title>3.C1 Meta：FOQS 從區域到全域佇列遷移</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/meta-foqs-global-migration/</link><pubDate>Thu, 07 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/meta-foqs-global-migration/</guid><description>&lt;p>這個案例的核心責任是說明 queue 轉換不只換 broker，還包含路由與可用性模型重整。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>FOQS 從區域安裝轉為全域架構，目標是讓災害期間佇列資料仍可被存取，並控制遷移期間的延遲與可用性風險。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>當 queue 成為跨區關鍵路徑，轉換焦點是 discoverability、routing freshness 與 tenant 遷移節奏。&lt;/p>
&lt;h2 id="策略">策略&lt;/h2>
&lt;ol>
&lt;li>先建立全域路由層，再分批搬遷租戶。&lt;/li>
&lt;li>針對 stale routing 做補貨延遲治理。&lt;/li>
&lt;li>用零停機遷移策略保留客戶端連續性。&lt;/li>
&lt;/ol>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/broker-basics/" data-link-title="3.1 broker 基礎與投遞模型" data-link-desc="先理解 broker、queue、consumer 與 delivery semantics">3.1 broker basics&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/durable-queue/" data-link-title="3.2 durable queue 與重試策略" data-link-desc="整理持久化佇列、DLQ 與重試流程">3.2 durable queue&lt;/a>。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://engineering.fb.com/2022/01/18/production-engineering/foqs-disaster-ready/">FOQS disaster-ready migration&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 queue 轉換不只換 broker，還包含路由與可用性模型重整。</p>
<h2 id="觀察">觀察</h2>
<p>FOQS 從區域安裝轉為全域架構，目標是讓災害期間佇列資料仍可被存取，並控制遷移期間的延遲與可用性風險。</p>
<h2 id="判讀">判讀</h2>
<p>當 queue 成為跨區關鍵路徑，轉換焦點是 discoverability、routing freshness 與 tenant 遷移節奏。</p>
<h2 id="策略">策略</h2>
<ol>
<li>先建立全域路由層，再分批搬遷租戶。</li>
<li>針對 stale routing 做補貨延遲治理。</li>
<li>用零停機遷移策略保留客戶端連續性。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/broker-basics/" data-link-title="3.1 broker 基礎與投遞模型" data-link-desc="先理解 broker、queue、consumer 與 delivery semantics">3.1 broker basics</a> 與 <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>。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://engineering.fb.com/2022/01/18/production-engineering/foqs-disaster-ready/">FOQS disaster-ready migration</a></li>
</ul>
]]></content:encoded></item><item><title>RabbitMQ</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/rabbitmq/</link><pubDate>Fri, 01 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/rabbitmq/</guid><description>&lt;p>RabbitMQ 是 AMQP 協議實作的 classic broker、承擔三個責任：訊息持久化與重試（durable queue + ack/nack）、靈活路由（exchange + routing key + binding）、跨服務任務分派（worker pool + DLQ）。設計取捨偏向「處理即承諾、broker 負責重新投遞、consumer 負責 idempotency」、可靠性建立在 ack 機制而非 replication。&lt;/p>
&lt;p>對「任務隊列、worker pool、複雜 routing、RPC over messaging」這條路徑、RabbitMQ 是業界主流。本頁先給最短路徑、再展開日常 publisher / consumer 操作與 exchange 設計、最後進階治理（quorum queue、cluster、federation）跟排錯。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>讀完本章後、你應該能：&lt;/p>
&lt;ol>
&lt;li>用 docker 跑起 RabbitMQ + management UI、驗證 broker 健康&lt;/li>
&lt;li>用 CLI / Management API 建 exchange、queue、binding&lt;/li>
&lt;li>設計 exchange type（direct / fanout / topic / headers）對齊路由需求&lt;/li>
&lt;li>看懂 queue depth、unacked、connection / channel 數量訊號、定位故障層&lt;/li>
&lt;li>評估 quorum queue、stream、federation、shovel 等規模化議題&lt;/li>
&lt;/ol>
&lt;h2 id="最短路徑5-分鐘把-rabbitmq-跑起來">最短路徑：5 分鐘把 RabbitMQ 跑起來&lt;/h2>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 1. 啟動 RabbitMQ + management plugin&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">docker run -d --name rabbitmq -p 5672:5672 -p 15672:15672 rabbitmq:3-management
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="c1"># 2. 建 exchange / queue / binding（rabbitmqadmin 可重現、Management UI 在 http://localhost:15672、預設 guest/guest）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">docker &lt;span class="nb">exec&lt;/span> rabbitmq rabbitmqadmin &lt;span class="nb">declare&lt;/span> exchange &lt;span class="nv">name&lt;/span>&lt;span class="o">=&lt;/span>demo.direct &lt;span class="nv">type&lt;/span>&lt;span class="o">=&lt;/span>direct
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">docker &lt;span class="nb">exec&lt;/span> rabbitmq rabbitmqadmin &lt;span class="nb">declare&lt;/span> queue &lt;span class="nv">name&lt;/span>&lt;span class="o">=&lt;/span>demo.q
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">docker &lt;span class="nb">exec&lt;/span> rabbitmq rabbitmqadmin &lt;span class="nb">declare&lt;/span> binding &lt;span class="nv">source&lt;/span>&lt;span class="o">=&lt;/span>demo.direct &lt;span class="nv">destination&lt;/span>&lt;span class="o">=&lt;/span>demo.q &lt;span class="nv">routing_key&lt;/span>&lt;span class="o">=&lt;/span>demo
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="c1"># 3. 用 rabbitmqctl 驗證 broker 狀態&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">docker &lt;span class="nb">exec&lt;/span> rabbitmq rabbitmqctl list_queues
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">docker &lt;span class="nb">exec&lt;/span> rabbitmq rabbitmqctl list_exchanges
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">docker &lt;span class="nb">exec&lt;/span> rabbitmq rabbitmqctl list_bindings&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>最短路徑驗證「broker 起來、UI 能訪、能 enqueue/dequeue」。實際寫程式用 AMQP client、見&lt;a href="#%e6%97%a5%e5%b8%b8%e6%93%8d%e4%bd%9c%e8%88%87%e6%b1%ba%e7%ad%96%e5%bd%a2%e7%8b%80">日常操作&lt;/a>。&lt;/p>
&lt;h2 id="日常操作與決策形狀">日常操作與決策形狀&lt;/h2>
&lt;h3 id="cli-與-client-api">CLI 與 client API&lt;/h3>
&lt;p>子議題：&lt;/p>
&lt;ul>
&lt;li>CLI 指令對照表（rabbitmqctl / rabbitmq-diagnostics / rabbitmqadmin）&lt;/li>
&lt;li>Management API 形狀（HTTP API、適合自動化）&lt;/li>
&lt;li>AMQP client 配置：connection / channel / consumer prefetch / publisher confirm&lt;/li>
&lt;li>對應指令範例：&lt;code>rabbitmqctl list_queues name messages messages_unacknowledged consumers&lt;/code>&lt;/li>
&lt;/ul>
&lt;h3 id="exchange-types-與-routing-設計">Exchange types 與 routing 設計&lt;/h3>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">Exchange&lt;/a> 承擔訊息分流責任、不同 type 對應不同路由語意。子議題：&lt;/p></description><content:encoded><![CDATA[<p>RabbitMQ 是 AMQP 協議實作的 classic broker、承擔三個責任：訊息持久化與重試（durable queue + ack/nack）、靈活路由（exchange + routing key + binding）、跨服務任務分派（worker pool + DLQ）。設計取捨偏向「處理即承諾、broker 負責重新投遞、consumer 負責 idempotency」、可靠性建立在 ack 機制而非 replication。</p>
<p>對「任務隊列、worker pool、複雜 routing、RPC over messaging」這條路徑、RabbitMQ 是業界主流。本頁先給最短路徑、再展開日常 publisher / consumer 操作與 exchange 設計、最後進階治理（quorum queue、cluster、federation）跟排錯。</p>
<h2 id="本章目標">本章目標</h2>
<p>讀完本章後、你應該能：</p>
<ol>
<li>用 docker 跑起 RabbitMQ + management UI、驗證 broker 健康</li>
<li>用 CLI / Management API 建 exchange、queue、binding</li>
<li>設計 exchange type（direct / fanout / topic / headers）對齊路由需求</li>
<li>看懂 queue depth、unacked、connection / channel 數量訊號、定位故障層</li>
<li>評估 quorum queue、stream、federation、shovel 等規模化議題</li>
</ol>
<h2 id="最短路徑5-分鐘把-rabbitmq-跑起來">最短路徑：5 分鐘把 RabbitMQ 跑起來</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"># 1. 啟動 RabbitMQ + management plugin</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">docker run -d --name rabbitmq -p 5672:5672 -p 15672:15672 rabbitmq:3-management
</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"># 2. 建 exchange / queue / binding（rabbitmqadmin 可重現、Management UI 在 http://localhost:15672、預設 guest/guest）</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">docker <span class="nb">exec</span> rabbitmq rabbitmqadmin <span class="nb">declare</span> exchange <span class="nv">name</span><span class="o">=</span>demo.direct <span class="nv">type</span><span class="o">=</span>direct
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">docker <span class="nb">exec</span> rabbitmq rabbitmqadmin <span class="nb">declare</span> queue <span class="nv">name</span><span class="o">=</span>demo.q
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">docker <span class="nb">exec</span> rabbitmq rabbitmqadmin <span class="nb">declare</span> binding <span class="nv">source</span><span class="o">=</span>demo.direct <span class="nv">destination</span><span class="o">=</span>demo.q <span class="nv">routing_key</span><span class="o">=</span>demo
</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="c1"># 3. 用 rabbitmqctl 驗證 broker 狀態</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">docker <span class="nb">exec</span> rabbitmq rabbitmqctl list_queues
</span></span><span class="line"><span class="ln">11</span><span class="cl">docker <span class="nb">exec</span> rabbitmq rabbitmqctl list_exchanges
</span></span><span class="line"><span class="ln">12</span><span class="cl">docker <span class="nb">exec</span> rabbitmq rabbitmqctl list_bindings</span></span></code></pre></div><p>最短路徑驗證「broker 起來、UI 能訪、能 enqueue/dequeue」。實際寫程式用 AMQP client、見<a href="#%e6%97%a5%e5%b8%b8%e6%93%8d%e4%bd%9c%e8%88%87%e6%b1%ba%e7%ad%96%e5%bd%a2%e7%8b%80">日常操作</a>。</p>
<h2 id="日常操作與決策形狀">日常操作與決策形狀</h2>
<h3 id="cli-與-client-api">CLI 與 client API</h3>
<p>子議題：</p>
<ul>
<li>CLI 指令對照表（rabbitmqctl / rabbitmq-diagnostics / rabbitmqadmin）</li>
<li>Management API 形狀（HTTP API、適合自動化）</li>
<li>AMQP client 配置：connection / channel / consumer prefetch / publisher confirm</li>
<li>對應指令範例：<code>rabbitmqctl list_queues name messages messages_unacknowledged consumers</code></li>
</ul>
<h3 id="exchange-types-與-routing-設計">Exchange types 與 routing 設計</h3>
<p><a href="/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">Exchange</a> 承擔訊息分流責任、不同 type 對應不同路由語意。子議題：</p>
<ul>
<li>Direct：精準 routing key 匹配（point-to-point）</li>
<li>Fanout：忽略 routing key、廣播到所有 binding queue</li>
<li>Topic：層級式 routing key（<code>*</code> 單層、<code>#</code> 多層萬用字元）</li>
<li>Headers：依 message header 路由（少用）</li>
<li>對應指令：宣告 exchange / queue / binding 的 CLI 與 client 範例</li>
</ul>
<h3 id="queue-設計與-acknack-策略">Queue 設計與 ack/nack 策略</h3>
<p><a href="/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">Ack/nack</a> 是 RabbitMQ 的 delivery 控制點。子議題：</p>
<ul>
<li>Durable queue vs transient queue</li>
<li>Manual ack vs auto ack（後者等同 at-most-once）</li>
<li>Prefetch 設定（<a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a> + 併發控制）</li>
<li><a href="/blog/backend/knowledge-cards/dead-letter-queue/" data-link-title="Dead-Letter Queue" data-link-desc="說明 dead-letter queue 如何隔離多次處理失敗的訊息">Dead-letter exchange</a>（DLX）配置</li>
<li>Message TTL 與 queue length limit</li>
</ul>
<h2 id="進階主題按需閱讀">進階主題（按需閱讀）</h2>
<p>本段主題已展開為 deep article：<a href="queue-types-classic-quorum-stream/">classic vs quorum vs stream 選型</a>、<a href="network-partition-clustering/">network partition 與 cluster 一致性</a>、<a href="dlq-retry-escalation/">DLQ retry escalation</a>。下列子議題段保留選題判讀入口。</p>
<h3 id="classic-queue-vs-quorum-queue-vs-stream">Classic queue vs Quorum queue vs Stream</h3>
<p>子議題：</p>
<ul>
<li>Classic queue：原生持久化 queue、mirrored queue 已 deprecated</li>
<li>Quorum queue：Raft-based、取代 mirrored、跨節點一致性</li>
<li>Stream（3.9+）：append-only log、log-based 模型、類似 Kafka 但仍是 RabbitMQ 體系</li>
<li>三種模型的選擇判讀（throughput、retention、replay 需求）</li>
</ul>
<h3 id="federation-與-shovel">Federation 與 Shovel</h3>
<p>子議題：</p>
<ul>
<li>Federation：upstream / downstream broker 鏈接、適合鬆耦合跨資料中心</li>
<li>Shovel：點對點轉發、適合單純訊息搬運</li>
<li>跨區 / 多 cluster 場景的選擇</li>
</ul>
<h3 id="erlang-clustering-與-network-partition">Erlang clustering 與 network partition</h3>
<p>子議題：</p>
<ul>
<li>Cluster 拓樸（disc node、ram node）</li>
<li><code>cluster_partition_handling</code> 策略（ignore、autoheal、pause_minority）</li>
<li>腦裂偵測與處理</li>
</ul>
<h3 id="多-vhost--多租戶">多 vhost / 多租戶</h3>
<p>子議題：</p>
<ul>
<li>Vhost 隔離（namespace、ACL、user permission）</li>
<li>User / Role / Permission 設計</li>
<li>Per-vhost resource limit（max connection、max queue）</li>
</ul>
<h3 id="prefetch-與-consumer-併發控制">Prefetch 與 consumer 併發控制</h3>
<p>子議題：</p>
<ul>
<li>Prefetch count 對 throughput / fairness 的影響</li>
<li>Channel-level vs Consumer-level prefetch</li>
<li>配合 <a href="/blog/backend/knowledge-cards/retry-budget/" data-link-title="Retry Budget" data-link-desc="說明重試次數如何受整體容量與錯誤預算限制">retry budget</a> 控制重試壓力</li>
</ul>
<h3 id="rabbitmq-cluster-operatork8s">RabbitMQ Cluster Operator（K8s）</h3>
<p>子議題：</p>
<ul>
<li>Cluster Operator vs 自管 StatefulSet</li>
<li>持久化卷（PVC）與資料保護</li>
<li>升級流程（rolling restart 與資料完整性）</li>
</ul>
<h3 id="plugin-機制與多協議">Plugin 機制與多協議</h3>
<p>子議題：</p>
<ul>
<li>MQTT plugin（IoT 場景、橋接 device-to-broker）</li>
<li>STOMP plugin</li>
<li>對應 <a href="/blog/backend/03-message-queue/broker-basics/#%e8%aa%9e%e6%84%8f%e4%bf%9d%e8%ad%89%e7%9a%84%e4%b8%8d%e5%90%8c%e5%af%a6%e4%bd%9c%e6%a9%9f%e5%88%b6" data-link-title="3.1 broker 基礎與投遞模型" data-link-desc="先理解 broker、queue、consumer 與 delivery semantics">3.1 broker basics 的 QoS / ACK 機制橋接</a></li>
</ul>
<h2 id="排錯快速判讀">排錯快速判讀</h2>
<h3 id="queue-堆積messages-增加unacked-不收斂">Queue 堆積（messages 增加、unacked 不收斂）</h3>
<p>操作原則：先看 consumer 是否存在、再看 ack 速率 vs publish 速率、最後看 prefetch / poison message。</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">rabbitmqctl list_queues name messages messages_unacknowledged consumers</span></span></code></pre></div><p>判讀路徑：無 consumer（client crash）→ consumer 慢（下游卡）→ poison message 卡住（看單一 message redelivery 次數）。</p>
<h3 id="connection--channel-limit">Connection / Channel limit</h3>
<p>操作原則：client 設計不當會用滿 connection / channel，看每個 connection 的 channel 數。</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">rabbitmqctl list_connections
</span></span><span class="line"><span class="ln">2</span><span class="cl">rabbitmqctl list_channels</span></span></code></pre></div><h3 id="disk-alarm-觸發">Disk alarm 觸發</h3>
<p>操作原則：disk 低於 <code>disk_free_limit</code>、broker 暫停 publisher。判讀：保留期太長 / 訊息大小 / 未消費 queue 過大。</p>
<h3 id="memory-alarm-觸發">Memory alarm 觸發</h3>
<p>操作原則：記憶體超過 watermark、broker 觸發 <a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">paging</a>、publisher 變慢。判讀路徑：訊息累積、consumer 失聯、queue 設定錯誤。</p>
<h3 id="network-partition腦裂">Network partition（腦裂）</h3>
<p>操作原則：cluster 節點互相不可達、看 <code>cluster_partition_handling</code> 與 partition log。對應 <a href="/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9 語義誤配</a> 思路。</p>
<h2 id="何時改走其他服務">何時改走其他服務</h2>
<table>
  <thead>
      <tr>
          <th>需求形狀</th>
          <th>改走</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>高吞吐事件流、長期 replay</td>
          <td><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></td>
      </tr>
      <tr>
          <td>Managed queue（AWS 生態）</td>
          <td><a href="/blog/backend/03-message-queue/vendors/aws-sqs/" data-link-title="AWS SQS" data-link-desc="AWS managed queue、簡單可靠、無 ordering（standard）">AWS SQS</a></td>
      </tr>
      <tr>
          <td>Managed pub/sub（GCP 生態）</td>
          <td><a href="/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Google Pub/Sub</a></td>
      </tr>
      <tr>
          <td>輕量 messaging + 微服務</td>
          <td><a href="/blog/backend/03-message-queue/vendors/nats/" data-link-title="NATS" data-link-desc="Lightweight messaging、JetStream 加持久化與 streams">NATS</a></td>
      </tr>
      <tr>
          <td>Redis 生態 stream</td>
          <td><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></td>
      </tr>
      <tr>
          <td>IoT device 接入</td>
          <td>EMQX / HiveMQ / Mosquitto（MQTT broker、或用 RabbitMQ MQTT plugin）</td>
      </tr>
      <tr>
          <td>Workflow + durable execution</td>
          <td>Temporal（T4 候選）</td>
      </tr>
  </tbody>
</table>
<h2 id="不在本頁內的主題">不在本頁內的主題</h2>
<ul>
<li>各語言 AMQP client 完整 API（依官方文件）</li>
<li>所有 plugin 細節（只列主流 plugin）</li>
<li>RabbitMQ Streams 跟 Kafka 的詳細對照（見 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka vendor 頁</a>）</li>
</ul>
<h2 id="案例回寫">案例回寫</h2>
<h3 id="rabbitmq-專屬案例c23-c33">RabbitMQ 專屬案例（C23-C33）</h3>
<table>
  <thead>
      <tr>
          <th>案例</th>
          <th>主討論議題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/rabbitmq-bloomberg-multi-tenant-vhost/" data-link-title="3.C23 Bloomberg：多租戶 vhost &#43; 自助平台化" data-link-desc="Bloomberg 從幾個團隊推到上百個團隊、靠自助 vhost 註冊跟專用叢集分離應用與 broker。">3.C23 Bloomberg vhost 多租戶</a></td>
          <td>多 vhost + 自助平台化</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/rabbitmq-soundcloud-fanout-audio/" data-link-title="3.C24 SoundCloud：AMQP fan-out 音訊處理 pipeline" data-link-desc="SoundCloud 每秒 20-30K persistent message、不同處理類型分開隊列、各自獨立 scale。">3.C24 SoundCloud fan-out</a></td>
          <td>音訊處理 pipeline 分隊列</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/rabbitmq-indeed-delay-dlq-escalation/" data-link-title="3.C25 Indeed：Delay queue &#43; DLQ 三層 escalation" data-link-desc="Indeed 每天 35M&#43; 職缺、設計 Requeue → Delay queue → DLQ 三層 escalation 避開 head-of-line blocking。">3.C25 Indeed Delay + DLQ</a></td>
          <td>三層 retry escalation</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/rabbitmq-gocardless-hutch-service-mesh/" data-link-title="3.C26 GoCardless：Hutch &#43; 單一 topic exchange service mesh" data-link-desc="GoCardless 單一 RabbitMQ cluster 作所有 service 通訊中樞、routing key 用 service.subject.action 格式、JSON 多語言可讀。">3.C26 GoCardless Hutch</a></td>
          <td>單一 topic exchange 服務 mesh</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/rabbitmq-zalando-aws-master-selection/" data-link-title="3.C27 Zalando：RabbitMQ on AWS 自動化 master selection" data-link-desc="Zalando 用 sidekick 服務查 AWS API 動態識別 cluster、指定最老 instance 當 master、跨版本升級用 federation 過渡。">3.C27 Zalando AWS</a></td>
          <td>雲端自動 master selection / federation 升級</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/rabbitmq-wework-consistent-hash-ordering/" data-link-title="3.C28 WeWork：Consistent hash exchange 保證帳戶順序" data-link-desc="WeWork 固定數量 queue &#43; account ID hash 路由、每 queue 一個 worker &#43; exclusive consumer 保 partition-level ordering。">3.C28 WeWork hash ordering</a></td>
          <td>Consistent hash exchange / per-key ordering</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/rabbitmq-wework-bunny-channel-pool/" data-link-title="3.C29 WeWork：Bunny &#43; Puma 多執行緒 channel pool" data-link-desc="WeWork 從 Unicorn 切到 Puma 後遇 ConnectionClosedError、根因是 AMQP channel 跨執行緒共用、改用 connection_pool 管理。">3.C29 WeWork Bunny channel pool</a></td>
          <td>AMQP channel 不可跨執行緒</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/rabbitmq-runtastic-mirrored-queue-bottleneck/" data-link-title="3.C30 Runtastic：Mirrored queue 網路負載瓶頸" data-link-desc="Runtastic 2020 lockdown 流量暴增、performance test 揭露 mirroring 邏輯把網路元件壓垮、調整 mirroring 配置消除瓶頸。">3.C30 Runtastic mirrored bottleneck</a></td>
          <td>Mirrored queue 網路成本</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/rabbitmq-mozilla-pulse-naming-isolation/" data-link-title="3.C31 Mozilla Pulse：命名前綴 &#43; ACL 取代 vhost 多租戶" data-link-desc="Mozilla Pulse 不用 vhost、改用權限 &#43; 命名前綴 (exchange/{user}/*) 做隔離、CloudAMQP 託管、PulseGuardian 管使用者。">3.C31 Mozilla Pulse</a></td>
          <td>ACL + naming 取代 vhost（反向）</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/rabbitmq-loyaltylion-monitoring-thousands/" data-link-title="3.C32 LoyaltyLion：監控數千 RabbitMQ queue" data-link-desc="LoyaltyLion 跑數千 queue、用 rabbitmqctl &#43; statsd 推 Datadog、揭露大規模 queue 拓樸下原生 plugin API 不夠用。">3.C32 LoyaltyLion monitoring</a></td>
          <td>大規模 queue topology 監控</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/rabbitmq-wargaming-game-portal-decoupling/" data-link-title="3.C33 Wargaming：World of Tanks 戰後 dossier 解耦" data-link-desc="Wargaming WoT server 全 Linux、戰後 dossier 寫 RabbitMQ、portal 顯示統計而不增 game server load。">3.C33 Wargaming game portal</a></td>
          <td>異步解耦 game server / portal</td>
      </tr>
  </tbody>
</table>
<h3 id="跨-vendor-對照">跨 vendor 對照</h3>
<table>
  <thead>
      <tr>
          <th>案例</th>
          <th>對 RabbitMQ 的對應</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9 反例：語義誤配</a></td>
          <td>manual ack + DLX + idempotency 三層責任邊界</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/contrast-queue-model-by-scale/" data-link-title="3.C10 對照：規模差異下的佇列模型" data-link-desc="同一 queue 模型在不同規模下的治理與失敗邊界差異。">3.C10 規模對照</a></td>
          <td>小型直接用 / 中型補 idempotency / 大型分 vhost</td>
      </tr>
  </tbody>
</table>
<p><strong>MQTT plugin + Cluster Operator 缺直接 customer case</strong>：可補 RabbitMQ 官方 native MQTT blog 跟 K8s Operator docs、後續若有 customer 案例可加。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>上游概念：<a href="/blog/backend/00-service-selection/async-delivery-selection/" data-link-title="0.3 非同步與事件傳遞選型" data-link-desc="區分背景工作、durable queue、stream、pub/sub 與 outbox 的選型邊界">0.3 非同步選型</a>、<a href="/blog/backend/03-message-queue/broker-basics/" data-link-title="3.1 broker 基礎與投遞模型" data-link-desc="先理解 broker、queue、consumer 與 delivery semantics">3.1 broker basics</a></li>
<li>平行 vendor：<a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka</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>下游能力：<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>、<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></li>
</ul>
]]></content:encoded></item><item><title>3.1 broker 基礎與投遞模型</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/broker-basics/</link><pubDate>Thu, 23 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/broker-basics/</guid><description>&lt;p>這一章先建立訊息佇列的基本模型，後面的 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/durable-queue/" data-link-title="Durable Queue" data-link-desc="說明可持久化的 queue 如何在重啟與失敗後保留待處理工作">durable queue&lt;/a>、outbox 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer&lt;/a> 設計都會建立在這些語意上。&lt;/p>
&lt;p>訊息代理（broker）的核心責任是解耦 producer 與 consumer，讓非同步工作具備可排隊、可重試、可隔離的傳遞路徑。它定位在傳遞與協調層。&lt;/p>
&lt;h2 id="broker-跟-protocol-是兩個獨立的軸">broker 跟 protocol 是兩個獨立的軸&lt;/h2>
&lt;p>Broker 是訊息分發的具體實作產品（RabbitMQ、Kafka、NATS、EMQX）、protocol 是訊息交換的線路規格（AMQP、MQTT、STOMP、Kafka wire protocol）。兩個軸獨立、形成多對多關係：&lt;/p>
&lt;ul>
&lt;li>一個 broker 可實作多個 protocol：RabbitMQ 主走 AMQP、透過 plugin 也支援 MQTT 跟 STOMP；NATS 主走自家 protocol、JetStream 額外提供 KV 與 Object Store API&lt;/li>
&lt;li>一個 protocol 可被多個 broker 實作：MQTT 由 EMQX / HiveMQ / Mosquitto / RabbitMQ MQTT plugin 各自實作；AMQP 主要是 RabbitMQ 跟 Apache Qpid&lt;/li>
&lt;/ul>
&lt;p>選型討論時要分清「我需要的是 protocol（如 device 端要 MQTT 因為輕量 / IoT 標準）」還是「broker 產品（如 RabbitMQ vs EMQX 的運維 / 生態取捨）」。當 protocol 跟 broker 都需要、會出現 protocol 橋接場景 — 例：device 端透過 MQTT 連 RabbitMQ MQTT plugin、broker 內部把 MQTT topic 自動映射成 AMQP routing key、AMQP-side consumer 用 routing key 訂閱。&lt;/p>
&lt;p>這層分離也影響故障判讀：device 連不上是 protocol 層問題、broker 之間 routing 錯是 broker 內部 plugin / mapping 問題、consumer 收不到是 AMQP binding 問題 — 三層各自獨立、不能混為一談。&lt;/p>
&lt;h2 id="brokerqueueconsumer-的分工">broker、queue、consumer 的分工&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker&lt;/a> 管理訊息儲存、分發與確認流程；queue 或 topic 承載傳遞單位；consumer 承擔業務處理。分工清楚後，故障判讀才能定位在正確層級：投遞故障、消費故障或下游依賴故障。&lt;/p>
&lt;p>producer 發送成功只代表 broker 已接收（&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/publisher-confirm/" data-link-title="Publisher Confirm" data-link-desc="說明 producer 如何確認 broker 已接收並承擔訊息">publisher confirm&lt;/a>），不代表業務結果完成。業務完成需要 consumer 提交副作用並確認進度。&lt;/p>
&lt;h2 id="push-與-pull-模型">push 與 pull 模型&lt;/h2>
&lt;p>push 模型由 broker 主動推送訊息，適合低延遲場景；pull 模型由 consumer 主動拉取，適合吞吐控制與批次處理。實務上常結合使用：broker 管理可見性與重試，consumer 控制節流與併發。&lt;/p>
&lt;p>模型選擇重點是背壓控制。當下游變慢時，系統是否能限制消費速率並保留恢復空間，是穩定性的關鍵。&lt;/p>
&lt;h2 id="傳遞語意delivery-semantics">傳遞語意（delivery semantics）&lt;/h2>
&lt;p>三種常見 delivery semantics：&lt;/p>
&lt;ol>
&lt;li>at-most-once：可能丟失，不重送，低延遲低成本。&lt;/li>
&lt;li>at-least-once：可能重複，需冪等保護，最常見實務語意。&lt;/li>
&lt;li>exactly-once：語意成本高，通常在特定邊界內成立，需要嚴格協議與系統支持。&lt;/li>
&lt;/ol>
&lt;p>實務上多數後端系統採 at-least-once，再用 consumer 去重與補償達到業務可接受結果。&lt;/p>
&lt;h2 id="ack--nack-流程">ack / nack 流程&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">ack/nack&lt;/a> 是 delivery 控制點。ack 代表該訊息可從待處理集合移除；nack 代表稍後重試或分流。ack 時機過早會造成資料遺失，過晚會造成重複處理與堆積。&lt;/p>
&lt;p>穩定流程是：完成核心副作用後再 ack，暫時故障走受控重試，持續故障走 DLQ 隔離。&lt;/p></description><content:encoded><![CDATA[<p>這一章先建立訊息佇列的基本模型，後面的 <a href="/blog/backend/knowledge-cards/durable-queue/" data-link-title="Durable Queue" data-link-desc="說明可持久化的 queue 如何在重啟與失敗後保留待處理工作">durable queue</a>、outbox 與 <a href="/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer</a> 設計都會建立在這些語意上。</p>
<p>訊息代理（broker）的核心責任是解耦 producer 與 consumer，讓非同步工作具備可排隊、可重試、可隔離的傳遞路徑。它定位在傳遞與協調層。</p>
<h2 id="broker-跟-protocol-是兩個獨立的軸">broker 跟 protocol 是兩個獨立的軸</h2>
<p>Broker 是訊息分發的具體實作產品（RabbitMQ、Kafka、NATS、EMQX）、protocol 是訊息交換的線路規格（AMQP、MQTT、STOMP、Kafka wire protocol）。兩個軸獨立、形成多對多關係：</p>
<ul>
<li>一個 broker 可實作多個 protocol：RabbitMQ 主走 AMQP、透過 plugin 也支援 MQTT 跟 STOMP；NATS 主走自家 protocol、JetStream 額外提供 KV 與 Object Store API</li>
<li>一個 protocol 可被多個 broker 實作：MQTT 由 EMQX / HiveMQ / Mosquitto / RabbitMQ MQTT plugin 各自實作；AMQP 主要是 RabbitMQ 跟 Apache Qpid</li>
</ul>
<p>選型討論時要分清「我需要的是 protocol（如 device 端要 MQTT 因為輕量 / IoT 標準）」還是「broker 產品（如 RabbitMQ vs EMQX 的運維 / 生態取捨）」。當 protocol 跟 broker 都需要、會出現 protocol 橋接場景 — 例：device 端透過 MQTT 連 RabbitMQ MQTT plugin、broker 內部把 MQTT topic 自動映射成 AMQP routing key、AMQP-side consumer 用 routing key 訂閱。</p>
<p>這層分離也影響故障判讀：device 連不上是 protocol 層問題、broker 之間 routing 錯是 broker 內部 plugin / mapping 問題、consumer 收不到是 AMQP binding 問題 — 三層各自獨立、不能混為一談。</p>
<h2 id="brokerqueueconsumer-的分工">broker、queue、consumer 的分工</h2>
<p><a href="/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker</a> 管理訊息儲存、分發與確認流程；queue 或 topic 承載傳遞單位；consumer 承擔業務處理。分工清楚後，故障判讀才能定位在正確層級：投遞故障、消費故障或下游依賴故障。</p>
<p>producer 發送成功只代表 broker 已接收（<a href="/blog/backend/knowledge-cards/publisher-confirm/" data-link-title="Publisher Confirm" data-link-desc="說明 producer 如何確認 broker 已接收並承擔訊息">publisher confirm</a>），不代表業務結果完成。業務完成需要 consumer 提交副作用並確認進度。</p>
<h2 id="push-與-pull-模型">push 與 pull 模型</h2>
<p>push 模型由 broker 主動推送訊息，適合低延遲場景；pull 模型由 consumer 主動拉取，適合吞吐控制與批次處理。實務上常結合使用：broker 管理可見性與重試，consumer 控制節流與併發。</p>
<p>模型選擇重點是背壓控制。當下游變慢時，系統是否能限制消費速率並保留恢復空間，是穩定性的關鍵。</p>
<h2 id="傳遞語意delivery-semantics">傳遞語意（delivery semantics）</h2>
<p>三種常見 delivery semantics：</p>
<ol>
<li>at-most-once：可能丟失，不重送，低延遲低成本。</li>
<li>at-least-once：可能重複，需冪等保護，最常見實務語意。</li>
<li>exactly-once：語意成本高，通常在特定邊界內成立，需要嚴格協議與系統支持。</li>
</ol>
<p>實務上多數後端系統採 at-least-once，再用 consumer 去重與補償達到業務可接受結果。</p>
<h2 id="ack--nack-流程">ack / nack 流程</h2>
<p><a href="/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">ack/nack</a> 是 delivery 控制點。ack 代表該訊息可從待處理集合移除；nack 代表稍後重試或分流。ack 時機過早會造成資料遺失，過晚會造成重複處理與堆積。</p>
<p>穩定流程是：完成核心副作用後再 ack，暫時故障走受控重試，持續故障走 DLQ 隔離。</p>
<h2 id="語意保證的不同實作機制">語意保證的不同實作機制</h2>
<p>同一層 <a href="/blog/backend/knowledge-cards/delivery-semantics/" data-link-title="Delivery Semantics" data-link-desc="說明事件投遞語意如何定義遺失、重複、順序與補償策略">delivery semantics</a>、不同 broker 用不同協議機制達成。讀懂 broker 行為的關鍵、是辨認「at-least-once」這個語意承諾、底下是哪種具體機制負責 — 故障訊號跟操作旋鈕跟著不同。</p>
<p>三種常見實作機制：</p>
<table>
  <thead>
      <tr>
          <th>機制</th>
          <th>代表 broker</th>
          <th>達成方式</th>
          <th>主要操作旋鈕</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>QoS handshake</td>
          <td>MQTT 系列</td>
          <td>client 與 broker 之間的多次握手（QoS 0 / 1 / 2）</td>
          <td>QoS 等級、session persistence、retained message</td>
      </tr>
      <tr>
          <td>Broker ACK + retry</td>
          <td>RabbitMQ、SQS、NATS</td>
          <td>consumer 處理後回 ack、未 ack 由 broker 重新投遞</td>
          <td>ack / visibility timeout、prefetch、DLQ</td>
      </tr>
      <tr>
          <td>Replication + commit</td>
          <td>Kafka、Pulsar</td>
          <td>producer 寫入後等待 replica commit、consumer 用 offset</td>
          <td>acks 等級（0 / 1 / all）、min.insync.replicas、ISR</td>
      </tr>
  </tbody>
</table>
<p>三個機制的工程含義不同。QoS handshake 把可靠性責任拉到 wire protocol 層、適合 device-to-broker 場景但 broker-to-consumer 還要另外處理；broker ACK 把責任放在 consumer 處理完才確認、適合「處理即承諾」的任務隊列；replication 把責任放在訊息已被多份保存、適合「寫入即承諾」的事件流。</p>
<h3 id="機制差異的故障訊號">機制差異的故障訊號</h3>
<p>機制決定故障表現。同樣是「訊息重複投遞」、不同機制要看不同訊號：</p>
<ul>
<li>QoS handshake：QoS 1 重傳是設計、QoS 2 重傳代表握手失敗 — 看 broker 端的 PUBREL / PUBCOMP 完成率</li>
<li>Broker ACK：ack timeout 觸發 <a href="/blog/backend/knowledge-cards/redelivery/" data-link-title="Redelivery" data-link-desc="說明 broker 重新投遞訊息時 consumer 需要承擔的重入責任">redelivery</a> 是設計、頻繁 redelivery 代表 consumer 處理慢或下游卡 — 看 consumer 處理時間 vs ack timeout、視訊號為 <a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a></li>
<li>Replication：producer retry 觸發 duplicate 是設計、ISR shrink 代表 broker 副本不穩 — 看 ISR 狀態 vs producer acks 設定</li>
</ul>
<h3 id="機制差異的操作旋鈕">機制差異的操作旋鈕</h3>
<p>挑 broker 等同於挑「可調的旋鈕集合」。把「業務需要的語意」轉成「實際要調的旋鈕」、是 broker 選型落地的關鍵步驟：</p>
<ul>
<li>想保證「不丟」用 MQTT：QoS 等級提到 2、開 session persistence</li>
<li>想保證「不丟」用 RabbitMQ：consumer 走 manual ack、配 <a href="/blog/backend/knowledge-cards/dead-letter-queue/" data-link-title="Dead-Letter Queue" data-link-desc="說明 dead-letter queue 如何隔離多次處理失敗的訊息">DLQ</a>、設 prefetch 限併發</li>
<li>想保證「不丟」用 Kafka：producer acks=all、min.insync.replicas ≥ 2、consumer commit-after-process</li>
</ul>
<p>機制不同、可調旋鈕不同、operator 要熟悉的訊號也不同。這是「broker 系統複雜度」的真實來源 — 不是「broker 難安裝」、而是「broker 旋鈕集合的學習與調校曲線」。</p>
<h2 id="判讀訊號">判讀訊號</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>判讀重點</th>
          <th>對應動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>producer 發送成功但業務結果缺漏</td>
          <td>投遞成功與處理成功語意混淆</td>
          <td>補 consumer 確認與結果對帳</td>
      </tr>
      <tr>
          <td>queue depth 穩定但延遲持續上升</td>
          <td>消費速率不足或重試佔用主通道</td>
          <td>分離重試隊列、調整併發與節流</td>
      </tr>
      <tr>
          <td>ack 成功率高但 duplicate 增加</td>
          <td>ack 時機與副作用提交順序不對齊</td>
          <td>延後 ack、補 idempotency</td>
      </tr>
      <tr>
          <td>nack 事件集中在同類訊息</td>
          <td>payload 或下游契約失配</td>
          <td>分流到 DLQ、修復契約後定向重播</td>
      </tr>
      <tr>
          <td>消費重啟後堆積迅速擴大</td>
          <td>背壓與可見性控制不足</td>
          <td>限制拉取窗口、調整重試間隔</td>
      </tr>
  </tbody>
</table>
<h2 id="常見誤區">常見誤區</h2>
<p>把 broker 當成保證業務正確性的元件，會把消費責任與補償責任遺漏。broker 保證傳遞語意，業務正確性要由 consumer 設計承擔。</p>
<p>把 exactly-once 當成預設目標，也容易過度設計。先定義可接受失敗代價，再選擇對應語意，通常更符合實務。</p>
<h2 id="broker-規模化的角色變化">Broker 規模化的角色變化</h2>
<p>Broker 在規模化服務承擔的責任從「單隊列工具」轉到「平台治理問題」— 容量規劃焦點從擴 broker 變成多租戶隔離、配額管理、跨團隊觀測標準化。</p>
<p>對應 <a href="/blog/backend/03-message-queue/cases/uber-kafka-infrastructure-evolution/" data-link-title="3.C6 Uber：Kafka 事件平台演進" data-link-desc="事件平台從團隊自管走向多租戶共享基礎設施。">3.C6 Uber Kafka Infrastructure Evolution</a> — Uber 事件平台服務眾多團隊、focus 從 broker 容量是否充足轉到 team 之間的隔離邊界。對應 <a href="/blog/backend/03-message-queue/cases/linkedin-kafka-tiered-clusters/" data-link-title="3.C4 LinkedIn：Kafka 分層叢集治理" data-link-desc="Kafka 從單叢集走向 tiered clusters 的轉換案例。">3.C4 LinkedIn Tiered Clusters</a> — 規模化必然分層 cluster、按業務特性跟可靠性需求分配不同叢集、高優先 workload 跟低優先 workload 各自獨立。</p>
<p><strong>規模化的三個角色階段</strong>（依據 3.C6 / 3.C4 / 早期服務對照、整理出三個典型階段）：</p>
<ul>
<li><strong>單隊列工具</strong>（規模尚小階段）：一個 Kafka cluster、所有 service 共用、broker 擴容是主要工作、團隊各自管理自己的 topic</li>
<li><strong>多租戶平台</strong>（中大型階段）：跨團隊共用 cluster、平台 team 設定 quota、topic 命名規範、容量配額、觀測標準。3.C6 描述 Uber 在這階段「標準化 topic 治理與故障處理流程」、把跨團隊運維責任收斂到平台層</li>
<li><strong>分層治理平台</strong>（規模化階段）：不同業務特性走不同 cluster（critical / standard / experimental）、跨 cluster 路由跟治理變主要工作。3.C4 描述 LinkedIn「依流量與可靠性需求分層」、高優先 workload 提供獨立保護</li>
</ul>
<p>判讀含義：當 broker incident 影響多個 team 不相關業務、屬於該分層的訊號。規模化後焦點要轉向跨 team 隔離跟跨 cluster 治理、單純擴 broker 處理不了多租戶共擠的結構性問題。攻擊面跟控制面見 <a href="/blog/backend/03-message-queue/red-team-delivery-layer/" data-link-title="3.5 攻擊者視角（紅隊）：傳遞層弱點判讀" data-link-desc="從重複投遞、重放濫用、毒訊息與容量壓力，盤點 message delivery 的主要弱點">3.5 紅隊章 Multi-tenant broker 隔離邊界</a>。</p>
<h2 id="queue-變跨區關鍵路徑的特殊挑戰">Queue 變跨區關鍵路徑的特殊挑戰</h2>
<p>當 queue 變成跨區關鍵路徑（payment、order、notification 都靠它）、容量規劃焦點從 throughput 變成 <em>discoverability</em> 跟 <em>routing freshness</em>。</p>
<p>對應 <a href="/blog/backend/03-message-queue/cases/meta-foqs-global-migration/" data-link-title="3.C1 Meta：FOQS 從區域到全域佇列遷移" data-link-desc="佇列架構如何在不中斷下升級成 disaster-ready 模式。">3.C1 Meta FOQS</a> — FOQS 從區域升級到全域、目標是讓災害期間 queue 仍可被存取、控制遷移期間的延遲跟可用性風險。Focus 從 queue 吞吐量轉到災害時的 broker 可達性、routing 狀態新鮮度、tenant 遷移節奏。</p>
<p><strong>跨區 queue 的設計挑戰</strong>：</p>
<ul>
<li><strong>Discoverability</strong>：client 在 region failover 後需透過 service discovery + DNS / health check 動態解析 broker endpoint、找到新 primary broker</li>
<li><strong>Routing freshness</strong>：broker topology 變更後、client 多久能拿到新 routing 表、stale routing 期間 message 流向錯 broker、要設定 routing TTL + 主動 refresh</li>
<li><strong>Tenant 遷移節奏</strong>：規模化跨區 queue 採分批 cutover、保留 client 連續性</li>
<li><strong>Stale routing 補貨延遲治理</strong>：routing 過時造成 message 累積在錯誤 broker、要設定 timeout + 重新發現機制、讓 client 重新發現新 broker 並切換到健康路徑</li>
</ul>
<h2 id="案例回寫">案例回寫</h2>
<p>投遞語意可用 <a href="/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9 反例</a> 做回寫。先判讀事件是 delivery 層失配，還是 processing/recovery 層失配，再回到本章檢查 ack 時機、重試節奏與隔離策略是否清楚。
這個案例主要支撐的是「語意分層與投遞責任」判讀，不直接支撐資料庫 schema 演進或 LB timeout；若問題在資料模型或連線生命週期，應轉到 1.2 或 5.3。</p>
<p>若投遞成功但業務結果缺漏，先補齊語意分層，再分別回寫 <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> 與 <a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計</a>。</p>
<h2 id="跨模組路由">跨模組路由</h2>
<ol>
<li>與 3.2 的交接：持久化與重試節奏回到 <a href="/blog/backend/03-message-queue/durable-queue/" data-link-title="3.2 durable queue 與重試策略" data-link-desc="整理持久化佇列、DLQ 與重試流程">durable queue 與重試策略</a>。</li>
<li>與 3.4 的交接：消費恢復與去重回到 <a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">consumer 設計與去重</a>。</li>
<li>與 4.20 的交接：投遞與消費訊號納入 <a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">Observability Evidence Package</a>。</li>
<li>與 6.12 的交接：重播與冪等驗證回到 <a href="/blog/backend/06-reliability/idempotency-replay/" data-link-title="6.12 Idempotency 與 Replay 驗證" data-link-desc="把重試、重播與冪等性從口頭約定變成可驗證屬性">Idempotency 與 Replay 驗證</a>。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<p>要進一步處理持久化與重試控制，接著讀 <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>。要處理交易與發佈一致性，接著讀 <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>。</p>
]]></content:encoded></item><item><title>3.C2 VMware Tanzu CloudHealth：Kafka 轉 Amazon MSK</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/vmware-kafka-to-msk/</link><pubDate>Thu, 07 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/vmware-kafka-to-msk/</guid><description>&lt;p>這個案例的核心責任是把 broker 遷移拆成平台責任、運維責任與資料責任三層。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>CloudHealth 由自管 Kafka 遷移到 Amazon MSK，過程涵蓋 topic、存取控制、觀測與遷移執行節奏。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>這類轉換的實際風險在 ACL、topic policy、client 相容性與 cutover 節奏，服務名稱本身反而是次要問題。&lt;/p>
&lt;h2 id="策略">策略&lt;/h2>
&lt;ol>
&lt;li>先建立新叢集治理基線（ACL、觀測、部署）。&lt;/li>
&lt;li>分批 topic 遷移並持續監測 lag/錯誤。&lt;/li>
&lt;li>把回退與流量切換條件寫成明確門檻。&lt;/li>
&lt;/ol>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/broker-basics/" data-link-title="3.1 broker 基礎與投遞模型" data-link-desc="先理解 broker、queue、consumer 與 delivery semantics">3.1 broker basics&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/06-reliability/release-gate/" data-link-title="6.8 Release Gate 與變更節奏" data-link-desc="把驗證、migration、相容性納入放行判準">6.8 release gate&lt;/a>。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://aws.amazon.com/blogs/big-data/how-vmware-tanzu-cloudhealth-migrated-from-self-managed-kafka-to-amazon-msk/">VMware CloudHealth Kafka to MSK&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是把 broker 遷移拆成平台責任、運維責任與資料責任三層。</p>
<h2 id="觀察">觀察</h2>
<p>CloudHealth 由自管 Kafka 遷移到 Amazon MSK，過程涵蓋 topic、存取控制、觀測與遷移執行節奏。</p>
<h2 id="判讀">判讀</h2>
<p>這類轉換的實際風險在 ACL、topic policy、client 相容性與 cutover 節奏，服務名稱本身反而是次要問題。</p>
<h2 id="策略">策略</h2>
<ol>
<li>先建立新叢集治理基線（ACL、觀測、部署）。</li>
<li>分批 topic 遷移並持續監測 lag/錯誤。</li>
<li>把回退與流量切換條件寫成明確門檻。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/broker-basics/" data-link-title="3.1 broker 基礎與投遞模型" data-link-desc="先理解 broker、queue、consumer 與 delivery semantics">3.1 broker basics</a> 與 <a href="/blog/backend/06-reliability/release-gate/" data-link-title="6.8 Release Gate 與變更節奏" data-link-desc="把驗證、migration、相容性納入放行判準">6.8 release gate</a>。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://aws.amazon.com/blogs/big-data/how-vmware-tanzu-cloudhealth-migrated-from-self-managed-kafka-to-amazon-msk/">VMware CloudHealth Kafka to MSK</a></li>
</ul>
]]></content:encoded></item><item><title>Apache Kafka</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/</link><pubDate>Fri, 01 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/</guid><description>&lt;p>Kafka 是 distributed event streaming platform、承擔三個責任：log-based 訊息儲存（partition + replication）、事件流分發（consumer group 各自進度）、跨系統事件總線（schema-aware contract）。設計取捨偏向「寫入即承諾、可長期保留、多 consumer 各自 replay」、broker 級可靠性與 consumer 端 idempotency 拆開、broker 不負責業務正確性。&lt;/p>
&lt;p>對「事件驅動架構、CDC、跨系統事件分發、長期保留 + replay」這條路徑、Kafka 是業界事實標準。本頁先給最短路徑、再展開日常 producer / consumer 操作與 topic 設計、最後進階治理（多租戶、跨區、自動修復）跟排錯。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>讀完本章後、你應該能：&lt;/p>
&lt;ol>
&lt;li>用 docker-compose 跑起 Kafka + KRaft、驗證 broker 健康&lt;/li>
&lt;li>用 CLI 建 topic、produce / consume 訊息、看 partition 分布&lt;/li>
&lt;li>設計 producer acks / idempotence / consumer commit 策略對齊 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/delivery-semantics/" data-link-title="Delivery Semantics" data-link-desc="說明事件投遞語意如何定義遺失、重複、順序與補償策略">delivery semantics&lt;/a>&lt;/li>
&lt;li>看懂 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer-lag/" data-link-title="Consumer Lag" data-link-desc="說明 consumer lag 如何反映訊息堆積、處理能力與容量風險">consumer lag&lt;/a>、ISR shrink、rebalance 訊號、定位故障層&lt;/li>
&lt;li>評估 multi-tenant、cross-region、tiered storage、self-healing 等規模化議題&lt;/li>
&lt;/ol>
&lt;h2 id="最短路徑5-分鐘把-kafka-跑起來">最短路徑：5 分鐘把 Kafka 跑起來&lt;/h2>
&lt;p>最短路徑用 KRaft 模式（取代 ZooKeeper、單節點即可跑）、避免初學者卡在 ZK 安裝。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 1. 啟動 Kafka（apache/kafka 內建 KRaft、單一容器即含 broker + controller）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">docker run -d --name kafka -p 9092:9092 apache/kafka:latest
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="c1"># 2. 建 topic（CLI 在容器內 /opt/kafka/bin/）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">docker &lt;span class="nb">exec&lt;/span> kafka /opt/kafka/bin/kafka-topics.sh --create --topic demo --partitions &lt;span class="m">3&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --bootstrap-server localhost:9092
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">docker &lt;span class="nb">exec&lt;/span> kafka /opt/kafka/bin/kafka-topics.sh --describe --topic demo &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --bootstrap-server localhost:9092
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="c1"># 3. 驗證 produce / consume&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">docker &lt;span class="nb">exec&lt;/span> kafka bash -c &lt;span class="s2">&amp;#34;echo hello | /opt/kafka/bin/kafka-console-producer.sh \
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="s2"> --topic demo --bootstrap-server localhost:9092&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">docker &lt;span class="nb">exec&lt;/span> kafka /opt/kafka/bin/kafka-console-consumer.sh --topic demo &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --from-beginning --max-messages &lt;span class="m">1&lt;/span> --bootstrap-server localhost:9092&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>最短路徑只驗證「broker 起來、能寫能讀」。實際寫程式用 producer / consumer client、見&lt;a href="#%e6%97%a5%e5%b8%b8%e6%93%8d%e4%bd%9c%e8%88%87%e6%b1%ba%e7%ad%96%e5%bd%a2%e7%8b%80">日常操作&lt;/a>。&lt;/p>
&lt;h2 id="日常操作與決策形狀">日常操作與決策形狀&lt;/h2>
&lt;h3 id="cli-與-client-api">CLI 與 client API&lt;/h3>
&lt;p>子議題：&lt;/p>
&lt;ul>
&lt;li>CLI 指令對照表（kafka-topics / kafka-configs / kafka-consumer-groups / kafka-acls）&lt;/li>
&lt;li>Producer client 配置：acks / batch.size / linger.ms / compression / enable.idempotence&lt;/li>
&lt;li>Consumer client 配置：auto.offset.reset / enable.auto.commit / max.poll.records / max.poll.interval.ms&lt;/li>
&lt;li>對應指令範例：&lt;code>kafka-topics.sh --describe&lt;/code>、&lt;code>kafka-consumer-groups.sh --describe --group &amp;lt;id&amp;gt;&lt;/code>&lt;/li>
&lt;/ul>
&lt;h3 id="topic-設計">Topic 設計&lt;/h3>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">Topic&lt;/a> 承擔事件的邏輯邊界。子議題：&lt;/p></description><content:encoded><![CDATA[<p>Kafka 是 distributed event streaming platform、承擔三個責任：log-based 訊息儲存（partition + replication）、事件流分發（consumer group 各自進度）、跨系統事件總線（schema-aware contract）。設計取捨偏向「寫入即承諾、可長期保留、多 consumer 各自 replay」、broker 級可靠性與 consumer 端 idempotency 拆開、broker 不負責業務正確性。</p>
<p>對「事件驅動架構、CDC、跨系統事件分發、長期保留 + replay」這條路徑、Kafka 是業界事實標準。本頁先給最短路徑、再展開日常 producer / consumer 操作與 topic 設計、最後進階治理（多租戶、跨區、自動修復）跟排錯。</p>
<h2 id="本章目標">本章目標</h2>
<p>讀完本章後、你應該能：</p>
<ol>
<li>用 docker-compose 跑起 Kafka + KRaft、驗證 broker 健康</li>
<li>用 CLI 建 topic、produce / consume 訊息、看 partition 分布</li>
<li>設計 producer acks / idempotence / consumer commit 策略對齊 <a href="/blog/backend/knowledge-cards/delivery-semantics/" data-link-title="Delivery Semantics" data-link-desc="說明事件投遞語意如何定義遺失、重複、順序與補償策略">delivery semantics</a></li>
<li>看懂 <a href="/blog/backend/knowledge-cards/consumer-lag/" data-link-title="Consumer Lag" data-link-desc="說明 consumer lag 如何反映訊息堆積、處理能力與容量風險">consumer lag</a>、ISR shrink、rebalance 訊號、定位故障層</li>
<li>評估 multi-tenant、cross-region、tiered storage、self-healing 等規模化議題</li>
</ol>
<h2 id="最短路徑5-分鐘把-kafka-跑起來">最短路徑：5 分鐘把 Kafka 跑起來</h2>
<p>最短路徑用 KRaft 模式（取代 ZooKeeper、單節點即可跑）、避免初學者卡在 ZK 安裝。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 1. 啟動 Kafka（apache/kafka 內建 KRaft、單一容器即含 broker + controller）</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">docker run -d --name kafka -p 9092:9092 apache/kafka:latest
</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"># 2. 建 topic（CLI 在容器內 /opt/kafka/bin/）</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">docker <span class="nb">exec</span> kafka /opt/kafka/bin/kafka-topics.sh --create --topic demo --partitions <span class="m">3</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="se"></span>  --bootstrap-server localhost:9092
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">docker <span class="nb">exec</span> kafka /opt/kafka/bin/kafka-topics.sh --describe --topic demo <span class="se">\
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="se"></span>  --bootstrap-server localhost:9092
</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="c1"># 3. 驗證 produce / consume</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">docker <span class="nb">exec</span> kafka bash -c <span class="s2">&#34;echo hello | /opt/kafka/bin/kafka-console-producer.sh \
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="s2">  --topic demo --bootstrap-server localhost:9092&#34;</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">docker <span class="nb">exec</span> kafka /opt/kafka/bin/kafka-console-consumer.sh --topic demo <span class="se">\
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="se"></span>  --from-beginning --max-messages <span class="m">1</span> --bootstrap-server localhost:9092</span></span></code></pre></div><p>最短路徑只驗證「broker 起來、能寫能讀」。實際寫程式用 producer / consumer client、見<a href="#%e6%97%a5%e5%b8%b8%e6%93%8d%e4%bd%9c%e8%88%87%e6%b1%ba%e7%ad%96%e5%bd%a2%e7%8b%80">日常操作</a>。</p>
<h2 id="日常操作與決策形狀">日常操作與決策形狀</h2>
<h3 id="cli-與-client-api">CLI 與 client API</h3>
<p>子議題：</p>
<ul>
<li>CLI 指令對照表（kafka-topics / kafka-configs / kafka-consumer-groups / kafka-acls）</li>
<li>Producer client 配置：acks / batch.size / linger.ms / compression / enable.idempotence</li>
<li>Consumer client 配置：auto.offset.reset / enable.auto.commit / max.poll.records / max.poll.interval.ms</li>
<li>對應指令範例：<code>kafka-topics.sh --describe</code>、<code>kafka-consumer-groups.sh --describe --group &lt;id&gt;</code></li>
</ul>
<h3 id="topic-設計">Topic 設計</h3>
<p><a href="/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">Topic</a> 承擔事件的邏輯邊界。子議題：</p>
<ul>
<li><a href="/blog/backend/knowledge-cards/partition/" data-link-title="Partition" data-link-desc="說明事件流如何切分成多個可並行處理的有序片段">Partition</a> 數規劃（並行度 vs metadata 成本）</li>
<li>Replication factor 與 min.insync.replicas（資料保護等級）</li>
<li>Retention policy（time-based vs size-based、compact vs delete）</li>
<li>Key 策略（ordering 範圍、hot partition 避免）</li>
</ul>
<h3 id="producer-與-consumer-設計">Producer 與 Consumer 設計</h3>
<p>設計決定 <a href="/blog/backend/knowledge-cards/delivery-semantics/" data-link-title="Delivery Semantics" data-link-desc="說明事件投遞語意如何定義遺失、重複、順序與補償策略">delivery semantics</a> 實際達成。子議題：</p>
<ul>
<li>Producer：acks=0/1/all 對應的可靠性取捨、idempotence、transaction 邊界</li>
<li>Consumer：commit 策略（auto vs manual）、commit 時機與 at-least-once / at-most-once 對應</li>
<li><a href="/blog/backend/knowledge-cards/consumer-group/" data-link-title="Consumer Group" data-link-desc="說明一組 consumer 如何共同分攤 stream 或 topic 的處理責任">Consumer group</a>：rebalance protocol（eager vs cooperative）、static membership</li>
<li>對應指令：producer 配置範例、consumer 配置範例、<code>kafka-consumer-groups.sh --describe</code></li>
</ul>
<h2 id="進階主題按需閱讀">進階主題（按需閱讀）</h2>
<p>本段主題多數已展開為 deep article：<a href="consumer-rebalance-lag-diagnosis/">consumer rebalance 與 lag 診斷</a>、<a href="replication-isr-exactly-once/">replication / ISR / exactly-once</a>、<a href="retention-tiered-storage/">retention 與 tiered storage</a>、<a href="schema-registry-evolution/">Schema Registry 與 schema 演進</a>、<a href="multi-tenant-quota-acl/">multi-tenant quota 與 ACL 治理</a>。下列子議題段保留每個主題的選題判讀入口。</p>
<h3 id="multi-tenant-與配額治理">Multi-tenant 與配額治理</h3>
<p>對應案例 <a href="/blog/backend/03-message-queue/cases/uber-kafka-infrastructure-evolution/" data-link-title="3.C6 Uber：Kafka 事件平台演進" data-link-desc="事件平台從團隊自管走向多租戶共享基礎設施。">3.C6 Uber Kafka 事件平台</a>。子議題：</p>
<ul>
<li>Producer / Consumer quota（byte rate、request rate）</li>
<li>ACL 設計（principal、resource、operation）</li>
<li>Topic 命名規範與 ownership</li>
<li>對應指令：<code>kafka-configs.sh --alter --add-config 'producer_byte_rate=...'</code>、<code>kafka-acls.sh --add</code></li>
</ul>
<h3 id="cross-region-與分層叢集">Cross-region 與分層叢集</h3>
<p>對應案例 <a href="/blog/backend/03-message-queue/cases/meta-foqs-global-migration/" data-link-title="3.C1 Meta：FOQS 從區域到全域佇列遷移" data-link-desc="佇列架構如何在不中斷下升級成 disaster-ready 模式。">3.C1 Meta FOQS</a> 與 <a href="/blog/backend/03-message-queue/cases/linkedin-kafka-tiered-clusters/" data-link-title="3.C4 LinkedIn：Kafka 分層叢集治理" data-link-desc="Kafka 從單叢集走向 tiered clusters 的轉換案例。">3.C4 LinkedIn Tiered Clusters</a>。子議題：</p>
<ul>
<li>MirrorMaker 2 配置（active-active vs active-passive）</li>
<li>分層叢集策略（critical / standard / experimental）</li>
<li>跨區 consumer 路徑與 routing freshness</li>
</ul>
<h3 id="topic-生命週期治理">Topic 生命週期治理</h3>
<p>對應案例 <a href="/blog/backend/03-message-queue/cases/linkedin-topicgc-kafka-governance/" data-link-title="3.C3 LinkedIn：TopicGC 與 Kafka 治理轉換" data-link-desc="Kafka topic 從手動治理轉自動治理對叢集的影響。">3.C3 LinkedIn TopicGC</a>。子議題：</p>
<ul>
<li>Topic 活躍判準（last produce / consume timestamp）</li>
<li>自動回收條件與稽核</li>
<li>Metadata 壓力訊號（controller log、partition 數量上限）</li>
</ul>
<h3 id="replication-與-exactly-once-升級">Replication 與 exactly-once 升級</h3>
<p>對應案例 <a href="/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9 反例：語義誤配</a>。子議題：</p>
<ul>
<li>acks=all + min.insync.replicas ≥ 2 + producer idempotence</li>
<li>Kafka transaction 與 read_committed 邊界</li>
<li>端到端 exactly-once（Kafka Streams 場景）</li>
</ul>
<h3 id="self-healing-與自動修復">Self-healing 與自動修復</h3>
<p>對應案例 <a href="/blog/backend/03-message-queue/cases/linkedin-kafka-self-healing-automation/" data-link-title="3.C7 LinkedIn：Kafka 自動修復治理" data-link-desc="Kafka 維運從人工處置轉向自動修復的案例。">3.C7 LinkedIn Self-Healing</a>。子議題：</p>
<ul>
<li>可自動修復故障類型（disk full、broker offline、under-replicated partition）</li>
<li>自動修復 vs 人工升級邊界</li>
<li>修復過程的證據鏈納入觀測</li>
</ul>
<h3 id="kraft-與-schema-registry">KRaft 與 Schema Registry</h3>
<p>子議題：</p>
<ul>
<li>KRaft mode 取代 ZooKeeper（運維簡化、metadata 治理）</li>
<li>Schema Registry（Confluent / Apicurio）與 Avro / Protobuf</li>
<li>Schema 演進策略（forward / backward / full compatibility）</li>
</ul>
<h3 id="tiered-storage">Tiered storage</h3>
<p>子議題：</p>
<ul>
<li>冷熱分層（hot tier on local disk、cold tier on S3）</li>
<li>Retention 設計與成本</li>
<li>Read 路徑差異（hot vs cold）</li>
</ul>
<h3 id="kafka-connect-與-cdc">Kafka Connect 與 CDC</h3>
<p>子議題：</p>
<ul>
<li>Source connector / Sink connector 模型</li>
<li>Debezium CDC pipeline 與 outbox 整合</li>
<li>Connect cluster 治理與 schema evolution</li>
</ul>
<h2 id="排錯快速判讀">排錯快速判讀</h2>
<h3 id="consumer-lag-暴增">Consumer lag 暴增</h3>
<p>操作原則：先看 lag 是「均勻分布」還是「集中在少數 partition」、再定位 consumer 慢 vs partition 不平衡。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">kafka-consumer-groups.sh --describe --group &lt;id&gt; --bootstrap-server localhost:9092
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># 輸出含 CURRENT-OFFSET / LOG-END-OFFSET / LAG 逐 partition 列、可看 lag 集中在哪幾個 partition</span></span></span></code></pre></div><p>判讀路徑：consumer 慢（CPU / GC / 下游 I/O）→ producer 突增 → partition 不平衡（key 分布）。</p>
<h3 id="isr-shrink-與-under-replicated-partition">ISR shrink 與 under-replicated partition</h3>
<p>操作原則：ISR 縮小代表 follower 跟不上 leader、看 broker 健康 / 網路 / disk。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">kafka-topics.sh --describe --under-replicated-partitions --bootstrap-server localhost:9092
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># 輸出為空代表所有 partition 同步正常；列出的 partition 即 ISR 落後者</span></span></span></code></pre></div><h3 id="rebalance-storm">Rebalance storm</h3>
<p>操作原則：consumer 頻繁加入 / 離開觸發 rebalance、看 session.timeout.ms 與 max.poll.interval.ms。</p>
<h3 id="offset-reset-或重複消費">Offset reset 或重複消費</h3>
<p>對應反例 <a href="/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9</a>。判讀路徑：commit 策略錯誤、broker 端 offset 過期、auto.offset.reset = earliest。</p>
<h3 id="schema-不相容">Schema 不相容</h3>
<p>操作原則：producer 升級 schema、consumer 未升、看 compatibility level。</p>
<h2 id="何時改走其他服務">何時改走其他服務</h2>
<table>
  <thead>
      <tr>
          <th>需求形狀</th>
          <th>改走</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>任務隊列（中等吞吐、複雜 routing）</td>
          <td><a href="/blog/backend/03-message-queue/vendors/rabbitmq/" data-link-title="RabbitMQ" data-link-desc="Classic message broker、AMQP routing 為主">RabbitMQ</a></td>
      </tr>
      <tr>
          <td>Managed queue（AWS 生態、簡單）</td>
          <td><a href="/blog/backend/03-message-queue/vendors/aws-sqs/" data-link-title="AWS SQS" data-link-desc="AWS managed queue、簡單可靠、無 ordering（standard）">AWS SQS</a></td>
      </tr>
      <tr>
          <td>Managed pub/sub（GCP 生態）</td>
          <td><a href="/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Google Pub/Sub</a>（遷移路徑見 <a href="/blog/backend/03-message-queue/vendors/google-pubsub/migrate-from-kafka/" data-link-title="Kafka → Google Cloud Pub/Sub：從 partition 到 topic-subscription 的模型轉換" data-link-desc="從 Apache Kafka 遷移到 Google Cloud Pub/Sub，處理 partition → topic 模型轉換、ordering 語意差異、consumer group → subscription 對應、offset → ack deadline 切換的階段化流程">Kafka → Pub/Sub</a>）</td>
      </tr>
      <tr>
          <td>輕量 messaging + 微服務通訊</td>
          <td><a href="/blog/backend/03-message-queue/vendors/nats/" data-link-title="NATS" data-link-desc="Lightweight messaging、JetStream 加持久化與 streams">NATS</a></td>
      </tr>
      <tr>
          <td>Redis 生態內 stream</td>
          <td><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></td>
      </tr>
      <tr>
          <td>Managed Kafka</td>
          <td>AWS MSK / Confluent Cloud（見 <a href="/blog/backend/03-message-queue/cases/vmware-kafka-to-msk/" data-link-title="3.C2 VMware Tanzu CloudHealth：Kafka 轉 Amazon MSK" data-link-desc="自管 Kafka 遷移到託管平台時的治理重點。">3.C2</a>）</td>
      </tr>
      <tr>
          <td>Kafka 相容、單 binary</td>
          <td>Redpanda（T2 候選）</td>
      </tr>
      <tr>
          <td>多租戶 + 分層儲存原生</td>
          <td>Apache Pulsar（T2 候選）</td>
      </tr>
  </tbody>
</table>
<h2 id="不在本頁內的主題">不在本頁內的主題</h2>
<ul>
<li>各語言 client API reference（依官方文件）</li>
<li>Kafka Streams / ksqlDB（另開 stream processing 章節）</li>
<li>Confluent 商業功能（Confluent Cloud、Control Center）</li>
</ul>
<h2 id="案例回寫">案例回寫</h2>
<h3 id="既有通用案例c1-c10">既有通用案例（C1-C10）</h3>
<table>
  <thead>
      <tr>
          <th>案例</th>
          <th>主討論議題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/meta-foqs-global-migration/" data-link-title="3.C1 Meta：FOQS 從區域到全域佇列遷移" data-link-desc="佇列架構如何在不中斷下升級成 disaster-ready 模式。">3.C1 Meta FOQS</a></td>
          <td>跨區 queue、tenant 遷移節奏</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/vmware-kafka-to-msk/" data-link-title="3.C2 VMware Tanzu CloudHealth：Kafka 轉 Amazon MSK" data-link-desc="自管 Kafka 遷移到託管平台時的治理重點。">3.C2 VMware → MSK</a></td>
          <td>自管轉 managed、ACL / cutover</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/linkedin-topicgc-kafka-governance/" data-link-title="3.C3 LinkedIn：TopicGC 與 Kafka 治理轉換" data-link-desc="Kafka topic 從手動治理轉自動治理對叢集的影響。">3.C3 LinkedIn TopicGC</a></td>
          <td>Topic 生命週期治理</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/linkedin-kafka-tiered-clusters/" data-link-title="3.C4 LinkedIn：Kafka 分層叢集治理" data-link-desc="Kafka 從單叢集走向 tiered clusters 的轉換案例。">3.C4 LinkedIn Tiered Clusters</a></td>
          <td>分層叢集策略</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/slack-job-queue-kafka-redis/" data-link-title="3.C5 Slack：Job Queue 演進到 Kafka &#43; Redis" data-link-desc="背景工作通道在成長期如何從單一路徑演進成組合式架構。">3.C5 Slack Kafka+Redis</a></td>
          <td>多 broker 組合拓樸</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/uber-kafka-infrastructure-evolution/" data-link-title="3.C6 Uber：Kafka 事件平台演進" data-link-desc="事件平台從團隊自管走向多租戶共享基礎設施。">3.C6 Uber Kafka</a></td>
          <td>多租戶 + 平台治理</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/linkedin-kafka-self-healing-automation/" data-link-title="3.C7 LinkedIn：Kafka 自動修復治理" data-link-desc="Kafka 維運從人工處置轉向自動修復的案例。">3.C7 LinkedIn Self-Healing</a></td>
          <td>自動修復</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/cloudflare-queues-global-delivery-model/" data-link-title="3.C8 Cloudflare：Queues 全球交付模型" data-link-desc="事件佇列服務在全球網路下的交付語義與治理案例。">3.C8 Cloudflare Queues</a></td>
          <td>全球交付（對比）</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9 反例：語義誤配</a></td>
          <td>Replication + idempotence 升級</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/contrast-queue-model-by-scale/" data-link-title="3.C10 對照：規模差異下的佇列模型" data-link-desc="同一 queue 模型在不同規模下的治理與失敗邊界差異。">3.C10 規模對照</a></td>
          <td>不同規模下的佇列模型</td>
      </tr>
  </tbody>
</table>
<h3 id="kafka-專屬案例c11-c22">Kafka 專屬案例（C11-C22）</h3>
<table>
  <thead>
      <tr>
          <th>案例</th>
          <th>主討論議題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/kafka-pinterest-tiered-storage/" data-link-title="3.C11 Pinterest：Kafka tiered storage broker-decoupled" data-link-desc="Pinterest 採 broker-decoupled tiered storage、把 ~200 TB/day 熱資料卸到 S3、broker 不再是熱路徑。">3.C11 Pinterest Tiered Storage</a></td>
          <td>Broker-decoupled tiered storage / S3</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/kafka-pinterest-shallow-mirror/" data-link-title="3.C12 Pinterest：Shallow Mirror 優化 MirrorMaker" data-link-desc="Pinterest 跨 3 region MirrorMaker、原版解壓&#43;重壓造成 CPU/memory 2-10x spike、改 RecordBatch 層淺迭代。">3.C12 Pinterest Shallow Mirror</a></td>
          <td>MirrorMaker CPU/memory 優化</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/kafka-shopify-debezium-cdc/" data-link-title="3.C13 Shopify：Debezium CDC over sharded MySQL" data-link-desc="Shopify 100&#43; MySQL shard、150 Debezium connector、Black Friday 100K records/sec P99 &lt; 10s。">3.C13 Shopify Debezium CDC</a></td>
          <td>Sharded MySQL CDC pipeline</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/kafka-yelp-schematizer/" data-link-title="3.C14 Yelp：Schematizer 自建 Schema Registry" data-link-desc="Yelp data pipeline 強制所有 message 走 Avro、自建 Schematizer 做 schema evolution 與 topic 自動分配。">3.C14 Yelp Schematizer</a></td>
          <td>Schema Registry + 強制 compatibility</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/kafka-airbnb-spark-streaming-rebalance/" data-link-title="3.C15 Airbnb：Spark Streaming Kafka reader rebalance" data-link-desc="Airbnb logging pipeline 解 partition-task 1:1 造成的 data skew、catch-up 4 小時 lag 要再花 4 小時的反效率。">3.C15 Airbnb Spark Streaming</a></td>
          <td>Partition-task 解耦 / data skew</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/kafka-robinhood-faust-python-streaming/" data-link-title="3.C16 Robinhood：Faust Python stream processing" data-link-desc="Robinhood 每天 billions of events、Python 團隊不想用 JVM 生態、把 Kafka Streams 移植到 Python。">3.C16 Robinhood Faust</a></td>
          <td>Python stream processing 生態</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/kafka-walmart-mps-rebalance/" data-link-title="3.C17 Walmart：Messaging Proxy Service 解 rebalance storm" data-link-desc="Walmart 每天 trillions of message、25K&#43; consumer 在 K8s、partition-consumer 1:1 模型撞到擴張極限。">3.C17 Walmart MPS</a></td>
          <td>Partition-consumer 1:1 解耦 / K8s 擴張</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/kafka-wix-greyhound-troubleshooting/" data-link-title="3.C18 Wix：Greyhound TLLSR 解 consumer 卡住" data-link-desc="Wix 2000&#43; microservice 66B msg/day、自建 Greyhound 抽象、TLLSR 框架解 single-partition lag / poison pill / handler 卡住。">3.C18 Wix Greyhound</a></td>
          <td>TLLSR consumer troubleshooting</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/kafka-wix-multi-cluster-migration/" data-link-title="3.C19 Wix：Multi-cluster Kafka zero-downtime 遷移" data-link-desc="Wix metadata 從 5K topic 漲到 20K topic / 200K partition、controller startup 跟 broker stability 受壓垮、分多 cluster 解決。">3.C19 Wix Multi-cluster</a></td>
          <td>Metadata scaling ceiling / 分群</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/kafka-spotify-event-delivery-exodus/" data-link-title="3.C20 Spotify：Event Delivery 從 Kafka 遷出（反例）" data-link-desc="Spotify Kafka 0.7 MirrorMaker best-effort 會掉資料但回報成功、broker restart 後 producer 無法恢復、決定遷到 GCP Pub/Sub。">3.C20 Spotify 遷出 Kafka</a></td>
          <td>（反例）early Kafka 版本可靠性硬限制</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/kafka-goldman-sachs-msk-migration/" data-link-title="3.C21 Goldman Sachs：MSK 遷移 with MirrorMaker 2" data-link-desc="Goldman Sachs Global Investment Research 從 on-prem Kafka 遷到 MSK、用 MM2 同步 topic/ACL/offset、atomic cutover 7 小時完成。">3.C21 Goldman Sachs MSK</a></td>
          <td>MM2 + LB + timeout 整合 pitfall</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/kafka-trivago-keda-scale-to-zero/" data-link-title="3.C22 Trivago：KEDA scale-to-zero by Kafka lag" data-link-desc="Trivago 50&#43; Kafka sink、CPU/mem autoscaling 無效（I/O bottleneck）、KEDA 以 consumer lag 為訊號達到 scale-to-zero。">3.C22 Trivago KEDA</a></td>
          <td>Consumer lag 驅動 scale-to-zero</td>
      </tr>
  </tbody>
</table>
<p><strong>KRaft 缺直接 customer case</strong>：目前依官方 KIP-833 / Confluent 公告為準、後續若有 customer 一手案例可補。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>上游概念：<a href="/blog/backend/00-service-selection/async-delivery-selection/" data-link-title="0.3 非同步與事件傳遞選型" data-link-desc="區分背景工作、durable queue、stream、pub/sub 與 outbox 的選型邊界">0.3 非同步選型</a>、<a href="/blog/backend/03-message-queue/broker-basics/" data-link-title="3.1 broker 基礎與投遞模型" data-link-desc="先理解 broker、queue、consumer 與 delivery semantics">3.1 broker basics</a></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>下游能力：<a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計</a>、<a href="/blog/backend/06-reliability/idempotency-replay/" data-link-title="6.12 Idempotency 與 Replay 驗證" data-link-desc="把重試、重播與冪等性從口頭約定變成可驗證屬性">6.12 idempotency / replay</a></li>
</ul>
]]></content:encoded></item><item><title>3.2 durable queue 與重試策略</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/durable-queue/</link><pubDate>Thu, 23 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/durable-queue/</guid><description>&lt;p>持久化佇列（&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/durable-queue/" data-link-title="Durable Queue" data-link-desc="說明可持久化的 queue 如何在重啟與失敗後保留待處理工作">durable queue&lt;/a>）的核心責任是讓非同步工作在 process、節點或網路故障後仍可被恢復處理。它讓業務動作在失敗後仍有可追蹤、可重試、可隔離的路徑。&lt;/p>
&lt;h2 id="durable-與-ephemeral-的差異">durable 與 ephemeral 的差異&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> 在語意上可分 durable 與 ephemeral。ephemeral queue 側重低延遲與短暫協調，適合可丟失任務；durable queue 側重故障後可恢復，適合正式狀態相關副作用，例如付款通知、發票產生、庫存同步與合規事件記錄。&lt;/p>
&lt;p>這個選擇本質上是失敗代價選擇。若任務丟失可接受，ephemeral 可降低成本；若任務丟失會造成金流、合約或審計問題，durable 是必要基線。&lt;/p>
&lt;h2 id="重試策略">重試策略&lt;/h2>
&lt;p>重試策略的責任是把暫時性故障和系統性故障分開。durable queue 常見的重試組合是：有限次重試、指數退避、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/jitter/" data-link-title="Jitter" data-link-desc="說明重試或排程加入隨機偏移如何降低同步尖峰">jitter&lt;/a> 分散峰值、超過門檻後分流到 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/dead-letter-queue/" data-link-title="Dead-Letter Queue" data-link-desc="說明 dead-letter queue 如何隔離多次處理失敗的訊息">dead-letter queue&lt;/a>。&lt;/p>
&lt;p>重試上限與間隔要由下游承載能力決定。重試太快會形成故障放大，重試太慢會拖長恢復時間。穩定做法是把重試策略當成服務容量控制的一部分，而不是固定平台預設值。&lt;/p>
&lt;h2 id="dlq-與-requeue-風險">DLQ 與 requeue 風險&lt;/h2>
&lt;p>DLQ 的責任是隔離異常訊息，避免拖垮主消費流程。DLQ 是診斷與修復入口，把它當終點會讓問題沉積。每個進入 DLQ 的訊息，都應能回答：失敗原因是 payload 錯誤、下游不可用、版本不相容，還是消費邏輯缺陷。&lt;/p>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/requeue/" data-link-title="Requeue" data-link-desc="說明處理失敗的訊息重新排回 queue 時的風險與控制條件">requeue&lt;/a> 需要明確條件。直接把異常訊息無限 requeue，通常會造成隊列震盪與延遲累積。穩定做法是先隔離、分群、修復，再批次回放。&lt;/p>
&lt;h2 id="ordering-與吞吐取捨">ordering 與吞吐取捨&lt;/h2>
&lt;p>durable queue 在順序與吞吐之間需要明確取捨。全域順序通常成本極高，實務上多採用分區內順序：同一 key 保持順序，不同 key 可並行。這能兼顧一致性需求與處理吞吐。&lt;/p>
&lt;p>順序要求越高，恢復流程越需要明確 checkpoint 與補償策略。否則故障後的重播容易造成亂序副作用，放大修復成本。&lt;/p>
&lt;h2 id="判讀訊號">判讀訊號&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>訊號&lt;/th>
 &lt;th>判讀重點&lt;/th>
 &lt;th>對應動作&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>queue depth 持續上升&lt;/td>
 &lt;td>輸入速率高於消費能力&lt;/td>
 &lt;td>擴消費能力、調整重試節奏、分流高成本任務&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>retry ratio 升高且成功率下降&lt;/td>
 &lt;td>故障從暫時性轉為系統性&lt;/td>
 &lt;td>降級下游、縮小重試並啟動隔離策略&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>DLQ 量快速增加&lt;/td>
 &lt;td>payload/版本/邏輯異常集中爆發&lt;/td>
 &lt;td>分群診斷、修復邏輯、定向重播&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>requeue 循環導致延遲尖峰&lt;/td>
 &lt;td>缺少隔離邊界與停損機制&lt;/td>
 &lt;td>停止盲目 requeue、先隔離後回放&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>消費恢復後出現大量重複副作用&lt;/td>
 &lt;td>去重與冪等保護不足&lt;/td>
 &lt;td>補 idempotency key 與 side-effect guard&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="常見誤區">常見誤區&lt;/h2>
&lt;p>把 durable queue 視為「寫進去就安全」，會忽略消費與恢復責任。持久化只保證訊息可取回，不保證業務結果已正確提交。&lt;/p>
&lt;p>把 DLQ 當成長期倉庫，也會讓問題持續累積。DLQ 的工程價值在於快速定位異常類型並回到修復流程。&lt;/p>
&lt;h2 id="訊息系統的通知-vs-訊息分類">訊息系統的「通知 vs 訊息」分類&lt;/h2>
&lt;p>訊息系統設計區分兩種 SLO 不同的傳遞責任：&lt;em>transactional 通知&lt;/em> 承擔業務副作用的可靠送達、&lt;em>broadcast 訊息&lt;/em> 承擔大量低成本傳播。兩者用不同 storage、不同重試策略、不同投遞保證。&lt;/p>
&lt;p>對應 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/paypay-mobile-payment-messaging/" data-link-title="9.C26 PayPay：行動支付每日 3 億訊息的 DynamoDB 後端" data-link-desc="日本最大行動支付 PayPay 每日 3 億訊息、用 DynamoDB 處理通知與訊息功能、支撐次秒級反應">9.C26 PayPay&lt;/a> — 行動支付每日 3 億訊息、付款通知承擔「確認交易完成」的業務責任、SLO 包含秒級延遲跟高投遞率（用戶付完款後若 30 秒沒收到通知會打客服、產生重複扣款風險）。這層需求嚴於 OTA 推播、需要 durable queue + retry + 重複偵測。&lt;/p>
&lt;p>&lt;strong>分類設計&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Transactional 通知&lt;/strong>（付款收據、訂單狀態變更、配額警告）：承擔業務副作用確認、需 durable + idempotency key 去重、SLO 通常是 &lt;em>秒級延遲 + 99.99% 投遞率&lt;/em>&lt;/li>
&lt;li>&lt;strong>Broadcast 訊息&lt;/strong>（行銷推播、新片發布通知、社群動態）：承擔大量低成本傳播、SLO 是 &lt;em>吞吐量&lt;/em> 跟覆蓋率、允許 best-effort retry&lt;/li>
&lt;/ul>
&lt;p>判讀含義：規模化訊息系統的容量規劃要按類別分開、避免套同一個 broker capacity。3 億訊息 / 天看似一致、但 &lt;em>通知&lt;/em> 跟 &lt;em>訊息&lt;/em> 的工程負擔差數量級。&lt;/p>
&lt;h2 id="下游推送是隱性瓶頸">下游推送是隱性瓶頸&lt;/h2>
&lt;p>訊息系統的真正瓶頸常落在 &lt;em>下游推送通道&lt;/em>（APNs、FCM、SMS gateway、email provider）、不在 broker。下游 quota 是 hard ceiling、超過會被 throttle、訊息積壓回 broker 形成 backlog。&lt;/p>
&lt;p>對應 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/paypay-mobile-payment-messaging/" data-link-title="9.C26 PayPay：行動支付每日 3 億訊息的 DynamoDB 後端" data-link-desc="日本最大行動支付 PayPay 每日 3 億訊息、用 DynamoDB 處理通知與訊息功能、支撐次秒級反應">9.C26 PayPay&lt;/a> — DynamoDB 寫入可以撐 3K msg/sec 平均（PayPay 本身用 DynamoDB 作訊息後端、不是傳統 broker）、但 APNs 推送額度成為事故當下的隱性瓶頸。容量規劃要把下游 quota 算進去、不只看訊息後端吞吐。&lt;/p></description><content:encoded><![CDATA[<p>持久化佇列（<a href="/blog/backend/knowledge-cards/durable-queue/" data-link-title="Durable Queue" data-link-desc="說明可持久化的 queue 如何在重啟與失敗後保留待處理工作">durable queue</a>）的核心責任是讓非同步工作在 process、節點或網路故障後仍可被恢復處理。它讓業務動作在失敗後仍有可追蹤、可重試、可隔離的路徑。</p>
<h2 id="durable-與-ephemeral-的差異">durable 與 ephemeral 的差異</h2>
<p><a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> 在語意上可分 durable 與 ephemeral。ephemeral queue 側重低延遲與短暫協調，適合可丟失任務；durable queue 側重故障後可恢復，適合正式狀態相關副作用，例如付款通知、發票產生、庫存同步與合規事件記錄。</p>
<p>這個選擇本質上是失敗代價選擇。若任務丟失可接受，ephemeral 可降低成本；若任務丟失會造成金流、合約或審計問題，durable 是必要基線。</p>
<h2 id="重試策略">重試策略</h2>
<p>重試策略的責任是把暫時性故障和系統性故障分開。durable queue 常見的重試組合是：有限次重試、指數退避、<a href="/blog/backend/knowledge-cards/jitter/" data-link-title="Jitter" data-link-desc="說明重試或排程加入隨機偏移如何降低同步尖峰">jitter</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>。</p>
<p>重試上限與間隔要由下游承載能力決定。重試太快會形成故障放大，重試太慢會拖長恢復時間。穩定做法是把重試策略當成服務容量控制的一部分，而不是固定平台預設值。</p>
<h2 id="dlq-與-requeue-風險">DLQ 與 requeue 風險</h2>
<p>DLQ 的責任是隔離異常訊息，避免拖垮主消費流程。DLQ 是診斷與修復入口，把它當終點會讓問題沉積。每個進入 DLQ 的訊息，都應能回答：失敗原因是 payload 錯誤、下游不可用、版本不相容，還是消費邏輯缺陷。</p>
<p><a href="/blog/backend/knowledge-cards/requeue/" data-link-title="Requeue" data-link-desc="說明處理失敗的訊息重新排回 queue 時的風險與控制條件">requeue</a> 需要明確條件。直接把異常訊息無限 requeue，通常會造成隊列震盪與延遲累積。穩定做法是先隔離、分群、修復，再批次回放。</p>
<h2 id="ordering-與吞吐取捨">ordering 與吞吐取捨</h2>
<p>durable queue 在順序與吞吐之間需要明確取捨。全域順序通常成本極高，實務上多採用分區內順序：同一 key 保持順序，不同 key 可並行。這能兼顧一致性需求與處理吞吐。</p>
<p>順序要求越高，恢復流程越需要明確 checkpoint 與補償策略。否則故障後的重播容易造成亂序副作用，放大修復成本。</p>
<h2 id="判讀訊號">判讀訊號</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>判讀重點</th>
          <th>對應動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>queue depth 持續上升</td>
          <td>輸入速率高於消費能力</td>
          <td>擴消費能力、調整重試節奏、分流高成本任務</td>
      </tr>
      <tr>
          <td>retry ratio 升高且成功率下降</td>
          <td>故障從暫時性轉為系統性</td>
          <td>降級下游、縮小重試並啟動隔離策略</td>
      </tr>
      <tr>
          <td>DLQ 量快速增加</td>
          <td>payload/版本/邏輯異常集中爆發</td>
          <td>分群診斷、修復邏輯、定向重播</td>
      </tr>
      <tr>
          <td>requeue 循環導致延遲尖峰</td>
          <td>缺少隔離邊界與停損機制</td>
          <td>停止盲目 requeue、先隔離後回放</td>
      </tr>
      <tr>
          <td>消費恢復後出現大量重複副作用</td>
          <td>去重與冪等保護不足</td>
          <td>補 idempotency key 與 side-effect guard</td>
      </tr>
  </tbody>
</table>
<h2 id="常見誤區">常見誤區</h2>
<p>把 durable queue 視為「寫進去就安全」，會忽略消費與恢復責任。持久化只保證訊息可取回，不保證業務結果已正確提交。</p>
<p>把 DLQ 當成長期倉庫，也會讓問題持續累積。DLQ 的工程價值在於快速定位異常類型並回到修復流程。</p>
<h2 id="訊息系統的通知-vs-訊息分類">訊息系統的「通知 vs 訊息」分類</h2>
<p>訊息系統設計區分兩種 SLO 不同的傳遞責任：<em>transactional 通知</em> 承擔業務副作用的可靠送達、<em>broadcast 訊息</em> 承擔大量低成本傳播。兩者用不同 storage、不同重試策略、不同投遞保證。</p>
<p>對應 <a href="/blog/backend/09-performance-capacity/cases/paypay-mobile-payment-messaging/" data-link-title="9.C26 PayPay：行動支付每日 3 億訊息的 DynamoDB 後端" data-link-desc="日本最大行動支付 PayPay 每日 3 億訊息、用 DynamoDB 處理通知與訊息功能、支撐次秒級反應">9.C26 PayPay</a> — 行動支付每日 3 億訊息、付款通知承擔「確認交易完成」的業務責任、SLO 包含秒級延遲跟高投遞率（用戶付完款後若 30 秒沒收到通知會打客服、產生重複扣款風險）。這層需求嚴於 OTA 推播、需要 durable queue + retry + 重複偵測。</p>
<p><strong>分類設計</strong>：</p>
<ul>
<li><strong>Transactional 通知</strong>（付款收據、訂單狀態變更、配額警告）：承擔業務副作用確認、需 durable + idempotency key 去重、SLO 通常是 <em>秒級延遲 + 99.99% 投遞率</em></li>
<li><strong>Broadcast 訊息</strong>（行銷推播、新片發布通知、社群動態）：承擔大量低成本傳播、SLO 是 <em>吞吐量</em> 跟覆蓋率、允許 best-effort retry</li>
</ul>
<p>判讀含義：規模化訊息系統的容量規劃要按類別分開、避免套同一個 broker capacity。3 億訊息 / 天看似一致、但 <em>通知</em> 跟 <em>訊息</em> 的工程負擔差數量級。</p>
<h2 id="下游推送是隱性瓶頸">下游推送是隱性瓶頸</h2>
<p>訊息系統的真正瓶頸常落在 <em>下游推送通道</em>（APNs、FCM、SMS gateway、email provider）、不在 broker。下游 quota 是 hard ceiling、超過會被 throttle、訊息積壓回 broker 形成 backlog。</p>
<p>對應 <a href="/blog/backend/09-performance-capacity/cases/paypay-mobile-payment-messaging/" data-link-title="9.C26 PayPay：行動支付每日 3 億訊息的 DynamoDB 後端" data-link-desc="日本最大行動支付 PayPay 每日 3 億訊息、用 DynamoDB 處理通知與訊息功能、支撐次秒級反應">9.C26 PayPay</a> — DynamoDB 寫入可以撐 3K msg/sec 平均（PayPay 本身用 DynamoDB 作訊息後端、不是傳統 broker）、但 APNs 推送額度成為事故當下的隱性瓶頸。容量規劃要把下游 quota 算進去、不只看訊息後端吞吐。</p>
<p><strong>設計含義</strong>：</p>
<ul>
<li><strong>下游 quota 視為容量上限</strong>：APNs / FCM / SMS 的 daily quota 是 hard ceiling、訊息後端規劃要對應</li>
<li><strong>下游通道多元化</strong>：用 APNs / FCM / SMS / in-app notification 多通道分攤 quota 壓力、單通道飽和時其他通道仍可送出（具體降級策略需依各組織業務規則設計）</li>
<li><strong>重試節奏跟下游容量對齊</strong>：consumer 重試節奏依下游剩餘 quota 動態調整、讓重試節奏跟容量同步</li>
</ul>
<p>判讀重點：訊息系統事故當下、先看下游推送通道狀態（APNs status、FCM error rate）、再看訊息後端。下游 throttle 引發 backlog 是規模化訊息系統最常見的瓶頸來源。下游推送 quota 的攻擊面對照見 <a href="/blog/backend/03-message-queue/red-team-delivery-layer/" data-link-title="3.5 攻擊者視角（紅隊）：傳遞層弱點判讀" data-link-desc="從重複投遞、重放濫用、毒訊息與容量壓力，盤點 message delivery 的主要弱點">3.5 multi-tenant broker 配額耗盡</a>。</p>
<h2 id="案例回寫">案例回寫</h2>
<p>durable queue 的重試與隔離節奏可用 <a href="/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9 反例</a> 回寫。先看事件中的 backlog、retry、DLQ 變化，再回到本章判讀是重試策略失衡，還是隔離邊界不清楚。
這個案例主要支撐的是「重試隔離與停損門檻」判讀，不直接支撐 outbox 交易切分；若事件核心是資料提交與發布不一致，應轉到 3.3 與 1.3。</p>
<p>當重試量上升且主隊列延遲同步拉高時，先拆分重試通道並收斂 DLQ 分流條件，再把停損門檻接到 <a href="/blog/backend/06-reliability/rule-rollout-safety-gate/" data-link-title="6.24 規則推送安全閘門" data-link-desc="把規則、策略與控制面配置推送從部署步驟升級為可靠性 gate，避免小變更在秒級擴散成全網事故。">6.24 規則推送安全閘門</a>。</p>
<h2 id="跨模組路由">跨模組路由</h2>
<p>durable queue 是非同步可靠性的起點，不是終點。</p>
<ol>
<li>與 3.4 的交接：消費與恢復語意落在 <a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">consumer 設計與去重</a>。</li>
<li>與 3.3 的交接：發布一致性落在 <a href="/blog/backend/03-message-queue/outbox-pattern/" data-link-title="3.3 outbox pattern 與發佈一致性" data-link-desc="把 transaction 與 event publish 分離">outbox pattern</a>。</li>
<li>與 4.20 的交接：queue depth、retry、DLQ 指標進入 <a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">Observability Evidence Package</a>。</li>
<li>與 6.12 的交接：重試與重播驗證進入 <a href="/blog/backend/06-reliability/idempotency-replay/" data-link-title="6.12 Idempotency 與 Replay 驗證" data-link-desc="把重試、重播與冪等性從口頭約定變成可驗證屬性">Idempotency 與 Replay 驗證</a>。</li>
<li>與 8.19 的交接：故障隔離與回放決策進入 <a href="/blog/backend/08-incident-response/incident-decision-log/" data-link-title="8.19 Incident Decision Log" data-link-desc="把事中假設、決策、證據、回退條件與責任人留下可復盤紀錄">Incident Decision Log</a>。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<p>要從投遞語意往消費語意延伸，接著讀 <a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計與去重</a>。要看 queue 切換失敗模式，接著讀 <a href="/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9 反例</a>。</p>
]]></content:encoded></item><item><title>3.C3 LinkedIn：TopicGC 與 Kafka 治理轉換</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/linkedin-topicgc-kafka-governance/</link><pubDate>Thu, 07 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/linkedin-topicgc-kafka-governance/</guid><description>&lt;p>這個案例的核心責任是說明 queue 系統的轉換也包含 metadata 治理。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>LinkedIn 以 TopicGC 清理未使用 topic，降低 Kafka metadata 壓力並改善 produce/consume 效能。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>當 queue 規模擴大，僅靠容量擴充不夠，topic 生命週期與治理自動化會成為可靠性關鍵。&lt;/p>
&lt;h2 id="策略">策略&lt;/h2>
&lt;ol>
&lt;li>定義 topic 活躍判準與回收條件。&lt;/li>
&lt;li>自動化清理流程並保留稽核紀錄。&lt;/li>
&lt;li>監控清理前後的性能與穩定性指標。&lt;/li>
&lt;/ol>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer design&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/06-reliability/dependency-reliability-budget/" data-link-title="6.14 Dependency Reliability Budget" data-link-desc="把內外依賴的可靠性納入 SLO 計算與設計約束">6.14 dependency reliability budget&lt;/a>。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://engineering.linkedin.com/content/engineering/en-us/blog/2022/topicgc_how-linkedin-cleans-up-unused-metadata-for-its-kafka-clu">TopicGC at LinkedIn&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 queue 系統的轉換也包含 metadata 治理。</p>
<h2 id="觀察">觀察</h2>
<p>LinkedIn 以 TopicGC 清理未使用 topic，降低 Kafka metadata 壓力並改善 produce/consume 效能。</p>
<h2 id="判讀">判讀</h2>
<p>當 queue 規模擴大，僅靠容量擴充不夠，topic 生命週期與治理自動化會成為可靠性關鍵。</p>
<h2 id="策略">策略</h2>
<ol>
<li>定義 topic 活躍判準與回收條件。</li>
<li>自動化清理流程並保留稽核紀錄。</li>
<li>監控清理前後的性能與穩定性指標。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer design</a> 與 <a href="/blog/backend/06-reliability/dependency-reliability-budget/" data-link-title="6.14 Dependency Reliability Budget" data-link-desc="把內外依賴的可靠性納入 SLO 計算與設計約束">6.14 dependency reliability budget</a>。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://engineering.linkedin.com/content/engineering/en-us/blog/2022/topicgc_how-linkedin-cleans-up-unused-metadata-for-its-kafka-clu">TopicGC at LinkedIn</a></li>
</ul>
]]></content:encoded></item><item><title>NATS</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/nats/</link><pubDate>Fri, 01 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/nats/</guid><description>&lt;p>NATS 是 lightweight high-performance messaging system、承擔三個責任：subject-based routing（hierarchical wildcards）、low-latency messaging（Core NATS、fire-and-forget）、選擇性持久化（JetStream、streams + KV + Object Store）。設計取捨偏向「協議極簡、運維輕、必要時才開持久化」、適合微服務通訊跟 edge 場景。&lt;/p>
&lt;p>對「微服務 messaging、IoT/edge、Request/Reply、需要 messaging + KV 一體」這條路徑、NATS 是輕量首選。本頁先給最短路徑、再展開日常 publish / subscribe 與 subject 設計、最後進階治理（JetStream、supercluster、leaf node）跟排錯。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>讀完本章後、你應該能：&lt;/p>
&lt;ol>
&lt;li>用 nats-server 跑起 NATS（含 JetStream）、驗證 broker 健康&lt;/li>
&lt;li>用 nats CLI publish / subscribe、看 subject hierarchy 匹配&lt;/li>
&lt;li>區分 Core NATS（fire-and-forget）vs JetStream（durable）的選用判讀&lt;/li>
&lt;li>看懂 stream 配置、consumer 配置、pending 訊號&lt;/li>
&lt;li>評估 supercluster、leaf node、KV / Object Store 等延伸場景&lt;/li>
&lt;/ol>
&lt;h2 id="最短路徑5-分鐘把-nats-跑起來">最短路徑：5 分鐘把 NATS 跑起來&lt;/h2>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 1. 啟動 NATS server（-js 開 JetStream、-m 8222 開監控埠）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">docker run -d --name nats -p 4222:4222 -p 8222:8222 nats:latest -js -m &lt;span class="m">8222&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="c1"># 2. 用 nats CLI publish / subscribe（CLI 可用 natsio/nats-box 容器）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="c1"># docker run --rm --network host natsio/nats-box nats &amp;lt;subcommand&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">nats --server nats://localhost:4222 pub demo.hello &lt;span class="s2">&amp;#34;world&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">nats --server nats://localhost:4222 sub &lt;span class="s2">&amp;#34;demo.&amp;gt;&amp;#34;&lt;/span> &lt;span class="c1"># 另開一個 shell 持續訂閱&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="c1"># 3. 建 JetStream stream + pull consumer（持久化 + ack）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">nats --server nats://localhost:4222 stream add demo --subjects &lt;span class="s1">&amp;#39;demo.&amp;gt;&amp;#39;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --storage file --retention limits --discard old --defaults
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">nats --server nats://localhost:4222 consumer add demo worker &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --pull --deliver all --ack explicit --filter &lt;span class="s1">&amp;#39;demo.&amp;gt;&amp;#39;&lt;/span> --defaults&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>最短路徑驗證「Core NATS + JetStream 都可用」。實際寫程式用 nats client library、見&lt;a href="#%e6%97%a5%e5%b8%b8%e6%93%8d%e4%bd%9c%e8%88%87%e6%b1%ba%e7%ad%96%e5%bd%a2%e7%8b%80">日常操作&lt;/a>。&lt;/p>
&lt;h2 id="日常操作與決策形狀">日常操作與決策形狀&lt;/h2>
&lt;h3 id="cli-與-client-api">CLI 與 client API&lt;/h3>
&lt;p>子議題：&lt;/p>
&lt;ul>
&lt;li>nats CLI 指令對照表（pub / sub / stream / consumer / kv）&lt;/li>
&lt;li>監控 endpoint（&lt;code>/varz&lt;/code> / &lt;code>/connz&lt;/code> / &lt;code>/jsz&lt;/code> HTTP）&lt;/li>
&lt;li>Client library 配置：connection / reconnect / timeout / async / sync subscribe&lt;/li>
&lt;li>對應指令範例：&lt;code>nats stream info &amp;lt;name&amp;gt;&lt;/code>、&lt;code>nats consumer info &amp;lt;stream&amp;gt; &amp;lt;consumer&amp;gt;&lt;/code>&lt;/li>
&lt;/ul>
&lt;h3 id="subject-hierarchy-與-wildcard">Subject hierarchy 與 wildcard&lt;/h3>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">Subject&lt;/a> 是 NATS 路由的核心、層級式設計：&lt;/p></description><content:encoded><![CDATA[<p>NATS 是 lightweight high-performance messaging system、承擔三個責任：subject-based routing（hierarchical wildcards）、low-latency messaging（Core NATS、fire-and-forget）、選擇性持久化（JetStream、streams + KV + Object Store）。設計取捨偏向「協議極簡、運維輕、必要時才開持久化」、適合微服務通訊跟 edge 場景。</p>
<p>對「微服務 messaging、IoT/edge、Request/Reply、需要 messaging + KV 一體」這條路徑、NATS 是輕量首選。本頁先給最短路徑、再展開日常 publish / subscribe 與 subject 設計、最後進階治理（JetStream、supercluster、leaf node）跟排錯。</p>
<h2 id="本章目標">本章目標</h2>
<p>讀完本章後、你應該能：</p>
<ol>
<li>用 nats-server 跑起 NATS（含 JetStream）、驗證 broker 健康</li>
<li>用 nats CLI publish / subscribe、看 subject hierarchy 匹配</li>
<li>區分 Core NATS（fire-and-forget）vs JetStream（durable）的選用判讀</li>
<li>看懂 stream 配置、consumer 配置、pending 訊號</li>
<li>評估 supercluster、leaf node、KV / Object Store 等延伸場景</li>
</ol>
<h2 id="最短路徑5-分鐘把-nats-跑起來">最短路徑：5 分鐘把 NATS 跑起來</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"># 1. 啟動 NATS server（-js 開 JetStream、-m 8222 開監控埠）</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">docker run -d --name nats -p 4222:4222 -p 8222:8222 nats:latest -js -m <span class="m">8222</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"># 2. 用 nats CLI publish / subscribe（CLI 可用 natsio/nats-box 容器）</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1">#    docker run --rm --network host natsio/nats-box nats &lt;subcommand&gt;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">nats --server nats://localhost:4222 pub demo.hello <span class="s2">&#34;world&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">nats --server nats://localhost:4222 sub <span class="s2">&#34;demo.&gt;&#34;</span>   <span class="c1"># 另開一個 shell 持續訂閱</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="c1"># 3. 建 JetStream stream + pull consumer（持久化 + ack）</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">nats --server nats://localhost:4222 stream add demo --subjects <span class="s1">&#39;demo.&gt;&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="se"></span>  --storage file --retention limits --discard old --defaults
</span></span><span class="line"><span class="ln">12</span><span class="cl">nats --server nats://localhost:4222 consumer add demo worker <span class="se">\
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="se"></span>  --pull --deliver all --ack explicit --filter <span class="s1">&#39;demo.&gt;&#39;</span> --defaults</span></span></code></pre></div><p>最短路徑驗證「Core NATS + JetStream 都可用」。實際寫程式用 nats client library、見<a href="#%e6%97%a5%e5%b8%b8%e6%93%8d%e4%bd%9c%e8%88%87%e6%b1%ba%e7%ad%96%e5%bd%a2%e7%8b%80">日常操作</a>。</p>
<h2 id="日常操作與決策形狀">日常操作與決策形狀</h2>
<h3 id="cli-與-client-api">CLI 與 client API</h3>
<p>子議題：</p>
<ul>
<li>nats CLI 指令對照表（pub / sub / stream / consumer / kv）</li>
<li>監控 endpoint（<code>/varz</code> / <code>/connz</code> / <code>/jsz</code> HTTP）</li>
<li>Client library 配置：connection / reconnect / timeout / async / sync subscribe</li>
<li>對應指令範例：<code>nats stream info &lt;name&gt;</code>、<code>nats consumer info &lt;stream&gt; &lt;consumer&gt;</code></li>
</ul>
<h3 id="subject-hierarchy-與-wildcard">Subject hierarchy 與 wildcard</h3>
<p><a href="/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">Subject</a> 是 NATS 路由的核心、層級式設計：</p>
<ul>
<li>層級用 <code>.</code> 分隔（例：<code>orders.created.us-west</code>）</li>
<li>單層 wildcard <code>*</code>（匹配一層）</li>
<li>多層 wildcard <code>&gt;</code>（匹配剩餘所有層）</li>
<li>Subject 命名規範與 ownership</li>
</ul>
<h3 id="core-nats-vs-jetstream">Core NATS vs JetStream</h3>
<p>子議題：</p>
<ul>
<li>Core NATS：fire-and-forget、無持久化、極低延遲、適合即時通知 / 控制信號</li>
<li>JetStream：append-only stream + durable consumer、適合需要 replay / 持久化的事件流</li>
<li>兩者並存設計（同一 NATS server 同時跑）</li>
</ul>
<h3 id="requestreply-與-queue-groups">Request/Reply 與 Queue groups</h3>
<p>子議題：</p>
<ul>
<li>Request/Reply pattern（RPC over messaging）</li>
<li>Queue groups（load balancing、多 subscriber 分擔同 subject）</li>
<li>Pub/Sub vs Queue groups 的差異</li>
</ul>
<h2 id="進階主題按需閱讀">進階主題（按需閱讀）</h2>
<p>JetStream 已展開為兩篇 deep article：<a href="jetstream-durability-consumer/">core 到 JetStream 邊界</a>（採用決策入口）、<a href="jetstream-supercluster-design/">JetStream 設計與 supercluster/leaf node</a>（stream / consumer / 跨區拓樸 / 多租戶完整實作）。下列子議題段保留選題判讀入口。</p>
<h3 id="jetstream-stream-設計">JetStream stream 設計</h3>
<p>子議題：</p>
<ul>
<li>Stream 配置（subjects、retention policy、storage type）</li>
<li>File-based vs Memory-based storage</li>
<li>MaxMsgs / MaxBytes / MaxAge（保留策略）</li>
<li>Replicas（JetStream raft、跨節點一致性）</li>
</ul>
<h3 id="jetstream-consumer-設計">JetStream consumer 設計</h3>
<p>子議題：</p>
<ul>
<li>Durable vs ephemeral consumer</li>
<li>Push vs pull consumer</li>
<li>Ack 策略（explicit ack / all / none）</li>
<li>AckWait + MaxDeliver + DeliverPolicy（重試控制）</li>
</ul>
<h3 id="cluster--supercluster--leaf-node">Cluster / Supercluster / Leaf node</h3>
<p>子議題：</p>
<ul>
<li>Cluster：單一 region 多 broker、JetStream raft 同步</li>
<li>Supercluster：跨 cluster gateway、跨區延展</li>
<li>Leaf node：邊緣節點、subject mapping、適合 IoT / edge 場景</li>
<li>對應 <a href="/blog/backend/03-message-queue/cases/cloudflare-queues-global-delivery-model/" data-link-title="3.C8 Cloudflare：Queues 全球交付模型" data-link-desc="事件佇列服務在全球網路下的交付語義與治理案例。">3.C8 Cloudflare Queues 全球交付</a> 的對照思路</li>
</ul>
<h3 id="jetstream-kv--object-store">JetStream KV / Object Store</h3>
<p>子議題：</p>
<ul>
<li>KV store（基於 JetStream、簡單 key-value）</li>
<li>Object Store（基於 JetStream、大 blob）</li>
<li>何時用 NATS KV vs 真的 KV 服務（Redis / etcd）</li>
</ul>
<h3 id="subject-based-acl-與多租戶">Subject-based ACL 與多租戶</h3>
<p>子議題：</p>
<ul>
<li>Account 隔離（multi-tenancy 主機制）</li>
<li>Subject-level permission（publish / subscribe）</li>
<li>Cross-account import / export</li>
</ul>
<h2 id="排錯快速判讀">排錯快速判讀</h2>
<h3 id="consumer-pending-累積">Consumer pending 累積</h3>
<p>操作原則：先看 pending 是 ack-pending 還是 stream backlog、再定位 consumer 慢 vs 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:4222 consumer info &lt;stream&gt; &lt;consumer&gt;
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># 看 Unprocessed Messages（stream backlog）與 Redelivered / Acknowledgment Pending（ack-pending）區分兩種累積</span></span></span></code></pre></div><h3 id="stream-超-retention-limit">Stream 超 retention limit</h3>
<p>操作原則：超 MaxBytes / MaxMsgs 時 stream 觸發 discard policy、看是 old discard 還是 new discard。</p>
<h3 id="leaf-node-連線不穩">Leaf node 連線不穩</h3>
<p>操作原則：邊緣節點到 hub 的網路品質決定 subject mapping 延遲、看 reconnect 次數與 latency。</p>
<h3 id="subject-路由錯誤">Subject 路由錯誤</h3>
<p>操作原則：wildcard 設計錯導致訂閱不到、或匹配過多。看 subject hierarchy 規範與實際 subject。</p>
<h3 id="jetstream-raft-不一致">JetStream raft 不一致</h3>
<p>操作原則：replica 配置 R3 但只有 2 個健康節點、stream 變 read-only。看 cluster info 與 raft state。</p>
<h2 id="何時改走其他服務">何時改走其他服務</h2>
<table>
  <thead>
      <tr>
          <th>需求形狀</th>
          <th>改走</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>高吞吐事件流（百萬 msg/sec）</td>
          <td><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></td>
      </tr>
      <tr>
          <td>複雜 routing（exchange model）</td>
          <td><a href="/blog/backend/03-message-queue/vendors/rabbitmq/" data-link-title="RabbitMQ" data-link-desc="Classic message broker、AMQP routing 為主">RabbitMQ</a></td>
      </tr>
      <tr>
          <td>Managed queue（AWS / GCP）</td>
          <td><a href="/blog/backend/03-message-queue/vendors/aws-sqs/" data-link-title="AWS SQS" data-link-desc="AWS managed queue、簡單可靠、無 ordering（standard）">SQS</a> / <a href="/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Pub/Sub</a></td>
      </tr>
      <tr>
          <td>Redis 生態已存在</td>
          <td><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></td>
      </tr>
      <tr>
          <td>大型企業生態整合</td>
          <td>RabbitMQ / Kafka（社群更大）</td>
      </tr>
      <tr>
          <td>Managed NATS</td>
          <td>Synadia Cloud</td>
      </tr>
  </tbody>
</table>
<h2 id="不在本頁內的主題">不在本頁內的主題</h2>
<ul>
<li>各語言 client 完整 API（依官方文件）</li>
<li>NATS 跟 gRPC 的對比（在分散式通訊章節）</li>
<li>Synadia Cloud 商業功能</li>
</ul>
<h2 id="案例回寫">案例回寫</h2>
<h3 id="nats-專屬案例c34-c41">NATS 專屬案例（C34-C41）</h3>
<table>
  <thead>
      <tr>
          <th>案例</th>
          <th>主討論議題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/nats-netlify-data-plane-fanout/" data-link-title="3.C34 Netlify：NATS 當全球 metrics/logs 統一資料平面" data-link-desc="Netlify 70K&#43; 網站、10 億 PV/月、跨多雲、NATS 當 all-purpose data plane fan-out bus、超 RabbitMQ 評估。">3.C34 Netlify data plane</a></td>
          <td>全球 metrics / logs fan-out</td>
      </tr>
      <tr>
          <td><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 multi-cloud</a></td>
          <td>JetStream Leaf Node 跨雲低延遲支付</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/nats-intelecy-industrial-iot/" data-link-title="3.C36 Intelecy：工業 IoT 即時感測 &#43; 多租戶" data-link-desc="Intelecy 工廠 gateway 接數萬感測器、&lt; 2 秒往返延遲做即時 ML、從 BoltDB 本地快取演進到 JetStream 持久化。">3.C36 Intelecy IoT</a></td>
          <td>工業 IoT / BoltDB → JetStream</td>
      </tr>
      <tr>
          <td><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 edge</a></td>
          <td>Leaf node + KV + Object Store + 多租戶 Auth</td>
      </tr>
      <tr>
          <td><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 ML</a></td>
          <td>NATS Streaming queue group / at-least-once</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/nats-choria-orchestration-fleet/" data-link-title="3.C39 Choria：NATS 管 50 萬 server fleet" data-link-desc="Choria 替代 Puppet MCollective、NATS 單 binary 無 Zookeeper、4GB node 可達 50 萬 server、wildcard &#43; queue group 做 scatter-gather RPC。">3.C39 Choria fleet</a></td>
          <td>Request/Reply + Queue group / 50 萬 server</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/nats-resgate-realtime-api-gateway/" data-link-title="3.C40 Resgate：WebSocket-to-NATS realtime API gateway" data-link-desc="Resgate 把 NATS subject 暴露成 REST &#43; WebSocket、subject 階層當 schema、event 延遲 &lt; 1ms、純 Core NATS。">3.C40 Resgate API gateway</a></td>
          <td>Subject hierarchy 即 schema / Core NATS</td>
      </tr>
      <tr>
          <td><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 OT/IT</a></td>
          <td>多工廠 leaf node hub-and-spoke</td>
      </tr>
  </tbody>
</table>
<h3 id="跨-vendor-對照">跨 vendor 對照</h3>
<table>
  <thead>
      <tr>
          <th>案例</th>
          <th>對 NATS 的對應</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/cloudflare-queues-global-delivery-model/" data-link-title="3.C8 Cloudflare：Queues 全球交付模型" data-link-desc="事件佇列服務在全球網路下的交付語義與治理案例。">3.C8 Cloudflare Queues</a></td>
          <td>全球交付對照：leaf node + supercluster</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/contrast-queue-model-by-scale/" data-link-title="3.C10 對照：規模差異下的佇列模型" data-link-desc="同一 queue 模型在不同規模下的治理與失敗邊界差異。">3.C10 規模對照</a></td>
          <td>小型 messaging / 中型 JetStream / 大型 supercluster</td>
      </tr>
  </tbody>
</table>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>上游概念：<a href="/blog/backend/00-service-selection/async-delivery-selection/" data-link-title="0.3 非同步與事件傳遞選型" data-link-desc="區分背景工作、durable queue、stream、pub/sub 與 outbox 的選型邊界">0.3 非同步選型</a>、<a href="/blog/backend/03-message-queue/broker-basics/" data-link-title="3.1 broker 基礎與投遞模型" data-link-desc="先理解 broker、queue、consumer 與 delivery semantics">3.1 broker basics</a></li>
<li>平行 vendor：<a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka</a>、<a href="/blog/backend/03-message-queue/vendors/rabbitmq/" data-link-title="RabbitMQ" data-link-desc="Classic message broker、AMQP routing 為主">RabbitMQ</a></li>
<li>下游能力：<a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計</a>、<a href="/blog/backend/03-message-queue/processing-recovery-semantics/" data-link-title="3.6 Processing Semantics 與 Recovery Semantics" data-link-desc="說明投遞成功、處理成功與恢復成功為何是三個不同判斷。">3.6 processing recovery semantics</a></li>
</ul>
]]></content:encoded></item><item><title>3.3 outbox pattern 與發佈一致性</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/outbox-pattern/</link><pubDate>Thu, 23 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/outbox-pattern/</guid><description>&lt;p>這一章處理 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction&lt;/a> 與訊息發佈之間的一致性問題，後續可以再延伸到 polling、relay 與 failure recovery。&lt;/p>
&lt;p>外部發件箱模式（&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/outbox-pattern/" data-link-title="Outbox Pattern" data-link-desc="說明資料庫狀態變更與事件發布如何透過 outbox 維持一致">outbox pattern&lt;/a>）的核心責任是讓資料提交與事件發布在失敗時保持可恢復一致。它把重複發布轉成可判讀、可去重、可補償的治理問題。&lt;/p>
&lt;h2 id="基本流程">基本流程&lt;/h2>
&lt;p>transaction outbox 的典型流程是：在同一資料庫交易內，同時寫入業務資料與 outbox 記錄；交易提交後，由 relay worker 讀取 outbox 並發布到 broker；發布成功後標記或刪除 outbox 記錄。&lt;/p>
&lt;p>這個流程把一致性問題從「跨系統兩段提交」改成「單系統交易 + 非同步重送」，讓失敗路徑更可控。&lt;/p>
&lt;h2 id="relay-worker">relay worker&lt;/h2>
&lt;p>relay worker 的責任是穩定發布與可恢復進度。worker 需要具備批次拉取、順序控制、重試策略與停損條件。進度管理要明確，避免重啟後漏發或重複失控。&lt;/p>
&lt;p>當流量上升時，relay 吞吐會成為關鍵瓶頸。穩定做法是分 shard 處理、限制批次大小、對重試與正常發布做通道分流。&lt;/p>
&lt;h2 id="發布失敗與補償">發布失敗與補償&lt;/h2>
&lt;p>發布失敗通常分為暫時性與系統性。暫時性故障走有限重試，系統性故障走隔離與告警。關鍵是保留 outbox 記錄與發布狀態，讓恢復時可重播。&lt;/p>
&lt;p>duplicate publish 在 outbox 模式下屬於預期現象。消費端需要配合 idempotency 機制，確保重複事件不會產生重複業務結果。&lt;/p>
&lt;h2 id="判讀訊號">判讀訊號&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>訊號&lt;/th>
 &lt;th>判讀重點&lt;/th>
 &lt;th>對應動作&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>outbox backlog 持續堆積&lt;/td>
 &lt;td>relay 吞吐不足或下游故障持續&lt;/td>
 &lt;td>擴充 worker、分流重試、啟動降級流程&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>業務資料已更新但下游狀態延遲明顯&lt;/td>
 &lt;td>發布延遲超出可接受窗口&lt;/td>
 &lt;td>提升 relay 優先級、補告警與可視化&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>duplicate consume 比例上升&lt;/td>
 &lt;td>重試與重播增加，去重壓力上升&lt;/td>
 &lt;td>強化 consumer idempotency 與去重儲存&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>relay 重啟後出現漏發&lt;/td>
 &lt;td>進度標記與交易邊界設計不穩&lt;/td>
 &lt;td>收斂進度策略、補恢復測試&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>同步交易延遲上升且 outbox 寫入增加&lt;/td>
 &lt;td>outbox 表設計與索引不足&lt;/td>
 &lt;td>調整索引與分表策略、拆分熱路徑&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="常見誤區">常見誤區&lt;/h2>
&lt;p>把 outbox 當作「一次解決一致性」的銀彈，會忽略消費端冪等與補償責任。outbox 保證的是發布可恢復，不是端到端結果自動正確。&lt;/p>
&lt;p>把 outbox 表當一般業務表無上限累積，也會放大查詢與維護成本。需要定義保留與清理節奏，並確保稽核需求有對應方案。&lt;/p>
&lt;h2 id="self-managed-vs-managed-broker-的長期-tco">Self-managed vs Managed broker 的長期 TCO&lt;/h2>
&lt;p>Broker 選型本質是 long-term TCO 決策、需評估雲端費用 + 工程稅 + 治理負擔三層成本。Self-managed Kafka 的容量規劃 + broker 數量 + 副本因子 + disk + ZooKeeper / KRaft 治理是長期工程 tax、每次擴容是工程專案。&lt;/p>
&lt;p>對應 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/spotify-kafka-to-pubsub-migration-gcp/" data-link-title="9.C9 Spotify：從自管 Kafka 遷移到 GCP Pub/Sub 的事件交付系統" data-link-desc="Spotify 把自管 Kafka 事件系統遷移到 Google Cloud Pub/Sub、避免自管 broker 的容量規劃成本">9.C9 Spotify Kafka → Pub/Sub Migration&lt;/a> — Spotify 從自管 Kafka 遷到 Google Cloud Pub/Sub、動機是 &lt;em>容量規劃的工程成本&lt;/em> 在 sustained growth 下變得不划算、非 Kafka 效能不足。對 7500 萬用戶的事件交付系統、把 broker 容量規劃跟運維負擔卸給 vendor、釋放工程團隊 capacity。&lt;/p>
&lt;p>&lt;strong>TCO 評估的真實成本項&lt;/strong>（9.C9 case 列前 4 項 + 雲端費用、第 5 項屬跨案例綜合）：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Broker 雲端費用&lt;/strong>：明面成本、相對小&lt;/li>
&lt;li>&lt;strong>容量規劃工程&lt;/strong>：每季 partition planning、每年容量擴張專案&lt;/li>
&lt;li>&lt;strong>故障處理人力&lt;/strong>：broker 故障 oncall、ZooKeeper / KRaft 故障診斷&lt;/li>
&lt;li>&lt;strong>升級遷移成本&lt;/strong>：Kafka 每個 major version 升級是專案&lt;/li>
&lt;li>&lt;strong>跨團隊治理&lt;/strong>（從 3.C6 Uber 跨案例補充）：規模化後的 multi-tenant 隔離、quota 管理、observability 建設&lt;/li>
&lt;/ul>
&lt;p>判讀含義：Self-managed Kafka 在中小團隊可能比 Pub/Sub 便宜（雲端費用低）；但規模化後人力成本壓過雲端費用差、managed service 反而划算。對應 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/vmware-kafka-to-msk/" data-link-title="3.C2 VMware Tanzu CloudHealth：Kafka 轉 Amazon MSK" data-link-desc="自管 Kafka 遷移到託管平台時的治理重點。">3.C2 VMware Tanzu Kafka → MSK&lt;/a> 同樣是「自管 → managed」的決策。&lt;/p></description><content:encoded><![CDATA[<p>這一章處理 <a href="/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction</a> 與訊息發佈之間的一致性問題，後續可以再延伸到 polling、relay 與 failure recovery。</p>
<p>外部發件箱模式（<a href="/blog/backend/knowledge-cards/outbox-pattern/" data-link-title="Outbox Pattern" data-link-desc="說明資料庫狀態變更與事件發布如何透過 outbox 維持一致">outbox pattern</a>）的核心責任是讓資料提交與事件發布在失敗時保持可恢復一致。它把重複發布轉成可判讀、可去重、可補償的治理問題。</p>
<h2 id="基本流程">基本流程</h2>
<p>transaction outbox 的典型流程是：在同一資料庫交易內，同時寫入業務資料與 outbox 記錄；交易提交後，由 relay worker 讀取 outbox 並發布到 broker；發布成功後標記或刪除 outbox 記錄。</p>
<p>這個流程把一致性問題從「跨系統兩段提交」改成「單系統交易 + 非同步重送」，讓失敗路徑更可控。</p>
<h2 id="relay-worker">relay worker</h2>
<p>relay worker 的責任是穩定發布與可恢復進度。worker 需要具備批次拉取、順序控制、重試策略與停損條件。進度管理要明確，避免重啟後漏發或重複失控。</p>
<p>當流量上升時，relay 吞吐會成為關鍵瓶頸。穩定做法是分 shard 處理、限制批次大小、對重試與正常發布做通道分流。</p>
<h2 id="發布失敗與補償">發布失敗與補償</h2>
<p>發布失敗通常分為暫時性與系統性。暫時性故障走有限重試，系統性故障走隔離與告警。關鍵是保留 outbox 記錄與發布狀態，讓恢復時可重播。</p>
<p>duplicate publish 在 outbox 模式下屬於預期現象。消費端需要配合 idempotency 機制，確保重複事件不會產生重複業務結果。</p>
<h2 id="判讀訊號">判讀訊號</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>判讀重點</th>
          <th>對應動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>outbox backlog 持續堆積</td>
          <td>relay 吞吐不足或下游故障持續</td>
          <td>擴充 worker、分流重試、啟動降級流程</td>
      </tr>
      <tr>
          <td>業務資料已更新但下游狀態延遲明顯</td>
          <td>發布延遲超出可接受窗口</td>
          <td>提升 relay 優先級、補告警與可視化</td>
      </tr>
      <tr>
          <td>duplicate consume 比例上升</td>
          <td>重試與重播增加，去重壓力上升</td>
          <td>強化 consumer idempotency 與去重儲存</td>
      </tr>
      <tr>
          <td>relay 重啟後出現漏發</td>
          <td>進度標記與交易邊界設計不穩</td>
          <td>收斂進度策略、補恢復測試</td>
      </tr>
      <tr>
          <td>同步交易延遲上升且 outbox 寫入增加</td>
          <td>outbox 表設計與索引不足</td>
          <td>調整索引與分表策略、拆分熱路徑</td>
      </tr>
  </tbody>
</table>
<h2 id="常見誤區">常見誤區</h2>
<p>把 outbox 當作「一次解決一致性」的銀彈，會忽略消費端冪等與補償責任。outbox 保證的是發布可恢復，不是端到端結果自動正確。</p>
<p>把 outbox 表當一般業務表無上限累積，也會放大查詢與維護成本。需要定義保留與清理節奏，並確保稽核需求有對應方案。</p>
<h2 id="self-managed-vs-managed-broker-的長期-tco">Self-managed vs Managed broker 的長期 TCO</h2>
<p>Broker 選型本質是 long-term TCO 決策、需評估雲端費用 + 工程稅 + 治理負擔三層成本。Self-managed Kafka 的容量規劃 + broker 數量 + 副本因子 + disk + ZooKeeper / KRaft 治理是長期工程 tax、每次擴容是工程專案。</p>
<p>對應 <a href="/blog/backend/09-performance-capacity/cases/spotify-kafka-to-pubsub-migration-gcp/" data-link-title="9.C9 Spotify：從自管 Kafka 遷移到 GCP Pub/Sub 的事件交付系統" data-link-desc="Spotify 把自管 Kafka 事件系統遷移到 Google Cloud Pub/Sub、避免自管 broker 的容量規劃成本">9.C9 Spotify Kafka → Pub/Sub Migration</a> — Spotify 從自管 Kafka 遷到 Google Cloud Pub/Sub、動機是 <em>容量規劃的工程成本</em> 在 sustained growth 下變得不划算、非 Kafka 效能不足。對 7500 萬用戶的事件交付系統、把 broker 容量規劃跟運維負擔卸給 vendor、釋放工程團隊 capacity。</p>
<p><strong>TCO 評估的真實成本項</strong>（9.C9 case 列前 4 項 + 雲端費用、第 5 項屬跨案例綜合）：</p>
<ul>
<li><strong>Broker 雲端費用</strong>：明面成本、相對小</li>
<li><strong>容量規劃工程</strong>：每季 partition planning、每年容量擴張專案</li>
<li><strong>故障處理人力</strong>：broker 故障 oncall、ZooKeeper / KRaft 故障診斷</li>
<li><strong>升級遷移成本</strong>：Kafka 每個 major version 升級是專案</li>
<li><strong>跨團隊治理</strong>（從 3.C6 Uber 跨案例補充）：規模化後的 multi-tenant 隔離、quota 管理、observability 建設</li>
</ul>
<p>判讀含義：Self-managed Kafka 在中小團隊可能比 Pub/Sub 便宜（雲端費用低）；但規模化後人力成本壓過雲端費用差、managed service 反而划算。對應 <a href="/blog/backend/03-message-queue/cases/vmware-kafka-to-msk/" data-link-title="3.C2 VMware Tanzu CloudHealth：Kafka 轉 Amazon MSK" data-link-desc="自管 Kafka 遷移到託管平台時的治理重點。">3.C2 VMware Tanzu Kafka → MSK</a> 同樣是「自管 → managed」的決策。</p>
<p><strong>Managed service 的取捨</strong>：</p>
<ul>
<li>Pub/Sub 自動 scaling、伴隨 vendor lock-in、cost-per-message 累積、message ordering / latency 特性跟 Kafka 差異</li>
<li>業務語意對映（Kafka partition / offset / consumer group 在 Pub/Sub 對映成 subscription / ordering key / message attribute）需重新校準、見 <a href="/blog/backend/03-message-queue/event-contract-replay-boundary/" data-link-title="3.7 Event Contract 與 Replay Boundary" data-link-desc="說明 event schema、idempotency key、replay window 與補償如何先於 broker 選型。">3.7 跨 broker 業務語意對映</a></li>
<li>遷移本身需驗證業務語意 — 對應 <a href="/blog/backend/01-database/schema-migration-rollout-evidence/" data-link-title="1.7 Schema Migration Rollout 證據（Schema Migration Rollout Evidence）實作示範" data-link-desc="以訂單付款狀態欄位演進示範 schema migration 如何產出 evidence、release gate 與 incident decision log。">1.7 schema migration rollout evidence</a> 的同類流程</li>
</ul>
<h2 id="broker-遷移的階段流程">Broker 遷移的階段流程</h2>
<p>對應 <a href="/blog/backend/09-performance-capacity/cases/spotify-kafka-to-pubsub-migration-gcp/" data-link-title="9.C9 Spotify：從自管 Kafka 遷移到 GCP Pub/Sub 的事件交付系統" data-link-desc="Spotify 把自管 Kafka 事件系統遷移到 Google Cloud Pub/Sub、避免自管 broker 的容量規劃成本">9.C9 Spotify</a> — broker 遷移屬高併發容量工程、需維持 producer 連續寫入、保證 message 不丟。Spotify case 列三階段（dual write → shadow → cutover）、本章補第四階段（Decommission）作為清理收尾。replay 模型差異見 <a href="/blog/backend/03-message-queue/processing-recovery-semantics/" data-link-title="3.6 Processing Semantics 與 Recovery Semantics" data-link-desc="說明投遞成功、處理成功與恢復成功為何是三個不同判斷。">3.6 Replay 跟 Idempotency 共設計</a>。</p>
<ol>
<li><strong>Dual-write</strong>：producer 同時寫兩個 broker、確保 cutover 前新 broker 有完整資料</li>
<li><strong>Shadow consume</strong>：新 broker 有獨立 consumer group 消費、驗證業務結果跟舊 broker 一致</li>
<li><strong>Cutover</strong>：流量逐步切到新 broker、保留舊 broker 為 fallback</li>
<li><strong>Decommission</strong>（本章補充、case 未明文）：確認新 broker 穩定後關掉舊 broker、清理舊架構</li>
</ol>
<p>遷移期容量規劃含義：</p>
<ul>
<li>Dual-write 期間 broker 雙倍流量（writer side）</li>
<li>Shadow consume 期間 consumer 雙倍負載（reader side）</li>
<li>業務驗證（mismatch tracking）期間有額外的對帳工作量</li>
</ul>
<p>跟 <a href="/blog/backend/01-database/large-scale-db-migration/" data-link-title="1.12 大規模 DB 遷移實戰" data-link-desc="跨 DB 遷移的 dual-write、[shadow read](/backend/knowledge-cards/shadow-read/)、cutover、rollback 流程 — 從實戰案例提煉的工程做法">1.12 大規模 DB 遷移</a> 是同類流程、流程細節跟 evidence chain 可互相參考。</p>
<h2 id="案例回寫">案例回寫</h2>
<p>outbox 一致性可用 <a href="/blog/backend/08-incident-response/cases/github/2018-oct21-mysql-topology-incident/" data-link-title="GitHub 2018 Oct21 MySQL Topology Incident" data-link-desc="2018-10-21 GitHub 因 network partition 觸發跨區資料庫拓撲異常的事故解析：資料一致性優先、fail-forward 決策與長時間恢復。">GitHub 2018 Oct21 MySQL Topology Incident</a> 的恢復段落回寫。先看資料寫入與下游狀態同步是否脫節，再回到本章檢查 outbox backlog、relay 進度與重播策略。
這個案例主要支撐的是「提交後發布一致性」判讀，不直接支撐 broker 的底層投遞參數；若問題是 ack/partition 策略，應回到 3.1/3.2。</p>
<p>當資料已提交但事件遲到，或重播後副作用重複時，先調整 relay 節流與 consumer 冪等，再把驗證證據對齊 <a href="/blog/backend/06-reliability/verification-evidence-handoff/" data-link-title="6.23 Verification Evidence Handoff" data-link-desc="把 SLO、load、chaos、DR 與 readiness 結果包成 release / incident 可用證據">6.23 Verification Evidence Handoff</a>。</p>
<h2 id="跨模組路由">跨模組路由</h2>
<ol>
<li>與 1.3 的交接：交易邊界語意回到 <a href="/blog/backend/01-database/transaction-boundary/" data-link-title="1.3 Transaction 與一致性邊界" data-link-desc="交易邊界、isolation level、retry 策略、distributed transaction（2PC、Saga）與跨 region 強一致取捨">transaction 與一致性邊界</a>。</li>
<li>與 3.2 的交接：發布後重試與隔離回到 <a href="/blog/backend/03-message-queue/durable-queue/" data-link-title="3.2 durable queue 與重試策略" data-link-desc="整理持久化佇列、DLQ 與重試流程">durable queue 與重試策略</a>。</li>
<li>與 3.4 的交接：消費冪等與重播回到 <a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">consumer 設計與去重</a>。</li>
<li>與 6.12 的交接：一致性驗證與重播演練回到 <a href="/blog/backend/06-reliability/idempotency-replay/" data-link-title="6.12 Idempotency 與 Replay 驗證" data-link-desc="把重試、重播與冪等性從口頭約定變成可驗證屬性">Idempotency 與 Replay 驗證</a>。</li>
<li>與 8.19 的交接：發布故障決策回到 <a href="/blog/backend/08-incident-response/incident-decision-log/" data-link-title="8.19 Incident Decision Log" data-link-desc="把事中假設、決策、證據、回退條件與責任人留下可復盤紀錄">Incident Decision Log</a>。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<p>要從 outbox 延伸到消費恢復，接著讀 <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>。要看 queue 切換失敗時的一致性風險，接著讀 <a href="/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9 反例</a>。</p>
]]></content:encoded></item><item><title>模組三：訊息佇列與事件傳遞</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/</guid><description>&lt;p>訊息佇列模組的核心目標是說明事件離開單一 process 後，如何處理持久化、重試、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/duplicate-delivery/" data-link-title="Duplicate Delivery" data-link-desc="說明同一個訊息被處理多次時如何保持結果穩定">重複投遞&lt;/a>與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer&lt;/a> 協調。語言教材會先處理本地 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> abstraction、publisher port、processor 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency&lt;/a> interface；本模組負責 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker&lt;/a> 的具體語意。&lt;/p>
&lt;h2 id="vendor--platform-清單">Vendor / Platform 清單&lt;/h2>
&lt;p>實作時的常用選擇見 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/" data-link-title="訊息佇列 Vendor 清單" data-link-desc="規劃 broker、event streaming 與 managed queue 的服務頁撰寫順序與判準">vendors&lt;/a> — T1 收錄 RabbitMQ / Kafka / NATS / Redis Streams / AWS SQS / Google Pub/Sub，每個 vendor 有定位、適用場景、取捨與預計實作話題的骨架。&lt;/p>
&lt;p>Deep article（vendor 自身的配置、故障、容量）跟 migration playbook（跨 vendor 遷移流程）的撰寫進度見 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/" data-link-title="訊息佇列 Vendor 清單" data-link-desc="規劃 broker、event streaming 與 managed queue 的服務頁撰寫順序與判準">vendors/&lt;/a> 的「內容覆蓋進度」段。&lt;/p>
&lt;h2 id="暫定分類">暫定分類&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>分類&lt;/th>
 &lt;th>內容方向&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>RabbitMQ&lt;/td>
 &lt;td>exchange、queue、routing key、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">ack/nack&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/dead-letter-queue/" data-link-title="Dead-Letter Queue" data-link-desc="說明 dead-letter queue 如何隔離多次處理失敗的訊息">dead-letter queue&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>NATS&lt;/td>
 &lt;td>subject、consumer、JetStream、at-least-once delivery&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Kafka&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/partition/" data-link-title="Partition" data-link-desc="說明事件流如何切分成多個可並行處理的有序片段">partition&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer-group/" data-link-title="Consumer Group" data-link-desc="說明一組 consumer 如何共同分攤 stream 或 topic 的處理責任">consumer group&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/offset/" data-link-title="Offset" data-link-desc="說明 consumer 在事件流中的讀取位置與重放基準">offset&lt;/a>、ordering&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Redis Streams&lt;/td>
 &lt;td>stream、consumer group、pending entry、claim&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Outbox&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction&lt;/a> outbox、poller、publisher、重試策略&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Idempotency&lt;/td>
 &lt;td>idempotency key、dedup store、replay safety&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="選型入口">選型入口&lt;/h2>
&lt;p>訊息佇列選型的核心判斷是工作離開 request 或 process 後需要什麼投遞保證。當工作需要排隊、重試、跨服務傳遞、多 consumer 協作或事件補送時，&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker&lt;/a> 與 outbox 值得優先評估。&lt;/p>
&lt;p>RabbitMQ 適合明確 routing、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">ack/nack&lt;/a> 與工作佇列；NATS 適合 subject-based messaging 與較輕量的服務通訊，搭配 JetStream 可加入持久化；Kafka 適合高吞吐事件流、partition 與長期 replay；Redis Streams 適合 Redis 生態內的 stream 與 consumer group；&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/outbox-pattern/" data-link-title="Outbox Pattern" data-link-desc="說明資料庫狀態變更與事件發布如何透過 outbox 維持一致">outbox&lt;/a> 解決資料寫入與事件發布的一致性；&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency&lt;/a> 解決重複投遞造成的結果穩定性；&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/retry-budget/" data-link-title="Retry Budget" data-link-desc="說明重試次數如何受整體容量與錯誤預算限制">retry budget&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/jitter/" data-link-title="Jitter" data-link-desc="說明重試或排程加入隨機偏移如何降低同步尖峰">jitter&lt;/a> 則控制故障期間的重試壓力。&lt;/p></description><content:encoded><![CDATA[<p>訊息佇列模組的核心目標是說明事件離開單一 process 後，如何處理持久化、重試、<a href="/blog/backend/knowledge-cards/duplicate-delivery/" data-link-title="Duplicate Delivery" data-link-desc="說明同一個訊息被處理多次時如何保持結果穩定">重複投遞</a>與 <a href="/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer</a> 協調。語言教材會先處理本地 <a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> abstraction、publisher port、processor 與 <a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a> interface；本模組負責 <a href="/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker</a> 的具體語意。</p>
<h2 id="vendor--platform-清單">Vendor / Platform 清單</h2>
<p>實作時的常用選擇見 <a href="/blog/backend/03-message-queue/vendors/" data-link-title="訊息佇列 Vendor 清單" data-link-desc="規劃 broker、event streaming 與 managed queue 的服務頁撰寫順序與判準">vendors</a> — T1 收錄 RabbitMQ / Kafka / NATS / Redis Streams / AWS SQS / Google Pub/Sub，每個 vendor 有定位、適用場景、取捨與預計實作話題的骨架。</p>
<p>Deep article（vendor 自身的配置、故障、容量）跟 migration playbook（跨 vendor 遷移流程）的撰寫進度見 <a href="/blog/backend/03-message-queue/vendors/" data-link-title="訊息佇列 Vendor 清單" data-link-desc="規劃 broker、event streaming 與 managed queue 的服務頁撰寫順序與判準">vendors/</a> 的「內容覆蓋進度」段。</p>
<h2 id="暫定分類">暫定分類</h2>
<table>
  <thead>
      <tr>
          <th>分類</th>
          <th>內容方向</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>RabbitMQ</td>
          <td>exchange、queue、routing key、<a href="/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">ack/nack</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></td>
      </tr>
      <tr>
          <td>NATS</td>
          <td>subject、consumer、JetStream、at-least-once delivery</td>
      </tr>
      <tr>
          <td>Kafka</td>
          <td><a href="/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic</a>、<a href="/blog/backend/knowledge-cards/partition/" data-link-title="Partition" data-link-desc="說明事件流如何切分成多個可並行處理的有序片段">partition</a>、<a href="/blog/backend/knowledge-cards/consumer-group/" data-link-title="Consumer Group" data-link-desc="說明一組 consumer 如何共同分攤 stream 或 topic 的處理責任">consumer group</a>、<a href="/blog/backend/knowledge-cards/offset/" data-link-title="Offset" data-link-desc="說明 consumer 在事件流中的讀取位置與重放基準">offset</a>、ordering</td>
      </tr>
      <tr>
          <td>Redis Streams</td>
          <td>stream、consumer group、pending entry、claim</td>
      </tr>
      <tr>
          <td>Outbox</td>
          <td><a href="/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction</a> outbox、poller、publisher、重試策略</td>
      </tr>
      <tr>
          <td>Idempotency</td>
          <td>idempotency key、dedup store、replay safety</td>
      </tr>
  </tbody>
</table>
<h2 id="選型入口">選型入口</h2>
<p>訊息佇列選型的核心判斷是工作離開 request 或 process 後需要什麼投遞保證。當工作需要排隊、重試、跨服務傳遞、多 consumer 協作或事件補送時，<a href="/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker</a> 與 outbox 值得優先評估。</p>
<p>RabbitMQ 適合明確 routing、<a href="/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">ack/nack</a> 與工作佇列；NATS 適合 subject-based messaging 與較輕量的服務通訊，搭配 JetStream 可加入持久化；Kafka 適合高吞吐事件流、partition 與長期 replay；Redis Streams 適合 Redis 生態內的 stream 與 consumer group；<a href="/blog/backend/knowledge-cards/outbox-pattern/" data-link-title="Outbox Pattern" data-link-desc="說明資料庫狀態變更與事件發布如何透過 outbox 維持一致">outbox</a> 解決資料寫入與事件發布的一致性；<a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a> 解決重複投遞造成的結果穩定性；<a href="/blog/backend/knowledge-cards/retry-budget/" data-link-title="Retry Budget" data-link-desc="說明重試次數如何受整體容量與錯誤預算限制">retry budget</a> 與 <a href="/blog/backend/knowledge-cards/jitter/" data-link-title="Jitter" data-link-desc="說明重試或排程加入隨機偏移如何降低同步尖峰">jitter</a> 則控制故障期間的重試壓力。</p>
<p>接近真實網路服務的例子包括付款後寄信、影片轉檔、訂單事件傳給多個系統、IoT readings pipeline 與跨節點通知。這些場景的共同問題是 <a href="/blog/backend/knowledge-cards/delivery-semantics/" data-link-title="Delivery Semantics" data-link-desc="說明事件投遞語意如何定義遺失、重複、順序與補償策略">delivery semantics</a>，因此本模組會先處理 broker 模型、retry、<a href="/blog/backend/knowledge-cards/dead-letter-queue/" data-link-title="Dead-Letter Queue" data-link-desc="說明 dead-letter queue 如何隔離多次處理失敗的訊息">DLQ</a>、outbox 與 consumer 設計。</p>
<h2 id="與語言教材的分工">與語言教材的分工</h2>
<p>語言教材處理本地 <a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a>、processor 邊界、port / <a href="/blog/backend/knowledge-cards/message-protocol/" data-link-title="Message Protocol" data-link-desc="說明 queue 或 stream message 如何對齊格式與處理語意">Message Protocol</a> 設計與單一 process 內的去重。Backend message queue 模組處理 broker selection、ack/nack、DLQ、consumer group、outbox 與跨 process 重試。</p>
<h2 id="案例驅動讀法">案例驅動讀法</h2>
<p>佇列案例的核心讀法是先辨識遷移的是「資料路徑」還是「治理路徑」，再決定先做 broker 切換還是治理收斂。</p>
<table>
  <thead>
      <tr>
          <th>案例</th>
          <th>先看章節</th>
          <th>回寫目標</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/meta-foqs-global-migration/" data-link-title="3.C1 Meta：FOQS 從區域到全域佇列遷移" data-link-desc="佇列架構如何在不中斷下升級成 disaster-ready 模式。">3.C1 Meta：FOQS 全域遷移</a></td>
          <td><a href="/blog/backend/03-message-queue/broker-basics/" data-link-title="3.1 broker 基礎與投遞模型" data-link-desc="先理解 broker、queue、consumer 與 delivery semantics">3.1</a>、<a href="/blog/backend/03-message-queue/durable-queue/" data-link-title="3.2 durable queue 與重試策略" data-link-desc="整理持久化佇列、DLQ 與重試流程">3.2</a></td>
          <td>把跨區 queue 路由與可用性邊界前置</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/vmware-kafka-to-msk/" data-link-title="3.C2 VMware Tanzu CloudHealth：Kafka 轉 Amazon MSK" data-link-desc="自管 Kafka 遷移到託管平台時的治理重點。">3.C2 VMware：Kafka -&gt; MSK</a></td>
          <td><a href="/blog/backend/03-message-queue/broker-basics/" data-link-title="3.1 broker 基礎與投遞模型" data-link-desc="先理解 broker、queue、consumer 與 delivery semantics">3.1</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</a></td>
          <td>把 managed broker 遷移轉成 ACL/lag/回退治理</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/linkedin-topicgc-kafka-governance/" data-link-title="3.C3 LinkedIn：TopicGC 與 Kafka 治理轉換" data-link-desc="Kafka topic 從手動治理轉自動治理對叢集的影響。">3.C3 LinkedIn：TopicGC</a></td>
          <td><a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4</a></td>
          <td>把 topic 生命週期治理納入可靠性成本模型</td>
      </tr>
  </tbody>
</table>
<h2 id="跨語言適配評估">跨語言適配評估</h2>
<p>訊息佇列使用方式會受語言的 worker model、錯誤處理、序列化、背景任務框架與 idempotency 設計影響。同步 runtime 要控制 consumer thread 數量與 ack <a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a>；async runtime 要處理 backpressure 與 long-running handler；輕量並發 runtime 要限制同時處理量，避免 consumer 擴張超過下游容量。強型別語言適合建立 event schema 與 command model；動態語言要補足 payload validation、dead-letter 診斷與重播測試。</p>
<h2 id="章節列表">章節列表</h2>
<table>
  <thead>
      <tr>
          <th>章節</th>
          <th>主題</th>
          <th>關鍵收穫</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/backend/03-message-queue/broker-basics/" data-link-title="3.1 broker 基礎與投遞模型" data-link-desc="先理解 broker、queue、consumer 與 delivery semantics">3.1</a></td>
          <td>broker 基礎與投遞模型</td>
          <td>看懂 exchange、topic、consumer 與 delivery semantics</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/durable-queue/" data-link-title="3.2 durable queue 與重試策略" data-link-desc="整理持久化佇列、DLQ 與重試流程">3.2</a></td>
          <td><a href="/blog/backend/knowledge-cards/durable-queue/" data-link-title="Durable Queue" data-link-desc="說明可持久化的 queue 如何在重啟與失敗後保留待處理工作">durable queue</a> 與重試策略</td>
          <td>規劃持久化、ack/nack、DLQ 與 retry</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/outbox-pattern/" data-link-title="3.3 outbox pattern 與發佈一致性" data-link-desc="把 transaction 與 event publish 分離">3.3</a></td>
          <td><a href="/blog/backend/knowledge-cards/outbox-pattern/" data-link-title="Outbox Pattern" data-link-desc="說明資料庫狀態變更與事件發布如何透過 outbox 維持一致">outbox pattern</a> 與發佈一致性</td>
          <td>把交易寫入與事件發佈分離</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4</a></td>
          <td>consumer 設計與去重</td>
          <td>設計 idempotency、<a href="/blog/backend/knowledge-cards/checkpoint/" data-link-title="Checkpoint" data-link-desc="說明長時間處理流程如何記錄可恢復進度">checkpoint</a> 與 replay safety</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/red-team-delivery-layer/" data-link-title="3.5 攻擊者視角（紅隊）：傳遞層弱點判讀" data-link-desc="從重複投遞、重放濫用、毒訊息與容量壓力，盤點 message delivery 的主要弱點">3.5</a></td>
          <td>攻擊者視角（紅隊）：傳遞層弱點判讀</td>
          <td>用重放、重複、毒訊息與延遲累積檢查非同步傳遞邊界</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/processing-recovery-semantics/" data-link-title="3.6 Processing Semantics 與 Recovery Semantics" data-link-desc="說明投遞成功、處理成功與恢復成功為何是三個不同判斷。">3.6</a></td>
          <td>Processing Semantics 與 Recovery Semantics</td>
          <td>分辨投遞成功、處理成功與恢復成功</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/event-contract-replay-boundary/" data-link-title="3.7 Event Contract 與 Replay Boundary" data-link-desc="說明 event schema、idempotency key、replay window 與補償如何先於 broker 選型。">3.7</a></td>
          <td>Event Contract 與 Replay Boundary</td>
          <td>定義 event schema、idempotency key、replay window 與補償邊界</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/queue-consumer-retry-replay-handoff/" data-link-title="3.8 Queue Consumer Retry 與 Replay Handoff（實作示範）" data-link-desc="以 order_created consumer 示範 queue 路徑如何交付 idempotency evidence、DLQ handling、replay runbook 與 incident decision log。">3.8</a></td>
          <td>Queue Consumer Retry 與 Replay Handoff 實作示範</td>
          <td>以訂單事件 consumer 示範 evidence、DLQ、replay runbook 與 decision log</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/" data-link-title="模組三案例正文" data-link-desc="訊息佇列與事件傳遞的轉換案例入口、含通用案例與 6 個 vendor 的真實 production case 庫。">3.C</a></td>
          <td>轉換案例正文</td>
          <td>把 queue 架構、broker 遷移與 topic 治理轉成可操作案例</td>
      </tr>
  </tbody>
</table>
<p>反例與規模對照入口： <a href="/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9 反例</a> / <a href="/blog/backend/03-message-queue/cases/contrast-queue-model-by-scale/" data-link-title="3.C10 對照：規模差異下的佇列模型" data-link-desc="同一 queue 模型在不同規模下的治理與失敗邊界差異。">3.C10 對照</a>。</p>
<p>回退判讀寫法見 <a href="/blog/backend/00-service-selection/cases/post-scale-migration-language-tool-architecture/#%e5%9b%9e%e9%80%80%e5%88%a4%e8%ae%80%e5%af%ab%e6%b3%95" data-link-title="營運後技術轉換：語言、工具與架構何時該換" data-link-desc="服務營運一段時間後，如何判讀何時該轉語言、工具或架構，並用案例說明轉換動機。">0.C4 回退判讀寫法</a>，queue 案例要優先保留 delivery semantics、lag、DLQ 與 replay 條件。</p>
<p>跨模組端到端串聯（DB → cache → event → observability）見 <a href="/blog/backend/00-service-selection/cross-module-checkout-episode/" data-link-title="0.15 跨模組 Checkout Episode：從資料寫入到觀測證據" data-link-desc="以 checkout 為切片，走完 DB write → cache invalidation → event publish → observability evidence 四層串聯，標示各模組的交接欄位與失敗判讀">0.15 跨模組 Checkout Episode</a>。</p>
<h2 id="觀念網路補完方向">觀念網路補完方向</h2>
<p>訊息佇列章節下一輪的核心責任是把「投遞成功」和「業務結果正確」分開。現有章節已經有 broker、durable queue、outbox 與 consumer design，但還需要補上 delivery semantics、processing semantics 與 recovery semantics 的三層關係，讓讀者知道 queue 失敗同時包括訊息遺失、重複副作用、順序錯亂、重播風險與下游壓力放大。</p>
<table>
  <thead>
      <tr>
          <th>補完方向</th>
          <th>需要回答的問題</th>
          <th>主要路由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Delivery semantics</td>
          <td>broker 如何 ack、nack、redelivery、retry、送入 DLQ</td>
          <td><a href="/blog/backend/knowledge-cards/delivery-semantics/" data-link-title="Delivery Semantics" data-link-desc="說明事件投遞語意如何定義遺失、重複、順序與補償策略">delivery semantics</a>、<a href="/blog/backend/03-message-queue/durable-queue/" data-link-title="3.2 durable queue 與重試策略" data-link-desc="整理持久化佇列、DLQ 與重試流程">3.2</a></td>
      </tr>
      <tr>
          <td>Processing semantics</td>
          <td>consumer 的副作用是否能承受重複、亂序與部分失敗</td>
          <td><a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a>、<a href="/blog/backend/06-reliability/idempotency-replay/" data-link-title="6.12 Idempotency 與 Replay 驗證" data-link-desc="把重試、重播與冪等性從口頭約定變成可驗證屬性">6.12</a></td>
      </tr>
      <tr>
          <td>Recovery semantics</td>
          <td>replay、checkpoint、offset 與補償是否可重播與驗證</td>
          <td><a href="/blog/backend/knowledge-cards/offset/" data-link-title="Offset" data-link-desc="說明 consumer 在事件流中的讀取位置與重放基準">offset</a>、<a href="/blog/backend/08-incident-response/incident-decision-log/" data-link-title="8.19 Incident Decision Log" data-link-desc="把事中假設、決策、證據、回退條件與責任人留下可復盤紀錄">8.19</a></td>
      </tr>
      <tr>
          <td>Outbox boundary</td>
          <td>資料庫交易與事件發布是否有一致性邊界</td>
          <td><a href="/blog/backend/knowledge-cards/outbox-pattern/" data-link-title="Outbox Pattern" data-link-desc="說明資料庫狀態變更與事件發布如何透過 outbox 維持一致">outbox pattern</a>、<a href="/blog/backend/01-database/transaction-boundary/" data-link-title="1.3 Transaction 與一致性邊界" data-link-desc="交易邊界、isolation level、retry 策略、distributed transaction（2PC、Saga）與跨 region 強一致取捨">1.3</a></td>
      </tr>
      <tr>
          <td>Poison handling</td>
          <td>壞訊息是否會卡住 consumer 或被無限重試</td>
          <td><a href="/blog/backend/knowledge-cards/poison-message/" data-link-title="Poison Message" data-link-desc="說明特定訊息內容如何穩定造成 consumer 失敗">poison message</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></td>
      </tr>
  </tbody>
</table>
<p>這些方向要用非同步服務自己的語意展開。寄信、開 invoice、更新 CRM、同步 search index、發 webhook 的副作用不同，retry、DLQ 與 replay 的判準也不同。</p>
<h2 id="知識卡補強方向">知識卡補強方向</h2>
<p>佇列模組的 knowledge card 缺口集中在「處理語意」與「恢復語意」。已有 <a href="/blog/backend/knowledge-cards/consumer-lag/" data-link-title="Consumer Lag" data-link-desc="說明 consumer lag 如何反映訊息堆積、處理能力與容量風險">consumer lag</a>、<a href="/blog/backend/knowledge-cards/retry-budget/" data-link-title="Retry Budget" data-link-desc="說明重試次數如何受整體容量與錯誤預算限制">retry budget</a>、<a href="/blog/backend/knowledge-cards/poison-message/" data-link-title="Poison Message" data-link-desc="說明特定訊息內容如何穩定造成 consumer 失敗">poison message</a> 與 <a href="/blog/backend/knowledge-cards/offset/" data-link-title="Offset" data-link-desc="說明 consumer 在事件流中的讀取位置與重放基準">offset</a> 可以作為第一批錨點。</p>
<p>第二批卡片已補上 <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/recovery-semantics/" data-link-title="Recovery Semantics" data-link-desc="說明事件處理失敗後能否透過 replay、checkpoint 與補償重建正確狀態並驗證">recovery semantics</a>、<a href="/blog/backend/knowledge-cards/replay-window/" data-link-title="Replay Window" data-link-desc="說明事件可重播的時間或 offset 範圍邊界，由 retention 與 checkpoint 決定">replay window</a>、<a href="/blog/backend/knowledge-cards/consumer-pause/" data-link-title="Consumer Pause" data-link-desc="說明暫停消費作為事故控制手段，止住錯誤副作用擴大">consumer pause</a>、<a href="/blog/backend/knowledge-cards/event-schema-compatibility/" data-link-title="Event Schema Compatibility" data-link-desc="說明 event schema 演進時，新舊 producer 與 consumer 能否互通的相容性等級">event schema compatibility</a>、<a href="/blog/backend/knowledge-cards/dlq-drain/" data-link-title="DLQ Drain" data-link-desc="說明把 dead-letter queue 累積的訊息重新處理或排空的受控流程">DLQ drain</a> 與 <a href="/blog/backend/knowledge-cards/poison-message-quarantine/" data-link-title="Poison-Message Quarantine" data-link-desc="說明把毒訊息從主處理路徑隔離出來的機制，讓正常訊息繼續前進">poison-message quarantine</a>。這些卡片讓讀者能分辨「queue 有持久化」和「consumer 結果可恢復」分屬不同責任。</p>
<h2 id="實作探討入口">實作探討入口</h2>
<p>佇列的第一條實作路徑是 <a href="/blog/backend/03-message-queue/queue-consumer-retry-replay-handoff/" data-link-title="3.8 Queue Consumer Retry 與 Replay Handoff（實作示範）" data-link-desc="以 order_created consumer 示範 queue 路徑如何交付 idempotency evidence、DLQ handling、replay runbook 與 incident decision log。">3.8 Queue Consumer Retry 與 Replay Handoff（實作示範）</a>。這篇以 <code>order_created</code> consumer 為例，說明 idempotency evidence、DLQ handling、replay runbook 與 incident decision route 如何一起成立。</p>
<p>這條路徑的前置引用應該是 3.2 durable queue、3.3 outbox pattern、3.4 consumer design、<a href="/blog/backend/06-reliability/idempotency-replay/" data-link-title="6.12 Idempotency 與 Replay 驗證" data-link-desc="把重試、重播與冪等性從口頭約定變成可驗證屬性">6.12 Idempotency 與 Replay 驗證</a> 與 <a href="/blog/backend/06-reliability/verification-evidence-handoff/" data-link-title="6.23 Verification Evidence Handoff" data-link-desc="把 SLO、load、chaos、DR 與 readiness 結果包成 release / incident 可用證據">6.23 Verification Evidence Handoff</a>。完成後可依 <a href="/blog/backend/#%e5%ad%b8%e7%bf%92%e8%b7%af%e7%b7%9a" data-link-title="Backend 服務實務指南" data-link-desc="用跨語言教學路線整理資料庫、快取、訊息佇列、觀測、部署、可靠性、資安、事故與容量等後端服務能力">Backend 學習路線</a> 進入下一條服務路徑。</p>
<p>佇列路徑的 artifact 對齊重點是「把投遞成功與處理成功拆開記錄」。對 <a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">4.20</a> 要交 <code>Source/Time range/Query link/Owner/Data quality</code>，並覆蓋 consumer lag、retry、DLQ 與 duplicate side-effect；對 <a href="/blog/backend/06-reliability/idempotency-replay/" data-link-title="6.12 Idempotency 與 Replay 驗證" data-link-desc="把重試、重播與冪等性從口頭約定變成可驗證屬性">6.12</a> / <a href="/blog/backend/06-reliability/verification-evidence-handoff/" data-link-title="6.23 Verification Evidence Handoff" data-link-desc="把 SLO、load、chaos、DR 與 readiness 結果包成 release / incident 可用證據">6.23</a> / <a href="/blog/backend/06-reliability/release-gate/" data-link-title="6.8 Release Gate 與變更節奏" data-link-desc="把驗證、migration、相容性納入放行判準">6.8</a> 要交 <code>Gate decision/Checks/Stop condition/Rollback window/Owner</code>，呈現 replay 範圍、去重驗證與補償路徑；對 <a href="/blog/backend/08-incident-response/incident-decision-log/" data-link-title="8.19 Incident Decision Log" data-link-desc="把事中假設、決策、證據、回退條件與責任人留下可復盤紀錄">8.19</a> 要交 <code>Timestamp/Decision/Context/Evidence/Owner/Expected effect/Rollback condition</code>，記錄 pause consumer、drain DLQ、重播啟停的決策序列。</p>
]]></content:encoded></item><item><title>3.C4 LinkedIn：Kafka 分層叢集治理</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/linkedin-kafka-tiered-clusters/</link><pubDate>Thu, 07 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/linkedin-kafka-tiered-clusters/</guid><description>&lt;p>LinkedIn 的 Kafka 分層叢集案例呈現了 Kafka 在規模化之後，瓶頸從「broker 容量」轉移到「workload 互相干擾」。分層的核心判斷是按業務風險隔離，把叢集當成資源治理單位。&lt;/p>
&lt;h2 id="業務背景">業務背景&lt;/h2>
&lt;p>LinkedIn 是 Kafka 的誕生地，內部 Kafka 叢集承載的工作負載涵蓋即時推薦、搜尋索引更新、analytics pipeline、audit log 跟 monitoring。早期所有 workload 共用少數幾個大叢集，隨流量成長，叢集內不同 workload 的資源競爭開始互相影響。&lt;/p>
&lt;p>LinkedIn 的 Kafka 規模是全球最大的之一 — 數千個 broker、每秒數百萬筆訊息、PB 級資料保留。在這個規模下，單一叢集的容量限制是 broker 數量跟 ZooKeeper 的 metadata 管理上限，但更早觸及的限制是 workload 之間的干擾。&lt;/p>
&lt;h2 id="技術挑戰">技術挑戰&lt;/h2>
&lt;h3 id="noisy-neighbor">Noisy neighbor&lt;/h3>
&lt;p>即時推薦系統需要低延遲的 consumer（P99 &amp;lt; 50ms），analytics pipeline 是大量 batch consumer（高吞吐但延遲容忍到秒級）。兩者共用同一組 broker 時，batch consumer 的大範圍 sequential read 佔滿 disk I/O，擠壓即時推薦的 random read latency。&lt;/p>
&lt;p>一個 analytics job 的重跑（backfill 歷史資料）可以讓推薦系統的 consumer lag 從毫秒跳到秒級。在共享叢集中，這種干擾難以預防 — 只能在事後發現、人工協調。&lt;/p>
&lt;h3 id="broker-故障的影響面">Broker 故障的影響面&lt;/h3>
&lt;p>單一叢集中 broker 故障會觸發 partition reassignment，reassignment 的資料搬移佔用 disk I/O 跟網路頻寬。在混合 workload 的叢集中，reassignment 同時影響所有 workload 的效能 — 包括跟故障 broker 無直接關係的 topic。&lt;/p>
&lt;p>叢集越大、topic 越多、reassignment 的影響面越廣。&lt;/p>
&lt;h3 id="容量規劃的模糊邊界">容量規劃的模糊邊界&lt;/h3>
&lt;p>共享叢集的容量規劃沒有清楚的 owner — analytics 團隊說「我們需要更多 retention」、推薦團隊說「我們需要更低 latency」、audit 團隊說「我們的資料不能丟」。三種需求各自合理，但共享叢集無法同時最佳化。&lt;/p>
&lt;h2 id="解法分層叢集">解法：分層叢集&lt;/h2>
&lt;p>LinkedIn 按業務風險跟效能需求把 workload 分配到不同叢集：&lt;/p>
&lt;p>&lt;strong>Tier 1 — 即時關鍵路徑&lt;/strong>：即時推薦、搜尋索引更新、使用者通知。Broker 配置偏向低延遲（SSD、高 IOPS）、replication factor 3、retention 短（保留足夠的 consumer catchup 時間）。&lt;/p>
&lt;p>&lt;strong>Tier 2 — 可靠性要求高但延遲容忍&lt;/strong>：audit log、合規事件、支付事件。配置偏向持久性（replication factor 3、min.insync.replicas 2、acks=all）、retention 長。&lt;/p>
&lt;p>&lt;strong>Tier 3 — 高吞吐分析&lt;/strong>：analytics pipeline、ETL、batch processing。配置偏向吞吐（大 batch size、長 linger.ms、HDD）、retention 最長、容忍偶發 consumer lag。&lt;/p>
&lt;h3 id="分層的判準">分層的判準&lt;/h3>
&lt;p>分層的判準是「這個 workload 故障時，業務影響有多大、多快」：&lt;/p>
&lt;ul>
&lt;li>即時影響使用者體驗 → Tier 1&lt;/li>
&lt;li>影響合規或財務但可容忍分鐘級延遲 → Tier 2&lt;/li>
&lt;li>影響分析準確性但可容忍小時級延遲 → Tier 3&lt;/li>
&lt;/ul>
&lt;h2 id="取捨">取捨&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>面向&lt;/th>
 &lt;th>共享叢集&lt;/th>
 &lt;th>分層叢集&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>資源利用率&lt;/td>
 &lt;td>高（所有 workload 共用資源池）&lt;/td>
 &lt;td>低到中（每層有獨立的保留容量）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>隔離性&lt;/td>
 &lt;td>低（noisy neighbor 互相干擾）&lt;/td>
 &lt;td>高（故障跟效能退化限制在同層）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>運維複雜度&lt;/td>
 &lt;td>低（一組 broker 統一管理）&lt;/td>
 &lt;td>高（多組 broker、各自的監控跟維護）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>容量規劃清晰度&lt;/td>
 &lt;td>模糊（多種需求混合、難以歸因）&lt;/td>
 &lt;td>清楚（每層的需求跟 owner 明確）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>故障影響面&lt;/td>
 &lt;td>廣（reassignment 影響所有 topic）&lt;/td>
 &lt;td>有限（reassignment 只影響同層）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>分層的成本是資源利用率下降 — 每層都需要保留一定的 headroom 應對高峰，加總起來比共享叢集多。LinkedIn 的判斷是隔離性的價值大於利用率的損失 — 推薦系統一次 P99 退化的業務損失遠大於多幾台 broker 的成本。&lt;/p></description><content:encoded><![CDATA[<p>LinkedIn 的 Kafka 分層叢集案例呈現了 Kafka 在規模化之後，瓶頸從「broker 容量」轉移到「workload 互相干擾」。分層的核心判斷是按業務風險隔離，把叢集當成資源治理單位。</p>
<h2 id="業務背景">業務背景</h2>
<p>LinkedIn 是 Kafka 的誕生地，內部 Kafka 叢集承載的工作負載涵蓋即時推薦、搜尋索引更新、analytics pipeline、audit log 跟 monitoring。早期所有 workload 共用少數幾個大叢集，隨流量成長，叢集內不同 workload 的資源競爭開始互相影響。</p>
<p>LinkedIn 的 Kafka 規模是全球最大的之一 — 數千個 broker、每秒數百萬筆訊息、PB 級資料保留。在這個規模下，單一叢集的容量限制是 broker 數量跟 ZooKeeper 的 metadata 管理上限，但更早觸及的限制是 workload 之間的干擾。</p>
<h2 id="技術挑戰">技術挑戰</h2>
<h3 id="noisy-neighbor">Noisy neighbor</h3>
<p>即時推薦系統需要低延遲的 consumer（P99 &lt; 50ms），analytics pipeline 是大量 batch consumer（高吞吐但延遲容忍到秒級）。兩者共用同一組 broker 時，batch consumer 的大範圍 sequential read 佔滿 disk I/O，擠壓即時推薦的 random read latency。</p>
<p>一個 analytics job 的重跑（backfill 歷史資料）可以讓推薦系統的 consumer lag 從毫秒跳到秒級。在共享叢集中，這種干擾難以預防 — 只能在事後發現、人工協調。</p>
<h3 id="broker-故障的影響面">Broker 故障的影響面</h3>
<p>單一叢集中 broker 故障會觸發 partition reassignment，reassignment 的資料搬移佔用 disk I/O 跟網路頻寬。在混合 workload 的叢集中，reassignment 同時影響所有 workload 的效能 — 包括跟故障 broker 無直接關係的 topic。</p>
<p>叢集越大、topic 越多、reassignment 的影響面越廣。</p>
<h3 id="容量規劃的模糊邊界">容量規劃的模糊邊界</h3>
<p>共享叢集的容量規劃沒有清楚的 owner — analytics 團隊說「我們需要更多 retention」、推薦團隊說「我們需要更低 latency」、audit 團隊說「我們的資料不能丟」。三種需求各自合理，但共享叢集無法同時最佳化。</p>
<h2 id="解法分層叢集">解法：分層叢集</h2>
<p>LinkedIn 按業務風險跟效能需求把 workload 分配到不同叢集：</p>
<p><strong>Tier 1 — 即時關鍵路徑</strong>：即時推薦、搜尋索引更新、使用者通知。Broker 配置偏向低延遲（SSD、高 IOPS）、replication factor 3、retention 短（保留足夠的 consumer catchup 時間）。</p>
<p><strong>Tier 2 — 可靠性要求高但延遲容忍</strong>：audit log、合規事件、支付事件。配置偏向持久性（replication factor 3、min.insync.replicas 2、acks=all）、retention 長。</p>
<p><strong>Tier 3 — 高吞吐分析</strong>：analytics pipeline、ETL、batch processing。配置偏向吞吐（大 batch size、長 linger.ms、HDD）、retention 最長、容忍偶發 consumer lag。</p>
<h3 id="分層的判準">分層的判準</h3>
<p>分層的判準是「這個 workload 故障時，業務影響有多大、多快」：</p>
<ul>
<li>即時影響使用者體驗 → Tier 1</li>
<li>影響合規或財務但可容忍分鐘級延遲 → Tier 2</li>
<li>影響分析準確性但可容忍小時級延遲 → Tier 3</li>
</ul>
<h2 id="取捨">取捨</h2>
<table>
  <thead>
      <tr>
          <th>面向</th>
          <th>共享叢集</th>
          <th>分層叢集</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>資源利用率</td>
          <td>高（所有 workload 共用資源池）</td>
          <td>低到中（每層有獨立的保留容量）</td>
      </tr>
      <tr>
          <td>隔離性</td>
          <td>低（noisy neighbor 互相干擾）</td>
          <td>高（故障跟效能退化限制在同層）</td>
      </tr>
      <tr>
          <td>運維複雜度</td>
          <td>低（一組 broker 統一管理）</td>
          <td>高（多組 broker、各自的監控跟維護）</td>
      </tr>
      <tr>
          <td>容量規劃清晰度</td>
          <td>模糊（多種需求混合、難以歸因）</td>
          <td>清楚（每層的需求跟 owner 明確）</td>
      </tr>
      <tr>
          <td>故障影響面</td>
          <td>廣（reassignment 影響所有 topic）</td>
          <td>有限（reassignment 只影響同層）</td>
      </tr>
  </tbody>
</table>
<p>分層的成本是資源利用率下降 — 每層都需要保留一定的 headroom 應對高峰，加總起來比共享叢集多。LinkedIn 的判斷是隔離性的價值大於利用率的損失 — 推薦系統一次 P99 退化的業務損失遠大於多幾台 broker 的成本。</p>
<h2 id="回寫教材的連結">回寫教材的連結</h2>
<ul>
<li><a href="/blog/backend/03-message-queue/broker-basics/" data-link-title="3.1 broker 基礎與投遞模型" data-link-desc="先理解 broker、queue、consumer 與 delivery semantics">3.1 broker basics</a>：broker 配置怎麼影響延遲 vs 吞吐 vs 持久性的取捨。</li>
<li><a href="/blog/backend/06-reliability/dependency-reliability-budget/" data-link-title="6.14 Dependency Reliability Budget" data-link-desc="把內外依賴的可靠性納入 SLO 計算與設計約束">6.14 dependency reliability budget</a>：不同 tier 的 Kafka 叢集各自有不同的 reliability budget。</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>：batch consumer 跟 real-time consumer 的資源消耗差異。</li>
</ul>
<h2 id="判讀徵兆">判讀徵兆</h2>
<p>讀者在自己的系統看到以下訊號時，應該回讀本案例：</p>
<ul>
<li>即時消費者的 consumer lag 因為同叢集的 batch job 而上升</li>
<li>Broker 故障後的 partition reassignment 影響到跟故障無關的 topic</li>
<li>容量規劃會議中不同團隊的需求互相矛盾、無法在同一組配置中滿足</li>
<li>Kafka 叢集的 topic 數量超過 500 個、workload 類型超過三種</li>
</ul>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://engineering.linkedin.com/kafka/running-kafka-scale">Running Kafka at Scale at LinkedIn</a></li>
</ul>
]]></content:encoded></item><item><title>Redis Streams</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/redis-streams/</link><pubDate>Fri, 01 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/redis-streams/</guid><description>&lt;p>Redis Streams 是 Redis 5.0 引入的 append-only log data type、承擔三個責任：輕量 event stream（XADD / XREAD）、consumer group 與 pending entries list（XREADGROUP / XACK）、Redis 生態內整合（避免額外引入 Kafka）。設計取捨偏向「跟 Redis 本體生命週期綁定、低延遲 + 記憶體成本、適合中等規模」。Redis vendor 細節見 &lt;a href="https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/redis/" data-link-title="Redis" data-link-desc="OSS in-memory data structure store、cache 主流">02 redis&lt;/a>。&lt;/p>
&lt;p>對「已用 Redis、需要輕量 stream、不想引入額外基礎設施」這條路徑、Redis Streams 是務實選擇。本頁先給最短路徑、再展開日常 XADD/XREAD 操作與 consumer group 設計、最後進階治理（PEL、retention、Cluster 影響）跟排錯。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>讀完本章後、你應該能：&lt;/p>
&lt;ol>
&lt;li>用 redis-cli XADD / XREAD 操作 stream&lt;/li>
&lt;li>設計 consumer group + XCLAIM 處理 consumer 失敗的訊息接管&lt;/li>
&lt;li>看懂 pending entries list（PEL）累積訊號、定位 consumer 健康&lt;/li>
&lt;li>設計 MAXLEN / MINID retention 對齊記憶體預算&lt;/li>
&lt;li>評估 Redis Cluster 對 Streams 的影響與限制&lt;/li>
&lt;/ol>
&lt;h2 id="最短路徑5-分鐘把-redis-streams-跑起來">最短路徑：5 分鐘把 Redis Streams 跑起來&lt;/h2>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 1. 啟動 Redis（已有 Redis 跳過）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">docker run -d --name redis -p 6379:6379 redis:7
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="c1"># 2. XADD 寫入 stream（&amp;#39;*&amp;#39; 由 Redis 產生遞增 entry ID）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">docker &lt;span class="nb">exec&lt;/span> redis redis-cli XADD mystream &lt;span class="s1">&amp;#39;*&amp;#39;&lt;/span> field1 value1
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="c1"># 3. XREAD 讀取（從 0 起讀、最多 10 筆）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">docker &lt;span class="nb">exec&lt;/span> redis redis-cli XREAD COUNT &lt;span class="m">10&lt;/span> STREAMS mystream &lt;span class="m">0&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="c1"># 4. 建 consumer group 後用 group 模式讀（&amp;#39;&amp;gt;&amp;#39; 取未投遞訊息、進 PEL 等 ack）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">docker &lt;span class="nb">exec&lt;/span> redis redis-cli XGROUP CREATE mystream mygroup &lt;span class="m">0&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">docker &lt;span class="nb">exec&lt;/span> redis redis-cli XREADGROUP GROUP mygroup consumer1 COUNT &lt;span class="m">10&lt;/span> STREAMS mystream &lt;span class="s1">&amp;#39;&amp;gt;&amp;#39;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>最短路徑驗證「Redis 起來、stream 能寫能讀」。實際用 consumer group 場景見&lt;a href="#%e6%97%a5%e5%b8%b8%e6%93%8d%e4%bd%9c%e8%88%87%e6%b1%ba%e7%ad%96%e5%bd%a2%e7%8b%80">日常操作&lt;/a>。&lt;/p>
&lt;h2 id="日常操作與決策形狀">日常操作與決策形狀&lt;/h2>
&lt;h3 id="xadd--xread--xreadgroup">XADD / XREAD / XREADGROUP&lt;/h3>
&lt;p>子議題：&lt;/p>
&lt;ul>
&lt;li>XADD：寫入 entry、&lt;code>*&lt;/code> 自動 ID vs 手動 ID&lt;/li>
&lt;li>XREAD：簡單讀取（無 consumer group、適合單 consumer）&lt;/li>
&lt;li>XREADGROUP：consumer group 模式、配合 ACK&lt;/li>
&lt;li>對應指令範例：&lt;code>XADD&lt;/code>、&lt;code>XREAD&lt;/code>、&lt;code>XREADGROUP&lt;/code>、&lt;code>XACK&lt;/code>、&lt;code>XPENDING&lt;/code>&lt;/li>
&lt;/ul>
&lt;h3 id="consumer-group-與-pel">Consumer group 與 PEL&lt;/h3>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer-group/" data-link-title="Consumer Group" data-link-desc="說明一組 consumer 如何共同分攤 stream 或 topic 的處理責任">Consumer group&lt;/a> 是 Streams 的核心抽象、配合 Pending Entries List（PEL）追蹤未 ack 訊息。子議題：&lt;/p></description><content:encoded><![CDATA[<p>Redis Streams 是 Redis 5.0 引入的 append-only log data type、承擔三個責任：輕量 event stream（XADD / XREAD）、consumer group 與 pending entries list（XREADGROUP / XACK）、Redis 生態內整合（避免額外引入 Kafka）。設計取捨偏向「跟 Redis 本體生命週期綁定、低延遲 + 記憶體成本、適合中等規模」。Redis vendor 細節見 <a href="/blog/backend/02-cache-redis/vendors/redis/" data-link-title="Redis" data-link-desc="OSS in-memory data structure store、cache 主流">02 redis</a>。</p>
<p>對「已用 Redis、需要輕量 stream、不想引入額外基礎設施」這條路徑、Redis Streams 是務實選擇。本頁先給最短路徑、再展開日常 XADD/XREAD 操作與 consumer group 設計、最後進階治理（PEL、retention、Cluster 影響）跟排錯。</p>
<h2 id="本章目標">本章目標</h2>
<p>讀完本章後、你應該能：</p>
<ol>
<li>用 redis-cli XADD / XREAD 操作 stream</li>
<li>設計 consumer group + XCLAIM 處理 consumer 失敗的訊息接管</li>
<li>看懂 pending entries list（PEL）累積訊號、定位 consumer 健康</li>
<li>設計 MAXLEN / MINID retention 對齊記憶體預算</li>
<li>評估 Redis Cluster 對 Streams 的影響與限制</li>
</ol>
<h2 id="最短路徑5-分鐘把-redis-streams-跑起來">最短路徑：5 分鐘把 Redis Streams 跑起來</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"># 1. 啟動 Redis（已有 Redis 跳過）</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">docker run -d --name redis -p 6379:6379 redis:7
</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"># 2. XADD 寫入 stream（&#39;*&#39; 由 Redis 產生遞增 entry ID）</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">docker <span class="nb">exec</span> redis redis-cli XADD mystream <span class="s1">&#39;*&#39;</span> field1 value1
</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"><span class="c1"># 3. XREAD 讀取（從 0 起讀、最多 10 筆）</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">docker <span class="nb">exec</span> redis redis-cli XREAD COUNT <span class="m">10</span> STREAMS mystream <span class="m">0</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="c1"># 4. 建 consumer group 後用 group 模式讀（&#39;&gt;&#39; 取未投遞訊息、進 PEL 等 ack）</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">docker <span class="nb">exec</span> redis redis-cli XGROUP CREATE mystream mygroup <span class="m">0</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">docker <span class="nb">exec</span> redis redis-cli XREADGROUP GROUP mygroup consumer1 COUNT <span class="m">10</span> STREAMS mystream <span class="s1">&#39;&gt;&#39;</span></span></span></code></pre></div><p>最短路徑驗證「Redis 起來、stream 能寫能讀」。實際用 consumer group 場景見<a href="#%e6%97%a5%e5%b8%b8%e6%93%8d%e4%bd%9c%e8%88%87%e6%b1%ba%e7%ad%96%e5%bd%a2%e7%8b%80">日常操作</a>。</p>
<h2 id="日常操作與決策形狀">日常操作與決策形狀</h2>
<h3 id="xadd--xread--xreadgroup">XADD / XREAD / XREADGROUP</h3>
<p>子議題：</p>
<ul>
<li>XADD：寫入 entry、<code>*</code> 自動 ID vs 手動 ID</li>
<li>XREAD：簡單讀取（無 consumer group、適合單 consumer）</li>
<li>XREADGROUP：consumer group 模式、配合 ACK</li>
<li>對應指令範例：<code>XADD</code>、<code>XREAD</code>、<code>XREADGROUP</code>、<code>XACK</code>、<code>XPENDING</code></li>
</ul>
<h3 id="consumer-group-與-pel">Consumer group 與 PEL</h3>
<p><a href="/blog/backend/knowledge-cards/consumer-group/" data-link-title="Consumer Group" data-link-desc="說明一組 consumer 如何共同分攤 stream 或 topic 的處理責任">Consumer group</a> 是 Streams 的核心抽象、配合 Pending Entries List（PEL）追蹤未 ack 訊息。子議題：</p>
<ul>
<li>XGROUP CREATE / SETID / DESTROY</li>
<li>XACK：明確 ack</li>
<li>XPENDING：查 PEL 狀態</li>
<li>XCLAIM / XAUTOCLAIM：consumer 失敗時接管訊息</li>
</ul>
<h3 id="retentionmaxlen--minid">Retention：MAXLEN / MINID</h3>
<p>子議題：</p>
<ul>
<li>MAXLEN：保留最近 N 個 entry（近似或精確）</li>
<li>MINID：保留 ID 大於某值的 entry</li>
<li>XADD 寫入時帶 MAXLEN（最常用）</li>
<li>XTRIM 手動修剪</li>
</ul>
<h2 id="進階主題按需閱讀">進階主題（按需閱讀）</h2>
<p>PEL 失敗接管、retention 與 cluster 影響已展開為 deep article：<a href="xclaim-pel-recovery/">XCLAIM/PEL 失敗接管與 cluster 影響</a>。下列子議題段保留選題判讀入口。</p>
<h3 id="xclaim-與-consumer-失敗接管">XCLAIM 與 consumer 失敗接管</h3>
<p>子議題：</p>
<ul>
<li>Idle time 判讀（min-idle-time 參數）</li>
<li>XAUTOCLAIM（Redis 6.2+、自動接管）</li>
<li>接管後的去重責任（仍需 <a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a>）</li>
</ul>
<h3 id="memory-與-retention-取捨">Memory 與 retention 取捨</h3>
<p>子議題：</p>
<ul>
<li>Stream 佔用 Redis 記憶體、MAXLEN 是主要旋鈕</li>
<li>近似修剪（<code>~</code> 標記）vs 精確修剪的性能差異</li>
<li>配合 <code>maxmemory-policy</code> 與 eviction（注意 stream 不會被 eviction）</li>
</ul>
<h3 id="redis-cluster-對-streams-的影響">Redis Cluster 對 Streams 的影響</h3>
<p>子議題：</p>
<ul>
<li>Stream key 只在單一 shard（無 partition 概念）</li>
<li>多 stream 跨 shard 的設計（用 hash tag 控制分布）</li>
<li>Cluster failover 對 PEL 一致性的影響</li>
</ul>
<h3 id="stream--functionsredis-7">Stream + Functions（Redis 7+）</h3>
<p>子議題：</p>
<ul>
<li>Redis Functions（取代 Lua scripting）</li>
<li>Stream 處理寫成 Redis-side function</li>
<li>適用 / 不適用場景</li>
</ul>
<h3 id="redis-sentinel--cluster-對可靠性的影響">Redis Sentinel / Cluster 對可靠性的影響</h3>
<p>子議題：</p>
<ul>
<li>Replication lag 對 Streams 一致性的影響</li>
<li>AOF 與 RDB 對 Stream 持久化的差異</li>
<li>Failover 期間 PEL 是否完整</li>
</ul>
<h2 id="排錯快速判讀">排錯快速判讀</h2>
<h3 id="pel-累積xpending-數字持續增長">PEL 累積（XPENDING 數字持續增長）</h3>
<p>操作原則：先看是單一 consumer 還是整 group 都累積、再定位 consumer 失敗 vs ACK 漏寫。</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">redis-cli XPENDING mystream mygroup
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># 回傳 PEL 總數 + 每個 consumer 的待 ack 數、定位累積集中在哪個 consumer</span></span></span></code></pre></div><p>判讀路徑：consumer crash 沒 ACK → consumer 慢 → ACK 程式碼漏寫。</p>
<h3 id="memory-pressurestream-佔用過大">Memory pressure（stream 佔用過大）</h3>
<p>操作原則：MAXLEN 沒設或設太大、stream 持續增長。判讀：用 <code>MEMORY USAGE</code> 看 stream 佔用、調整 MAXLEN。</p>
<h3 id="跨-shard-stream-限制">跨 shard stream 限制</h3>
<p>操作原則：Streams 不支援 partition、單 stream 受單 shard 容量限制。設計：用 hash tag 強制分散到多 stream。</p>
<h3 id="consumer-重平衡無原生機制">Consumer 重平衡（無原生機制）</h3>
<p>操作原則：consumer group 沒有自動 rebalance、要手動 XCLAIM 接管。看 idle time 與 XPENDING 判斷該接管哪些。</p>
<h3 id="failover-後-pel-不一致">Failover 後 PEL 不一致</h3>
<p>操作原則：Sentinel / Cluster failover 後、replica 升 primary、PEL 可能不完整。對應 <a href="/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9 語義誤配</a> 的思路。</p>
<h2 id="何時改走其他服務">何時改走其他服務</h2>
<table>
  <thead>
      <tr>
          <th>需求形狀</th>
          <th>改走</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>高吞吐 / 長期 retention</td>
          <td><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></td>
      </tr>
      <tr>
          <td>複雜 routing</td>
          <td><a href="/blog/backend/03-message-queue/vendors/rabbitmq/" data-link-title="RabbitMQ" data-link-desc="Classic message broker、AMQP routing 為主">RabbitMQ</a></td>
      </tr>
      <tr>
          <td>跨節點 stream（partition + replication）</td>
          <td><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> / Pulsar</td>
      </tr>
      <tr>
          <td>輕量 messaging（不需 Redis）</td>
          <td><a href="/blog/backend/03-message-queue/vendors/nats/" data-link-title="NATS" data-link-desc="Lightweight messaging、JetStream 加持久化與 streams">NATS</a></td>
      </tr>
      <tr>
          <td>Managed queue</td>
          <td><a href="/blog/backend/03-message-queue/vendors/aws-sqs/" data-link-title="AWS SQS" data-link-desc="AWS managed queue、簡單可靠、無 ordering（standard）">SQS</a> / <a href="/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Pub/Sub</a></td>
      </tr>
      <tr>
          <td>Redis Pub/Sub（fire-and-forget）</td>
          <td>Redis Pub/Sub（同 Redis、不持久化）</td>
      </tr>
  </tbody>
</table>
<h2 id="不在本頁內的主題">不在本頁內的主題</h2>
<ul>
<li>Redis 本體運維（見 <a href="/blog/backend/02-cache-redis/vendors/redis/" data-link-title="Redis" data-link-desc="OSS in-memory data structure store、cache 主流">02 cache 模組 redis vendor</a>）</li>
<li>各語言 Redis client 完整 API</li>
<li>Redis Pub/Sub 細節（不是 Streams、語意不同）</li>
</ul>
<h2 id="案例回寫">案例回寫</h2>
<h3 id="redis-streams-專屬案例c42-c47">Redis Streams 專屬案例（C42-C47）</h3>
<table>
  <thead>
      <tr>
          <th>案例</th>
          <th>主討論議題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><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。">3.C42 Bitso Reliable Streams</a></td>
          <td>自建抽象層 + DLQ + idempotency</td>
      </tr>
      <tr>
          <td><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。">3.C43 Arcjet 取代 Kafka</a></td>
          <td>Janitor 自寫 retention / 6 位數 → $1k</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/redis-streams-harness-event-driven-state/" data-link-title="3.C44 Harness：CD 微服務 async state transfer" data-link-desc="Harness CD 平台用 Redis Streams 解 brittle HTTP、揭露監控缺口 / MAXLEN truncation / head-of-line blocking 三類問題。">3.C44 Harness event-driven</a></td>
          <td>XAUTOCLAIM head-of-line / 監控缺口</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/redis-streams-klaxit-rust-log-pipeline/" data-link-title="3.C45 Klaxit：Rust &#43; Redis Streams 處理 Heroku Logplex" data-link-desc="Klaxit carpool 用 Redis Streams 處理 Heroku Logplex 匯流、自動偵測修復平台 perf 問題、6 個月 production Rust。">3.C45 Klaxit Rust + Logplex</a></td>
          <td>High-throughput log ingestion / consumer group</td>
      </tr>
      <tr>
          <td><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。">3.C46 Learning.com 退場</a></td>
          <td>（反例）長期事件儲存因成本與延遲退場</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/redis-streams-mateusz-php-microservices/" data-link-title="3.C47 PHP 微服務：Redis Streams &#43; S3 hybrid storage" data-link-desc="PHP 雙微服務通訊、Kafka 在 PHP 生態工具薄弱、用 Redis Streams &#43; payload compression &#43; S3 hybrid 處理大訊息。">3.C47 PHP + S3 hybrid</a></td>
          <td>Payload 大小限制 / hybrid storage</td>
      </tr>
  </tbody>
</table>
<h3 id="跨-vendor-對照">跨 vendor 對照</h3>
<table>
  <thead>
      <tr>
          <th>案例</th>
          <th>對 Redis Streams 的對應</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/slack-job-queue-kafka-redis/" data-link-title="3.C5 Slack：Job Queue 演進到 Kafka &#43; Redis" data-link-desc="背景工作通道在成長期如何從單一路徑演進成組合式架構。">3.C5 Slack Kafka+Redis</a></td>
          <td>多 broker 組合：Kafka 處理量、Redis 處理即時性</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/contrast-queue-model-by-scale/" data-link-title="3.C10 對照：規模差異下的佇列模型" data-link-desc="同一 queue 模型在不同規模下的治理與失敗邊界差異。">3.C10 規模對照</a></td>
          <td>中等規模 / Redis 生態內 / 不跨 shard</td>
      </tr>
  </tbody>
</table>
<p><strong>Stream + Functions / Redis Cluster on Streams 缺直接 customer case</strong>：公開資料多在 single-instance / Sentinel 規模、Cluster 跟 Functions 案例稀薄、撰寫該段時要明示。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>上游概念：<a href="/blog/backend/00-service-selection/async-delivery-selection/" data-link-title="0.3 非同步與事件傳遞選型" data-link-desc="區分背景工作、durable queue、stream、pub/sub 與 outbox 的選型邊界">0.3 非同步選型</a>、<a href="/blog/backend/03-message-queue/broker-basics/" data-link-title="3.1 broker 基礎與投遞模型" data-link-desc="先理解 broker、queue、consumer 與 delivery semantics">3.1 broker basics</a></li>
<li>Redis 本體：<a href="/blog/backend/02-cache-redis/" data-link-title="模組二：快取與 Redis" data-link-desc="整理快取策略、Redis 資料型別與分散式狀態輔助能力">02 cache 模組</a></li>
<li>平行 vendor：<a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka</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>下游能力：<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></li>
</ul>
]]></content:encoded></item><item><title>3.4 consumer 設計與去重</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/consumer-design/</link><pubDate>Thu, 23 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/consumer-design/</guid><description>&lt;p>消費者設計（consumer design）的核心責任是把訊息投遞結果轉成可恢復的業務結果。queue 層提供 delivery 保證，consumer 層提供 processing 與 recovery 保證；三者對齊後，非同步流程才具備可預期性。&lt;/p>
&lt;h2 id="三層語意">三層語意&lt;/h2>
&lt;p>consumer 端需要同時處理三層語意：&lt;/p>
&lt;ol>
&lt;li>delivery semantics：訊息是否被成功投遞與確認，包含 ack/nack、retry、DLQ。&lt;/li>
&lt;li>processing semantics：業務副作用是否可承受重複、亂序與部分失敗。&lt;/li>
&lt;li>recovery semantics：故障後是否能重播、補償與回復到一致狀態。&lt;/li>
&lt;/ol>
&lt;p>這三層拆開後，才能看清問題落在哪一層。訊息送達不代表副作用完成；副作用完成不代表系統可恢復。&lt;/p>
&lt;h2 id="consumer-grouppartition-與順序責任">consumer group、partition 與順序責任&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer-group/" data-link-title="Consumer Group" data-link-desc="說明一組 consumer 如何共同分攤 stream 或 topic 的處理責任">consumer group&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/partition/" data-link-title="Partition" data-link-desc="說明事件流如何切分成多個可並行處理的有序片段">partition&lt;/a> 定義了並行與順序邊界。順序要求高的流程要把同一鍵值固定在同一 partition；吞吐優先的流程可提高 partition 數並分散處理。&lt;/p>
&lt;p>分區策略會直接影響恢復成本。分區鍵混亂時，重播與補償很難限定範圍，事故期間容易擴大影響面。&lt;/p>
&lt;h2 id="checkpointoffset-與-idempotency">checkpoint、offset 與 idempotency&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/checkpoint/" data-link-title="Checkpoint" data-link-desc="說明長時間處理流程如何記錄可恢復進度">checkpoint&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/offset/" data-link-title="Offset" data-link-desc="說明 consumer 在事件流中的讀取位置與重放基準">offset&lt;/a> 的責任是標記「處理到哪裡」，不是「業務一定完成」。寫 checkpoint 的時機要晚於副作用提交，避免進度前移導致資料遺漏。&lt;/p>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency&lt;/a> key 的責任是讓重試與重播可重入。付款、發票、通知、庫存變更都需要明確冪等鍵與去重儲存策略，讓「至少一次投遞」不會變成「多次業務結果」。&lt;/p>
&lt;h2 id="replay-safety">replay safety&lt;/h2>
&lt;p>replay safety 的核心是先定義可重播範圍，再定義副作用控制。常見做法包含：&lt;/p>
&lt;ol>
&lt;li>限定 replay window，避免一次重播跨越多個版本邊界。&lt;/li>
&lt;li>將副作用拆成可比對與可補償動作，保留對帳路徑。&lt;/li>
&lt;li>對 replay 期間的下游壓力設置節流與停損條件。&lt;/li>
&lt;/ol>
&lt;p>poison message 要獨立隔離。持續重試同一壞訊息會壓垮整體吞吐，穩定做法是送入 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/dead-letter-queue/" data-link-title="Dead-Letter Queue" data-link-desc="說明 dead-letter queue 如何隔離多次處理失敗的訊息">dead-letter queue&lt;/a>，再走診斷與修復流程。&lt;/p>
&lt;h2 id="queue-語意誤配是-broker-遷移最常見的失敗模式">Queue 語意誤配是 broker 遷移最常見的失敗模式&lt;/h2>
&lt;p>Broker 遷移失敗的根因通常是 &lt;em>consumer 對舊 broker 行為的隱式依賴&lt;/em>、不是 broker 本身效能。表面上訊息仍被送達、但業務資料開始出現重複扣款、重複寄信、狀態漏更新。&lt;/p>
&lt;p>對應 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9 反例：Queue Semantics Mismatch Cutover&lt;/a> — case 揭露切換後語意誤配三個方向：consumer 依賴特定 offset 行為、依賴特定重試節奏、依賴特定 idempotency 行為。失敗重播時、新系統即使提供相近 delivery semantics、結果可能不同。語意誤配會沿著下游資料寫入擴散、難以靠 queue depth 判斷。&lt;/p>
&lt;p>&lt;strong>典型誤配場景&lt;/strong>（基於通用 broker 行為知識展開、非 3.C9 case 原文具體列舉）：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>At-least-once 假設變成 exactly-once 依賴&lt;/strong>：consumer 假設 broker 僅送一次、靠記憶單次處理；新 broker 重送同一 message、consumer 處理兩次&lt;/li>
&lt;li>&lt;strong>Offset 跳號處理差異&lt;/strong>：舊系統重啟後 offset 從特定位置開始、新系統可能從 latest / earliest 不同位置開始&lt;/li>
&lt;li>&lt;strong>Consumer group rebalance 行為差異&lt;/strong>：rebalance 期間舊系統會 pause 處理、新系統可能繼續處理、產生並發寫入衝突&lt;/li>
&lt;li>&lt;strong>DLQ retry 節奏差異&lt;/strong>：舊系統 DLQ message 預設不重試、新系統可能自動重試、製造重複副作用&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>回退判讀&lt;/strong>：回退前要先確認哪一段資料已經被新語意處理過。直接切回舊 broker 可能讓同一批事件再次被處理。穩定做法是先凍結新 consumer、保留 offset 對照與 replay 範圍、再決定補償或重播。&lt;/p>
&lt;p>詳細處理 / 恢復語意分層見 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/processing-recovery-semantics/" data-link-title="3.6 Processing Semantics 與 Recovery Semantics" data-link-desc="說明投遞成功、處理成功與恢復成功為何是三個不同判斷。">3.6 processing-recovery-semantics&lt;/a>。規模差異判讀（小 / 中 / 大型服務的 job queue 治理重點）見 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/queue-consumer-retry-replay-handoff/" data-link-title="3.8 Queue Consumer Retry 與 Replay Handoff（實作示範）" data-link-desc="以 order_created consumer 示範 queue 路徑如何交付 idempotency evidence、DLQ handling、replay runbook 與 incident decision log。">3.8 queue-consumer-retry-replay-handoff&lt;/a> — 中型服務常見問題是 lag/DLQ 長期累積、需具備定向 replay 能力、否則退回全 topic 重播會放大下游壓力。&lt;/p></description><content:encoded><![CDATA[<p>消費者設計（consumer design）的核心責任是把訊息投遞結果轉成可恢復的業務結果。queue 層提供 delivery 保證，consumer 層提供 processing 與 recovery 保證；三者對齊後，非同步流程才具備可預期性。</p>
<h2 id="三層語意">三層語意</h2>
<p>consumer 端需要同時處理三層語意：</p>
<ol>
<li>delivery semantics：訊息是否被成功投遞與確認，包含 ack/nack、retry、DLQ。</li>
<li>processing semantics：業務副作用是否可承受重複、亂序與部分失敗。</li>
<li>recovery semantics：故障後是否能重播、補償與回復到一致狀態。</li>
</ol>
<p>這三層拆開後，才能看清問題落在哪一層。訊息送達不代表副作用完成；副作用完成不代表系統可恢復。</p>
<h2 id="consumer-grouppartition-與順序責任">consumer group、partition 與順序責任</h2>
<p><a href="/blog/backend/knowledge-cards/consumer-group/" data-link-title="Consumer Group" data-link-desc="說明一組 consumer 如何共同分攤 stream 或 topic 的處理責任">consumer group</a> 與 <a href="/blog/backend/knowledge-cards/partition/" data-link-title="Partition" data-link-desc="說明事件流如何切分成多個可並行處理的有序片段">partition</a> 定義了並行與順序邊界。順序要求高的流程要把同一鍵值固定在同一 partition；吞吐優先的流程可提高 partition 數並分散處理。</p>
<p>分區策略會直接影響恢復成本。分區鍵混亂時，重播與補償很難限定範圍，事故期間容易擴大影響面。</p>
<h2 id="checkpointoffset-與-idempotency">checkpoint、offset 與 idempotency</h2>
<p><a href="/blog/backend/knowledge-cards/checkpoint/" data-link-title="Checkpoint" data-link-desc="說明長時間處理流程如何記錄可恢復進度">checkpoint</a> 與 <a href="/blog/backend/knowledge-cards/offset/" data-link-title="Offset" data-link-desc="說明 consumer 在事件流中的讀取位置與重放基準">offset</a> 的責任是標記「處理到哪裡」，不是「業務一定完成」。寫 checkpoint 的時機要晚於副作用提交，避免進度前移導致資料遺漏。</p>
<p><a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a> key 的責任是讓重試與重播可重入。付款、發票、通知、庫存變更都需要明確冪等鍵與去重儲存策略，讓「至少一次投遞」不會變成「多次業務結果」。</p>
<h2 id="replay-safety">replay safety</h2>
<p>replay safety 的核心是先定義可重播範圍，再定義副作用控制。常見做法包含：</p>
<ol>
<li>限定 replay window，避免一次重播跨越多個版本邊界。</li>
<li>將副作用拆成可比對與可補償動作，保留對帳路徑。</li>
<li>對 replay 期間的下游壓力設置節流與停損條件。</li>
</ol>
<p>poison message 要獨立隔離。持續重試同一壞訊息會壓垮整體吞吐，穩定做法是送入 <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>，再走診斷與修復流程。</p>
<h2 id="queue-語意誤配是-broker-遷移最常見的失敗模式">Queue 語意誤配是 broker 遷移最常見的失敗模式</h2>
<p>Broker 遷移失敗的根因通常是 <em>consumer 對舊 broker 行為的隱式依賴</em>、不是 broker 本身效能。表面上訊息仍被送達、但業務資料開始出現重複扣款、重複寄信、狀態漏更新。</p>
<p>對應 <a href="/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9 反例：Queue Semantics Mismatch Cutover</a> — case 揭露切換後語意誤配三個方向：consumer 依賴特定 offset 行為、依賴特定重試節奏、依賴特定 idempotency 行為。失敗重播時、新系統即使提供相近 delivery semantics、結果可能不同。語意誤配會沿著下游資料寫入擴散、難以靠 queue depth 判斷。</p>
<p><strong>典型誤配場景</strong>（基於通用 broker 行為知識展開、非 3.C9 case 原文具體列舉）：</p>
<ul>
<li><strong>At-least-once 假設變成 exactly-once 依賴</strong>：consumer 假設 broker 僅送一次、靠記憶單次處理；新 broker 重送同一 message、consumer 處理兩次</li>
<li><strong>Offset 跳號處理差異</strong>：舊系統重啟後 offset 從特定位置開始、新系統可能從 latest / earliest 不同位置開始</li>
<li><strong>Consumer group rebalance 行為差異</strong>：rebalance 期間舊系統會 pause 處理、新系統可能繼續處理、產生並發寫入衝突</li>
<li><strong>DLQ retry 節奏差異</strong>：舊系統 DLQ message 預設不重試、新系統可能自動重試、製造重複副作用</li>
</ul>
<p><strong>回退判讀</strong>：回退前要先確認哪一段資料已經被新語意處理過。直接切回舊 broker 可能讓同一批事件再次被處理。穩定做法是先凍結新 consumer、保留 offset 對照與 replay 範圍、再決定補償或重播。</p>
<p>詳細處理 / 恢復語意分層見 <a href="/blog/backend/03-message-queue/processing-recovery-semantics/" data-link-title="3.6 Processing Semantics 與 Recovery Semantics" data-link-desc="說明投遞成功、處理成功與恢復成功為何是三個不同判斷。">3.6 processing-recovery-semantics</a>。規模差異判讀（小 / 中 / 大型服務的 job queue 治理重點）見 <a href="/blog/backend/03-message-queue/queue-consumer-retry-replay-handoff/" data-link-title="3.8 Queue Consumer Retry 與 Replay Handoff（實作示範）" data-link-desc="以 order_created consumer 示範 queue 路徑如何交付 idempotency evidence、DLQ handling、replay runbook 與 incident decision log。">3.8 queue-consumer-retry-replay-handoff</a> — 中型服務常見問題是 lag/DLQ 長期累積、需具備定向 replay 能力、否則退回全 topic 重播會放大下游壓力。</p>
<h2 id="三個工程議題要一起設計">三個工程議題要一起設計</h2>
<p><code>Consumer idempotency</code> + <code>重播流程</code> + <code>下游承載能力</code> 三件事是 consumer design 的鐵三角、需同步落地。缺一個會在規模化時暴露成事故：</p>
<ul>
<li><strong>Consumer idempotency 不完整</strong>：DLQ replay 後產生重複副作用、即使 broker 切換成功、業務帳本仍然錯亂</li>
<li><strong>重播流程不完整</strong>：事故當下需具備定向 replay 能力、否則退回全 topic 重播會放大下游壓力</li>
<li><strong>下游承載能力不足</strong>：consumer 跟 broker 都健康、但下游 DB / API 撐不住 replay 速率、形成新事故</li>
</ul>
<p>Job queue 的拓樸分工是另一個獨立議題、跟鐵三角互補但不重疊 — 詳見 <a href="/blog/backend/03-message-queue/queue-consumer-retry-replay-handoff/" data-link-title="3.8 Queue Consumer Retry 與 Replay Handoff（實作示範）" data-link-desc="以 order_created consumer 示範 queue 路徑如何交付 idempotency evidence、DLQ handling、replay runbook 與 incident decision log。">3.8 Job queue 拓樸分工</a>、主寫 Slack Kafka + Redis 案例。consumer 內部三件事要做好之外、不同類工作（高吞吐 / 即時 / 持久）也應專注單一目標、其他目標拆到對應路徑。</p>
<h2 id="判讀訊號">判讀訊號</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>判讀重點</th>
          <th>對應動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/consumer-lag/" data-link-title="Consumer Lag" data-link-desc="說明 consumer lag 如何反映訊息堆積、處理能力與容量風險">consumer lag</a> 持續上升</td>
          <td>consumer 吞吐低於輸入速率</td>
          <td>提升併發、拆分 partition、檢查下游瓶頸</td>
      </tr>
      <tr>
          <td>retry count 上升且成功率下降</td>
          <td>錯誤已從暫時性轉為系統性</td>
          <td>啟動降級、切換路由、保留重播窗口</td>
      </tr>
      <tr>
          <td>duplicate side effect 增加</td>
          <td>冪等鍵或去重流程失效</td>
          <td>修正 idempotency store、暫停高風險副作用</td>
      </tr>
      <tr>
          <td>DLQ 量快速增加</td>
          <td>payload 或版本相容性問題集中爆發</td>
          <td>分批隔離、加 schema 檢查、修復後定向重播</td>
      </tr>
      <tr>
          <td>replay 期間下游 timeout 同步上升</td>
          <td>重播速率超出依賴容量</td>
          <td>節流 replay、分段回放、加 backpressure 控制</td>
      </tr>
  </tbody>
</table>
<h2 id="常見誤區">常見誤區</h2>
<p>把 consumer 設計等同於「把 handler 寫完」，會漏掉恢復責任。consumer 的工程價值在於故障後仍可追蹤、可補償、可重播。</p>
<p>把 DLQ 當成終點，會讓問題在下次事件再出現。DLQ 的責任是隔離與診斷入口，最終要回到 schema、邏輯或依賴治理。</p>
<h2 id="案例回寫">案例回寫</h2>
<p>consumer 恢復語意可用 <a href="/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9 反例</a> 與 <a href="/blog/backend/03-message-queue/cases/linkedin-topicgc-kafka-governance/" data-link-title="3.C3 LinkedIn：TopicGC 與 Kafka 治理轉換" data-link-desc="Kafka topic 從手動治理轉自動治理對叢集的影響。">3.C3 LinkedIn：TopicGC</a> 對照回寫。先判讀問題是 idempotency 失效、checkpoint 前移，還是 replay 邊界失控，再對應本章的 processing/recovery 段落。
這組案例主要支撐的是「處理恢復語意」判讀，不直接支撐 deployment drain 或 cache eviction；若根因在切流順序或快取容量，應轉到 5.3 或 2.3。</p>
<p>若重播成功但業務狀態仍不一致，先補副作用補償與對帳路徑，並把決策證據同步到 <a href="/blog/backend/08-incident-response/incident-decision-log/" data-link-title="8.19 Incident Decision Log" data-link-desc="把事中假設、決策、證據、回退條件與責任人留下可復盤紀錄">8.19 Incident Decision Log</a>。</p>
<h2 id="跨模組路由">跨模組路由</h2>
<p>consumer 設計是 01/03/04/06/08 的交界點。</p>
<ol>
<li>與 03 內部的交接：processing/recovery 語意完整定義在 <a href="/blog/backend/03-message-queue/processing-recovery-semantics/" data-link-title="3.6 Processing Semantics 與 Recovery Semantics" data-link-desc="說明投遞成功、處理成功與恢復成功為何是三個不同判斷。">3.6 processing-recovery-semantics</a>；event contract 跟 replay boundary 在 <a href="/blog/backend/03-message-queue/event-contract-replay-boundary/" data-link-title="3.7 Event Contract 與 Replay Boundary" data-link-desc="說明 event schema、idempotency key、replay window 與補償如何先於 broker 選型。">3.7</a>；規模差異判讀跟 job queue 拓樸分工在 <a href="/blog/backend/03-message-queue/queue-consumer-retry-replay-handoff/" data-link-title="3.8 Queue Consumer Retry 與 Replay Handoff（實作示範）" data-link-desc="以 order_created consumer 示範 queue 路徑如何交付 idempotency evidence、DLQ handling、replay runbook 與 incident decision log。">3.8</a>。</li>
<li>與 01 的交接：交易與發布一致性回到 <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/01-database/transaction-boundary/" data-link-title="1.3 Transaction 與一致性邊界" data-link-desc="交易邊界、isolation level、retry 策略、distributed transaction（2PC、Saga）與跨 region 強一致取捨">1.3 transaction boundary</a>。</li>
<li>與 04 的交接：lag、retry、DLQ、duplicate 指標進入 <a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">Observability Evidence Package</a>。</li>
<li>與 06 的交接：重試與重播驗證進入 <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>與 08 的交接：pause consumer、replay 決策與補償判斷記錄到 <a href="/blog/backend/08-incident-response/incident-decision-log/" data-link-title="8.19 Incident Decision Log" data-link-desc="把事中假設、決策、證據、回退條件與責任人留下可復盤紀錄">Incident Decision Log</a>。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<p>要看 processing / recovery 三層語意完整定義、接著讀 <a href="/blog/backend/03-message-queue/processing-recovery-semantics/" data-link-title="3.6 Processing Semantics 與 Recovery Semantics" data-link-desc="說明投遞成功、處理成功與恢復成功為何是三個不同判斷。">3.6 processing-recovery-semantics</a>。要建立 broker 層投遞模型，接著讀 <a href="/blog/backend/03-message-queue/broker-basics/" data-link-title="3.1 broker 基礎與投遞模型" data-link-desc="先理解 broker、queue、consumer 與 delivery semantics">3.1 broker 基礎與投遞模型</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>。要看錯誤切換案例，接著讀 <a href="/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9 反例</a>。</p>
]]></content:encoded></item><item><title>3.C5 Slack：Job Queue 演進到 Kafka + Redis</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/slack-job-queue-kafka-redis/</link><pubDate>Thu, 07 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/slack-job-queue-kafka-redis/</guid><description>&lt;p>這個案例的核心責任是說明工作佇列轉換常是拓樸重整，而不是單點替換。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>Slack 在 job queue 擴展中使用 Kafka 與 Redis 分工，處理吞吐與即時性需求。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>當背景工作同時要高吞吐與快速反應，單一通道模型通常會變成瓶頸。&lt;/p>
&lt;h2 id="策略">策略&lt;/h2>
&lt;ol>
&lt;li>把不同工作類型切到不同傳遞路徑。&lt;/li>
&lt;li>分別治理持久性與即時性目標。&lt;/li>
&lt;li>以 lag、重試與失敗重播驗證穩定性。&lt;/li>
&lt;/ol>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/durable-queue/" data-link-title="3.2 durable queue 與重試策略" data-link-desc="整理持久化佇列、DLQ 與重試流程">3.2 durable queue&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/outbox-pattern/" data-link-title="3.3 outbox pattern 與發佈一致性" data-link-desc="把 transaction 與 event publish 分離">3.3 outbox pattern&lt;/a>。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://slack.engineering/scaling-slacks-job-queue/">Scaling Slack&amp;rsquo;s Job Queue&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明工作佇列轉換常是拓樸重整，而不是單點替換。</p>
<h2 id="觀察">觀察</h2>
<p>Slack 在 job queue 擴展中使用 Kafka 與 Redis 分工，處理吞吐與即時性需求。</p>
<h2 id="判讀">判讀</h2>
<p>當背景工作同時要高吞吐與快速反應，單一通道模型通常會變成瓶頸。</p>
<h2 id="策略">策略</h2>
<ol>
<li>把不同工作類型切到不同傳遞路徑。</li>
<li>分別治理持久性與即時性目標。</li>
<li>以 lag、重試與失敗重播驗證穩定性。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <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> 與 <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>。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://slack.engineering/scaling-slacks-job-queue/">Scaling Slack&rsquo;s Job Queue</a></li>
</ul>
]]></content:encoded></item><item><title>AWS SQS</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/aws-sqs/</link><pubDate>Fri, 01 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/aws-sqs/</guid><description>&lt;p>AWS SQS 是 AWS managed queue 服務、承擔三個責任：訊息排隊與重試（visibility timeout + DLQ）、解耦 producer / consumer（無 broker 運維）、AWS 生態原生整合（Lambda / EventBridge / Step Functions）。設計取捨偏向「極簡 API + managed 運維、用 visibility timeout 取代 broker ACK、無原生 ordering（standard queue）」。&lt;/p>
&lt;p>對「AWS 生態 task queue、不想自管 broker、配合 Lambda 事件處理」這條路徑、SQS 是首選。本頁先給最短路徑、再展開日常 SendMessage / ReceiveMessage 操作與 visibility timeout 設計、最後進階治理（FIFO、DLQ、IAM、VPC endpoint）跟排錯。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>讀完本章後、你應該能：&lt;/p>
&lt;ol>
&lt;li>用 AWS CLI 建 standard / FIFO queue、發送與接收訊息&lt;/li>
&lt;li>設計 visibility timeout 對齊 consumer 處理時間&lt;/li>
&lt;li>配置 DLQ（dead-letter queue）與 maxReceiveCount&lt;/li>
&lt;li>區分 long polling vs short polling、配合 Lambda event source mapping&lt;/li>
&lt;li>評估 IAM policy、VPC endpoint、cross-account 訪問等治理場景&lt;/li>
&lt;/ol>
&lt;h2 id="最短路徑5-分鐘把-sqs-跑起來">最短路徑：5 分鐘把 SQS 跑起來&lt;/h2>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 1. 建 queue（回傳 QueueUrl、後續操作都用它）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">aws sqs create-queue --queue-name demo-queue
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1"># 2. 發送訊息&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">aws sqs send-message --queue-url &amp;lt;url&amp;gt; --message-body &lt;span class="s2">&amp;#34;hello&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="c1"># 3. 接收訊息（long polling、最多等 20 秒）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">aws sqs receive-message --queue-url &amp;lt;url&amp;gt; --wait-time-seconds &lt;span class="m">20&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>最短路徑驗證「queue 建得起來、能發能收」。實際應用配合 SDK / Lambda、見&lt;a href="#%e6%97%a5%e5%b8%b8%e6%93%8d%e4%bd%9c%e8%88%87%e6%b1%ba%e7%ad%96%e5%bd%a2%e7%8b%80">日常操作&lt;/a>。指令對真實 AWS 需設定 credentials 與 region；本機要先驗證可加 &lt;code>--endpoint-url&lt;/code> 指向 SQS-相容的 local 模擬器跑同一組指令。&lt;/p>
&lt;h2 id="日常操作與決策形狀">日常操作與決策形狀&lt;/h2>
&lt;h3 id="aws-cli-與-sdk">AWS CLI 與 SDK&lt;/h3>
&lt;p>子議題：&lt;/p>
&lt;ul>
&lt;li>AWS CLI 指令對照表（create-queue / send-message / receive-message / delete-message / set-queue-attributes）&lt;/li>
&lt;li>SDK 配置：region / credentials / retry policy / timeout&lt;/li>
&lt;li>Batch operation（SendMessageBatch、DeleteMessageBatch、最多 10 條）&lt;/li>
&lt;li>對應指令範例：&lt;code>aws sqs get-queue-attributes --queue-url &amp;lt;url&amp;gt;&lt;/code>&lt;/li>
&lt;/ul>
&lt;h3 id="standard-vs-fifo-queue">Standard vs FIFO queue&lt;/h3>
&lt;p>子議題：&lt;/p>
&lt;ul>
&lt;li>Standard：高吞吐、at-least-once、無 ordering、適合多數 task queue&lt;/li>
&lt;li>FIFO：exactly-once-ish（去重 5 分鐘窗口）、ordering（per MessageGroupId）、吞吐受限（3000 msg/sec with batching）&lt;/li>
&lt;li>選擇判讀（ordering 需求 vs 吞吐）&lt;/li>
&lt;/ul>
&lt;h3 id="visibility-timeout-與-in-flight">Visibility timeout 與 in-flight&lt;/h3>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/in-flight/" data-link-title="In-Flight Work" data-link-desc="目前已接收但尚未完成處理的工作量">Visibility timeout&lt;/a> 是 SQS 的 delivery 控制機制、取代 broker ACK：&lt;/p></description><content:encoded><![CDATA[<p>AWS SQS 是 AWS managed queue 服務、承擔三個責任：訊息排隊與重試（visibility timeout + DLQ）、解耦 producer / consumer（無 broker 運維）、AWS 生態原生整合（Lambda / EventBridge / Step Functions）。設計取捨偏向「極簡 API + managed 運維、用 visibility timeout 取代 broker ACK、無原生 ordering（standard queue）」。</p>
<p>對「AWS 生態 task queue、不想自管 broker、配合 Lambda 事件處理」這條路徑、SQS 是首選。本頁先給最短路徑、再展開日常 SendMessage / ReceiveMessage 操作與 visibility timeout 設計、最後進階治理（FIFO、DLQ、IAM、VPC endpoint）跟排錯。</p>
<h2 id="本章目標">本章目標</h2>
<p>讀完本章後、你應該能：</p>
<ol>
<li>用 AWS CLI 建 standard / FIFO queue、發送與接收訊息</li>
<li>設計 visibility timeout 對齊 consumer 處理時間</li>
<li>配置 DLQ（dead-letter queue）與 maxReceiveCount</li>
<li>區分 long polling vs short polling、配合 Lambda event source mapping</li>
<li>評估 IAM policy、VPC endpoint、cross-account 訪問等治理場景</li>
</ol>
<h2 id="最短路徑5-分鐘把-sqs-跑起來">最短路徑：5 分鐘把 SQS 跑起來</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"># 1. 建 queue（回傳 QueueUrl、後續操作都用它）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">aws sqs create-queue --queue-name demo-queue
</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"># 2. 發送訊息</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">aws sqs send-message --queue-url &lt;url&gt; --message-body <span class="s2">&#34;hello&#34;</span>
</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"><span class="c1"># 3. 接收訊息（long polling、最多等 20 秒）</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">aws sqs receive-message --queue-url &lt;url&gt; --wait-time-seconds <span class="m">20</span></span></span></code></pre></div><p>最短路徑驗證「queue 建得起來、能發能收」。實際應用配合 SDK / Lambda、見<a href="#%e6%97%a5%e5%b8%b8%e6%93%8d%e4%bd%9c%e8%88%87%e6%b1%ba%e7%ad%96%e5%bd%a2%e7%8b%80">日常操作</a>。指令對真實 AWS 需設定 credentials 與 region；本機要先驗證可加 <code>--endpoint-url</code> 指向 SQS-相容的 local 模擬器跑同一組指令。</p>
<h2 id="日常操作與決策形狀">日常操作與決策形狀</h2>
<h3 id="aws-cli-與-sdk">AWS CLI 與 SDK</h3>
<p>子議題：</p>
<ul>
<li>AWS CLI 指令對照表（create-queue / send-message / receive-message / delete-message / set-queue-attributes）</li>
<li>SDK 配置：region / credentials / retry policy / timeout</li>
<li>Batch operation（SendMessageBatch、DeleteMessageBatch、最多 10 條）</li>
<li>對應指令範例：<code>aws sqs get-queue-attributes --queue-url &lt;url&gt;</code></li>
</ul>
<h3 id="standard-vs-fifo-queue">Standard vs FIFO queue</h3>
<p>子議題：</p>
<ul>
<li>Standard：高吞吐、at-least-once、無 ordering、適合多數 task queue</li>
<li>FIFO：exactly-once-ish（去重 5 分鐘窗口）、ordering（per MessageGroupId）、吞吐受限（3000 msg/sec with batching）</li>
<li>選擇判讀（ordering 需求 vs 吞吐）</li>
</ul>
<h3 id="visibility-timeout-與-in-flight">Visibility timeout 與 in-flight</h3>
<p><a href="/blog/backend/knowledge-cards/in-flight/" data-link-title="In-Flight Work" data-link-desc="目前已接收但尚未完成處理的工作量">Visibility timeout</a> 是 SQS 的 delivery 控制機制、取代 broker ACK：</p>
<ul>
<li>訊息被接收後變 in-flight、其他 consumer 看不到</li>
<li>Consumer 處理完呼叫 DeleteMessage、否則 timeout 後回到 queue</li>
<li>ChangeMessageVisibility 動態延長（長任務）</li>
<li>預設 30 秒、上限 12 小時</li>
</ul>
<h3 id="dlq-設計dead-letter-queue">DLQ 設計（dead-letter queue）</h3>
<p>子議題：</p>
<ul>
<li>maxReceiveCount：訊息被接收 N 次後送 DLQ</li>
<li>DLQ 監控與 alarm（CloudWatch metric）</li>
<li>Redrive policy（從 DLQ 重新放回 main queue）</li>
<li>對應 <a href="/blog/backend/knowledge-cards/poison-message/" data-link-title="Poison Message" data-link-desc="說明特定訊息內容如何穩定造成 consumer 失敗">poison message</a> 處理思路</li>
</ul>
<h2 id="進階主題按需閱讀">進階主題（按需閱讀）</h2>
<p>visibility timeout、polling、Lambda event source 與 cost 已展開為 deep article：<a href="visibility-polling-lambda-cost/">visibility timeout / long polling / Lambda + cost</a>。下列子議題段保留選題判讀入口。</p>
<h3 id="long-polling-vs-short-polling">Long polling vs Short polling</h3>
<p>子議題：</p>
<ul>
<li>Short polling（預設）：立即回應、可能空回（高 cost）</li>
<li>Long polling（WaitTimeSeconds 1-20）：等到有訊息或超時</li>
<li>對 cost 與 latency 的取捨</li>
</ul>
<h3 id="sqs--lambda-event-source-mapping">SQS + Lambda event source mapping</h3>
<p>子議題：</p>
<ul>
<li>Lambda 自動 poll SQS（managed event source）</li>
<li>Batch size / batch window 配置</li>
<li>Partial batch failure（ReportBatchItemFailures）</li>
<li>對應 <a href="/blog/backend/03-message-queue/cases/cloudflare-queues-global-delivery-model/" data-link-title="3.C8 Cloudflare：Queues 全球交付模型" data-link-desc="事件佇列服務在全球網路下的交付語義與治理案例。">3.C8 Cloudflare Queues</a> 的全球交付對照</li>
</ul>
<h3 id="iam--cross-account-訪問">IAM / Cross-account 訪問</h3>
<p>子議題：</p>
<ul>
<li>Queue policy（resource-based）vs IAM policy（identity-based）</li>
<li>Cross-account producer / consumer 設定</li>
<li>Encryption（SSE-SQS / SSE-KMS）</li>
</ul>
<h3 id="vpc-endpoint私網訪問">VPC endpoint（私網訪問）</h3>
<p>子議題：</p>
<ul>
<li>Interface endpoint（PrivateLink）</li>
<li>適合不想經 public internet 的場景</li>
<li>跟 NAT Gateway 的 cost 對照</li>
</ul>
<h3 id="cloudwatch-metric-與-alarm">CloudWatch metric 與 alarm</h3>
<p>子議題：</p>
<ul>
<li>ApproximateNumberOfMessagesVisible（queue depth）</li>
<li>ApproximateAgeOfOldestMessage（lag 訊號）</li>
<li>NumberOfMessagesSent / Received / Deleted</li>
<li>Alarm 設計（depth 暴增、age 超 SLO）</li>
</ul>
<h3 id="cost-模型">Cost 模型</h3>
<p>子議題：</p>
<ul>
<li>Request cost（每百萬 request）</li>
<li>Data transfer cost（跨 region 才有）</li>
<li>FIFO 比 standard 貴的判讀</li>
<li>對應 <a href="/blog/backend/00-service-selection/cost-risk-tradeoffs/" data-link-title="0.6 成本、風險與選型取捨" data-link-desc="用人力成本、雲端成本、操作成本與失敗代價判斷後端能力投入順序">0.6 成本取捨</a></li>
</ul>
<h2 id="排錯快速判讀">排錯快速判讀</h2>
<h3 id="message-反覆-redelivery看到同訊息多次">Message 反覆 redelivery（看到同訊息多次）</h3>
<p>操作原則：visibility timeout 設定 &lt; consumer 處理時間、訊息回 queue 又被另一 consumer 領走。</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">aws sqs get-queue-attributes --queue-url &lt;url&gt; --attribute-names VisibilityTimeout
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># 新建 queue 預設 VisibilityTimeout 為 30 秒、處理時間長於此值就會看到 redelivery</span></span></span></code></pre></div><p>調整：延長 VisibilityTimeout 或 consumer 主動 ChangeMessageVisibility。</p>
<h3 id="dlq-累積">DLQ 累積</h3>
<p>操作原則：先看 DLQ 訊息內容、判斷 poison message vs 下游卡。</p>
<p>判讀路徑：訊息格式錯（永遠失敗）→ 下游服務 down（暫時失敗、可 redrive）→ consumer bug。</p>
<h3 id="throttlingaccount-quota">Throttling（account quota）</h3>
<p>操作原則：超過 account-level SendMessage / ReceiveMessage TPS、看 CloudWatch ThrottledRequests。處理：requeue exchange、quota 申請。</p>
<h3 id="iam-權限錯">IAM 權限錯</h3>
<p>操作原則：access denied 大多是 queue policy 跟 IAM policy 互動。判讀：用 IAM Policy Simulator 或 CloudTrail 看 deny 原因。</p>
<h3 id="lambda-event-source-失敗">Lambda event source 失敗</h3>
<p>操作原則：Lambda 失敗會自動 retry、超過 retry 進 DLQ。看 Lambda 的 DLQ 跟 SQS 的 DLQ 分工。</p>
<h2 id="何時改走其他服務">何時改走其他服務</h2>
<table>
  <thead>
      <tr>
          <th>需求形狀</th>
          <th>改走</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>需要 streaming / replay</td>
          <td>AWS Kinesis / <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> / MSK</td>
      </tr>
      <tr>
          <td>需要 pub/sub fan-out</td>
          <td>AWS SNS（搭配 SQS 做 fan-out）/ EventBridge</td>
      </tr>
      <tr>
          <td>需要複雜 routing</td>
          <td><a href="/blog/backend/03-message-queue/vendors/rabbitmq/" data-link-title="RabbitMQ" data-link-desc="Classic message broker、AMQP routing 為主">RabbitMQ</a> on EC2</td>
      </tr>
      <tr>
          <td>跨雲 / 跨平台</td>
          <td><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></td>
      </tr>
      <tr>
          <td>嚴格低延遲（&lt; 100ms）</td>
          <td><a href="/blog/backend/03-message-queue/vendors/nats/" data-link-title="NATS" data-link-desc="Lightweight messaging、JetStream 加持久化與 streams">NATS</a> / Redis</td>
      </tr>
      <tr>
          <td>Workflow + durable execution</td>
          <td>AWS Step Functions / Temporal</td>
      </tr>
  </tbody>
</table>
<h2 id="不在本頁內的主題">不在本頁內的主題</h2>
<ul>
<li>SNS / EventBridge 細節（另開 cloud event routing 章節）</li>
<li>Step Functions / Lambda 完整功能</li>
<li>AWS SDK 各語言完整 API</li>
</ul>
<h2 id="案例回寫">案例回寫</h2>
<h3 id="sqs-專屬案例c48-c59">SQS 專屬案例（C48-C59）</h3>
<table>
  <thead>
      <tr>
          <th>案例</th>
          <th>主討論議題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/sqs-airbnb-dynein-delayed-jobs/" data-link-title="3.C48 Airbnb Dynein：SQS 分散式延遲任務排程" data-link-desc="Airbnb 用 SQS at-least-once &#43; DLQ 取代 Resque 單 Redis 限制、每 scheduler 1000 QPS、SQS wrap DynamoDB 處理 &gt; 15 分鐘 delay。">3.C48 Airbnb Dynein</a></td>
          <td>分散式延遲任務 / at-least-once + DLQ</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/sqs-airbnb-inspekt-data-protection/" data-link-title="3.C49 Airbnb Inspekt：Visibility timeout 當 retry budget" data-link-desc="Airbnb Inspekt 隱私掃描器、scanner pull message、visibility timeout 自然觸發重現、用重現次數當 retry budget。">3.C49 Airbnb Inspekt</a></td>
          <td>Visibility timeout 當隱式 retry</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/sqs-capital-one-visibility-timeout/" data-link-title="3.C50 Capital One：Visibility timeout 設計與 Lambda event source" data-link-desc="Capital One tech blog 講 SQS &#43; Lambda：visibility timeout 應略高於最大處理時間、Lambda 初 5 個 long polling、可擴 60/min。">3.C50 Capital One</a></td>
          <td>Visibility timeout / Lambda event source</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/sqs-atlassian-jirt-kinesis-sqs/" data-link-title="3.C51 Atlassian JiRT：Kinesis &#43; SQS subscription" data-link-desc="Atlassian StreamHub Kinesis 底層、每 consumer 自己一個 SQS queue、JiRT 把輪詢 1 min 改成秒級 event-driven。">3.C51 Atlassian JiRT</a></td>
          <td>Kinesis + per-consumer SQS</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/sqs-nielsen-spark-eks-dual-queue/" data-link-title="3.C52 Nielsen：Spark on EKS 雙 SQS 工作流" data-link-desc="Nielsen 每日 25TB / 30B event、work queue &#43; completion queue 雙 SQS、queue depth autoscale EKS pod。">3.C52 Nielsen Spark on EKS</a></td>
          <td>雙 SQS / queue depth autoscale</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/sqs-finra-large-file-service/" data-link-title="3.C53 FINRA：S3 → SQS notification 大檔上傳" data-link-desc="FINRA 金融監管、broker 上傳大檔、S3 → SQS notification → LFS、KMS &#43; bucket policy &#43; queue policy 三層稽核。">3.C53 FINRA Large File</a></td>
          <td>S3 → SQS 合規 / IAM 多層稽核</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/sqs-twitch-eventsub-fanout/" data-link-title="3.C54 Twitch EventSub：SNS&#43;SQS fan-out 給第三方" data-link-desc="Twitch Event Bus ~1660 events/sec 進 SNS、EventSub 用 SQS 接收 &#43; Dispatcher fan-out 給訂閱者。">3.C54 Twitch EventSub</a></td>
          <td>SNS-SQS fan-out + Dispatcher</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/sqs-smugmug-search-pipeline/" data-link-title="3.C55 SmugMug：SQS 驅動可重放搜尋管線" data-link-desc="SmugMug 用 SQS 兩種模式：DynamoDB scan-segment 平行 backfill &#43; production query 鏡像 replay 到 replica。">3.C55 SmugMug search</a></td>
          <td>Workload generator / 平行 scan + replay</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/sqs-postnl-mission-critical-ebe/" data-link-title="3.C56 PostNL EBE：完整 DLQ &#43; retention &#43; redrive 設計" data-link-desc="PostNL 物流每天 1000 萬訊息、每 producer/consumer 隔離 stack、24h 內 100 次 retry、final DLQ 可 consumer redrive。">3.C56 PostNL EBE</a></td>
          <td>完整 DLQ + redrive + 隔離 stack</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/sqs-lob-sqs-consumer-library/" data-link-title="3.C57 Lob：自家 fork @lob/sqs-consumer 修 FIFO bug" data-link-desc="Lob 原用 bbc/sqs-consumer 鎖 SDK v2、fork 出 @lob/sqs-consumer 支援 SDK v3 &#43; TypeScript &#43; 修 FIFO bug。">3.C57 Lob sqs-consumer</a></td>
          <td>Client library / SDK v3 / FIFO bug</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/sqs-twilio-webhook-buffer/" data-link-title="3.C58 Twilio：SQS 緩衝高流量 webhook" data-link-desc="Twilio 教用 SQS 緩衝 SMS / status callback webhook、分 queue（SMS vs callback）、long polling 減 cost、FIFO 300 TPS 上限要分片。">3.C58 Twilio webhook</a></td>
          <td>Webhook → SQS buffer / FIFO 300 TPS</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/sqs-rapid7-scale-billion-messages/" data-link-title="3.C59 Rapid7：SQS 100 億 message/day 規模" data-link-desc="Rapid7 公開引述：SQS 撐 10s of billions of messages per day、是架構關鍵元件、scale 量級的具體參考。">3.C59 Rapid7 scale</a></td>
          <td>100 億 msg/day 規模參考點</td>
      </tr>
  </tbody>
</table>
<h3 id="跨-vendor-對照">跨 vendor 對照</h3>
<table>
  <thead>
      <tr>
          <th>案例</th>
          <th>對 SQS 的對應</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/vmware-kafka-to-msk/" data-link-title="3.C2 VMware Tanzu CloudHealth：Kafka 轉 Amazon MSK" data-link-desc="自管 Kafka 遷移到託管平台時的治理重點。">3.C2 VMware → MSK</a></td>
          <td>反面對照：何時 managed queue 不夠用、要升 streaming</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/cloudflare-queues-global-delivery-model/" data-link-title="3.C8 Cloudflare：Queues 全球交付模型" data-link-desc="事件佇列服務在全球網路下的交付語義與治理案例。">3.C8 Cloudflare Queues</a></td>
          <td>全球交付對照（SQS 是 region-scoped）</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/contrast-queue-model-by-scale/" data-link-title="3.C10 對照：規模差異下的佇列模型" data-link-desc="同一 queue 模型在不同規模下的治理與失敗邊界差異。">3.C10 規模對照</a></td>
          <td>小型直接用 SQS / 中型補 idempotency / 大型補 streaming</td>
      </tr>
  </tbody>
</table>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>上游概念：<a href="/blog/backend/00-service-selection/async-delivery-selection/" data-link-title="0.3 非同步與事件傳遞選型" data-link-desc="區分背景工作、durable queue、stream、pub/sub 與 outbox 的選型邊界">0.3 非同步選型</a>、<a href="/blog/backend/03-message-queue/broker-basics/" data-link-title="3.1 broker 基礎與投遞模型" data-link-desc="先理解 broker、queue、consumer 與 delivery semantics">3.1 broker basics</a></li>
<li>平行 vendor：<a href="/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Google Pub/Sub</a>、<a href="/blog/backend/03-message-queue/vendors/rabbitmq/" data-link-title="RabbitMQ" data-link-desc="Classic message broker、AMQP routing 為主">RabbitMQ</a></li>
<li>下游能力：<a href="/blog/backend/03-message-queue/durable-queue/" data-link-title="3.2 durable queue 與重試策略" data-link-desc="整理持久化佇列、DLQ 與重試流程">3.2 durable queue</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></li>
</ul>
]]></content:encoded></item><item><title>3.5 攻擊者視角（紅隊）：傳遞層弱點判讀</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/red-team-delivery-layer/</link><pubDate>Fri, 24 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/red-team-delivery-layer/</guid><description>&lt;p>傳遞層紅隊判讀的核心目標是確認「訊息如何被重送、重放、放大與耗盡資源」。這裡的紅隊指攻擊者視角的風險檢查：先找可被放大的傳遞路徑，再回推控制面。只要系統採用 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker&lt;/a> 或 stream，弱點就會同時落在 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/delivery-semantics/" data-link-title="Delivery Semantics" data-link-desc="說明事件投遞語意如何定義遺失、重複、順序與補償策略">delivery semantics&lt;/a>、consumer 容量與回復流程。&lt;/p>
&lt;h2 id="判讀傳遞層弱點的主要軸線">【判讀】傳遞層弱點的主要軸線&lt;/h2>
&lt;p>傳遞層弱點可分成三條軸線：投遞語意、處理語意、回復語意。投遞語意看 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">ack/nack&lt;/a> 與重送條件；處理語意看 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency&lt;/a> 與 side effect；回復語意看 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/dead-letter-queue/" data-link-title="Dead-Letter Queue" data-link-desc="說明 dead-letter queue 如何隔離多次處理失敗的訊息">dead-letter queue&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/replay-runbook/" data-link-title="Replay Runbook" data-link-desc="說明事件重放前需要控制的範圍、順序、驗證與副作用">replay runbook&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/data-reconciliation/" data-link-title="Data Reconciliation" data-link-desc="說明多個資料來源不一致時如何比對、修復與留下證據">data reconciliation&lt;/a>。&lt;/p>
&lt;h2 id="可觀察訊號何時要提高紅隊檢查優先級">【可觀察訊號】何時要提高紅隊檢查優先級&lt;/h2>
&lt;p>下列訊號出現時，傳遞層通常需要先做弱點盤點：&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer-lag/" data-link-title="Consumer Lag" data-link-desc="說明 consumer lag 如何反映訊息堆積、處理能力與容量風險">consumer lag&lt;/a> 持續增加，且重試量同步升高&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/dead-letter-queue/" data-link-title="Dead-Letter Queue" data-link-desc="說明 dead-letter queue 如何隔離多次處理失敗的訊息">DLQ&lt;/a> 累積速度高於排空速度&lt;/li>
&lt;li>同一事件會被多路 consumer 讀取並觸發多個下游 side effect&lt;/li>
&lt;li>回放流程缺少操作邊界與審核節點&lt;/li>
&lt;/ul>
&lt;h2 id="失敗代價傳遞層弱點的代價型態">【失敗代價】傳遞層弱點的代價型態&lt;/h2>
&lt;p>傳遞層弱點會把局部錯誤放大成系統性壓力。重複投遞會造成重複扣款、重複通知或重複建單；毒訊息會阻塞分區與 worker；重放策略缺少邊界會把歷史事件再次推進生產流程。這些問題的共同代價是資料偏移、事故窗口延長與操作風險上升。&lt;/p>
&lt;h2 id="最低控制面進入服務實體前要先定義">【最低控制面】進入服務實體前要先定義&lt;/h2>
&lt;p>傳遞層在討論具體服務前，先定義四個控制面最穩定：&lt;/p>
&lt;ol>
&lt;li>投遞保證模型：哪些流程接受 at-least-once、哪些流程需要更嚴格保證。&lt;/li>
&lt;li>去重與副作用模型：哪些操作必須具備 idempotency，如何界定重複。&lt;/li>
&lt;li>重試與降載模型：重試節奏、上限、退避與壓力保護機制。&lt;/li>
&lt;li>回復與重放模型：DLQ 分流、回放準入條件與結果校正流程。&lt;/li>
&lt;/ol>
&lt;h2 id="多租戶-broker-的隔離邊界">多租戶 broker 的隔離邊界&lt;/h2>
&lt;p>Multi-tenant broker 的隔離邊界承擔「單租戶故障不放大到其他租戶」的責任。Multi-tenant broker 的紅隊重點是跨租戶邊界能否擋住攻擊放大跟資源耗盡。3.1 已建立規模化分層討論、本段聚焦攻擊面跟控制面。&lt;/p>
&lt;p>對應 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/uber-kafka-infrastructure-evolution/" data-link-title="3.C6 Uber：Kafka 事件平台演進" data-link-desc="事件平台從團隊自管走向多租戶共享基礎設施。">3.C6 Uber Kafka Infrastructure Evolution&lt;/a> — case 提出方向：定義租戶隔離、配額規則、標準化 topic 治理、平台指標治理。對應 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/linkedin-kafka-tiered-clusters/" data-link-title="3.C4 LinkedIn：Kafka 分層叢集治理" data-link-desc="Kafka 從單叢集走向 tiered clusters 的轉換案例。">3.C4 LinkedIn Tiered Clusters&lt;/a> — 規模化分層 cluster、高優先 workload 跟低優先 workload 各自獨立、降低 noisy neighbor 風險。以下攻擊面 taxonomy 基於通用 multi-tenant broker 知識展開、非 case 原文列舉。&lt;/p>
&lt;p>&lt;strong>Multi-tenant broker 的攻擊面&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>配額耗盡&lt;/strong>：單一 tenant 大量 publish 占光 broker bandwidth / storage、其他 tenant 投遞延遲拉長。對應控制是 &lt;em>per-tenant quota&lt;/em> + &lt;em>rate limit&lt;/em>。下游推送 quota 作為硬上限見 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/durable-queue/" data-link-title="3.2 durable queue 與重試策略" data-link-desc="整理持久化佇列、DLQ 與重試流程">3.2 下游推送是隱性瓶頸&lt;/a>&lt;/li>
&lt;li>&lt;strong>Topic 命名衝突 / 越權&lt;/strong>：tenant A 透過命名衝突或缺失 ACL 取得 tenant B topic 存取權限。對應控制是 &lt;em>namespace 強制隔離&lt;/em> + &lt;em>IAM topic-level ACL&lt;/em>&lt;/li>
&lt;li>&lt;strong>DLQ 跨租戶污染&lt;/strong>：tenant A 的 poison message 進共用 DLQ、影響 tenant B 的 DLQ 處理流程。對應控制是 &lt;em>per-tenant DLQ&lt;/em> + &lt;em>獨立排空策略&lt;/em>&lt;/li>
&lt;li>&lt;strong>Consumer group 命名衝突&lt;/strong>：意外或惡意註冊跟其他 tenant 同名的 consumer group、搶 partition 分配。對應控制是 &lt;em>consumer group naming convention&lt;/em> + &lt;em>prefix-based ACL&lt;/em>&lt;/li>
&lt;/ul>
&lt;p>判讀重點：multi-tenant broker 的紅隊不只看 broker 容量是否充足、還要看單一 tenant 出事時其他 tenant 是否受影響。單一租戶事件擴散到其他租戶屬隔離失敗、非 broker 效能問題。&lt;/p></description><content:encoded><![CDATA[<p>傳遞層紅隊判讀的核心目標是確認「訊息如何被重送、重放、放大與耗盡資源」。這裡的紅隊指攻擊者視角的風險檢查：先找可被放大的傳遞路徑，再回推控制面。只要系統採用 <a href="/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker</a> 或 stream，弱點就會同時落在 <a href="/blog/backend/knowledge-cards/delivery-semantics/" data-link-title="Delivery Semantics" data-link-desc="說明事件投遞語意如何定義遺失、重複、順序與補償策略">delivery semantics</a>、consumer 容量與回復流程。</p>
<h2 id="判讀傳遞層弱點的主要軸線">【判讀】傳遞層弱點的主要軸線</h2>
<p>傳遞層弱點可分成三條軸線：投遞語意、處理語意、回復語意。投遞語意看 <a href="/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">ack/nack</a> 與重送條件；處理語意看 <a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a> 與 side effect；回復語意看 <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/replay-runbook/" data-link-title="Replay Runbook" data-link-desc="說明事件重放前需要控制的範圍、順序、驗證與副作用">replay runbook</a> 與 <a href="/blog/backend/knowledge-cards/data-reconciliation/" data-link-title="Data Reconciliation" data-link-desc="說明多個資料來源不一致時如何比對、修復與留下證據">data reconciliation</a>。</p>
<h2 id="可觀察訊號何時要提高紅隊檢查優先級">【可觀察訊號】何時要提高紅隊檢查優先級</h2>
<p>下列訊號出現時，傳遞層通常需要先做弱點盤點：</p>
<ul>
<li><a href="/blog/backend/knowledge-cards/consumer-lag/" data-link-title="Consumer Lag" data-link-desc="說明 consumer lag 如何反映訊息堆積、處理能力與容量風險">consumer lag</a> 持續增加，且重試量同步升高</li>
<li><a href="/blog/backend/knowledge-cards/dead-letter-queue/" data-link-title="Dead-Letter Queue" data-link-desc="說明 dead-letter queue 如何隔離多次處理失敗的訊息">DLQ</a> 累積速度高於排空速度</li>
<li>同一事件會被多路 consumer 讀取並觸發多個下游 side effect</li>
<li>回放流程缺少操作邊界與審核節點</li>
</ul>
<h2 id="失敗代價傳遞層弱點的代價型態">【失敗代價】傳遞層弱點的代價型態</h2>
<p>傳遞層弱點會把局部錯誤放大成系統性壓力。重複投遞會造成重複扣款、重複通知或重複建單；毒訊息會阻塞分區與 worker；重放策略缺少邊界會把歷史事件再次推進生產流程。這些問題的共同代價是資料偏移、事故窗口延長與操作風險上升。</p>
<h2 id="最低控制面進入服務實體前要先定義">【最低控制面】進入服務實體前要先定義</h2>
<p>傳遞層在討論具體服務前，先定義四個控制面最穩定：</p>
<ol>
<li>投遞保證模型：哪些流程接受 at-least-once、哪些流程需要更嚴格保證。</li>
<li>去重與副作用模型：哪些操作必須具備 idempotency，如何界定重複。</li>
<li>重試與降載模型：重試節奏、上限、退避與壓力保護機制。</li>
<li>回復與重放模型：DLQ 分流、回放準入條件與結果校正流程。</li>
</ol>
<h2 id="多租戶-broker-的隔離邊界">多租戶 broker 的隔離邊界</h2>
<p>Multi-tenant broker 的隔離邊界承擔「單租戶故障不放大到其他租戶」的責任。Multi-tenant broker 的紅隊重點是跨租戶邊界能否擋住攻擊放大跟資源耗盡。3.1 已建立規模化分層討論、本段聚焦攻擊面跟控制面。</p>
<p>對應 <a href="/blog/backend/03-message-queue/cases/uber-kafka-infrastructure-evolution/" data-link-title="3.C6 Uber：Kafka 事件平台演進" data-link-desc="事件平台從團隊自管走向多租戶共享基礎設施。">3.C6 Uber Kafka Infrastructure Evolution</a> — case 提出方向：定義租戶隔離、配額規則、標準化 topic 治理、平台指標治理。對應 <a href="/blog/backend/03-message-queue/cases/linkedin-kafka-tiered-clusters/" data-link-title="3.C4 LinkedIn：Kafka 分層叢集治理" data-link-desc="Kafka 從單叢集走向 tiered clusters 的轉換案例。">3.C4 LinkedIn Tiered Clusters</a> — 規模化分層 cluster、高優先 workload 跟低優先 workload 各自獨立、降低 noisy neighbor 風險。以下攻擊面 taxonomy 基於通用 multi-tenant broker 知識展開、非 case 原文列舉。</p>
<p><strong>Multi-tenant broker 的攻擊面</strong>：</p>
<ul>
<li><strong>配額耗盡</strong>：單一 tenant 大量 publish 占光 broker bandwidth / storage、其他 tenant 投遞延遲拉長。對應控制是 <em>per-tenant quota</em> + <em>rate limit</em>。下游推送 quota 作為硬上限見 <a href="/blog/backend/03-message-queue/durable-queue/" data-link-title="3.2 durable queue 與重試策略" data-link-desc="整理持久化佇列、DLQ 與重試流程">3.2 下游推送是隱性瓶頸</a></li>
<li><strong>Topic 命名衝突 / 越權</strong>：tenant A 透過命名衝突或缺失 ACL 取得 tenant B topic 存取權限。對應控制是 <em>namespace 強制隔離</em> + <em>IAM topic-level ACL</em></li>
<li><strong>DLQ 跨租戶污染</strong>：tenant A 的 poison message 進共用 DLQ、影響 tenant B 的 DLQ 處理流程。對應控制是 <em>per-tenant DLQ</em> + <em>獨立排空策略</em></li>
<li><strong>Consumer group 命名衝突</strong>：意外或惡意註冊跟其他 tenant 同名的 consumer group、搶 partition 分配。對應控制是 <em>consumer group naming convention</em> + <em>prefix-based ACL</em></li>
</ul>
<p>判讀重點：multi-tenant broker 的紅隊不只看 broker 容量是否充足、還要看單一 tenant 出事時其他 tenant 是否受影響。單一租戶事件擴散到其他租戶屬隔離失敗、非 broker 效能問題。</p>
<h2 id="replay-攻擊跟-dlq-濫用">Replay 攻擊跟 DLQ 濫用</h2>
<p>Replay 機制是事故恢復工具、也是攻擊面。攻擊者可能濫用 replay 重複觸發副作用（重複退款、重複送通知、重複下單）、或讓 DLQ 變成 backdoor 通道。以下 3 個攻擊向量基於通用紅隊知識展開、非 case 原文列舉。</p>
<p><strong>Replay 攻擊向量</strong>：</p>
<ul>
<li><strong>未授權 replay 觸發</strong>：攻擊者拿到 replay 控制權、replay 舊事件造成重複副作用。對應控制是 <em>replay 授權需獨立審核</em> + <em>audit trail 記錄誰 replay 什麼</em></li>
<li><strong>Replay window 越界</strong>：replay 跨越 idempotency 紀錄到期、舊事件被當新事件處理。對應控制是 <em>replay window 上限 = idempotency 保留期</em>、見 <a href="/blog/backend/03-message-queue/processing-recovery-semantics/" data-link-title="3.6 Processing Semantics 與 Recovery Semantics" data-link-desc="說明投遞成功、處理成功與恢復成功為何是三個不同判斷。">3.6 processing-recovery-semantics 的 replay 跟 idempotency 共設計</a></li>
<li><strong>DLQ message 注入</strong>：攻擊者把惡意 message 直接寫進 DLQ、繞過主通道驗證、等 replay 時觸發副作用。對應控制是 <em>DLQ 寫入權限獨立於主通道</em> + <em>replay 前 schema 重新驗證</em></li>
</ul>
<p>判讀重點：replay 屬 production 操作、跟 <a href="/blog/backend/01-database/reconciliation-data-repair/" data-link-title="1.9 Reconciliation 與 Data Repair" data-link-desc="資料不一致的分類、偵測模式、修復策略、audit trail、跟 backup / PITR 整合">1.9 reconciliation 修復權限管理</a> 同層級、要 audit trail + 審核流程。合規 replay 路徑應具備 audit trail + window 上限 + DLQ 寫入隔離三層控制、把 replay 從事故工具升級為可稽核的 production 操作。</p>
<h2 id="案例對照">【案例對照】</h2>
<table>
  <thead>
      <tr>
          <th>案例</th>
          <th>紅隊視角重點</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/uber-kafka-infrastructure-evolution/" data-link-title="3.C6 Uber：Kafka 事件平台演進" data-link-desc="事件平台從團隊自管走向多租戶共享基礎設施。">3.C6 Uber Kafka Infrastructure</a></td>
          <td>治理視角、反推 multi-tenant 隔離攻擊面</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/linkedin-kafka-tiered-clusters/" data-link-title="3.C4 LinkedIn：Kafka 分層叢集治理" data-link-desc="Kafka 從單叢集走向 tiered clusters 的轉換案例。">3.C4 LinkedIn Tiered Clusters</a></td>
          <td>治理視角、反推分層 cluster 跟 workload 隔離防護</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9 反例 Queue Semantics Mismatch</a></td>
          <td>切換語意誤配引發重複副作用、replay 跟 idempotency 失準</td>
      </tr>
  </tbody>
</table>
<p>以上 3.C6 / 3.C4 屬治理視角案例、紅隊章節做反推使用（從控制面反推攻擊面）。</p>
<h2 id="跨模組路由">跨模組路由</h2>
<ol>
<li>與 03 內部：規模化分層治理回 <a href="/blog/backend/03-message-queue/broker-basics/" data-link-title="3.1 broker 基礎與投遞模型" data-link-desc="先理解 broker、queue、consumer 與 delivery semantics">3.1 broker-basics</a>；下游推送 quota 攻擊面跟 <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> 互補；replay 跟 idempotency 共設計回 <a href="/blog/backend/03-message-queue/processing-recovery-semantics/" data-link-title="3.6 Processing Semantics 與 Recovery Semantics" data-link-desc="說明投遞成功、處理成功與恢復成功為何是三個不同判斷。">3.6</a></li>
<li>與 01 的交接：replay / 補償權限管理回 <a href="/blog/backend/01-database/reconciliation-data-repair/" data-link-title="1.9 Reconciliation 與 Data Repair" data-link-desc="資料不一致的分類、偵測模式、修復策略、audit trail、跟 backup / PITR 整合">1.9 reconciliation 修復權限管理</a></li>
<li>與 04 的交接：紅隊偵測訊號（DLQ 速率、retry storm、duplicate）進 <a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">4.20 Observability Evidence Package</a></li>
<li>與 06 的交接：rule rollout 安全閘門進 <a href="/blog/backend/06-reliability/rule-rollout-safety-gate/" data-link-title="6.24 規則推送安全閘門" data-link-desc="把規則、策略與控制面配置推送從部署步驟升級為可靠性 gate，避免小變更在秒級擴散成全網事故。">6.24 rule-rollout-safety-gate</a></li>
<li>與 08 的交接：事故當下決策進 <a href="/blog/backend/08-incident-response/incident-decision-log/" data-link-title="8.19 Incident Decision Log" data-link-desc="把事中假設、決策、證據、回退條件與責任人留下可復盤紀錄">8.19 Incident Decision Log</a></li>
</ol>
<h2 id="關聯卡片">【關聯卡片】</h2>
<ul>
<li><a href="/blog/backend/knowledge-cards/poison-message/" data-link-title="Poison Message" data-link-desc="說明特定訊息內容如何穩定造成 consumer 失敗">Poison Message</a></li>
<li><a href="/blog/backend/knowledge-cards/duplicate-delivery/" data-link-title="Duplicate Delivery" data-link-desc="說明同一個訊息被處理多次時如何保持結果穩定">Duplicate Delivery</a></li>
<li><a href="/blog/backend/knowledge-cards/retry-storm/" data-link-title="Retry Storm" data-link-desc="說明大量重試如何把局部故障放大成系統壓力">Retry Storm</a></li>
<li><a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">Backpressure</a></li>
<li><a href="/blog/backend/knowledge-cards/runbook/" data-link-title="Runbook" data-link-desc="說明 runbook 如何把事故判斷與操作步驟標準化">Runbook</a></li>
</ul>
]]></content:encoded></item><item><title>3.6 Processing Semantics 與 Recovery Semantics</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/processing-recovery-semantics/</link><pubDate>Mon, 11 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/processing-recovery-semantics/</guid><description>&lt;p>Processing semantics 與 recovery semantics 的核心責任是把訊息送達、業務副作用完成、故障後可恢復三件事分開判斷。進入 Kafka、RabbitMQ、SQS、NATS 或 Redis Streams 前，讀者需要先知道 broker 保證主要落在傳遞語意的一部分。&lt;/p>
&lt;h2 id="delivery--processing--recovery">Delivery / Processing / Recovery&lt;/h2>
&lt;p>三層語意的責任不同：&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>Delivery semantics&lt;/td>
 &lt;td>訊息是否被 broker 投遞、確認、重送或隔離&lt;/td>
 &lt;td>ack、nack、redelivery、DLQ&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Processing semantics&lt;/td>
 &lt;td>consumer 副作用是否能承受重複、亂序與部分失敗&lt;/td>
 &lt;td>idempotency、side effect、ordering&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Recovery semantics&lt;/td>
 &lt;td>故障後是否能重播、補償與恢復一致&lt;/td>
 &lt;td>replay、checkpoint、reconciliation&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/delivery-semantics/" data-link-title="Delivery Semantics" data-link-desc="說明事件投遞語意如何定義遺失、重複、順序與補償策略">delivery semantics&lt;/a> 成立不代表 processing 成立。訊息被 ack 也不代表發票、email、search index 或 webhook 都已完成。&lt;/p>
&lt;p>Delivery 層的判讀重點是 broker 是否還能掌握訊息位置。Processing 層的判讀重點是 consumer 是否已經完成業務副作用。Recovery 層的判讀重點是事故後能否用 replay、checkpoint 與 reconciliation 回到一致狀態。這三層拆開後，隊列工具選型才會對到真正問題。&lt;/p>
&lt;h2 id="processing-semantics">Processing Semantics&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/processing-semantics/" data-link-title="Processing Semantics" data-link-desc="說明 consumer 處理事件後業務結果是否正確，與投遞成功分屬不同責任">Processing semantics&lt;/a> 的責任是讓 consumer 副作用在重複投遞與部分失敗下仍可控。常見副作用包含寫資料庫、呼叫外部 API、寄信、建立發票、更新 search index。&lt;/p>
&lt;p>每個副作用都要先回答：&lt;/p>
&lt;ol>
&lt;li>idempotency key 是什麼。&lt;/li>
&lt;li>副作用完成後如何記錄。&lt;/li>
&lt;li>重複執行時結果是否穩定。&lt;/li>
&lt;li>部分成功時如何補償。&lt;/li>
&lt;/ol>
&lt;p>缺少這些答案時，at-least-once delivery 會轉成多次業務結果。&lt;/p>
&lt;h2 id="recovery-semantics">Recovery Semantics&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/recovery-semantics/" data-link-title="Recovery Semantics" data-link-desc="說明事件處理失敗後能否透過 replay、checkpoint 與補償重建正確狀態並驗證">Recovery semantics&lt;/a> 的責任是讓系統在 consumer crash、DLQ 爆量、下游故障或資料修復後能恢復一致。它依賴 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/replay-window/" data-link-title="Replay Window" data-link-desc="說明事件可重播的時間或 offset 範圍邊界，由 retention 與 checkpoint 決定">replay window&lt;/a>、checkpoint、offset、去重紀錄與對帳查詢。&lt;/p>
&lt;p>恢復流程要先分範圍。按時間、tenant、partition、schema version 或 event type 分段，能降低 replay 造成的下游壓力與重複副作用。&lt;/p>
&lt;h2 id="checkpoint-與-side-effect">Checkpoint 與 Side Effect&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/checkpoint/" data-link-title="Checkpoint" data-link-desc="說明長時間處理流程如何記錄可恢復進度">checkpoint&lt;/a> 的責任是標記處理進度，業務完成則要由副作用紀錄與對帳證據證明。若 checkpoint 早於副作用提交，consumer crash 後可能漏做副作用；若 checkpoint 太晚，重啟後會造成重複處理。&lt;/p>
&lt;p>穩定設計通常讓副作用具備 idempotency，再把 checkpoint 放在可恢復的位置。checkpoint 與 idempotency 是一組設計，需要一起審查。&lt;/p>
&lt;h2 id="poison-message-的處理層次">Poison Message 的處理層次&lt;/h2>
&lt;p>Poison message 屬於觸發 consumer 持續失敗、需要被隔離處理的訊息類型。處理流程從 &lt;em>偵測 / 隔離 / 診斷 / 修復&lt;/em> 四個層次設計、屬於 DLQ 之後的延伸責任。&lt;/p>
&lt;p>對應 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9 反例：Queue Semantics Mismatch&lt;/a> — case 提供切換後 DLQ 激增的觀察方向、是 broker 遷移時 consumer 沒對齊 processing/recovery 語意的訊號、poison message 是其下游表徵之一。&lt;/p>
&lt;p>&lt;strong>四個處理層次&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>偵測&lt;/strong>：retry count 超過組織自定閾值後識別為 poison candidate。早期偵測訊號是 retry rate 升高但 success rate 沒同步上升、單一 consumer 反覆失敗&lt;/li>
&lt;li>&lt;strong>隔離&lt;/strong>：把 poison message 移出主通道、進 DLQ 或 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/poison-message-quarantine/" data-link-title="Poison-Message Quarantine" data-link-desc="說明把毒訊息從主處理路徑隔離出來的機制，讓正常訊息繼續前進">quarantine queue&lt;/a>。隔離要即時、避免持續占用主通道吞吐&lt;/li>
&lt;li>&lt;strong>診斷&lt;/strong>：DLQ 內 poison message 要分群分析、找出共同 failure pattern（payload schema 不符、外部 API 永久失敗、邏輯 bug）&lt;/li>
&lt;li>&lt;strong>修復&lt;/strong>：依據 root cause 修 consumer / contract / 邏輯後、再&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/dlq-drain/" data-link-title="DLQ Drain" data-link-desc="說明把 dead-letter queue 累積的訊息重新處理或排空的受控流程">定向回放 DLQ&lt;/a> 內 poison message、避免 zombie cycle（同一 message 反覆進 DLQ）&lt;/li>
&lt;/ul>
&lt;p>判讀重點：DLQ size 持續增加但沒有對應修復 commit、表示處理流程斷在「隔離」這層、要回到「診斷 / 修復」。release gate 加「DLQ 排空速率 &amp;gt;= 流入速率」的條件、讓 DLQ 維持診斷入口的角色。未授權 replay 跟 window 越界攻擊面見 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/red-team-delivery-layer/" data-link-title="3.5 攻擊者視角（紅隊）：傳遞層弱點判讀" data-link-desc="從重複投遞、重放濫用、毒訊息與容量壓力，盤點 message delivery 的主要弱點">3.5 紅隊章 Replay 攻擊&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>Processing semantics 與 recovery semantics 的核心責任是把訊息送達、業務副作用完成、故障後可恢復三件事分開判斷。進入 Kafka、RabbitMQ、SQS、NATS 或 Redis Streams 前，讀者需要先知道 broker 保證主要落在傳遞語意的一部分。</p>
<h2 id="delivery--processing--recovery">Delivery / Processing / Recovery</h2>
<p>三層語意的責任不同：</p>
<table>
  <thead>
      <tr>
          <th>語意層</th>
          <th>負責問題</th>
          <th>主要訊號</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Delivery semantics</td>
          <td>訊息是否被 broker 投遞、確認、重送或隔離</td>
          <td>ack、nack、redelivery、DLQ</td>
      </tr>
      <tr>
          <td>Processing semantics</td>
          <td>consumer 副作用是否能承受重複、亂序與部分失敗</td>
          <td>idempotency、side effect、ordering</td>
      </tr>
      <tr>
          <td>Recovery semantics</td>
          <td>故障後是否能重播、補償與恢復一致</td>
          <td>replay、checkpoint、reconciliation</td>
      </tr>
  </tbody>
</table>
<p><a href="/blog/backend/knowledge-cards/delivery-semantics/" data-link-title="Delivery Semantics" data-link-desc="說明事件投遞語意如何定義遺失、重複、順序與補償策略">delivery semantics</a> 成立不代表 processing 成立。訊息被 ack 也不代表發票、email、search index 或 webhook 都已完成。</p>
<p>Delivery 層的判讀重點是 broker 是否還能掌握訊息位置。Processing 層的判讀重點是 consumer 是否已經完成業務副作用。Recovery 層的判讀重點是事故後能否用 replay、checkpoint 與 reconciliation 回到一致狀態。這三層拆開後，隊列工具選型才會對到真正問題。</p>
<h2 id="processing-semantics">Processing Semantics</h2>
<p><a href="/blog/backend/knowledge-cards/processing-semantics/" data-link-title="Processing Semantics" data-link-desc="說明 consumer 處理事件後業務結果是否正確，與投遞成功分屬不同責任">Processing semantics</a> 的責任是讓 consumer 副作用在重複投遞與部分失敗下仍可控。常見副作用包含寫資料庫、呼叫外部 API、寄信、建立發票、更新 search index。</p>
<p>每個副作用都要先回答：</p>
<ol>
<li>idempotency key 是什麼。</li>
<li>副作用完成後如何記錄。</li>
<li>重複執行時結果是否穩定。</li>
<li>部分成功時如何補償。</li>
</ol>
<p>缺少這些答案時，at-least-once delivery 會轉成多次業務結果。</p>
<h2 id="recovery-semantics">Recovery Semantics</h2>
<p><a href="/blog/backend/knowledge-cards/recovery-semantics/" data-link-title="Recovery Semantics" data-link-desc="說明事件處理失敗後能否透過 replay、checkpoint 與補償重建正確狀態並驗證">Recovery semantics</a> 的責任是讓系統在 consumer crash、DLQ 爆量、下游故障或資料修復後能恢復一致。它依賴 <a href="/blog/backend/knowledge-cards/replay-window/" data-link-title="Replay Window" data-link-desc="說明事件可重播的時間或 offset 範圍邊界，由 retention 與 checkpoint 決定">replay window</a>、checkpoint、offset、去重紀錄與對帳查詢。</p>
<p>恢復流程要先分範圍。按時間、tenant、partition、schema version 或 event type 分段，能降低 replay 造成的下游壓力與重複副作用。</p>
<h2 id="checkpoint-與-side-effect">Checkpoint 與 Side Effect</h2>
<p><a href="/blog/backend/knowledge-cards/checkpoint/" data-link-title="Checkpoint" data-link-desc="說明長時間處理流程如何記錄可恢復進度">checkpoint</a> 的責任是標記處理進度，業務完成則要由副作用紀錄與對帳證據證明。若 checkpoint 早於副作用提交，consumer crash 後可能漏做副作用；若 checkpoint 太晚，重啟後會造成重複處理。</p>
<p>穩定設計通常讓副作用具備 idempotency，再把 checkpoint 放在可恢復的位置。checkpoint 與 idempotency 是一組設計，需要一起審查。</p>
<h2 id="poison-message-的處理層次">Poison Message 的處理層次</h2>
<p>Poison message 屬於觸發 consumer 持續失敗、需要被隔離處理的訊息類型。處理流程從 <em>偵測 / 隔離 / 診斷 / 修復</em> 四個層次設計、屬於 DLQ 之後的延伸責任。</p>
<p>對應 <a href="/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9 反例：Queue Semantics Mismatch</a> — case 提供切換後 DLQ 激增的觀察方向、是 broker 遷移時 consumer 沒對齊 processing/recovery 語意的訊號、poison message 是其下游表徵之一。</p>
<p><strong>四個處理層次</strong>：</p>
<ul>
<li><strong>偵測</strong>：retry count 超過組織自定閾值後識別為 poison candidate。早期偵測訊號是 retry rate 升高但 success rate 沒同步上升、單一 consumer 反覆失敗</li>
<li><strong>隔離</strong>：把 poison message 移出主通道、進 DLQ 或 <a href="/blog/backend/knowledge-cards/poison-message-quarantine/" data-link-title="Poison-Message Quarantine" data-link-desc="說明把毒訊息從主處理路徑隔離出來的機制，讓正常訊息繼續前進">quarantine queue</a>。隔離要即時、避免持續占用主通道吞吐</li>
<li><strong>診斷</strong>：DLQ 內 poison message 要分群分析、找出共同 failure pattern（payload schema 不符、外部 API 永久失敗、邏輯 bug）</li>
<li><strong>修復</strong>：依據 root cause 修 consumer / contract / 邏輯後、再<a href="/blog/backend/knowledge-cards/dlq-drain/" data-link-title="DLQ Drain" data-link-desc="說明把 dead-letter queue 累積的訊息重新處理或排空的受控流程">定向回放 DLQ</a> 內 poison message、避免 zombie cycle（同一 message 反覆進 DLQ）</li>
</ul>
<p>判讀重點：DLQ size 持續增加但沒有對應修復 commit、表示處理流程斷在「隔離」這層、要回到「診斷 / 修復」。release gate 加「DLQ 排空速率 &gt;= 流入速率」的條件、讓 DLQ 維持診斷入口的角色。未授權 replay 跟 window 越界攻擊面見 <a href="/blog/backend/03-message-queue/red-team-delivery-layer/" data-link-title="3.5 攻擊者視角（紅隊）：傳遞層弱點判讀" data-link-desc="從重複投遞、重放濫用、毒訊息與容量壓力，盤點 message delivery 的主要弱點">3.5 紅隊章 Replay 攻擊</a>。</p>
<h2 id="replay-跟-idempotency-的共設計">Replay 跟 Idempotency 的共設計</h2>
<p>Replay safety 跟 idempotency 屬於同一個設計階段、需共設計並落地後才能上線。replay window 設多大、idempotency key 怎麼定、checkpoint 何時提交、三者互相影響、任一改動都會破壞其他。</p>
<p><strong>共設計的判讀順序</strong>：</p>
<ol>
<li><strong>先定 idempotency key</strong>：什麼欄位組合能唯一標記副作用（event_id、entity_id + version、business operation id）</li>
<li><strong>再定 idempotency 儲存策略</strong>：去重紀錄存多久（決定 replay window 上限）、儲存在 cache / DB / 應用層 memory</li>
<li><strong>依儲存策略反推 replay window</strong>：去重紀錄保留 7 天、replay window 上限就是 7 天、超過會出現重複副作用</li>
<li><strong>再依 replay window 反推 checkpoint 策略</strong>：checkpoint 落地時機要保證 crash 後 replay window 內可恢復</li>
</ol>
<p>對應 <a href="/blog/backend/09-performance-capacity/cases/spotify-kafka-to-pubsub-migration-gcp/" data-link-title="9.C9 Spotify：從自管 Kafka 遷移到 GCP Pub/Sub 的事件交付系統" data-link-desc="Spotify 把自管 Kafka 事件系統遷移到 Google Cloud Pub/Sub、避免自管 broker 的容量規劃成本">9.C9 Spotify Kafka → Pub/Sub</a> — broker 遷移要驗證業務語意跟新 broker 兼容、replay 模型在 Kafka（offset）跟 Pub/Sub（snapshot + seek）不同、idempotency 策略要重新校準。</p>
<p>判讀重點：replay window 由 idempotency 儲存策略反推、不是 broker 設定值。先看 idempotency key 跟去重儲存、再決定 replay window 安全範圍。順序顛倒會踩到「replay 跨越去重紀錄到期」的事故、表現是 replay 後出現本來該被去重的重複副作用。</p>
<h2 id="選型前判準">選型前判準</h2>
<p>Queue 選型前要先回答：</p>
<ol>
<li>需要保證的是投遞、處理還是恢復。</li>
<li>哪些副作用必須 idempotent。</li>
<li>哪些事件需要順序，順序邊界是全域、tenant、entity 還是 partition。</li>
<li>Replay 時下游能承受多少吞吐。</li>
<li>DLQ 是診斷入口還是已經變成長期倉庫。</li>
</ol>
<p>這些答案會決定後續比較 Kafka、RabbitMQ、SQS、NATS 或 Redis Streams 時該看哪些能力。</p>
<h2 id="實體服務討論承接點">實體服務討論承接點</h2>
<p>實體 queue/broker 文章要承接本篇的 processing 與 recovery semantics。Kafka、RabbitMQ、SQS、NATS 或 Redis Streams 的比較，應先問服務需要什麼投遞、處理與恢復責任，再比較 topic、queue、partition、consumer group、DLQ 或 retention。</p>
<p>若主問題是高吞吐事件流，後續文章要比較 partition、retention、consumer lag 與 replay 能力。若主問題是工作派發，後續文章要比較 ack/nack、routing、DLQ 與 retry。若主問題是受管服務操作成本，後續文章要比較可觀測性、IAM、區域能力與 failure mode。</p>
<h2 id="跨模組路由">跨模組路由</h2>
<ol>
<li>與 03 內部：consumer 端去重跟 ack timing 詳見 <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>；event payload 跟 replay 邊界寫入事件契約見 <a href="/blog/backend/03-message-queue/event-contract-replay-boundary/" data-link-title="3.7 Event Contract 與 Replay Boundary" data-link-desc="說明 event schema、idempotency key、replay window 與補償如何先於 broker 選型。">3.7</a>；規模差異判讀跟 job queue 拓樸分工見 <a href="/blog/backend/03-message-queue/queue-consumer-retry-replay-handoff/" data-link-title="3.8 Queue Consumer Retry 與 Replay Handoff（實作示範）" data-link-desc="以 order_created consumer 示範 queue 路徑如何交付 idempotency evidence、DLQ handling、replay runbook 與 incident decision log。">3.8</a></li>
<li>與 04 的交接：lag、retry、DLQ、duplicate 訊號進 <a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">4.20 Observability Evidence Package</a></li>
<li>與 06 的交接：idempotency 跟 replay 驗證進 <a href="/blog/backend/06-reliability/idempotency-replay/" data-link-title="6.12 Idempotency 與 Replay 驗證" data-link-desc="把重試、重播與冪等性從口頭約定變成可驗證屬性">6.12 Idempotency 與 Replay 驗證</a></li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<p>要把 event payload 跟 replay 邊界寫進事件契約、接著讀 <a href="/blog/backend/03-message-queue/event-contract-replay-boundary/" data-link-title="3.7 Event Contract 與 Replay Boundary" data-link-desc="說明 event schema、idempotency key、replay window 與補償如何先於 broker 選型。">3.7 Event Contract 與 Replay Boundary</a>。要建立 broker 投遞模型，接著讀 <a href="/blog/backend/03-message-queue/broker-basics/" data-link-title="3.1 broker 基礎與投遞模型" data-link-desc="先理解 broker、queue、consumer 與 delivery semantics">3.1 broker 基礎與投遞模型</a>。要把三層語意放進完整服務路徑，接著讀 <a href="/blog/backend/03-message-queue/queue-consumer-retry-replay-handoff/" data-link-title="3.8 Queue Consumer Retry 與 Replay Handoff（實作示範）" data-link-desc="以 order_created consumer 示範 queue 路徑如何交付 idempotency evidence、DLQ handling、replay runbook 與 incident decision log。">3.8 Queue Consumer Retry 與 Replay Handoff</a>。</p>
]]></content:encoded></item><item><title>3.C6 Uber：Kafka 事件平台演進</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/uber-kafka-infrastructure-evolution/</link><pubDate>Thu, 07 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/uber-kafka-infrastructure-evolution/</guid><description>&lt;p>Uber 的 Kafka 演進案例揭露了 MQ 從「幾個團隊自管的 broker」到「全公司共享的事件平台」的治理轉折點。轉折的核心判斷是：規模化之後，broker 容量擴展的成本小於 workload 治理缺失的成本。&lt;/p>
&lt;h2 id="業務背景">業務背景&lt;/h2>
&lt;p>Uber 的事件流涵蓋行程追蹤、司機定位、計費事件、推播通知、即時定價、ETA 計算跟 analytics。早期各團隊各自架設 Kafka 叢集，隨著 Kafka 在 Uber 內部的採用率上升，叢集數量跟 topic 數量快速增長，但沒有統一的治理。&lt;/p>
&lt;p>Uber 的 Kafka 規模峰值達到每秒數百萬筆訊息、數十個叢集、數千個 topic。在這個規模下，管理壓力從「單一叢集的 broker 夠不夠」轉到「誰在用、用多少、怎麼收費、故障時誰負責」。&lt;/p>
&lt;h2 id="技術挑戰">技術挑戰&lt;/h2>
&lt;h3 id="團隊自管的碎片化">團隊自管的碎片化&lt;/h3>
&lt;p>各團隊各自架設 Kafka 時，每個叢集的版本、配置、監控、備份策略都不同。運維知識散落在各團隊，沒有共享的 runbook 或值班流程。某個團隊的 Kafka 出問題時，其他團隊幫不上忙；知識在人員流動時遺失。&lt;/p>
&lt;p>碎片化的另一個後果是資源浪費。每個團隊各自預留的容量加總起來遠大於集中管理所需。低流量團隊的叢集常年使用率低於 10%，但因為自管模式下沒有共享容量的機制，資源無法調配。&lt;/p>
&lt;h3 id="topic-爆炸與無主-topic">Topic 爆炸與無主 topic&lt;/h3>
&lt;p>沒有 topic 建立的治理流程時，任何人都可以建 topic。Topic 的命名不一致、retention 設定不一致、owner 不明。離職的工程師建立的 topic 仍在接收資料、佔用 broker 資源，但沒人知道這些 topic 服務什麼業務。&lt;/p>
&lt;p>LinkedIn 後來也遇到同樣的問題並開發了 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/linkedin-topicgc-kafka-governance/" data-link-title="3.C3 LinkedIn：TopicGC 與 Kafka 治理轉換" data-link-desc="Kafka topic 從手動治理轉自動治理對叢集的影響。">TopicGC&lt;/a> 做 topic 生命週期管理。Uber 的解法路線類似 — 把 topic 建立變成需要 owner、retention policy 跟業務標籤的審核流程。&lt;/p>
&lt;h3 id="故障排查的責任不清">故障排查的責任不清&lt;/h3>
&lt;p>叢集內的故障（broker OOM、partition leader 不均衡、consumer lag spike）需要 Kafka 專業知識排查。團隊自管模式下，每個團隊都需要一定程度的 Kafka 運維能力，但多數團隊的核心能力是業務邏輯而非 MQ 運維。&lt;/p>
&lt;p>故障排查的慣性是「先問 Kafka 團隊有沒有人可以幫忙」— 但沒有正式的 Kafka 團隊，所以問的是「上次修過 Kafka 的那個人」。&lt;/p>
&lt;h2 id="解法平台化">解法：平台化&lt;/h2>
&lt;p>Uber 的解法是把 Kafka 從分散自管收斂到集中平台 — 一個專責的 Kafka platform team 統一管理所有叢集、提供標準化的使用介面。&lt;/p>
&lt;h3 id="多租戶治理">多租戶治理&lt;/h3>
&lt;p>平台化的核心是多租戶模型 — 每個業務團隊是一個 tenant，tenant 有 quota（ingestion rate、partition 數量上限、retention 上限）跟 cost attribution。&lt;/p>
&lt;p>Quota 的目的是防止單一 tenant 的爆量拖累整個平台。Cost attribution 的目的是讓 tenant 看到自己的用量跟成本，驅動合理使用。&lt;/p>
&lt;h3 id="標準化-topic-管理">標準化 topic 管理&lt;/h3>
&lt;p>Topic 的建立走 self-service portal — 團隊填寫 owner、業務用途、預估流量、retention 需求，portal 自動配置 topic 並建立監控。沒有 owner 的 topic 不允許建立；owner 離職時 topic 需要交接或標記為候選淘汰。&lt;/p>
&lt;h3 id="統一監控與值班">統一監控與值班&lt;/h3>
&lt;p>Platform team 統一監控所有叢集的 broker 健康（replication lag、under-replicated partitions、disk usage、CPU），提供共用的 dashboard 跟 alert。值班由 platform team 負責 broker 層面的問題，業務層面的問題（consumer 設計錯誤、message 格式不對）由各 tenant team 自行處理。&lt;/p>
&lt;h2 id="取捨">取捨&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>面向&lt;/th>
 &lt;th>團隊自管&lt;/th>
 &lt;th>平台化&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>自主性&lt;/td>
 &lt;td>高（團隊想怎麼配就怎麼配）&lt;/td>
 &lt;td>低到中（受 quota 跟 policy 約束）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>運維負擔分配&lt;/td>
 &lt;td>分散（每個團隊各自負擔）&lt;/td>
 &lt;td>集中（platform team 吸收 broker 層）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>資源利用率&lt;/td>
 &lt;td>低（各自預留、無法共用）&lt;/td>
 &lt;td>高（共享容量、動態分配）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>治理一致性&lt;/td>
 &lt;td>低（版本、配置、命名各自為政）&lt;/td>
 &lt;td>高（統一版本、統一配置標準）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>故障影響面&lt;/td>
 &lt;td>小（自管叢集只影響自己的團隊）&lt;/td>
 &lt;td>大（共享平台故障影響所有 tenant）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>專業知識需求&lt;/td>
 &lt;td>每個團隊都要一些 Kafka 運維知識&lt;/td>
 &lt;td>集中在 platform team&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>平台化的最大風險是共享平台成為單點 — broker 故障影響所有 tenant。Uber 用跟 LinkedIn 類似的分層叢集策略（critical vs best-effort）降低共享風險，但這也讓平台的運維複雜度上升。&lt;/p></description><content:encoded><![CDATA[<p>Uber 的 Kafka 演進案例揭露了 MQ 從「幾個團隊自管的 broker」到「全公司共享的事件平台」的治理轉折點。轉折的核心判斷是：規模化之後，broker 容量擴展的成本小於 workload 治理缺失的成本。</p>
<h2 id="業務背景">業務背景</h2>
<p>Uber 的事件流涵蓋行程追蹤、司機定位、計費事件、推播通知、即時定價、ETA 計算跟 analytics。早期各團隊各自架設 Kafka 叢集，隨著 Kafka 在 Uber 內部的採用率上升，叢集數量跟 topic 數量快速增長，但沒有統一的治理。</p>
<p>Uber 的 Kafka 規模峰值達到每秒數百萬筆訊息、數十個叢集、數千個 topic。在這個規模下，管理壓力從「單一叢集的 broker 夠不夠」轉到「誰在用、用多少、怎麼收費、故障時誰負責」。</p>
<h2 id="技術挑戰">技術挑戰</h2>
<h3 id="團隊自管的碎片化">團隊自管的碎片化</h3>
<p>各團隊各自架設 Kafka 時，每個叢集的版本、配置、監控、備份策略都不同。運維知識散落在各團隊，沒有共享的 runbook 或值班流程。某個團隊的 Kafka 出問題時，其他團隊幫不上忙；知識在人員流動時遺失。</p>
<p>碎片化的另一個後果是資源浪費。每個團隊各自預留的容量加總起來遠大於集中管理所需。低流量團隊的叢集常年使用率低於 10%，但因為自管模式下沒有共享容量的機制，資源無法調配。</p>
<h3 id="topic-爆炸與無主-topic">Topic 爆炸與無主 topic</h3>
<p>沒有 topic 建立的治理流程時，任何人都可以建 topic。Topic 的命名不一致、retention 設定不一致、owner 不明。離職的工程師建立的 topic 仍在接收資料、佔用 broker 資源，但沒人知道這些 topic 服務什麼業務。</p>
<p>LinkedIn 後來也遇到同樣的問題並開發了 <a href="/blog/backend/03-message-queue/cases/linkedin-topicgc-kafka-governance/" data-link-title="3.C3 LinkedIn：TopicGC 與 Kafka 治理轉換" data-link-desc="Kafka topic 從手動治理轉自動治理對叢集的影響。">TopicGC</a> 做 topic 生命週期管理。Uber 的解法路線類似 — 把 topic 建立變成需要 owner、retention policy 跟業務標籤的審核流程。</p>
<h3 id="故障排查的責任不清">故障排查的責任不清</h3>
<p>叢集內的故障（broker OOM、partition leader 不均衡、consumer lag spike）需要 Kafka 專業知識排查。團隊自管模式下，每個團隊都需要一定程度的 Kafka 運維能力，但多數團隊的核心能力是業務邏輯而非 MQ 運維。</p>
<p>故障排查的慣性是「先問 Kafka 團隊有沒有人可以幫忙」— 但沒有正式的 Kafka 團隊，所以問的是「上次修過 Kafka 的那個人」。</p>
<h2 id="解法平台化">解法：平台化</h2>
<p>Uber 的解法是把 Kafka 從分散自管收斂到集中平台 — 一個專責的 Kafka platform team 統一管理所有叢集、提供標準化的使用介面。</p>
<h3 id="多租戶治理">多租戶治理</h3>
<p>平台化的核心是多租戶模型 — 每個業務團隊是一個 tenant，tenant 有 quota（ingestion rate、partition 數量上限、retention 上限）跟 cost attribution。</p>
<p>Quota 的目的是防止單一 tenant 的爆量拖累整個平台。Cost attribution 的目的是讓 tenant 看到自己的用量跟成本，驅動合理使用。</p>
<h3 id="標準化-topic-管理">標準化 topic 管理</h3>
<p>Topic 的建立走 self-service portal — 團隊填寫 owner、業務用途、預估流量、retention 需求，portal 自動配置 topic 並建立監控。沒有 owner 的 topic 不允許建立；owner 離職時 topic 需要交接或標記為候選淘汰。</p>
<h3 id="統一監控與值班">統一監控與值班</h3>
<p>Platform team 統一監控所有叢集的 broker 健康（replication lag、under-replicated partitions、disk usage、CPU），提供共用的 dashboard 跟 alert。值班由 platform team 負責 broker 層面的問題，業務層面的問題（consumer 設計錯誤、message 格式不對）由各 tenant team 自行處理。</p>
<h2 id="取捨">取捨</h2>
<table>
  <thead>
      <tr>
          <th>面向</th>
          <th>團隊自管</th>
          <th>平台化</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>自主性</td>
          <td>高（團隊想怎麼配就怎麼配）</td>
          <td>低到中（受 quota 跟 policy 約束）</td>
      </tr>
      <tr>
          <td>運維負擔分配</td>
          <td>分散（每個團隊各自負擔）</td>
          <td>集中（platform team 吸收 broker 層）</td>
      </tr>
      <tr>
          <td>資源利用率</td>
          <td>低（各自預留、無法共用）</td>
          <td>高（共享容量、動態分配）</td>
      </tr>
      <tr>
          <td>治理一致性</td>
          <td>低（版本、配置、命名各自為政）</td>
          <td>高（統一版本、統一配置標準）</td>
      </tr>
      <tr>
          <td>故障影響面</td>
          <td>小（自管叢集只影響自己的團隊）</td>
          <td>大（共享平台故障影響所有 tenant）</td>
      </tr>
      <tr>
          <td>專業知識需求</td>
          <td>每個團隊都要一些 Kafka 運維知識</td>
          <td>集中在 platform team</td>
      </tr>
  </tbody>
</table>
<p>平台化的最大風險是共享平台成為單點 — broker 故障影響所有 tenant。Uber 用跟 LinkedIn 類似的分層叢集策略（critical vs best-effort）降低共享風險，但這也讓平台的運維複雜度上升。</p>
<h2 id="回寫教材的連結">回寫教材的連結</h2>
<ul>
<li><a href="/blog/backend/03-message-queue/broker-basics/" data-link-title="3.1 broker 基礎與投遞模型" data-link-desc="先理解 broker、queue、consumer 與 delivery semantics">3.1 broker basics</a>：broker 容量規劃跟 topic 管理的基礎。</li>
<li><a href="/blog/backend/06-reliability/dependency-reliability-budget/" data-link-title="6.14 Dependency Reliability Budget" data-link-desc="把內外依賴的可靠性納入 SLO 計算與設計約束">6.14 dependency reliability budget</a>：共享 Kafka 平台作為 dependency，tenant team 的 reliability budget 怎麼計算。</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>：平台化後 consumer 設計的規範跟限制。</li>
<li><a href="/blog/backend/04-observability/cost-attribution/" data-link-title="4.15 Cost Attribution / Chargeback" data-link-desc="把 observability 成本拆到團隊、產品、環境維度">4.15 cost attribution</a>：平台成本歸因到 tenant 的做法。</li>
</ul>
<h2 id="判讀徵兆">判讀徵兆</h2>
<p>讀者在自己的系統看到以下訊號時，應該回讀本案例：</p>
<ul>
<li>組織內有 3 個以上團隊各自架設 Kafka、版本跟配置不統一</li>
<li>Topic 數量持續增長但沒人能說清楚哪些 topic 還在用</li>
<li>故障排查依賴特定個人而非共用的 runbook</li>
<li>叢集資源利用率低但各團隊仍要求擴容</li>
<li>管理層問「Kafka 總共花多少錢、誰在用」但沒人能回答</li>
</ul>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://www.uber.com/en-TW/blog/kafka/">Building Uber&rsquo;s Kafka Infrastructure</a></li>
</ul>
]]></content:encoded></item><item><title>Google Cloud Pub/Sub</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/google-pubsub/</link><pubDate>Fri, 01 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/google-pubsub/</guid><description>&lt;p>Google Cloud Pub/Sub 是 GCP managed pub/sub 服務、承擔三個責任：全球 topic 路由（無 region 概念）、彈性 delivery（push 跟 pull 並存）、GCP 生態整合（BigQuery / Dataflow / Cloud Run）。設計取捨偏向「topic 是 first-class、subscription 各自進度、ack deadline 控制重試」、跟 Kafka 的 partition / consumer group 思路不同。&lt;/p>
&lt;p>對「GCP 生態事件分發、跨 region 全球路由、push HTTP endpoint 接收事件、Dataflow streaming」這條路徑、Pub/Sub 是首選。本頁先給最短路徑、再展開日常 topic / subscription 操作與 ack deadline 設計、最後進階治理（ordering、DLT、push endpoint、IAM）跟排錯。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>讀完本章後、你應該能：&lt;/p>
&lt;ol>
&lt;li>用 gcloud CLI 建 topic / subscription、publish / pull 訊息&lt;/li>
&lt;li>區分 push vs pull subscription、選擇對應的 delivery 模型&lt;/li>
&lt;li>設計 ack deadline 與 ackExtension、處理長任務&lt;/li>
&lt;li>配置 dead-letter topic 與 retry policy&lt;/li>
&lt;li>評估 ordering key、Pub/Sub Lite、BigQuery subscription 等延伸場景&lt;/li>
&lt;/ol>
&lt;h2 id="最短路徑5-分鐘把-pubsub-跑起來">最短路徑：5 分鐘把 Pub/Sub 跑起來&lt;/h2>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 1. 建 topic&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">gcloud pubsub topics create demo-topic
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1"># 2. 建 subscription（pull 模式、綁定 topic）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">gcloud pubsub subscriptions create demo-sub --topic&lt;span class="o">=&lt;/span>demo-topic
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="c1"># 3. publish + pull 驗證&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">gcloud pubsub topics publish demo-topic --message&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;hello&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">gcloud pubsub subscriptions pull demo-sub --auto-ack&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>最短路徑驗證「topic / subscription 建得起來、能發能收」。實際應用見&lt;a href="#%e6%97%a5%e5%b8%b8%e6%93%8d%e4%bd%9c%e8%88%87%e6%b1%ba%e7%ad%96%e5%bd%a2%e7%8b%80">日常操作&lt;/a>。指令對真實 GCP 需設定 project 與認證；本機要先驗證可啟動 Pub/Sub emulator、用 &lt;code>gcloud config set api_endpoint_overrides/pubsub&lt;/code> 把同一組 CLI 指向 emulator 跑通。&lt;/p>
&lt;h2 id="日常操作與決策形狀">日常操作與決策形狀&lt;/h2>
&lt;h3 id="gcloud-cli-與-client-library">gcloud CLI 與 client library&lt;/h3>
&lt;p>子議題：&lt;/p>
&lt;ul>
&lt;li>gcloud CLI 指令對照表（topics / subscriptions / publish / pull / ack）&lt;/li>
&lt;li>Client library 配置：credentials / flow control / async vs sync&lt;/li>
&lt;li>Batch publish（提高吞吐、增加延遲的取捨）&lt;/li>
&lt;li>對應指令範例：&lt;code>gcloud pubsub subscriptions describe &amp;lt;sub&amp;gt;&lt;/code>&lt;/li>
&lt;/ul>
&lt;h3 id="topic--subscription-設計">Topic / Subscription 設計&lt;/h3>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">Topic&lt;/a> 是 first-class entity、跟 Kafka 不同的是 subscription 才是 consumer 抽象：&lt;/p></description><content:encoded><![CDATA[<p>Google Cloud Pub/Sub 是 GCP managed pub/sub 服務、承擔三個責任：全球 topic 路由（無 region 概念）、彈性 delivery（push 跟 pull 並存）、GCP 生態整合（BigQuery / Dataflow / Cloud Run）。設計取捨偏向「topic 是 first-class、subscription 各自進度、ack deadline 控制重試」、跟 Kafka 的 partition / consumer group 思路不同。</p>
<p>對「GCP 生態事件分發、跨 region 全球路由、push HTTP endpoint 接收事件、Dataflow streaming」這條路徑、Pub/Sub 是首選。本頁先給最短路徑、再展開日常 topic / subscription 操作與 ack deadline 設計、最後進階治理（ordering、DLT、push endpoint、IAM）跟排錯。</p>
<h2 id="本章目標">本章目標</h2>
<p>讀完本章後、你應該能：</p>
<ol>
<li>用 gcloud CLI 建 topic / subscription、publish / pull 訊息</li>
<li>區分 push vs pull subscription、選擇對應的 delivery 模型</li>
<li>設計 ack deadline 與 ackExtension、處理長任務</li>
<li>配置 dead-letter topic 與 retry policy</li>
<li>評估 ordering key、Pub/Sub Lite、BigQuery subscription 等延伸場景</li>
</ol>
<h2 id="最短路徑5-分鐘把-pubsub-跑起來">最短路徑：5 分鐘把 Pub/Sub 跑起來</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"># 1. 建 topic</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">gcloud pubsub topics create demo-topic
</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"># 2. 建 subscription（pull 模式、綁定 topic）</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">gcloud pubsub subscriptions create demo-sub --topic<span class="o">=</span>demo-topic
</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"><span class="c1"># 3. publish + pull 驗證</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">gcloud pubsub topics publish demo-topic --message<span class="o">=</span><span class="s2">&#34;hello&#34;</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl">gcloud pubsub subscriptions pull demo-sub --auto-ack</span></span></code></pre></div><p>最短路徑驗證「topic / subscription 建得起來、能發能收」。實際應用見<a href="#%e6%97%a5%e5%b8%b8%e6%93%8d%e4%bd%9c%e8%88%87%e6%b1%ba%e7%ad%96%e5%bd%a2%e7%8b%80">日常操作</a>。指令對真實 GCP 需設定 project 與認證；本機要先驗證可啟動 Pub/Sub emulator、用 <code>gcloud config set api_endpoint_overrides/pubsub</code> 把同一組 CLI 指向 emulator 跑通。</p>
<h2 id="日常操作與決策形狀">日常操作與決策形狀</h2>
<h3 id="gcloud-cli-與-client-library">gcloud CLI 與 client library</h3>
<p>子議題：</p>
<ul>
<li>gcloud CLI 指令對照表（topics / subscriptions / publish / pull / ack）</li>
<li>Client library 配置：credentials / flow control / async vs sync</li>
<li>Batch publish（提高吞吐、增加延遲的取捨）</li>
<li>對應指令範例：<code>gcloud pubsub subscriptions describe &lt;sub&gt;</code></li>
</ul>
<h3 id="topic--subscription-設計">Topic / Subscription 設計</h3>
<p><a href="/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">Topic</a> 是 first-class entity、跟 Kafka 不同的是 subscription 才是 consumer 抽象：</p>
<ul>
<li>1 topic ↔ N subscription（fan-out 內建）</li>
<li>Subscription 各自進度（無 consumer group 概念）</li>
<li>Subscription expiration policy（閒置 N 天自動刪）</li>
</ul>
<h3 id="push-vs-pull-subscription">Push vs Pull subscription</h3>
<p>子議題：</p>
<ul>
<li>Push：Pub/Sub 主動 POST 到 HTTP endpoint、適合無狀態 worker / Cloud Run</li>
<li>Pull：consumer 主動拉取、適合長 worker / 需要 flow control</li>
<li>Push endpoint 要求（HTTPS、認證）</li>
<li>兩者的可靠性 / latency / cost 對照</li>
</ul>
<h3 id="ack-deadline-與-ack-extension">Ack deadline 與 ack extension</h3>
<p>子議題：</p>
<ul>
<li>Ack deadline：subscription 等待 ack 的時間（預設 10 秒、上限 600 秒）</li>
<li>Modify ack deadline（長任務動態延長）</li>
<li>Client library 的自動 ack extension</li>
<li>跟 SQS visibility timeout 的對照（語意類似、機制不同）</li>
</ul>
<h2 id="進階主題按需閱讀">進階主題（按需閱讀）</h2>
<p>ordering key、dead-letter topic 與 schema enforcement 已展開為 deep article：<a href="ordering-dlt-schema/">ordering key / DLT / schema enforcement</a>、<a href="push-pull-ack-flow-control/">push / pull / ack flow control</a>。下列子議題段保留選題判讀入口。</p>
<h3 id="ordering-key">Ordering key</h3>
<p>子議題：</p>
<ul>
<li>啟用 ordering 的限制（subscription 設定 enableMessageOrdering）</li>
<li>Ordering 在 push 跟 pull 的差異</li>
<li>跟 Kafka partition + key 的對照</li>
<li>性能影響（throughput 受限）</li>
</ul>
<h3 id="dead-letter-topic">Dead-letter topic</h3>
<p>子議題：</p>
<ul>
<li>設定 max delivery attempt、超過送到 DLT</li>
<li>DLT 是另一個 topic、可以再訂閱重處理</li>
<li>跟 SQS DLQ 的差異（DLT 是 topic、不是 queue）</li>
</ul>
<h3 id="pubsub-lite">Pub/Sub Lite</h3>
<p>子議題：</p>
<ul>
<li>Pub/Sub Lite vs Pub/Sub（partition-based、zonal、cost 低）</li>
<li>何時用 Lite（高吞吐、確定 region）</li>
<li>何時用 standard（global routing 內建）</li>
</ul>
<h3 id="bigquery-subscription--cloud-storage-subscription">BigQuery subscription / Cloud Storage subscription</h3>
<p>子議題：</p>
<ul>
<li>BigQuery subscription：訊息直接寫入 BQ table（無需 Dataflow）</li>
<li>Cloud Storage subscription：訊息批次寫入 GCS object</li>
<li>適合 streaming analytics / data lake 場景</li>
</ul>
<h3 id="schema-enforcement">Schema enforcement</h3>
<p>子議題：</p>
<ul>
<li>Topic 綁定 schema（Avro / Protobuf）</li>
<li>Schema evolution</li>
<li>跟 Kafka Schema Registry 的對照</li>
</ul>
<h3 id="iam--service-account">IAM / Service Account</h3>
<p>子議題：</p>
<ul>
<li>Pub/Sub IAM role（publisher / subscriber / viewer）</li>
<li>Service Account 認證（push endpoint 用）</li>
<li>VPC Service Controls</li>
</ul>
<h2 id="排錯快速判讀">排錯快速判讀</h2>
<h3 id="subscriber-backlogunacked-messages-累積">Subscriber backlog（unacked messages 累積）</h3>
<p>操作原則：先看是 push 還是 pull、再定位 endpoint 失敗 vs flow control 限制。</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">gcloud pubsub subscriptions describe &lt;sub&gt;
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># 看 ackDeadlineSeconds（預設 10s）與 messageRetentionDuration（預設 604800s / 7 天）是否符合處理時間與 replay 需求</span></span></span></code></pre></div><p>判讀：Cloud Monitoring metric 的 <code>num_undelivered_messages</code> 與 <code>oldest_unacked_message_age</code>。</p>
<h3 id="push-endpoint-500retry-storm">Push endpoint 500（retry storm）</h3>
<p>操作原則：push endpoint 持續 500、Pub/Sub 會 backoff retry、看 retry policy 設定。判讀：endpoint 健康 vs 訊息毒性。</p>
<h3 id="ordering-key-限制誤用">Ordering key 限制誤用</h3>
<p>操作原則：啟用 ordering 後 throughput 變低、單一 ordering key 是順序的。判讀：throughput 是否被 ordering 限制、可拆 ordering key。</p>
<h3 id="iam-權限錯">IAM 權限錯</h3>
<p>操作原則：publish / pull / ack 各自需要不同 IAM role。判讀：用 Cloud Logging 看 deny 原因。</p>
<h3 id="subscription-expired">Subscription expired</h3>
<p>操作原則：閒置太久 subscription 被 GC。判讀：subscription expiration policy 設定 + 監控 lastReceiveTime。</p>
<h2 id="何時改走其他服務">何時改走其他服務</h2>
<table>
  <thead>
      <tr>
          <th>需求形狀</th>
          <th>改走</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>需要 streaming + replay long window</td>
          <td><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> / Confluent Cloud</td>
      </tr>
      <tr>
          <td>需要 partition + consumer group</td>
          <td><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> / Pub/Sub Lite</td>
      </tr>
      <tr>
          <td>需要複雜 routing</td>
          <td><a href="/blog/backend/03-message-queue/vendors/rabbitmq/" data-link-title="RabbitMQ" data-link-desc="Classic message broker、AMQP routing 為主">RabbitMQ</a> on GKE</td>
      </tr>
      <tr>
          <td>跨雲 / 跨平台</td>
          <td><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></td>
      </tr>
      <tr>
          <td>AWS 生態</td>
          <td><a href="/blog/backend/03-message-queue/vendors/aws-sqs/" data-link-title="AWS SQS" data-link-desc="AWS managed queue、簡單可靠、無 ordering（standard）">AWS SQS</a> / SNS</td>
      </tr>
      <tr>
          <td>Workflow + durable execution</td>
          <td>Google Workflows / Temporal</td>
      </tr>
  </tbody>
</table>
<h2 id="不在本頁內的主題">不在本頁內的主題</h2>
<ul>
<li>Dataflow / BigQuery 完整功能（另開 streaming analytics 章節）</li>
<li>Cloud Run / Functions 整合細節</li>
<li>各語言 client 完整 API</li>
</ul>
<h2 id="案例回寫">案例回寫</h2>
<h3 id="pubsub-專屬案例c60-c69">Pub/Sub 專屬案例（C60-C69）</h3>
<table>
  <thead>
      <tr>
          <th>案例</th>
          <th>主討論議題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/pubsub-spotify-event-delivery-platform/" data-link-title="3.C60 Spotify：Event Delivery 從 Kafka 遷到 Pub/Sub" data-link-desc="Spotify 全球 event delivery 從 Kafka 遷到 Pub/Sub、~2500 VM、Q1 2019 8M events/s、350TB/day raw、自建 dedup。">3.C60 Spotify Event Delivery</a></td>
          <td>從 Kafka 遷入 / 自建 dedup</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/pubsub-spotify-autoscaling-consumers/" data-link-title="3.C61 Spotify：Autoscaling Pub/Sub consumer 反效果" data-link-desc="Spotify 下游失敗時 consumer 不 ack 仍耗 CPU、autoscaling 越拉越高、解法是 exponential backoff 抑制 CPU。">3.C61 Spotify autoscaling</a></td>
          <td>Backlog ≠ healthy / autoscale 反效果</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/pubsub-spotify-cloud-storage-export/" data-link-title="3.C62 Spotify：Pub/Sub → GCS reliable export" data-link-desc="Spotify 用 Oldest Unacknowledged Message metric 判斷 hourly bucket 何時可安全關閉、ack 綁定下游 commit。">3.C62 Spotify GCS export</a></td>
          <td>Ack = end-to-end commit</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/pubsub-mercari-actionable-history/" data-link-title="3.C63 Mercari Actionable History：ack deadline 是 batch-level" data-link-desc="Merpay 支付流水帳用 Pub/Sub、ack deadline 是整批 batch 而非單訊息、acked 訊息會跟同批 expired 一起 redeliver。">3.C63 Mercari Actionable History</a></td>
          <td>Ack deadline 是 batch-level（陷阱）</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/pubsub-mercari-item-feed-dlt/" data-link-title="3.C64 Mercari Item Feed：DLT 防 poison message 阻塞" data-link-desc="Mercari 商品 feed 同步、ack 整批 / nack 重送、重試多次仍失敗送 DLT、topic 同時當 load-leveling buffer。">3.C64 Mercari Item Feed DLT</a></td>
          <td>DLT 防 poison message 阻塞</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/pubsub-mercari-line-flow-control/" data-link-title="3.C65 Mercari LINE：Pull subscription 對齊外部 RPS" data-link-desc="Mercari LINE webhook 轉 Pub/Sub、worker pull subscription 精確控制 RPS、應 LINE API 限制。">3.C65 Mercari LINE flow control</a></td>
          <td>Pull subscription 對齊外部 RPS</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/pubsub-mercari-b2c-grpc-pusher/" data-link-title="3.C66 Mercari B2C：自建 PubSub gRPC Pusher" data-link-desc="Mercari 全球商品同步、原生 HTTP push 在「長 job &#43; 高吞吐 &#43; 動態 RPS」場景受限、自建 gRPC 版 push。">3.C66 Mercari B2C gRPC pusher</a></td>
          <td>自建 push / 長 job + 動態 RPS</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/pubsub-niantic-pokemon-go-telemetry/" data-link-title="3.C67 Niantic Pokémon GO：Pub/Sub 當 telemetry ingest" data-link-desc="Pokémon GO frontend publish 玩家事件、~1M TPS、Pub/Sub elastic buffer、下游 BigQuery streaming。">3.C67 Niantic Pokémon GO</a></td>
          <td>Elastic buffer / BQ streaming</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/pubsub-wix-clickstream-dashboard/" data-link-title="3.C68 Wix：Pub/Sub decouple &#43; Dataflow &#43; BQ archive" data-link-desc="Wix App Engine 收 clickstream 進 Pub/Sub、Dataflow 進 Datastore &lt; 100ms、BigQuery 並行存 raw recovery。">3.C68 Wix clickstream</a></td>
          <td>Pub/Sub + Dataflow + BQ 教科書組合</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/pubsub-twitter-ad-engagement/" data-link-title="3.C69 Twitter Ad Engagement：把 stream 切成多 topic 做 partition" data-link-desc="Twitter 把 80K msg/s stream 切成 6 個 topic 做 partition、Avro schema、Beam/Dataflow → Bigtable/BQ。">3.C69 Twitter Ad Engagement</a></td>
          <td>多 topic 切分取代 partition</td>
      </tr>
  </tbody>
</table>
<h3 id="跨-vendor-對照">跨 vendor 對照</h3>
<table>
  <thead>
      <tr>
          <th>案例</th>
          <th>對 Pub/Sub 的對應</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/cloudflare-queues-global-delivery-model/" data-link-title="3.C8 Cloudflare：Queues 全球交付模型" data-link-desc="事件佇列服務在全球網路下的交付語義與治理案例。">3.C8 Cloudflare Queues</a></td>
          <td>全球交付對照：Pub/Sub global routing 內建</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/contrast-queue-model-by-scale/" data-link-title="3.C10 對照：規模差異下的佇列模型" data-link-desc="同一 queue 模型在不同規模下的治理與失敗邊界差異。">3.C10 規模對照</a></td>
          <td>中小型直接用 / 大型考慮 Pub/Sub Lite / 超大跨雲走 Kafka</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/kafka-spotify-event-delivery-exodus/" data-link-title="3.C20 Spotify：Event Delivery 從 Kafka 遷出（反例）" data-link-desc="Spotify Kafka 0.7 MirrorMaker best-effort 會掉資料但回報成功、broker restart 後 producer 無法恢復、決定遷到 GCP Pub/Sub。">3.C20 Spotify 遷出 Kafka</a></td>
          <td>Pub/Sub 遷入的源頭（為何遷出 Kafka）</td>
      </tr>
  </tbody>
</table>
<p><strong>IAM + Service Account 缺直接 customer engineering case</strong>：customer engineering blog 著墨少、建議撰寫該段時依 GCP 官方 IAM 文件 + 通用安全原則。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>上游概念：<a href="/blog/backend/00-service-selection/async-delivery-selection/" data-link-title="0.3 非同步與事件傳遞選型" data-link-desc="區分背景工作、durable queue、stream、pub/sub 與 outbox 的選型邊界">0.3 非同步選型</a>、<a href="/blog/backend/03-message-queue/broker-basics/" data-link-title="3.1 broker 基礎與投遞模型" data-link-desc="先理解 broker、queue、consumer 與 delivery semantics">3.1 broker basics</a></li>
<li>平行 vendor：<a href="/blog/backend/03-message-queue/vendors/aws-sqs/" data-link-title="AWS SQS" data-link-desc="AWS managed queue、簡單可靠、無 ordering（standard）">AWS SQS</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/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計</a>、<a href="/blog/backend/06-reliability/idempotency-replay/" data-link-title="6.12 Idempotency 與 Replay 驗證" data-link-desc="把重試、重播與冪等性從口頭約定變成可驗證屬性">6.12 idempotency / replay</a></li>
</ul>
]]></content:encoded></item><item><title>3.7 Event Contract 與 Replay Boundary</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/event-contract-replay-boundary/</link><pubDate>Mon, 11 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/event-contract-replay-boundary/</guid><description>&lt;p>Event contract 與 replay boundary 的核心責任是讓事件在版本演進、重試與重播時仍可被理解與驗證。進入具體 broker 前，讀者需要先知道事件 payload 是跨服務副作用的契約。&lt;/p>
&lt;h2 id="event-contract">Event Contract&lt;/h2>
&lt;p>Event contract 的責任是定義 producer 發出的事實、consumer 能依賴的欄位，以及版本演進時的相容窗口。最小 contract 包含 event id、schema version、occurred time、producer、entity id、dedup key 與資料保護範圍。&lt;/p>
&lt;p>event id 讓訊息可追蹤；schema version 讓版本演進可判斷；occurred time 讓 replay 可分時間窗；dedup key 讓 consumer 可去重；PII scope 讓事件能接到資料保護。&lt;/p>
&lt;p>event id 支撐 incident timeline 與重複投遞判讀。schema version 支撐新舊 consumer 共存。occurred time 支撐 replay window 與對帳查詢。dedup key 支撐 idempotency。PII scope 支撐 audit 與資料保護。這些欄位先成立，broker &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/retention/" data-link-title="Retention" data-link-desc="說明資料或事件保留多久，以及保留期限如何影響重放與成本">retention&lt;/a> 或 partition 設計才有可依附的語意。&lt;/p>
&lt;h2 id="schema-compatibility">Schema Compatibility&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/event-schema-compatibility/" data-link-title="Event Schema Compatibility" data-link-desc="說明 event schema 演進時，新舊 producer 與 consumer 能否互通的相容性等級">Schema compatibility&lt;/a> 的責任是讓 producer 與 consumer 可以分批升級。新增欄位要保留 optional，移除欄位要有相容窗口，語意改變要用新 version 或新 event type。&lt;/p>
&lt;p>序列化能解析是相容性的第一層。若欄位仍存在但語意改變，consumer 仍可能產生錯誤副作用。這類變更需要在 release gate 中驗證。&lt;/p>
&lt;h2 id="replay-boundary">Replay Boundary&lt;/h2>
&lt;p>Replay boundary 的責任是限制重播範圍，避免修復動作擴大事故。Replay 要能指定 time range、tenant、partition、event type、schema version 與 downstream capacity。&lt;/p>
&lt;p>replay window 要和 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/time-range/" data-link-title="Time Range" data-link-desc="說明證據、查詢與事故判讀如何用時間窗保留可回放上下文">time range&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/query-link/" data-link-title="Query Link" data-link-desc="說明證據包如何保存可重跑查詢入口，而不是只保留截圖或口頭結論">query link&lt;/a> 對齊，讓事後能回放當時重播的是哪一批事件。&lt;/p>
&lt;h2 id="compensation">Compensation&lt;/h2>
&lt;p>Compensation 的責任是處理副作用已經發生但結果不正確的情況。寄信、發票、付款通知與 webhook 都可能需要補償，重播是其中一種恢復方式。&lt;/p>
&lt;p>補償前要先判斷副作用是否可逆、是否會通知使用者、是否需要人工審核。不可逆副作用要比可重播副作用更早接到 &lt;a href="https://tarrragon.github.io/blog/backend/08-incident-response/incident-decision-log/" data-link-title="8.19 Incident Decision Log" data-link-desc="把事中假設、決策、證據、回退條件與責任人留下可復盤紀錄">Incident Decision Log&lt;/a>。&lt;/p>
&lt;h2 id="跨-broker-業務語意對映">跨 broker 業務語意對映&lt;/h2>
&lt;p>跨 broker migration 的工程責任是維持業務語意對映、broker 吞吐是次要驗證項。同一份 event contract 在 Kafka、Pub/Sub、SQS、NATS 的對映概念不同、需要逐項校準。&lt;/p>
&lt;p>對應 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/spotify-kafka-to-pubsub-migration-gcp/" data-link-title="9.C9 Spotify：從自管 Kafka 遷移到 GCP Pub/Sub 的事件交付系統" data-link-desc="Spotify 把自管 Kafka 事件系統遷移到 Google Cloud Pub/Sub、避免自管 broker 的容量規劃成本">9.C9 Spotify Kafka → Pub/Sub Migration&lt;/a> — Spotify 7500 萬用戶事件交付系統遷移、case 明確點出 Kafka 的 partition / offset / consumer group 對映成 Pub/Sub 的 subscription / ordering key / message attribute、需要校準業務語意而非直接搬。&lt;/p>
&lt;p>&lt;strong>典型概念對映差異&lt;/strong>（依據 9.C9 case 列出的三組對映展開、Pub/Sub 實際 API 細節為文章補充）：&lt;/p></description><content:encoded><![CDATA[<p>Event contract 與 replay boundary 的核心責任是讓事件在版本演進、重試與重播時仍可被理解與驗證。進入具體 broker 前，讀者需要先知道事件 payload 是跨服務副作用的契約。</p>
<h2 id="event-contract">Event Contract</h2>
<p>Event contract 的責任是定義 producer 發出的事實、consumer 能依賴的欄位，以及版本演進時的相容窗口。最小 contract 包含 event id、schema version、occurred time、producer、entity id、dedup key 與資料保護範圍。</p>
<p>event id 讓訊息可追蹤；schema version 讓版本演進可判斷；occurred time 讓 replay 可分時間窗；dedup key 讓 consumer 可去重；PII scope 讓事件能接到資料保護。</p>
<p>event id 支撐 incident timeline 與重複投遞判讀。schema version 支撐新舊 consumer 共存。occurred time 支撐 replay window 與對帳查詢。dedup key 支撐 idempotency。PII scope 支撐 audit 與資料保護。這些欄位先成立，broker <a href="/blog/backend/knowledge-cards/retention/" data-link-title="Retention" data-link-desc="說明資料或事件保留多久，以及保留期限如何影響重放與成本">retention</a> 或 partition 設計才有可依附的語意。</p>
<h2 id="schema-compatibility">Schema Compatibility</h2>
<p><a href="/blog/backend/knowledge-cards/event-schema-compatibility/" data-link-title="Event Schema Compatibility" data-link-desc="說明 event schema 演進時，新舊 producer 與 consumer 能否互通的相容性等級">Schema compatibility</a> 的責任是讓 producer 與 consumer 可以分批升級。新增欄位要保留 optional，移除欄位要有相容窗口，語意改變要用新 version 或新 event type。</p>
<p>序列化能解析是相容性的第一層。若欄位仍存在但語意改變，consumer 仍可能產生錯誤副作用。這類變更需要在 release gate 中驗證。</p>
<h2 id="replay-boundary">Replay Boundary</h2>
<p>Replay boundary 的責任是限制重播範圍，避免修復動作擴大事故。Replay 要能指定 time range、tenant、partition、event type、schema version 與 downstream capacity。</p>
<p>replay window 要和 <a href="/blog/backend/knowledge-cards/time-range/" data-link-title="Time Range" data-link-desc="說明證據、查詢與事故判讀如何用時間窗保留可回放上下文">time range</a> 與 <a href="/blog/backend/knowledge-cards/query-link/" data-link-title="Query Link" data-link-desc="說明證據包如何保存可重跑查詢入口，而不是只保留截圖或口頭結論">query link</a> 對齊，讓事後能回放當時重播的是哪一批事件。</p>
<h2 id="compensation">Compensation</h2>
<p>Compensation 的責任是處理副作用已經發生但結果不正確的情況。寄信、發票、付款通知與 webhook 都可能需要補償，重播是其中一種恢復方式。</p>
<p>補償前要先判斷副作用是否可逆、是否會通知使用者、是否需要人工審核。不可逆副作用要比可重播副作用更早接到 <a href="/blog/backend/08-incident-response/incident-decision-log/" data-link-title="8.19 Incident Decision Log" data-link-desc="把事中假設、決策、證據、回退條件與責任人留下可復盤紀錄">Incident Decision Log</a>。</p>
<h2 id="跨-broker-業務語意對映">跨 broker 業務語意對映</h2>
<p>跨 broker migration 的工程責任是維持業務語意對映、broker 吞吐是次要驗證項。同一份 event contract 在 Kafka、Pub/Sub、SQS、NATS 的對映概念不同、需要逐項校準。</p>
<p>對應 <a href="/blog/backend/09-performance-capacity/cases/spotify-kafka-to-pubsub-migration-gcp/" data-link-title="9.C9 Spotify：從自管 Kafka 遷移到 GCP Pub/Sub 的事件交付系統" data-link-desc="Spotify 把自管 Kafka 事件系統遷移到 Google Cloud Pub/Sub、避免自管 broker 的容量規劃成本">9.C9 Spotify Kafka → Pub/Sub Migration</a> — Spotify 7500 萬用戶事件交付系統遷移、case 明確點出 Kafka 的 partition / offset / consumer group 對映成 Pub/Sub 的 subscription / ordering key / message attribute、需要校準業務語意而非直接搬。</p>
<p><strong>典型概念對映差異</strong>（依據 9.C9 case 列出的三組對映展開、Pub/Sub 實際 API 細節為文章補充）：</p>
<ul>
<li><strong>Partition (Kafka) 跟 Subscription (Pub/Sub)</strong>：Kafka partition 是物理分片 + 順序邊界；Pub/Sub subscription 是邏輯 <a href="/blog/backend/knowledge-cards/fan-out/" data-link-title="Fan-out" data-link-desc="說明單一事件同時分發給多個下游的訊息拓撲">fan-out</a>、無物理分片概念。靠 Kafka partition 保證 per-key 順序的 consumer、遷到 Pub/Sub 改用 ordering key</li>
<li><strong>Offset (Kafka) 對映成 message attribute (Pub/Sub)</strong>：9.C9 case 原文對映方向；replay 模型差異上、Kafka offset 是位置指標、可任意回放到某個 offset；Pub/Sub 用 Snapshot + Seek API 達成類似 replay 能力、模型不同</li>
<li><strong>Consumer Group (Kafka) 跟 Subscription (Pub/Sub)</strong>：Kafka consumer group 內部 rebalance 自動分 partition；Pub/Sub subscription 自動分 message、語意接近但 rebalance 細節差異會影響 <a href="/blog/backend/knowledge-cards/in-flight/" data-link-title="In-Flight Work" data-link-desc="目前已接收但尚未完成處理的工作量">in-flight</a> message 處理順序</li>
</ul>
<p><strong>遷移評估要驗證的業務語意</strong>：</p>
<ul>
<li>順序保證：原系統靠 partition / consumer group 保證什麼順序、新系統能否複製</li>
<li>Replay 模型：原系統 replay 方式、新系統的 replay 工具能否達成同範圍。replay window 上限由 idempotency 保留期反推、見 <a href="/blog/backend/03-message-queue/processing-recovery-semantics/" data-link-title="3.6 Processing Semantics 與 Recovery Semantics" data-link-desc="說明投遞成功、處理成功與恢復成功為何是三個不同判斷。">3.6 Replay 跟 Idempotency 共設計</a></li>
<li>失敗模式：consumer 故障時、原系統的 rebalance / redelivery 行為、新系統會不會差異</li>
</ul>
<p>判讀重點：broker migration 屬語意對映工程、吞吐能力比較是次要驗證項。對應 <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 的「Broker 遷移階段流程」</a>、實作面用 dual-write + shadow consume + cutover、驗證面靠 event contract 跟 replay 邊界做對帳。</p>
<h2 id="跨模組路由">跨模組路由</h2>
<ol>
<li>與 03 內部：replay window 跟 idempotency 共設計回到 <a href="/blog/backend/03-message-queue/processing-recovery-semantics/" data-link-title="3.6 Processing Semantics 與 Recovery Semantics" data-link-desc="說明投遞成功、處理成功與恢復成功為何是三個不同判斷。">3.6 processing-recovery-semantics</a>；broker 遷移階段流程回到 <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></li>
<li>與 04 的交接：event contract 演進 + replay 邊界進 <a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">4.20 Observability Evidence Package</a></li>
<li>與 06 的交接：event contract 跟 replay 驗證進 <a href="/blog/backend/06-reliability/idempotency-replay/" data-link-title="6.12 Idempotency 與 Replay 驗證" data-link-desc="把重試、重播與冪等性從口頭約定變成可驗證屬性">6.12 Idempotency 與 Replay 驗證</a> 跟 <a href="/blog/backend/06-reliability/verification-evidence-handoff/" data-link-title="6.23 Verification Evidence Handoff" data-link-desc="把 SLO、load、chaos、DR 與 readiness 結果包成 release / incident 可用證據">6.23 Verification Evidence Handoff</a></li>
<li>與 07 的交接：event payload 的 PII / audit 邊界進 <a href="/blog/backend/07-security-data-protection/data-protection-and-masking-governance/" data-link-title="7.4 資料保護與遮罩治理" data-link-desc="以問題驅動方式整理資料分級、遮罩、匯出與備份治理">7.4 data protection and masking</a></li>
</ol>
<h2 id="選型前判準">選型前判準</h2>
<p>Broker 選型前要先回答：</p>
<ol>
<li>event contract 是否能支援版本相容。</li>
<li>consumer 是否能用 dedup key 判斷重複。</li>
<li>replay window 是否能用查詢與指標證明。</li>
<li>不可逆副作用是否有補償流程。</li>
<li>event payload 是否包含 PII 或 audit-sensitive 欄位。</li>
</ol>
<p>這些問題決定後續要比較 broker retention、schema registry、DLQ、partition 與 replay 工具，並把吞吐放回服務語意下判讀。</p>
<h2 id="實體服務討論承接點">實體服務討論承接點</h2>
<p>實體 broker 文章要承接本篇的 event contract 與 replay boundary。Kafka 的長期 retention、RabbitMQ 的 routing 與 DLQ、SQS 的 <a href="/blog/backend/knowledge-cards/visibility-timeout/" data-link-title="Visibility Timeout" data-link-desc="說明訊息被取走後對其他 consumer 暫時不可見的時間窗，timeout 後重新投遞">visibility timeout</a>、NATS JetStream 的 stream/consumer 模型，都要放回事件契約與重播邊界下判讀。</p>
<p>若事件需要長期 replay，後續文章要比較 retention、offset、partition 與 schema evolution。若事件主要是工作任務，後續文章要比較 visibility、ack、DLQ 與重試治理。若事件包含 PII 或高風險副作用，後續文章要比較 audit、encryption、access control 與補償流程。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>要處理 outbox 與事件發布一致性，接著讀 <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>。要處理 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>。</p>
]]></content:encoded></item><item><title>3.C7 LinkedIn：Kafka 自動修復治理</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/linkedin-kafka-self-healing-automation/</link><pubDate>Thu, 07 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/linkedin-kafka-self-healing-automation/</guid><description>&lt;p>這個案例的核心責任是把 queue 可靠性從人力值班轉成自動化機制。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>LinkedIn 在 Kafka 維運中導入自動化治理，降低人工介入與恢復時間波動。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>當叢集規模超過人力可及範圍，自動修復與治理工具會成為必要能力。&lt;/p>
&lt;h2 id="策略">策略&lt;/h2>
&lt;ol>
&lt;li>明確定義可自動修復的故障類型。&lt;/li>
&lt;li>將自動修復與人工升級條件分離。&lt;/li>
&lt;li>把修復過程納入可觀測證據鏈。&lt;/li>
&lt;/ol>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/durable-queue/" data-link-title="3.2 durable queue 與重試策略" data-link-desc="整理持久化佇列、DLQ 與重試流程">3.2&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/08-incident-response/runbook-lifecycle/" data-link-title="8.16 Runbook Lifecycle 管理" data-link-desc="把 runbook 從一次性文件變成有版本、有演練、會過期的 artifact">8.16&lt;/a>。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://engineering.linkedin.com/blog">Automating Kafka Self-Healing at LinkedIn&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是把 queue 可靠性從人力值班轉成自動化機制。</p>
<h2 id="觀察">觀察</h2>
<p>LinkedIn 在 Kafka 維運中導入自動化治理，降低人工介入與恢復時間波動。</p>
<h2 id="判讀">判讀</h2>
<p>當叢集規模超過人力可及範圍，自動修復與治理工具會成為必要能力。</p>
<h2 id="策略">策略</h2>
<ol>
<li>明確定義可自動修復的故障類型。</li>
<li>將自動修復與人工升級條件分離。</li>
<li>把修復過程納入可觀測證據鏈。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/durable-queue/" data-link-title="3.2 durable queue 與重試策略" data-link-desc="整理持久化佇列、DLQ 與重試流程">3.2</a> 與 <a href="/blog/backend/08-incident-response/runbook-lifecycle/" data-link-title="8.16 Runbook Lifecycle 管理" data-link-desc="把 runbook 從一次性文件變成有版本、有演練、會過期的 artifact">8.16</a>。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://engineering.linkedin.com/blog">Automating Kafka Self-Healing at LinkedIn</a></li>
</ul>
]]></content:encoded></item><item><title>3.8 Queue Consumer Retry 與 Replay Handoff（實作示範）</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/queue-consumer-retry-replay-handoff/</link><pubDate>Mon, 11 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/queue-consumer-retry-replay-handoff/</guid><description>&lt;p>Queue consumer retry 與 replay handoff 的核心責任是把 request 外副作用做成可重試、可去重、可隔離、可重播的服務流程。這篇以 &lt;code>order_created&lt;/code> consumer 為例，示範 delivery、processing、recovery 三層語意如何交接到 evidence package、release gate 與 incident decision log。&lt;/p>
&lt;h2 id="服務路徑與語意分層">服務路徑與語意分層&lt;/h2>
&lt;p>這條路徑是 &lt;code>order-service -&amp;gt; broker -&amp;gt; order-created-consumer -&amp;gt; invoice/email/search/webhook&lt;/code>。Producer 把事件交給 broker 後，真正的業務完成要看 consumer 是否正確提交副作用。&lt;/p>
&lt;p>這篇先固定三層語意：&lt;/p>
&lt;ol>
&lt;li>Delivery semantics：訊息是否投遞與確認。&lt;/li>
&lt;li>Processing semantics：副作用是否可承受重複與部分失敗。&lt;/li>
&lt;li>Recovery semantics：故障後是否可重播並恢復一致。&lt;/li>
&lt;/ol>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">ack/nack&lt;/a> 成功只代表 delivery 進度，不代表發票與通知已完成。&lt;/p>
&lt;h2 id="event-contract-與相容邊界">Event Contract 與相容邊界&lt;/h2>
&lt;p>Event contract 的責任是讓 producer 與 consumer 在版本演進時仍可互通，且可被觀測與回放。&lt;/p>
&lt;p>&lt;code>order_created&lt;/code> 最小欄位：&lt;/p>
&lt;ol>
&lt;li>&lt;code>event_id&lt;/code>：全域唯一識別。&lt;/li>
&lt;li>&lt;code>schema_version&lt;/code>：事件版本。&lt;/li>
&lt;li>&lt;code>occurred_at&lt;/code>：事件發生時間。&lt;/li>
&lt;li>&lt;code>order_id&lt;/code>、&lt;code>tenant_id&lt;/code>：業務定位。&lt;/li>
&lt;li>&lt;code>idempotency_key&lt;/code>：副作用去重鍵。&lt;/li>
&lt;li>&lt;code>pii_scope&lt;/code>：敏感欄位範圍。&lt;/li>
&lt;/ol>
&lt;p>版本演進採向後相容優先：新增欄位可選、舊欄位保留窗口。schema 演進前要先確認 consumer 端 fallback 解析邏輯存在，避免切版後整批進 DLQ。&lt;/p>
&lt;h2 id="retry--dlq--quarantine">Retry / DLQ / Quarantine&lt;/h2>
&lt;p>Retry 的責任是吸收暫時性故障，不把短暫抖動升級成事故。這條路徑使用有限重試 + backoff + jitter：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>階段&lt;/th>
 &lt;th>判讀重點&lt;/th>
 &lt;th>動作&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>即時重試&lt;/td>
 &lt;td>下游短暫 timeout 或限流&lt;/td>
 &lt;td>在主通道重試少量次數&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>延遲重試&lt;/td>
 &lt;td>故障持續但可恢復&lt;/td>
 &lt;td>延長 backoff，避免重試風暴&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>DLQ 隔離&lt;/td>
 &lt;td>payload 或版本異常、長時故障&lt;/td>
 &lt;td>轉入 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/dead-letter-queue/" data-link-title="Dead-Letter Queue" data-link-desc="說明 dead-letter queue 如何隔離多次處理失敗的訊息">dead-letter queue&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Quarantine&lt;/td>
 &lt;td>同型 poison message 連續爆發&lt;/td>
 &lt;td>停主通道回放，先分群診斷&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>DLQ 的責任是隔離與診斷，不是永久儲存。重點是把異常訊息分群後對應修法，修完再定向回放。&lt;/p>
&lt;h2 id="idempotency-與-ack-timing">Idempotency 與 Ack Timing&lt;/h2>
&lt;p>Idempotency 的責任是把 at-least-once 交付轉成可接受業務結果。副作用如發票、email、webhook 都要以 &lt;code>idempotency_key&lt;/code> 做去重。&lt;/p>
&lt;p>Ack timing 的原則是「核心副作用提交後再 ack」：&lt;/p>
&lt;ol>
&lt;li>先執行副作用或落地可追蹤結果。&lt;/li>
&lt;li>成功後寫去重紀錄或 checkpoint。&lt;/li>
&lt;li>最後 ack broker。&lt;/li>
&lt;/ol>
&lt;p>先 ack 再副作用會造成資料遺失；副作用成功但去重紀錄失敗，則要由 recovery 層補償。&lt;/p>
&lt;h2 id="replay-runbook">Replay Runbook&lt;/h2>
&lt;p>Replay 的責任是故障後在可控範圍內恢復，不把修復變成第二次事故。&lt;/p>
&lt;p>這條路徑的 replay runbook：&lt;/p>
&lt;ol>
&lt;li>選定 replay window：依 &lt;code>occurred_at&lt;/code> 與 &lt;code>schema_version&lt;/code> 分段。&lt;/li>
&lt;li>Dry run：先在影子通道跑去重與下游容量驗證。&lt;/li>
&lt;li>限速回放：按 tenant 或 partition 分批，監控下游錯誤率。&lt;/li>
&lt;li>Reconciliation：對帳發票、通知、索引結果。&lt;/li>
&lt;li>Stop condition：duplicate side-effect、downstream timeout、DLQ 再爆發即停。&lt;/li>
&lt;/ol>
&lt;p>replay window 要能被明確描述與回放，不可用「重播昨天全部」這種不可驗證句子。&lt;/p>
&lt;h2 id="job-queue-的拓樸分工">Job queue 的拓樸分工&lt;/h2>
&lt;p>當背景工作同時要 &lt;em>高吞吐&lt;/em> 跟 &lt;em>快速反應&lt;/em>、單一通道模型會變成瓶頸。job queue 的擴展通常是 &lt;em>拓樸重整&lt;/em>、把不同工作類型切到不同傳遞路徑、而非單點替換。&lt;/p>
&lt;p>對應 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/slack-job-queue-kafka-redis/" data-link-title="3.C5 Slack：Job Queue 演進到 Kafka &amp;#43; Redis" data-link-desc="背景工作通道在成長期如何從單一路徑演進成組合式架構。">3.C5 Slack Job Queue 演進到 Kafka + Redis&lt;/a> — Slack 在 job queue 擴展時把工作切到不同傳遞路徑、Kafka 跟 Redis 分別承擔持久性跟即時性目標、分開治理 lag、重試跟失敗重播。&lt;/p></description><content:encoded><![CDATA[<p>Queue consumer retry 與 replay handoff 的核心責任是把 request 外副作用做成可重試、可去重、可隔離、可重播的服務流程。這篇以 <code>order_created</code> consumer 為例，示範 delivery、processing、recovery 三層語意如何交接到 evidence package、release gate 與 incident decision log。</p>
<h2 id="服務路徑與語意分層">服務路徑與語意分層</h2>
<p>這條路徑是 <code>order-service -&gt; broker -&gt; order-created-consumer -&gt; invoice/email/search/webhook</code>。Producer 把事件交給 broker 後，真正的業務完成要看 consumer 是否正確提交副作用。</p>
<p>這篇先固定三層語意：</p>
<ol>
<li>Delivery semantics：訊息是否投遞與確認。</li>
<li>Processing semantics：副作用是否可承受重複與部分失敗。</li>
<li>Recovery semantics：故障後是否可重播並恢復一致。</li>
</ol>
<p><a href="/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">ack/nack</a> 成功只代表 delivery 進度，不代表發票與通知已完成。</p>
<h2 id="event-contract-與相容邊界">Event Contract 與相容邊界</h2>
<p>Event contract 的責任是讓 producer 與 consumer 在版本演進時仍可互通，且可被觀測與回放。</p>
<p><code>order_created</code> 最小欄位：</p>
<ol>
<li><code>event_id</code>：全域唯一識別。</li>
<li><code>schema_version</code>：事件版本。</li>
<li><code>occurred_at</code>：事件發生時間。</li>
<li><code>order_id</code>、<code>tenant_id</code>：業務定位。</li>
<li><code>idempotency_key</code>：副作用去重鍵。</li>
<li><code>pii_scope</code>：敏感欄位範圍。</li>
</ol>
<p>版本演進採向後相容優先：新增欄位可選、舊欄位保留窗口。schema 演進前要先確認 consumer 端 fallback 解析邏輯存在，避免切版後整批進 DLQ。</p>
<h2 id="retry--dlq--quarantine">Retry / DLQ / Quarantine</h2>
<p>Retry 的責任是吸收暫時性故障，不把短暫抖動升級成事故。這條路徑使用有限重試 + backoff + jitter：</p>
<table>
  <thead>
      <tr>
          <th>階段</th>
          <th>判讀重點</th>
          <th>動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>即時重試</td>
          <td>下游短暫 timeout 或限流</td>
          <td>在主通道重試少量次數</td>
      </tr>
      <tr>
          <td>延遲重試</td>
          <td>故障持續但可恢復</td>
          <td>延長 backoff，避免重試風暴</td>
      </tr>
      <tr>
          <td>DLQ 隔離</td>
          <td>payload 或版本異常、長時故障</td>
          <td>轉入 <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></td>
      </tr>
      <tr>
          <td>Quarantine</td>
          <td>同型 poison message 連續爆發</td>
          <td>停主通道回放，先分群診斷</td>
      </tr>
  </tbody>
</table>
<p>DLQ 的責任是隔離與診斷，不是永久儲存。重點是把異常訊息分群後對應修法，修完再定向回放。</p>
<h2 id="idempotency-與-ack-timing">Idempotency 與 Ack Timing</h2>
<p>Idempotency 的責任是把 at-least-once 交付轉成可接受業務結果。副作用如發票、email、webhook 都要以 <code>idempotency_key</code> 做去重。</p>
<p>Ack timing 的原則是「核心副作用提交後再 ack」：</p>
<ol>
<li>先執行副作用或落地可追蹤結果。</li>
<li>成功後寫去重紀錄或 checkpoint。</li>
<li>最後 ack broker。</li>
</ol>
<p>先 ack 再副作用會造成資料遺失；副作用成功但去重紀錄失敗，則要由 recovery 層補償。</p>
<h2 id="replay-runbook">Replay Runbook</h2>
<p>Replay 的責任是故障後在可控範圍內恢復，不把修復變成第二次事故。</p>
<p>這條路徑的 replay runbook：</p>
<ol>
<li>選定 replay window：依 <code>occurred_at</code> 與 <code>schema_version</code> 分段。</li>
<li>Dry run：先在影子通道跑去重與下游容量驗證。</li>
<li>限速回放：按 tenant 或 partition 分批，監控下游錯誤率。</li>
<li>Reconciliation：對帳發票、通知、索引結果。</li>
<li>Stop condition：duplicate side-effect、downstream timeout、DLQ 再爆發即停。</li>
</ol>
<p>replay window 要能被明確描述與回放，不可用「重播昨天全部」這種不可驗證句子。</p>
<h2 id="job-queue-的拓樸分工">Job queue 的拓樸分工</h2>
<p>當背景工作同時要 <em>高吞吐</em> 跟 <em>快速反應</em>、單一通道模型會變成瓶頸。job queue 的擴展通常是 <em>拓樸重整</em>、把不同工作類型切到不同傳遞路徑、而非單點替換。</p>
<p>對應 <a href="/blog/backend/03-message-queue/cases/slack-job-queue-kafka-redis/" data-link-title="3.C5 Slack：Job Queue 演進到 Kafka &#43; Redis" data-link-desc="背景工作通道在成長期如何從單一路徑演進成組合式架構。">3.C5 Slack Job Queue 演進到 Kafka + Redis</a> — Slack 在 job queue 擴展時把工作切到不同傳遞路徑、Kafka 跟 Redis 分別承擔持久性跟即時性目標、分開治理 lag、重試跟失敗重播。</p>
<p><strong>拓樸分工的判讀</strong>（基於 Slack case 揭露的雙通道分工方向）：</p>
<ul>
<li><strong>持久性主導的 job</strong>（發票、付款通知、合規記錄）→ Kafka / 持久 queue、保證 at-least-once</li>
<li><strong>即時性主導的 job</strong>（線上提醒、playback control、UI 更新）→ Redis / 輕量 queue、low latency</li>
</ul>
<p>設計含義：同一 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-design 三個工程議題鐵三角</a> — idempotency / 重播流程 / 下游承載能力是 consumer 內部設計、拓樸分工是 <em>跨 consumer</em> 的責任拆分、兩者互補。</p>
<h2 id="job-queue-規模差異的治理重點">Job queue 規模差異的治理重點</h2>
<p>不同規模服務的 job queue 治理問題差異大、SSoT 在本章。對應 <a href="/blog/backend/03-message-queue/cases/contrast-queue-model-by-scale/" data-link-title="3.C10 對照：規模差異下的佇列模型" data-link-desc="同一 queue 模型在不同規模下的治理與失敗邊界差異。">3.C10 對照：規模差異下的佇列模型</a>：</p>
<ul>
<li><strong>小型服務</strong>：優先用 managed queue（SQS / Pub/Sub）、運維成本最低。最容易忽略的是語意邊界（重試次數、死信規則、重播責任）、規模一上來會出現資料重複與漏處理。<strong>升級訊號</strong>：team 數超 3-5 個、各自寫 consumer 開始出現 idempotency 不一致、進中型階段</li>
<li><strong>中型服務</strong>：常見問題是 lag 與 DLQ 長期累積。原因是 consumer idempotency + 重播流程 + 下游承載能力沒一起設計。對應前段 Job queue 拓樸分工。<strong>升級訊號</strong>：DLQ 累積速度高於排空速度連續 7 天、單一 tenant 流量尖峰拖垮其他 tenant、進大型階段</li>
<li><strong>大型服務</strong>：需要處理跨租戶跟跨區壓力。單叢集思維會讓任何一類流量尖峰拖垮整體。對應 <a href="/blog/backend/03-message-queue/cases/linkedin-kafka-tiered-clusters/" data-link-title="3.C4 LinkedIn：Kafka 分層叢集治理" data-link-desc="Kafka 從單叢集走向 tiered clusters 的轉換案例。">3.C4 LinkedIn Tiered Clusters</a> 跟 <a href="/blog/backend/03-message-queue/broker-basics/" data-link-title="3.1 broker 基礎與投遞模型" data-link-desc="先理解 broker、queue、consumer 與 delivery semantics">3.1 broker-basics 分層治理平台</a>、重點從「怎麼送訊息」轉成「怎麼隔離失敗」</li>
</ul>
<p>判讀重點：當前服務規模決定要處理的 <em>主要</em> 問題。規模尚小的服務硬上 multi-tenant 隔離治理屬過度設計、規模化服務應同時考慮 broker 容量是否充足跟隔離邊界是否完整。判斷自己在哪個階段、看 <em>升級訊號</em> 對應的指標。</p>
<h2 id="evidence-package">Evidence Package</h2>
<p>Queue evidence 的責任是證明「投遞可達」與「處理可恢復」兩者同時成立。</p>
<table>
  <thead>
      <tr>
          <th>欄位</th>
          <th>內容</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Source</td>
          <td>broker metric、consumer metric、DLQ log、reconciliation query</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/time-range/" data-link-title="Time Range" data-link-desc="說明證據、查詢與事故判讀如何用時間窗保留可回放上下文">Time range</a></td>
          <td>retry/replay 批次窗口</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/query-link/" data-link-title="Query Link" data-link-desc="說明證據包如何保存可重跑查詢入口，而不是只保留截圖或口頭結論">Query link</a></td>
          <td>lag、retry count、DLQ count、duplicate side-effect、throughput</td>
      </tr>
      <tr>
          <td>Owner</td>
          <td>queue owner、consumer owner、downstream owner</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/data-quality/" data-link-title="Data Quality" data-link-desc="說明證據欄位如何標示 completeness、freshness、sampling 與資料限制">Data quality</a></td>
          <td>指標延遲、抽樣缺口、對帳覆蓋率</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/confidence/" data-link-title="Confidence" data-link-desc="說明證據包如何標示 confirmed、suspected 或 needs follow-up 的判讀信心">Confidence</a></td>
          <td>confirmed / suspected / needs follow-up</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/known-gap/" data-link-title="Known Gap" data-link-desc="說明證據包如何明確保存已知缺口，避免下游高估證據完整性">Known gap</a></td>
          <td>尚未驗證之下游 webhook 供應商、低流量 tenant replay</td>
      </tr>
  </tbody>
</table>
<p>這份 evidence 要對齊 <a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">4.20 Observability Evidence Package</a> 與 <a href="/blog/backend/06-reliability/verification-evidence-handoff/" data-link-title="6.23 Verification Evidence Handoff" data-link-desc="把 SLO、load、chaos、DR 與 readiness 結果包成 release / incident 可用證據">6.23 Verification Evidence Handoff</a>。</p>
<h2 id="release-gate">Release Gate</h2>
<p>Queue release gate 的責任是決定是否擴大回放或恢復主通道，而不是只看單一 lag 指標。</p>
<table>
  <thead>
      <tr>
          <th>Gate 欄位</th>
          <th>最小內容</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/gate-decision/" data-link-title="Gate Decision" data-link-desc="說明 release gate 如何把證據轉成放行、暫停、回退或補證據的決策">Gate decision</a></td>
          <td>放行下一批 replay、維持觀察、暫停 consumer</td>
      </tr>
      <tr>
          <td>Checks</td>
          <td>idempotency proof、DLQ drain 結果、下游容量、duplicate 比例</td>
      </tr>
      <tr>
          <td>Stop condition</td>
          <td>retry storm、DLQ 再爆發、下游錯誤率超門檻</td>
      </tr>
      <tr>
          <td>Rollback window</td>
          <td>replay 可中止窗口、主通道可回切時間</td>
      </tr>
      <tr>
          <td>Owner</td>
          <td>queue on-call、business owner</td>
      </tr>
  </tbody>
</table>
<p>這組欄位對齊 <a href="/blog/backend/06-reliability/idempotency-replay/" data-link-title="6.12 Idempotency 與 Replay 驗證" data-link-desc="把重試、重播與冪等性從口頭約定變成可驗證屬性">6.12 Idempotency 與 Replay 驗證</a> 與 <a href="/blog/backend/06-reliability/release-gate/" data-link-title="6.8 Release Gate 與變更節奏" data-link-desc="把驗證、migration、相容性納入放行判準">6.8 Release Gate</a>。</p>
<h2 id="incident-decision-log">Incident Decision Log</h2>
<p>pause consumer、drain DLQ、啟動 replay、停止 replay、執行補償都屬事故決策，需寫入 <a href="/blog/backend/08-incident-response/incident-decision-log/" data-link-title="8.19 Incident Decision Log" data-link-desc="把事中假設、決策、證據、回退條件與責任人留下可復盤紀錄">8.19 Incident Decision Log</a>。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nt">incident_decision</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w">  </span><span class="nt">timestamp</span><span class="p">:</span><span class="w"> </span><span class="ld">2026-05-11T13:18:00Z</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">  </span><span class="nt">decision</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;pause invoice consumer and start scoped replay for tenant A&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">  </span><span class="nt">context</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;duplicate invoices increased after consumer version rollout&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">  </span><span class="nt">evidence</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">    </span>- <span class="nt">query</span><span class="p">:</span><span class="w"> </span><span class="l">duplicate_invoice_ratio_tenant_a</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">    </span>- <span class="nt">query</span><span class="p">:</span><span class="w"> </span><span class="l">dlq_events_by_schema_version</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">  </span><span class="nt">owner</span><span class="p">:</span><span class="w"> </span><span class="l">queue-incident-commander</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">  </span><span class="nt">expected_effect</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;stop duplicate side effects and restore invoice consistency&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">  </span><span class="nt">rollback_condition</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;duplicate ratio does not decrease within two replay batches&#34;</span></span></span></code></pre></div><h2 id="case-write-back-與邊界">Case Write-back 與邊界</h2>
<p>這篇回寫對齊 <a href="/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9 反例</a>，重點是切換時語意分層混淆導致 delivery 成功但業務結果失真。</p>
<p>這篇不處理同步 API latency、cache TTL 或 deployment drain。若風險在同步交易壓力、快取失效或流量切換，路由到 <a href="/blog/backend/04-observability/checkout-api-evidence-package/" data-link-title="4.22 Checkout API Evidence Package 實作示範" data-link-desc="用 checkout 路徑示範 evidence package 如何交接給 release gate 與 incident decision。">4.22 Checkout API Evidence Package</a>、<a href="/blog/backend/02-cache-redis/cache-migration-stampede-rollback/" data-link-title="2.9 Cache Migration 與 Stampede Rollback（實作示範）" data-link-desc="以商品詳情與價格快取示範 cache migration 如何交付 evidence package、release gate 與 incident decision log。">2.9 Cache Migration 與 Stampede Rollback</a> 或 <a href="/blog/backend/05-deployment-platform/deployment-rollout-drain-rollback/" data-link-title="5.8 Deployment Rollout with Drain and Rollback（實作示範）" data-link-desc="以 checkout service 示範部署切換如何交付 canary evidence、drain signal、release gate 與 incident decision log。">5.8 Deployment Rollout with Drain and Rollback</a>。</p>
]]></content:encoded></item><item><title>3.C8 Cloudflare：Queues 全球交付模型</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/cloudflare-queues-global-delivery-model/</link><pubDate>Thu, 07 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/cloudflare-queues-global-delivery-model/</guid><description>&lt;p>這個案例的核心責任是把 queue 選型從單區域傳遞提升為全球交付治理。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>Cloudflare Queues 以邊緣網路為背景，提供事件傳遞與 consumer 處理能力。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>全球部署下，queue 模型要同時考慮延遲、重試語義與跨區運維一致性。&lt;/p>
&lt;h2 id="策略">策略&lt;/h2>
&lt;ol>
&lt;li>明確設定 delivery semantics 與重試策略。&lt;/li>
&lt;li>把 consumer 行為與死信處理流程標準化。&lt;/li>
&lt;li>將 queue lag 與失敗率接入平台觀測。&lt;/li>
&lt;/ol>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/04-observability/telemetry-pipeline/" data-link-title="4.11 Telemetry Pipeline 架構" data-link-desc="把 log / metric / trace 的 agent → collector → ingest → storage → query 分層治理">4.11&lt;/a>。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.cloudflare.com/introducing-cloudflare-queues/">Introducing Cloudflare Queues&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是把 queue 選型從單區域傳遞提升為全球交付治理。</p>
<h2 id="觀察">觀察</h2>
<p>Cloudflare Queues 以邊緣網路為背景，提供事件傳遞與 consumer 處理能力。</p>
<h2 id="判讀">判讀</h2>
<p>全球部署下，queue 模型要同時考慮延遲、重試語義與跨區運維一致性。</p>
<h2 id="策略">策略</h2>
<ol>
<li>明確設定 delivery semantics 與重試策略。</li>
<li>把 consumer 行為與死信處理流程標準化。</li>
<li>將 queue lag 與失敗率接入平台觀測。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4</a> 與 <a href="/blog/backend/04-observability/telemetry-pipeline/" data-link-title="4.11 Telemetry Pipeline 架構" data-link-desc="把 log / metric / trace 的 agent → collector → ingest → storage → query 分層治理">4.11</a>。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://blog.cloudflare.com/introducing-cloudflare-queues/">Introducing Cloudflare Queues</a></li>
</ul>
]]></content:encoded></item><item><title>3.C9 反例：Queue 語義切換誤配</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/</link><pubDate>Thu, 07 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/</guid><description>&lt;p>這個反例的核心責任是說明 broker 遷移失敗常發生在語義假設錯置。&lt;/p>
&lt;h2 id="事故長相">事故長相&lt;/h2>
&lt;p>切換 broker 或 consumer group 後，表面上訊息仍然被送達，但業務資料開始出現重複扣款、重複寄信、狀態漏更新這類問題。這種事故很難只靠 queue depth 判斷，因為錯誤發生在「處理語義」而不是「是否有訊息」。&lt;/p>
&lt;h2 id="為什麼會擴大">為什麼會擴大&lt;/h2>
&lt;p>舊系統若依賴特定 offset 行為、重試節奏或 consumer idempotency，新系統即使名稱上提供相近 delivery semantics，也可能在失敗重播時產生不同結果。語義誤配會沿著下游資料寫入擴散。&lt;/p>
&lt;h2 id="回退判讀">回退判讀&lt;/h2>
&lt;p>回退前要先確認哪一段資料已經被新語義處理過。若直接切回舊 broker，可能讓同一批事件再次被處理。更穩定的做法是先凍結新 consumer，保留 offset 對照與 replay 範圍，再決定補償或重播。&lt;/p>
&lt;h2 id="queue-專屬告警條件">Queue 專屬告警條件&lt;/h2>
&lt;ul>
&lt;li>下游 reconciliation 同時出現重複與遺漏&lt;/li>
&lt;li>DLQ 激增且重播後仍回到相同錯誤&lt;/li>
&lt;li>consumer lag 下降但業務結果沒有收斂&lt;/li>
&lt;/ul>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/06-reliability/contract-testing/" data-link-title="6.10 Contract Testing 與 Schema 演進" data-link-desc="把跨服務 / API / event schema 的隱性期待變成可驗證契約，控制演進相容性">6.10&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>這個反例的核心責任是說明 broker 遷移失敗常發生在語義假設錯置。</p>
<h2 id="事故長相">事故長相</h2>
<p>切換 broker 或 consumer group 後，表面上訊息仍然被送達，但業務資料開始出現重複扣款、重複寄信、狀態漏更新這類問題。這種事故很難只靠 queue depth 判斷，因為錯誤發生在「處理語義」而不是「是否有訊息」。</p>
<h2 id="為什麼會擴大">為什麼會擴大</h2>
<p>舊系統若依賴特定 offset 行為、重試節奏或 consumer idempotency，新系統即使名稱上提供相近 delivery semantics，也可能在失敗重播時產生不同結果。語義誤配會沿著下游資料寫入擴散。</p>
<h2 id="回退判讀">回退判讀</h2>
<p>回退前要先確認哪一段資料已經被新語義處理過。若直接切回舊 broker，可能讓同一批事件再次被處理。更穩定的做法是先凍結新 consumer，保留 offset 對照與 replay 範圍，再決定補償或重播。</p>
<h2 id="queue-專屬告警條件">Queue 專屬告警條件</h2>
<ul>
<li>下游 reconciliation 同時出現重複與遺漏</li>
<li>DLQ 激增且重播後仍回到相同錯誤</li>
<li>consumer lag 下降但業務結果沒有收斂</li>
</ul>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4</a> 與 <a href="/blog/backend/06-reliability/contract-testing/" data-link-title="6.10 Contract Testing 與 Schema 演進" data-link-desc="把跨服務 / API / event schema 的隱性期待變成可驗證契約，控制演進相容性">6.10</a>。</p>
]]></content:encoded></item><item><title>3.C10 對照：規模差異下的佇列模型</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/contrast-queue-model-by-scale/</link><pubDate>Thu, 07 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/contrast-queue-model-by-scale/</guid><description>&lt;p>這篇對照的核心責任是說明 queue 選型要跟著流量與組織規模改變。&lt;/p>
&lt;h2 id="小型服務常見判讀">小型服務常見判讀&lt;/h2>
&lt;p>小型服務優先用 managed queue 往往最穩，因為運維成本最低。這時候最容易忽略的是語義邊界：重試次數、死信規則、重播責任如果沒先定義，規模一上來就會出現資料重複與漏處理。&lt;/p>
&lt;h2 id="中型服務常見判讀">中型服務常見判讀&lt;/h2>
&lt;p>中型服務常見問題是 lag 與 DLQ 長期累積。根因通常是 consumer idempotency、重播流程、下游承載能力沒有一起設計，broker 效能本身很少是單點問題。&lt;/p>
&lt;h2 id="大型服務常見判讀">大型服務常見判讀&lt;/h2>
&lt;p>大型服務需要處理跨租戶與跨區壓力。此時若還用單叢集思維，任何一類流量尖峰都會拖垮整體。重點會從「怎麼送訊息」轉成「怎麼隔離失敗」。&lt;/p>
&lt;h2 id="這個情境的專屬告警條件">這個情境的專屬告警條件&lt;/h2>
&lt;ul>
&lt;li>&lt;code>consumer lag&lt;/code> 連續超出 SLO 窗口&lt;/li>
&lt;li>&lt;code>DLQ&lt;/code> 速率上升且無法在固定時間內回收&lt;/li>
&lt;li>重播後仍出現相同失敗模式&lt;/li>
&lt;/ul>
&lt;p>出現上述條件應先凍結切換，回到前一語義設定，再逐步修正 consumer 契約與重播流程。&lt;/p></description><content:encoded><![CDATA[<p>這篇對照的核心責任是說明 queue 選型要跟著流量與組織規模改變。</p>
<h2 id="小型服務常見判讀">小型服務常見判讀</h2>
<p>小型服務優先用 managed queue 往往最穩，因為運維成本最低。這時候最容易忽略的是語義邊界：重試次數、死信規則、重播責任如果沒先定義，規模一上來就會出現資料重複與漏處理。</p>
<h2 id="中型服務常見判讀">中型服務常見判讀</h2>
<p>中型服務常見問題是 lag 與 DLQ 長期累積。根因通常是 consumer idempotency、重播流程、下游承載能力沒有一起設計，broker 效能本身很少是單點問題。</p>
<h2 id="大型服務常見判讀">大型服務常見判讀</h2>
<p>大型服務需要處理跨租戶與跨區壓力。此時若還用單叢集思維，任何一類流量尖峰都會拖垮整體。重點會從「怎麼送訊息」轉成「怎麼隔離失敗」。</p>
<h2 id="這個情境的專屬告警條件">這個情境的專屬告警條件</h2>
<ul>
<li><code>consumer lag</code> 連續超出 SLO 窗口</li>
<li><code>DLQ</code> 速率上升且無法在固定時間內回收</li>
<li>重播後仍出現相同失敗模式</li>
</ul>
<p>出現上述條件應先凍結切換，回到前一語義設定，再逐步修正 consumer 契約與重播流程。</p>
]]></content:encoded></item><item><title>AWS SQS → Google Pub/Sub：queue 模型搬到 topic + subscription 模型的跨雲遷移</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/aws-sqs/migrate-to-google-pubsub/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/aws-sqs/migrate-to-google-pubsub/</guid><description>&lt;blockquote>
&lt;p>本文是跨 vendor migration playbook、cross-link 到 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/aws-sqs/" data-link-title="AWS SQS" data-link-desc="AWS managed queue、簡單可靠、無 ordering（standard）">AWS SQS&lt;/a> 跟 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Google Pub/Sub&lt;/a>。這是一個 &lt;em>跨雲 managed-to-managed&lt;/em> 遷移：兩端都是 cloud-managed、運維負擔都低、但 &lt;em>資料拓樸&lt;/em> 跟 &lt;em>消費抽象&lt;/em> 不同 — SQS 是 region-scoped 的單一 pull queue、Pub/Sub 是 global topic + 多個 first-class subscription。主結構走 operational redesign hybrid（Type C）、額外為 components / data topology 兩個高維度抽獨立段。&lt;/p>&lt;/blockquote>
&lt;h2 id="sqs-跟-pubsub-不是同一種訊息抽象">SQS 跟 Pub/Sub 不是同一種訊息抽象&lt;/h2>
&lt;p>SQS 跟 Pub/Sub 都是 cloud-managed 非同步訊息服務、都解「解耦 producer / consumer、不自管 broker」這個問題、application 程式碼裡都是「發訊息、收訊息、處理完確認」的形狀。從這層看兩者可互換、遷移像是換 SDK。&lt;/p>
&lt;p>差別在 &lt;em>消費抽象&lt;/em> 跟 &lt;em>資料拓樸&lt;/em>。SQS 的核心實體是 queue：一條 region-scoped 的訊息隊列、訊息被一個 consumer 領走（in-flight）就對其他 consumer 隱形、處理完 DeleteMessage 就消失。要讓同一筆事件送給多個下游、SQS 端的做法是在 SNS 前面 fan-out、再各接一條 SQS queue。Pub/Sub 的核心實體是 topic + subscription 兩層：topic 收訊息、subscription 是 &lt;em>first-class&lt;/em> 的消費端點、一個 topic 可掛 N 個 subscription、每個 subscription 各自維護消費進度、fan-out 是模型內建而不是外接。&lt;/p>
&lt;p>這個差別決定了遷移的形狀。如果原系統只是「一條 queue、一群 worker 競爭領取」、那 Pub/Sub 端是「一個 topic、一個 pull subscription」、對位乾淨、application 改動小。如果原系統靠 SNS-to-many-SQS 做扇出、那 Pub/Sub 端是「一個 topic、多個 subscription」、整個 fan-out 拓樸要重畫、這不是換 SDK、是重設計訊息流。先判斷自己屬於哪一種、再決定 playbook 的重量。&lt;/p>
&lt;h2 id="為什麼會跨雲遷這條路徑">為什麼會跨雲遷這條路徑&lt;/h2>
&lt;p>跨雲從 SQS 遷到 Pub/Sub 的 driver 跟同雲 vendor 切換不同、通常不是「Pub/Sub 比 SQS 好」、而是 &lt;em>整體 workload 的重心移到 GCP&lt;/em>：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>資料平台落在 GCP&lt;/strong>：下游分析走 BigQuery、streaming 走 Dataflow、容器跑 Cloud Run。事件如果留在 AWS、每筆都要跨雲搬到 GCP 才能進 BigQuery、跨雲 egress 費用跟延遲都是常態成本。把訊息層也移到 Pub/Sub、事件可以用 BigQuery subscription 直接落地、省掉中間搬運。&lt;/li>
&lt;li>&lt;strong>需要 global topic、不想管 region&lt;/strong>：SQS queue 綁 region、跨 region 要自己複製或在前面架路由。Pub/Sub topic 沒有 region 概念、publish 進去全球可訂閱、多區域服務的事件分發是 first-class。&lt;/li>
&lt;li>&lt;strong>fan-out 從外接變內建&lt;/strong>：原本靠 SNS + 多條 SQS 維護的扇出拓樸、在 Pub/Sub 是「一個 topic 掛多個 subscription」、少一層 SNS、扇出關係在 subscription 列表一覽。&lt;/li>
&lt;/ol>
&lt;p>這三條 driver 都假設 &lt;em>重心已經或即將在 GCP&lt;/em>。如果系統長期紮根 AWS、只為了「換個 queue」跨雲、會付出跨雲 IAM 重對位、雙雲計費、跨雲網路延遲的代價、ROI 通常不成立。遷移前先確認 driver 是 workload 重心轉移、不是單純偏好。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是跨 vendor migration playbook、cross-link 到 <a href="/blog/backend/03-message-queue/vendors/aws-sqs/" data-link-title="AWS SQS" data-link-desc="AWS managed queue、簡單可靠、無 ordering（standard）">AWS SQS</a> 跟 <a href="/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Google Pub/Sub</a>。這是一個 <em>跨雲 managed-to-managed</em> 遷移：兩端都是 cloud-managed、運維負擔都低、但 <em>資料拓樸</em> 跟 <em>消費抽象</em> 不同 — SQS 是 region-scoped 的單一 pull queue、Pub/Sub 是 global topic + 多個 first-class subscription。主結構走 operational redesign hybrid（Type C）、額外為 components / data topology 兩個高維度抽獨立段。</p></blockquote>
<h2 id="sqs-跟-pubsub-不是同一種訊息抽象">SQS 跟 Pub/Sub 不是同一種訊息抽象</h2>
<p>SQS 跟 Pub/Sub 都是 cloud-managed 非同步訊息服務、都解「解耦 producer / consumer、不自管 broker」這個問題、application 程式碼裡都是「發訊息、收訊息、處理完確認」的形狀。從這層看兩者可互換、遷移像是換 SDK。</p>
<p>差別在 <em>消費抽象</em> 跟 <em>資料拓樸</em>。SQS 的核心實體是 queue：一條 region-scoped 的訊息隊列、訊息被一個 consumer 領走（in-flight）就對其他 consumer 隱形、處理完 DeleteMessage 就消失。要讓同一筆事件送給多個下游、SQS 端的做法是在 SNS 前面 fan-out、再各接一條 SQS queue。Pub/Sub 的核心實體是 topic + subscription 兩層：topic 收訊息、subscription 是 <em>first-class</em> 的消費端點、一個 topic 可掛 N 個 subscription、每個 subscription 各自維護消費進度、fan-out 是模型內建而不是外接。</p>
<p>這個差別決定了遷移的形狀。如果原系統只是「一條 queue、一群 worker 競爭領取」、那 Pub/Sub 端是「一個 topic、一個 pull subscription」、對位乾淨、application 改動小。如果原系統靠 SNS-to-many-SQS 做扇出、那 Pub/Sub 端是「一個 topic、多個 subscription」、整個 fan-out 拓樸要重畫、這不是換 SDK、是重設計訊息流。先判斷自己屬於哪一種、再決定 playbook 的重量。</p>
<h2 id="為什麼會跨雲遷這條路徑">為什麼會跨雲遷這條路徑</h2>
<p>跨雲從 SQS 遷到 Pub/Sub 的 driver 跟同雲 vendor 切換不同、通常不是「Pub/Sub 比 SQS 好」、而是 <em>整體 workload 的重心移到 GCP</em>：</p>
<ol>
<li><strong>資料平台落在 GCP</strong>：下游分析走 BigQuery、streaming 走 Dataflow、容器跑 Cloud Run。事件如果留在 AWS、每筆都要跨雲搬到 GCP 才能進 BigQuery、跨雲 egress 費用跟延遲都是常態成本。把訊息層也移到 Pub/Sub、事件可以用 BigQuery subscription 直接落地、省掉中間搬運。</li>
<li><strong>需要 global topic、不想管 region</strong>：SQS queue 綁 region、跨 region 要自己複製或在前面架路由。Pub/Sub topic 沒有 region 概念、publish 進去全球可訂閱、多區域服務的事件分發是 first-class。</li>
<li><strong>fan-out 從外接變內建</strong>：原本靠 SNS + 多條 SQS 維護的扇出拓樸、在 Pub/Sub 是「一個 topic 掛多個 subscription」、少一層 SNS、扇出關係在 subscription 列表一覽。</li>
</ol>
<p>這三條 driver 都假設 <em>重心已經或即將在 GCP</em>。如果系統長期紮根 AWS、只為了「換個 queue」跨雲、會付出跨雲 IAM 重對位、雙雲計費、跨雲網路延遲的代價、ROI 通常不成立。遷移前先確認 driver 是 workload 重心轉移、不是單純偏好。</p>
<h2 id="結構為什麼是-operational-hybrid-加兩個高維度獨立段">結構為什麼是 operational hybrid 加兩個高維度獨立段</h2>
<p>寫這篇前先跑 <a href="/blog/posts/migration-playbook-%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84stage-0-variant-%E8%A6%8F%E5%8A%83%E6%8A%8A-collapse-%E7%8E%87%E5%BE%9E-60-%E9%99%8D%E5%88%B0-0/" data-link-title="Migration Playbook 方法論的演化紀錄：Stage 0 variant 規劃把 collapse 率從 60% 降到 0%" data-link-desc="跨 vendor migration playbook 需要獨立寫作方法論的依據，以及這套方法論從三輪 batch dogfood 中演化出來的驗證證據。">diff dimension audit</a>、6 維評級如下：</p>
<table>
  <thead>
      <tr>
          <th>Diff 維度</th>
          <th>評級</th>
          <th>SQS → Pub/Sub 的具體差異</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Schema / API</td>
          <td>Medium</td>
          <td>都是「發 / 收 / 確認」、但 API 名詞與參數全換（QueueUrl → topic+subscription）</td>
      </tr>
      <tr>
          <td>Operational model</td>
          <td>High</td>
          <td>IAM policy → Service Account、CloudWatch → Cloud Monitoring、redrive → DLT 重訂閱</td>
      </tr>
      <tr>
          <td>Abstraction</td>
          <td>Medium</td>
          <td>都是訊息服務、但 pull queue ↔ topic/subscription 的消費抽象不同</td>
      </tr>
      <tr>
          <td>Components（數量）</td>
          <td>High</td>
          <td>單一 queue ↔ topic + N subscription 兩層實體；SNS+SQS 扇出 ↔ topic 內建扇出</td>
      </tr>
      <tr>
          <td>Application change</td>
          <td>Medium</td>
          <td>SDK 換、ack / fan-out 邏輯改、但商業邏輯多數可保留</td>
      </tr>
      <tr>
          <td>Data topology</td>
          <td>High</td>
          <td>region-scoped queue ↔ global topic；single-consumer ↔ multi-subscription fan-out</td>
      </tr>
  </tbody>
</table>
<p>主導維度是 <em>operational model</em>（跨雲身份與監控全換）、所以主結構走 Type C operational redesign hybrid。但 components 跟 data topology 也是 High — 不是把它們塞進 operational 段就能講清楚的、消費抽象從「一條 queue」變「topic + 多 subscription」是讀者最容易踩雷的地方。按 <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 方法論的 multi-axis 規則</a>、高維度抽成獨立段補充、不硬塞進單一 type 標籤。所以本篇結構是：operational 對位主軸 + 「消費抽象重設計」獨立段（components / topology 軸）+ 跨雲特有的 IAM 與網路段。</p>
<h2 id="operational-對位機制名詞換語意要逐一確認">Operational 對位：機制名詞換、語意要逐一確認</h2>
<p>跨雲遷移最容易出錯的環節、是 <em>找到語意相近的功能、卻假設行為一致</em>。SQS 跟 Pub/Sub 多數機制都有對位、但每一組都有行為差、找得到對應功能只是第一步。下表先給對照、後面逐項展開語意陷阱。</p>
<table>
  <thead>
      <tr>
          <th>SQS 機制</th>
          <th>Pub/Sub 對位</th>
          <th>語意是否等價</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Visibility timeout</td>
          <td>Ack deadline</td>
          <td>近似、但上限與延長機制不同</td>
      </tr>
      <tr>
          <td>DeleteMessage</td>
          <td>Ack（acknowledge）</td>
          <td>近似、但 Pub/Sub 自動 extension 改變實際行為</td>
      </tr>
      <tr>
          <td>maxReceiveCount + DLQ + redrive</td>
          <td>Dead-letter topic + 重訂閱</td>
          <td>概念對應、DLT 是 topic 不是 queue、重處理方式不同</td>
      </tr>
      <tr>
          <td>Long polling（WaitTimeSeconds）</td>
          <td>Streaming pull</td>
          <td>不等價、streaming pull 是長連線串流、不是輪詢</td>
      </tr>
      <tr>
          <td>Message attributes</td>
          <td>Message attributes</td>
          <td>概念對應、型別與大小限制不同</td>
      </tr>
      <tr>
          <td>FIFO queue（MessageGroupId）</td>
          <td>Ordering key</td>
          <td>都給順序、但去重與吞吐取捨不同</td>
      </tr>
      <tr>
          <td>IAM policy + Queue policy</td>
          <td>IAM role + Service Account</td>
          <td>跨雲身份模型完全不同、不是改語法是重對位</td>
      </tr>
      <tr>
          <td>CloudWatch metric / alarm</td>
          <td>Cloud Monitoring metric / alert</td>
          <td>metric 名詞與語意不同、alarm 邏輯要重寫</td>
      </tr>
  </tbody>
</table>
<h3 id="visibility-timeout--ack-deadline">Visibility timeout → ack deadline</h3>
<p>Visibility timeout 跟 ack deadline 都回答同一個問題：consumer 領走訊息後、多久沒確認就視為失敗、把訊息重新投遞。語意對位成立、但兩端的數字與延長機制不同。</p>
<p>SQS visibility timeout 預設 30 秒、上限 12 小時、consumer 要延長就主動呼叫 ChangeMessageVisibility。Pub/Sub ack deadline 預設 10 秒、上限 600 秒（10 分鐘）、而且 client library 預設會 <em>自動</em> 在背景延長 deadline（lease management）。這個自動延長是最容易踩到的差異：在 SQS 端習慣「設一個夠長的 visibility timeout、處理完再 delete」、搬到 Pub/Sub 如果只把 ack deadline 設成 600 秒上限、卻沒意識到 client library 在背景幫忙延長、長任務的行為會跟預期不同；反過來、如果關掉自動延長又設了預設 10 秒、處理稍久就重投。對位的正確做法是先理解 client library 的 lease 行為、再決定 ack deadline 跟 MaxAckPending、而不是把 SQS 的 timeout 數字直接搬過去。</p>
<h3 id="maxreceivecount--redrive--dead-letter-topic">maxReceiveCount / redrive → dead-letter topic</h3>
<p>兩端都用「重試 N 次仍失敗就隔離」防止 poison message 阻塞 pipeline、但隔離後的容器不同。SQS 的 DLQ 是另一條 <em>queue</em>、用 maxReceiveCount 控制門檻、修好下游後用 redrive policy 把訊息放回原 queue。Pub/Sub 的 dead-letter topic 是另一個 <em>topic</em>、用 subscription 的 max delivery attempt 控制門檻、超過就 publish 到 DLT。</p>
<p>差別在重處理路徑。SQS redrive 是把 DLQ 訊息搬回 main queue、是一個 queue-to-queue 的搬移動作。Pub/Sub 的 DLT 是 topic、要重處理得在 DLT 上再開一個 subscription 來消費、沒有內建的「放回原 topic」按鈕。<a href="/blog/backend/03-message-queue/cases/pubsub-mercari-item-feed-dlt/" data-link-title="3.C64 Mercari Item Feed：DLT 防 poison message 阻塞" data-link-desc="Mercari 商品 feed 同步、ack 整批 / nack 重送、重試多次仍失敗送 DLT、topic 同時當 load-leveling buffer。">Mercari item feed 的案例</a>就是用 DLT 把重試多次仍失敗的訊息隔離、讓後續訊息優先處理、同時把 topic 當突發流量的 load-leveling buffer。從 SQS 搬過來時、redrive 的心智模型要換成「DLT 是一個獨立 topic、重處理是另開 subscription」、不是「按一個按鈕放回去」。設定 DLT 還需要給 Pub/Sub service account 對 DLT 的 publisher 權限跟對原 subscription 的 subscriber 權限、漏設會讓訊息卡住不進 DLT。</p>
<h3 id="long-polling--streaming-pull">Long polling → streaming pull</h3>
<p>這一組不是等價對位、是機制不同。SQS long polling 是 consumer 發一個 ReceiveMessage 請求、最多等 20 秒、有訊息就回、沒有就空回、本質仍是 <em>輪詢</em>、只是把空輪詢的頻率降下來省 cost。Pub/Sub 的 pull 在 client library 預設是 <em>streaming pull</em>：consumer 跟 Pub/Sub 建一條長連線、訊息一到就推過來、不是 consumer 反覆問。</p>
<p>對位時不要把 long polling 的「WaitTimeSeconds 20 秒」翻譯成某個 Pub/Sub 參數 — 沒有對應參數、因為機制不同。要關注的是 flow control：streaming pull 因為訊息會主動推來、要用 MaxOutstandingMessages / MaxAckPending 控制同時在處理的訊息量、否則 consumer 會被一次塞太多訊息壓垮。SQS 端「一次拉最多 10 條」的批次節流、在 Pub/Sub 端變成 flow control 設定。<a href="/blog/backend/03-message-queue/cases/pubsub-spotify-autoscaling-consumers/" data-link-title="3.C61 Spotify：Autoscaling Pub/Sub consumer 反效果" data-link-desc="Spotify 下游失敗時 consumer 不 ack 仍耗 CPU、autoscaling 越拉越高、解法是 exponential backoff 抑制 CPU。">Spotify autoscaling 的案例</a>揭露了相關陷阱：下游失敗時 consumer 不 ack 仍持續消耗 CPU、autoscaling 反而把資源越拉越高 — autoscale 訊號要看處理成功率、不是 backlog 加 CPU。</p>
<h3 id="iam-policy--service-account">IAM policy → Service Account</h3>
<p>跨雲遷移裡、身份模型是 <em>重對位</em> 而不是改語法的部分。SQS 的存取控制是 IAM policy（identity-based、掛在 user / role）加 queue policy（resource-based、掛在 queue）兩層、cross-account 靠這兩層互動。Pub/Sub 是 GCP IAM role（publisher / subscriber / viewer 等）加 Service Account、push subscription 要用 Service Account 認證到目標 endpoint。</p>
<p>兩套身份模型沒有自動轉換工具、要逐條重畫：誰能 publish 對應誰有 topic 的 publisher role、誰能消費對應誰有 subscription 的 subscriber role。跨雲場景還多一層 — 如果遷移期 AWS 端的服務要 publish 到 GCP 的 topic、得用 workload identity federation 或 service account key、讓 AWS 的工作負載拿到 GCP 身份。這部分沒有 case 可引、依 GCP 官方 IAM 文件加最小權限原則設計：每個 service account 只給它實際需要的 role、不要為了遷移方便給 broad role 再說以後收緊、那個「以後」通常不會來。</p>
<h3 id="cloudwatch--cloud-monitoring">CloudWatch → Cloud Monitoring</h3>
<p>監控訊號要重建、不是改名。SQS 在 CloudWatch 看 ApproximateNumberOfMessagesVisible（queue 深度）跟 ApproximateAgeOfOldestMessage（lag）。Pub/Sub 在 Cloud Monitoring 看 num_undelivered_messages（backlog）跟 oldest_unacked_message_age（最老未確認訊息年齡）。語意相近、但 alarm 邏輯要重寫、而且 Pub/Sub 的 backlog 數字要配合 subscription 維度看 — 同一個 topic 的不同 subscription 各自有 backlog、一個堵住不代表全部堵住。遷移時要把原本對 queue 深度的告警、改成對每個 subscription 的 backlog 與 age 告警。</p>
<h2 id="消費抽象重設計從一條-queue-到-topic-加多-subscription">消費抽象重設計：從一條 queue 到 topic 加多 subscription</h2>
<p>這是 components 跟 data topology 兩個高維度的核心、也是從 SQS 搬到 Pub/Sub 最需要重新畫圖的地方。SQS 的世界裡、一條 queue 對應一群競爭領取的 worker；要扇出就在前面架 SNS、SNS 後面接多條 SQS、每條 queue 各一群 worker。Pub/Sub 把這個拓樸壓平：一個 topic 收訊息、掛多少個 subscription 就有多少條獨立的消費流、每個 subscription 各自記進度、彼此不影響。</p>
<p>重設計從盤點現有拓樸開始。先列出：哪些是「單一 queue、一群 worker」的簡單情境、哪些是「SNS fan-out 到多條 SQS」的扇出情境。簡單情境對位乾淨 — 一個 topic、一個 pull subscription、原本競爭領取的 worker 改成同一個 subscription 的多個 consumer、Pub/Sub 自動把訊息分給它們。扇出情境要把 SNS + 多 SQS 換成「一個 topic + 多 subscription」、原本每條 SQS queue 變成一個 subscription、SNS 那一層消失。</p>
<p>扇出情境裡有個方向相反的陷阱要避免：不要把「多個下游」誤設計成「多個 consumer 共用一個 subscription」。同一個 subscription 的多個 consumer 是 <em>競爭</em> 關係、訊息只會給其中一個 — 那是負載分攤、不是扇出。要每個下游都收到完整一份、就要每個下游一個 <em>獨立</em> subscription。這跟 SQS 端「一條 queue 一個下游、扇出靠 SNS 複製」的直覺方向一致、但實體換了：在 SQS 是多條 queue、在 Pub/Sub 是多個 subscription。畫遷移圖時、SQS 的每條 fan-out queue 一對一映射到 Pub/Sub 的一個 subscription、不要合併。</p>
<h2 id="application-重設計範例sqs-receive-delete-換成-pubsub-pull-ack">Application 重設計範例：SQS receive-delete 換成 Pub/Sub pull-ack</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">// SQS 端：long polling receive、處理完 DeleteMessage</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="nx">svc</span> <span class="o">:=</span> <span class="nx">sqs</span><span class="p">.</span><span class="nf">NewFromConfig</span><span class="p">(</span><span class="nx">cfg</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="k">for</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">out</span><span class="p">,</span> <span class="nx">_</span> <span class="o">:=</span> <span class="nx">svc</span><span class="p">.</span><span class="nf">ReceiveMessage</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="o">&amp;</span><span class="nx">sqs</span><span class="p">.</span><span class="nx">ReceiveMessageInput</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">QueueUrl</span><span class="p">:</span>            <span class="o">&amp;</span><span class="nx">queueURL</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">MaxNumberOfMessages</span><span class="p">:</span> <span class="mi">10</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">WaitTimeSeconds</span><span class="p">:</span>     <span class="mi">20</span><span class="p">,</span> <span class="c1">// long polling</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 class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">m</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">out</span><span class="p">.</span><span class="nx">Messages</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nf">process</span><span class="p">(</span><span class="nx">m</span><span class="p">.</span><span class="nx">Body</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">svc</span><span class="p">.</span><span class="nf">DeleteMessage</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="o">&amp;</span><span class="nx">sqs</span><span class="p">.</span><span class="nx">DeleteMessageInput</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">            <span class="nx">QueueUrl</span><span class="p">:</span>      <span class="o">&amp;</span><span class="nx">queueURL</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">            <span class="nx">ReceiptHandle</span><span class="p">:</span> <span class="nx">m</span><span class="p">.</span><span class="nx">ReceiptHandle</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="p">})</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">16</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">// Pub/Sub 端：streaming pull、處理完 Ack、用 flow control 節流</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">sub</span> <span class="o">:=</span> <span class="nx">client</span><span class="p">.</span><span class="nf">Subscription</span><span class="p">(</span><span class="s">&#34;orders-sub&#34;</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">ReceiveSettings</span><span class="p">.</span><span class="nx">MaxOutstandingMessages</span> <span class="p">=</span> <span class="mi">100</span> <span class="c1">// flow control、取代「一次拉 10 條」</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="nx">err</span> <span class="o">:=</span> <span class="nx">sub</span><span class="p">.</span><span class="nf">Receive</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="kd">func</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">msg</span> <span class="o">*</span><span class="nx">pubsub</span><span class="p">.</span><span class="nx">Message</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nf">process</span><span class="p">(</span><span class="nx">msg</span><span class="p">.</span><span class="nx">Data</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">msg</span><span class="p">.</span><span class="nf">Ack</span><span class="p">()</span> <span class="c1">// 取代 DeleteMessage；client library 在背景自動延長 ack deadline</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">})</span></span></span></code></pre></div><p>差異：</p>
<ul>
<li>SQS 主動輪詢（ReceiveMessage 迴圈）→ Pub/Sub 回呼模型（Receive 把訊息推進 callback）</li>
<li>SQS DeleteMessage → Pub/Sub msg.Ack()、語意都是「確認處理完、別重投」</li>
<li>SQS WaitTimeSeconds 控制輪詢等待 → Pub/Sub MaxOutstandingMessages 控制 flow control</li>
<li>SQS 一次最多 10 條的批次上限 → Pub/Sub 沒有這個上限、改用 flow control 設同時在途量</li>
<li>ack deadline 的延長在 SQS 要主動 ChangeMessageVisibility、在 Pub/Sub 由 client library 自動處理</li>
</ul>
<p>application 邏輯的商業處理部分（process 函式）多數可保留、改動集中在收訊息的框架跟確認語意、估計 20-40% 程式碼。</p>
<h2 id="production-故障演練">Production 故障演練</h2>
<h3 id="case-1fan-out-設計成共用-subscription下游各收到一部分">Case 1：fan-out 設計成共用 subscription、下游各收到一部分</h3>
<p><strong>徵兆</strong>：把原本 SNS fan-out 到 3 條 SQS 的拓樸搬到 Pub/Sub、為了省事建一個 topic + 一個 subscription、讓 3 個下游服務都連這個 subscription。上線後發現每個下游只收到大約三分之一的訊息、不是各收完整一份。</p>
<p><strong>根因</strong>：同一個 subscription 的多個 consumer 是負載分攤關係、Pub/Sub 把訊息分給其中一個 consumer、不是每個都送。這對應到 SQS 端「一條 queue 多個 worker 競爭領取」的行為、但被誤用在需要扇出的場景。SQS 端的扇出靠 SNS 複製訊息到多條 queue、那個複製動作在 Pub/Sub 應該由「多個 subscription」承擔、不是多個 consumer 共用一個 subscription。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>每個下游一個獨立 subscription</strong>：3 個下游就建 3 個 subscription 掛同一個 topic、每個各收完整一份</li>
<li><strong>遷移圖一對一映射</strong>：SQS 的每條 fan-out queue 對應一個 Pub/Sub subscription、不合併</li>
<li><strong>負載分攤跟扇出分開設計</strong>：同一下游要多 worker 分攤、是同一 subscription 多 consumer；不同下游各收一份、是多 subscription</li>
</ol>
<h3 id="case-2ack-deadline-沿用-sqs-數字太短長任務反覆重投">Case 2：ack deadline 沿用 SQS 數字太短、長任務反覆重投</h3>
<p><strong>徵兆</strong>：SQS 端 visibility timeout 設 5 分鐘跑得好好的、搬到 Pub/Sub 隨手把 ack deadline 設成預設或一個小數字、結果處理時間稍長的訊息被反覆重投、同一筆訊息處理多次、下游出現重複副作用。</p>
<p><strong>根因</strong>：Pub/Sub ack deadline 預設 10 秒、上限 600 秒、跟 SQS visibility timeout 上限 12 小時差很多。如果關掉 client library 的自動 lease extension、又把 ack deadline 設小、處理時間一超過就被判定失敗重投。SQS 的「設一個夠長的 timeout」直覺搬過來不適用、因為 Pub/Sub 的上限低很多、且延長機制是 client library 自動做。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>理解 client library 的 lease 行為</strong>：多數 client library 預設會背景自動延長 ack deadline 到處理完、優先依賴這個而不是手動設超長 deadline</li>
<li><strong>長任務拆短或改架構</strong>：單筆處理超過 10 分鐘上限的、考慮拆成多階段或把長任務移出訊息處理路徑</li>
<li><strong>下游做 idempotency</strong>：跟 SQS 一樣、Pub/Sub 是 at-least-once、重投本來就會發生、下游用 message ID 去重才是根本解</li>
</ol>
<h3 id="case-3fifo-順序需求對位到-ordering-key吞吐落差超出預期">Case 3：FIFO 順序需求對位到 ordering key、吞吐落差超出預期</h3>
<p><strong>徵兆</strong>：原系統用 SQS FIFO queue + MessageGroupId 保證同一群訊息順序處理、搬到 Pub/Sub 啟用 ordering key 對位、上線後吞吐比預期低很多、且某些情境順序仍亂。</p>
<p><strong>根因</strong>：SQS FIFO 跟 Pub/Sub ordering key 都提供順序、但取捨點不同。SQS FIFO 同時給「順序」跟「5 分鐘去重窗口」、吞吐受限（每 MessageGroupId 串行）。Pub/Sub ordering key 給「同一 key 的訊息按 publish 順序送達」、但要 publish 端跟 subscription 端都正確設定（publish 要設 ordering key、subscription 要 enableMessageOrdering）、漏一邊順序就不保證；而且啟用 ordering 後同一 key 串行、吞吐同樣受限。把 FIFO 的「去重 + 順序」一包功能、誤以為 ordering key 也一包提供、是落差來源。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>拆開「順序」跟「去重」兩個需求</strong>：Pub/Sub ordering key 只管順序、去重要 application 端自己用 message ID 做</li>
<li><strong>publish 跟 subscription 兩端都設 ordering</strong>：缺一邊順序不保證、遷移檢查清單要把兩端都列上</li>
<li><strong>重新評估是否真需要全域順序</strong>：FIFO 常被過度使用、很多場景只需要 per-entity 順序、用 ordering key 按 entity 分 key、比強制全域串行吞吐高很多</li>
</ol>
<h3 id="case-4跨雲遷移期雙雲都在跑egress-成本與延遲被低估">Case 4：跨雲遷移期雙雲都在跑、egress 成本與延遲被低估</h3>
<p><strong>徵兆</strong>：漸進 cutover 期間 AWS 跟 GCP 兩邊都在處理訊息、為了對帳把訊息在兩雲之間搬、月底帳單跨雲 egress 費用遠超預估、且跨雲呼叫的尾延遲拖慢端到端處理。</p>
<p><strong>根因</strong>：SQS 在 AWS region 內、Pub/Sub 在 GCP、遷移期的 dual publish 或對帳如果讓資料反覆跨雲、每一筆出 AWS 的訊息都計 egress 費。跨雲不只是錢、跨雲網路的延遲跟抖動比同雲高、放在同步處理路徑上會放大尾延遲。同雲 vendor 切換沒有這個維度、跨雲遷移必須把它列進成本模型。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>縮短雙雲並行窗口</strong>：dual publish 的對帳期越短越省、設明確的並行截止日、不要無限期雙跑</li>
<li><strong>對帳用抽樣不用全量搬運</strong>：驗證一致性用抽樣比對 message ID / count、不要把所有訊息都搬到對面雲比對</li>
<li><strong>生產者就近落點</strong>：遷移後讓 producer 直接 publish 到 Pub/Sub、不要繞 AWS 再跨雲、消除穩態的跨雲 egress</li>
</ol>
<h3 id="case-5dead-letter-topic-權限沒配齊毒訊息卡住不進-dlt">Case 5：dead-letter topic 權限沒配齊、毒訊息卡住不進 DLT</h3>
<p><strong>徵兆</strong>：subscription 設了 dead-letter topic 跟 max delivery attempt、預期重試超限的訊息進 DLT、實際上毒訊息一直在原 subscription 反覆重投、DLT 是空的、後續訊息被堵。</p>
<p><strong>根因</strong>：Pub/Sub 要把訊息送進 DLT、是由 Pub/Sub 的 service account 代為 publish 到 DLT topic；同時它也要對原 subscription 有 subscriber 權限才能 ack 掉原訊息。這兩個權限漏任一個、forwarding 到 DLT 就失敗、訊息卡在原 subscription。SQS 端 DLQ 是 queue 屬性、不需要額外給 service 權限、所以這個跨雲差異容易被漏掉。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>配齊 DLT 雙權限</strong>：給 Pub/Sub service account 對 DLT topic 的 publisher role、跟對原 subscription 的 subscriber role</li>
<li><strong>遷移後做毒訊息演練</strong>：故意 publish 一筆會失敗的訊息、確認它真的在 max attempt 後進 DLT、不是卡在原 subscription</li>
<li><strong>監控 DLT backlog</strong>：DLT 開一個 subscription 監控其 num_undelivered_messages、確認毒訊息有被導流且有人處理、對照 <a href="/blog/backend/03-message-queue/cases/pubsub-mercari-item-feed-dlt/" data-link-title="3.C64 Mercari Item Feed：DLT 防 poison message 阻塞" data-link-desc="Mercari 商品 feed 同步、ack 整批 / nack 重送、重試多次仍失敗送 DLT、topic 同時當 load-leveling buffer。">Mercari DLT 案例</a>的設計</li>
</ol>
<h2 id="漸進-cutoverdual-publish-加雙消費對帳">漸進 cutover：dual publish 加雙消費對帳</h2>
<p>跨雲遷移風險高、不適合一次切換、走漸進 cutover 把可逆邊界拉長：</p>
<ol>
<li><strong>Phase 0：拓樸盤點</strong> — 列出所有 SQS queue、標記哪些是單一 queue、哪些是 SNS fan-out、各自映射到 Pub/Sub 的 topic / subscription 結構</li>
<li><strong>Phase 1：Pub/Sub 端建好對位資源</strong> — 建 topic / subscription / DLT、配齊 IAM 與 service account、重建 Cloud Monitoring 告警、application 寫好 Pub/Sub consumer 但先不收流量</li>
<li><strong>Phase 2：dual publish</strong> — producer 同時 publish 到 SQS 跟 Pub/Sub、兩邊 consumer 都跑、Pub/Sub 端的處理結果先寫到隔離區或標記、不影響正式下游</li>
<li><strong>Phase 3：雙消費對帳</strong> — 抽樣比對兩邊處理的訊息 ID 與數量、確認 Pub/Sub 端沒漏、沒重複到無法接受的程度、ack deadline / fan-out / ordering 行為都符合預期</li>
<li><strong>Phase 4：流量切換</strong> — 對帳通過後、把正式下游切到 Pub/Sub 端、SQS 端轉成備援、保留一段觀察期可回切</li>
<li><strong>Phase 5：下線 SQS</strong> — 觀察期穩定後停掉 dual publish、移除 SQS 資源、消除穩態跨雲 egress（這是不可逆階段、不要在對帳沒過時提前做）</li>
</ol>
<p>對帳期是這套流程的核心保險、也是 Case 4 跨雲成本的來源 — 對帳用抽樣、並行窗口設明確截止日、平衡「驗證信心」跟「雙雲成本」。</p>
<h2 id="capacity--cost-對照">Capacity / cost 對照</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>AWS SQS</th>
          <th>Google Pub/Sub</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>計費模型</td>
          <td>每百萬 request（含 send / receive / delete）</td>
          <td>按 throughput（publish + subscribe 的資料量計費）</td>
      </tr>
      <tr>
          <td>Region 模型</td>
          <td>Region-scoped、跨 region 自己處理</td>
          <td>Global topic、無 region 概念</td>
      </tr>
      <tr>
          <td>扇出成本</td>
          <td>SNS + 多 SQS、每條 queue 各計費</td>
          <td>一個 topic 多 subscription、按各 subscription throughput</td>
      </tr>
      <tr>
          <td>訊息保留</td>
          <td>預設 4 天、上限 14 天</td>
          <td>預設 7 天、可調</td>
      </tr>
      <tr>
          <td>順序成本</td>
          <td>FIFO queue 比 standard 貴</td>
          <td>ordering key 啟用後吞吐受限、計費同 standard</td>
      </tr>
      <tr>
          <td>跨雲 egress</td>
          <td>出 AWS 計 egress</td>
          <td>出 GCP 計 egress；穩態應讓 producer 就近 publish</td>
      </tr>
      <tr>
          <td>監控</td>
          <td>CloudWatch（隨用量計費）</td>
          <td>Cloud Monitoring</td>
      </tr>
  </tbody>
</table>
<p><strong>判讀</strong>：穩態成本兩者量級相近、真正的成本差在 <em>遷移期</em> — dual publish 雙雲並行加跨雲對帳搬運是一次性高峰、不是穩態。把這段窗口縮短、是控制跨雲遷移成本的關鍵、不是去比 SQS 跟 Pub/Sub 的單價。扇出重度的系統遷到 Pub/Sub 後、少掉 SNS 那一層、扇出的計費結構也變簡單。</p>
<h2 id="整合--下一步">整合 / 下一步</h2>
<h3 id="遷移後事件可直接落-gcp-資料平台">遷移後事件可直接落 GCP 資料平台</h3>
<p>遷到 Pub/Sub 的一個結構性好處、是事件可以用 BigQuery subscription 直接寫進 BigQuery、不需要再寫 Dataflow pipeline 搬運；或用 Cloud Storage subscription 批次落 GCS。這正是「workload 重心在 GCP」這條 driver 的回報 — 事件層跟資料平台同雲、省掉跨雲搬運。這也是評估是否該跨雲遷移時、要放進 ROI 的一邊。</p>
<h3 id="跟-kafka-遷移的結構對照">跟 Kafka 遷移的結構對照</h3>
<table>
  <thead>
      <tr>
          <th>篇</th>
          <th>主導差異維度</th>
          <th>結構</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><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></td>
          <td>Paradigm（高）</td>
          <td>partial + 長期混合</td>
      </tr>
      <tr>
          <td>SQS → Pub/Sub（本篇）</td>
          <td>Operational（高）+ components / topology（高）</td>
          <td>operational hybrid + 高維度獨立段</td>
      </tr>
  </tbody>
</table>
<p><strong>結論</strong>：SQS → Pub/Sub 不是 paradigm shift（兩端都是 cloud-managed 訊息服務、可收斂成單一目標）、是 operational redesign 為主、消費抽象重設計為輔的跨雲遷移；結構由主導差異維度（operational）決定主軸、高維度（components / topology）抽獨立段補充。</p>
<h2 id="相關連結">相關連結</h2>
<ul>
<li>Source / target vendor：<a href="/blog/backend/03-message-queue/vendors/aws-sqs/" data-link-title="AWS SQS" data-link-desc="AWS managed queue、簡單可靠、無 ordering（standard）">AWS SQS</a> / <a href="/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Google Pub/Sub</a></li>
<li>平行 vendor：<a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka</a> / <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>平行 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></li>
<li>引用案例：<a href="/blog/backend/03-message-queue/cases/pubsub-mercari-item-feed-dlt/" data-link-title="3.C64 Mercari Item Feed：DLT 防 poison message 阻塞" data-link-desc="Mercari 商品 feed 同步、ack 整批 / nack 重送、重試多次仍失敗送 DLT、topic 同時當 load-leveling buffer。">3.C64 Mercari Item Feed DLT</a> / <a href="/blog/backend/03-message-queue/cases/pubsub-spotify-autoscaling-consumers/" data-link-title="3.C61 Spotify：Autoscaling Pub/Sub consumer 反效果" data-link-desc="Spotify 下游失敗時 consumer 不 ack 仍耗 CPU、autoscaling 越拉越高、解法是 exponential backoff 抑制 CPU。">3.C61 Spotify autoscaling</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>
<li>上游概念：<a href="/blog/backend/00-service-selection/async-delivery-selection/" data-link-title="0.3 非同步與事件傳遞選型" data-link-desc="區分背景工作、durable queue、stream、pub/sub 與 outbox 的選型邊界">0.3 非同步選型</a> / <a href="/blog/backend/06-reliability/idempotency-replay/" data-link-title="6.12 Idempotency 與 Replay 驗證" data-link-desc="把重試、重播與冪等性從口頭約定變成可驗證屬性">6.12 idempotency / replay</a></li>
</ul>
]]></content:encoded></item><item><title>Google Pub/Sub push vs pull：不是實作偏好，是下游容量的判讀</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/google-pubsub/push-pull-ack-flow-control/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/google-pubsub/push-pull-ack-flow-control/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Google Cloud Pub/Sub&lt;/a> overview 的 implementation-layer deep article。選型層（Pub/Sub vs Kafka / SQS）見 overview；本文只處理「決定用 Pub/Sub 後，subscription 與 ack 怎麼設」。Pub/Sub 是 managed SaaS、無法本機 docker 驗證，本文 config 依 &lt;a href="https://cloud.google.com/pubsub/docs/subscriber">Pub/Sub 官方文件&lt;/a> 與下列 production case、最後檢查日 2026-06-16；引數與計費以官方為準。&lt;/p>&lt;/blockquote>
&lt;h2 id="push-vs-pull-不是實作偏好">push vs pull 不是實作偏好&lt;/h2>
&lt;p>把 Pub/Sub 的 subscription 設成 push 還是 pull，常被當成「看團隊習慣」的實作選擇。但它其實是一個關於下游容量的判讀。差別在流量控制權在誰手上：push subscription 由 Pub/Sub 主動把訊息 HTTP POST 到目標 endpoint——流量節奏由 Pub/Sub 決定，尖峰時瞬間打過來；pull subscription 由 consumer 主動拉，要拉多少、多快由 consumer 自己控制。&lt;/p>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-mercari-line-flow-control/" data-link-title="3.C65 Mercari LINE：Pull subscription 對齊外部 RPS" data-link-desc="Mercari LINE webhook 轉 Pub/Sub、worker pull subscription 精確控制 RPS、應 LINE API 限制。">Mercari 的 LINE 整合&lt;/a>把這個判讀講得很具體：Braze webhook 進來轉成 Pub/Sub event，下游要呼叫 LINE API——而 &lt;strong>LINE API 有 RPS 限制&lt;/strong>。如果用 push，Pub/Sub 會把訊息瞬間打到 worker、worker 再打 LINE、直接超過 LINE 的 RPS 上限。所以他們用 pull subscription，worker「精確控制每秒處理訊息數」來對齊 LINE 的限制。這個案例揭露的原則是——&lt;strong>push vs pull 不是實作偏好，是「下游能不能承受 push 的流量衝擊」的判讀&lt;/strong>：下游有速率限制、處理能力有限、或需要平滑流量，就走 pull 自我節流。&lt;/p>
&lt;p>本文展開 subscription 模型、ack deadline、flow control 與 dead-letter topic——這些決定了訊息怎麼被可靠地、以下游能承受的速度消費。&lt;/p>
&lt;h2 id="核心概念subscriptionack-deadline-與-flow-control">核心概念：subscription、ack deadline 與 flow control&lt;/h2>
&lt;p>Pub/Sub 把「topic（發布）」跟「subscription（訂閱）」分開，可靠消費的旋鈕都在 subscription 上。&lt;/p>
&lt;p>&lt;strong>一個 topic、多個 subscription、各自獨立&lt;/strong>。發布者發到 topic，每個 subscription 收到一份完整的訊息流、各自維護消費進度。這天然支援 fanout（多個服務各建一個 subscription）。&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-mercari-item-feed-dlt/" data-link-title="3.C64 Mercari Item Feed：DLT 防 poison message 阻塞" data-link-desc="Mercari 商品 feed 同步、ack 整批 / nack 重送、重試多次仍失敗送 DLT、topic 同時當 load-leveling buffer。">Mercari 的另一個案例&lt;/a>還揭露 topic 的雙重角色——它同時是「dispatch」跟「load-leveling buffer」，突發流量先進 topic 緩衝、consumer 按自己節奏消化。&lt;/p>
&lt;p>&lt;strong>ack deadline 是 Pub/Sub 版的可見性逾時&lt;/strong>。consumer 收到訊息後，有一段 ack deadline 來處理並 &lt;code>ack&lt;/code>。在 deadline 內沒 ack，Pub/Sub 重新投遞（at-least-once）。跟 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/aws-sqs/visibility-polling-lambda-cost/" data-link-title="AWS SQS：Visibility timeout、long polling 與 Lambda event source 的成本與失敗形狀" data-link-desc="SQS deep article：visibility timeout 對齊 consumer 處理時間（ChangeMessageVisibility）、long vs short polling 的 cost 取捨（WaitTimeSeconds）、SQS &amp;#43; Lambda event source mapping（batch size / batch window / 並行 ramp-up）、DLQ &amp;#43; redrive policy（maxReceiveCount）、message size 與 extended client、per-request cost 模型；含 5 個 production 故障演練（VT &amp;lt; 處理時間 redelivery、polling 設定省成本、Lambda 部分失敗整批重投、DLQ maxReceiveCount、FIFO 吞吐上限）">SQS visibility timeout&lt;/a> 同樣是雙邊風險：太短→處理中就重投、太長→失敗後恢復慢。處理中可用 &lt;code>modifyAckDeadline&lt;/code>（client library 通常自動 lease extension）延長。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Google Cloud Pub/Sub</a> overview 的 implementation-layer deep article。選型層（Pub/Sub vs Kafka / SQS）見 overview；本文只處理「決定用 Pub/Sub 後，subscription 與 ack 怎麼設」。Pub/Sub 是 managed SaaS、無法本機 docker 驗證，本文 config 依 <a href="https://cloud.google.com/pubsub/docs/subscriber">Pub/Sub 官方文件</a> 與下列 production case、最後檢查日 2026-06-16；引數與計費以官方為準。</p></blockquote>
<h2 id="push-vs-pull-不是實作偏好">push vs pull 不是實作偏好</h2>
<p>把 Pub/Sub 的 subscription 設成 push 還是 pull，常被當成「看團隊習慣」的實作選擇。但它其實是一個關於下游容量的判讀。差別在流量控制權在誰手上：push subscription 由 Pub/Sub 主動把訊息 HTTP POST 到目標 endpoint——流量節奏由 Pub/Sub 決定，尖峰時瞬間打過來；pull subscription 由 consumer 主動拉，要拉多少、多快由 consumer 自己控制。</p>
<p><a href="/blog/backend/03-message-queue/cases/pubsub-mercari-line-flow-control/" data-link-title="3.C65 Mercari LINE：Pull subscription 對齊外部 RPS" data-link-desc="Mercari LINE webhook 轉 Pub/Sub、worker pull subscription 精確控制 RPS、應 LINE API 限制。">Mercari 的 LINE 整合</a>把這個判讀講得很具體：Braze webhook 進來轉成 Pub/Sub event，下游要呼叫 LINE API——而 <strong>LINE API 有 RPS 限制</strong>。如果用 push，Pub/Sub 會把訊息瞬間打到 worker、worker 再打 LINE、直接超過 LINE 的 RPS 上限。所以他們用 pull subscription，worker「精確控制每秒處理訊息數」來對齊 LINE 的限制。這個案例揭露的原則是——<strong>push vs pull 不是實作偏好，是「下游能不能承受 push 的流量衝擊」的判讀</strong>：下游有速率限制、處理能力有限、或需要平滑流量，就走 pull 自我節流。</p>
<p>本文展開 subscription 模型、ack deadline、flow control 與 dead-letter topic——這些決定了訊息怎麼被可靠地、以下游能承受的速度消費。</p>
<h2 id="核心概念subscriptionack-deadline-與-flow-control">核心概念：subscription、ack deadline 與 flow control</h2>
<p>Pub/Sub 把「topic（發布）」跟「subscription（訂閱）」分開，可靠消費的旋鈕都在 subscription 上。</p>
<p><strong>一個 topic、多個 subscription、各自獨立</strong>。發布者發到 topic，每個 subscription 收到一份完整的訊息流、各自維護消費進度。這天然支援 fanout（多個服務各建一個 subscription）。<a href="/blog/backend/03-message-queue/cases/pubsub-mercari-item-feed-dlt/" data-link-title="3.C64 Mercari Item Feed：DLT 防 poison message 阻塞" data-link-desc="Mercari 商品 feed 同步、ack 整批 / nack 重送、重試多次仍失敗送 DLT、topic 同時當 load-leveling buffer。">Mercari 的另一個案例</a>還揭露 topic 的雙重角色——它同時是「dispatch」跟「load-leveling buffer」，突發流量先進 topic 緩衝、consumer 按自己節奏消化。</p>
<p><strong>ack deadline 是 Pub/Sub 版的可見性逾時</strong>。consumer 收到訊息後，有一段 ack deadline 來處理並 <code>ack</code>。在 deadline 內沒 ack，Pub/Sub 重新投遞（at-least-once）。跟 <a href="/blog/backend/03-message-queue/vendors/aws-sqs/visibility-polling-lambda-cost/" data-link-title="AWS SQS：Visibility timeout、long polling 與 Lambda event source 的成本與失敗形狀" data-link-desc="SQS deep article：visibility timeout 對齊 consumer 處理時間（ChangeMessageVisibility）、long vs short polling 的 cost 取捨（WaitTimeSeconds）、SQS &#43; Lambda event source mapping（batch size / batch window / 並行 ramp-up）、DLQ &#43; redrive policy（maxReceiveCount）、message size 與 extended client、per-request cost 模型；含 5 個 production 故障演練（VT &lt; 處理時間 redelivery、polling 設定省成本、Lambda 部分失敗整批重投、DLQ maxReceiveCount、FIFO 吞吐上限）">SQS visibility timeout</a> 同樣是雙邊風險：太短→處理中就重投、太長→失敗後恢復慢。處理中可用 <code>modifyAckDeadline</code>（client library 通常自動 lease extension）延長。</p>
<p><strong>flow control 限制 client 端同時持有的未 ack 量</strong>。pull subscription 的 client library 可設 <code>max_outstanding_messages</code> / <code>max_outstanding_bytes</code>——consumer 最多同時持有多少未 ack 訊息。這是 consumer 端自我節流的旋鈕，避免一次拉太多撐爆自己或下游。Mercari 對齊 LINE RPS 靠的就是這層控制。</p>
<p><strong>dead-letter topic（DLT）給毒訊息出口</strong>。subscription 設 dead-letter policy（<code>maxDeliveryAttempts</code> + dead-letter topic）後，重投超過上限的訊息被轉到 DLT，不再阻塞後續。Mercari item feed 正是「重試多次仍失敗送 DLT、後續訊息優先處理」——避免 poison message 卡住 pipeline。</p>
<h2 id="配置subscription--ack-deadline--dlt依官方文件">配置：subscription + ack deadline + DLT（依官方文件）</h2>
<p>Pub/Sub 是 managed、以下 gcloud 依官方文件（未本機 docker 驗證、引數以官方為準）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 1. 建 topic + dead-letter topic</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">gcloud pubsub topics create orders
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">gcloud pubsub topics create orders-dlt
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"># 2. pull subscription：ack deadline + dead-letter policy</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">gcloud pubsub subscriptions create orders-worker <span class="se">\
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="se"></span>  --topic<span class="o">=</span>orders <span class="se">\
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="se"></span>  --ack-deadline<span class="o">=</span><span class="m">60</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="se"></span>  --dead-letter-topic<span class="o">=</span>orders-dlt <span class="se">\
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="se"></span>  --max-delivery-attempts<span class="o">=</span><span class="m">5</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="c1"># 3. consumer 端 flow control（client library、以 Python 為例、概念跨語言一致）</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1">#    flow_control = FlowControl(max_messages=100, max_bytes=10*1024*1024)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1">#    subscriber.subscribe(sub_path, callback=handle, flow_control=flow_control)</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="c1">#    handle 內：處理成功 message.ack()、失敗 message.nack()</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="c1"># push subscription（僅當下游能承受 Pub/Sub 主動推的流量時）：</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="c1"># gcloud pubsub subscriptions create orders-push \</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="c1">#   --topic=orders --push-endpoint=https://my-svc/handler --ack-deadline=60</span></span></span></code></pre></div><p>判讀：</p>
<ul>
<li>下游有 RPS 限制 / 處理能力有限 → pull + flow control（self-throttle，Mercari 模式）</li>
<li>下游能吸收推送尖峰、要 serverless 簡單 → push</li>
<li><code>ack-deadline</code> 略高於處理時間；長任務靠 client library 的 lease extension</li>
<li><code>max-delivery-attempts</code> + DLT 給毒訊息出口</li>
</ul>
<h2 id="production-故障演練">Production 故障演練</h2>
<h3 id="case-1用-push下游被瞬間流量打爆">Case 1：用 push、下游被瞬間流量打爆</h3>
<p><strong>徵兆</strong>：流量尖峰時下游 endpoint 5xx 暴增、或下游的第三方 API 回 429（rate limited），訊息大量重投惡化。</p>
<p><strong>根因</strong>：用 push subscription，Pub/Sub 把訊息瞬間 POST 到 endpoint，超過下游（或下游依賴的外部 API）的處理 / 速率上限。正是 <a href="/blog/backend/03-message-queue/cases/pubsub-mercari-line-flow-control/" data-link-title="3.C65 Mercari LINE：Pull subscription 對齊外部 RPS" data-link-desc="Mercari LINE webhook 轉 Pub/Sub、worker pull subscription 精確控制 RPS、應 LINE API 限制。">Mercari LINE</a> 要避開的情形。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>下游有速率限制改用 pull subscription + flow control，由 consumer 自我節流</li>
<li>flow control 的 <code>max_outstanding_messages</code> 對齊下游能承受的並發</li>
<li>push 只用在下游能吸收推送尖峰的場景</li>
<li>push 場景下游要自己擋（rate limit / 佇列），不能假設 Pub/Sub 會幫你平滑</li>
</ol>
<h3 id="case-2ack-deadline-太短訊息處理中就被重投">Case 2：ack deadline 太短、訊息處理中就被重投</h3>
<p><strong>徵兆</strong>：同一則訊息被處理多次，尤其處理較慢時；訂閱的 redelivery 指標偏高。</p>
<p><strong>根因</strong>：ack deadline 設得比處理時間短，訊息在處理途中 deadline 到期、Pub/Sub 重投。跟 SQS visibility timeout 太短同類。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>ack deadline 設成略高於處理時間 p99</li>
<li>用 client library 的自動 lease extension（modifyAckDeadline）處理長尾任務</li>
<li>消費端冪等——at-least-once 本來就可能重投（見 <a href="/blog/backend/06-reliability/idempotency-replay/" data-link-title="6.12 Idempotency 與 Replay 驗證" data-link-desc="把重試、重播與冪等性從口頭約定變成可驗證屬性">6.12 idempotency</a>）</li>
<li>監控 redelivery 率，偏高代表 deadline 偏短或處理變慢</li>
</ol>
<h3 id="case-3沒設-dlt毒訊息一直重投阻塞">Case 3：沒設 DLT、毒訊息一直重投阻塞</h3>
<p><strong>徵兆</strong>：某則訊息一直失敗、一直被重投，後續訊息處理被拖慢。</p>
<p><strong>根因</strong>：subscription 沒設 dead-letter policy。處理失敗（nack 或沒 ack）的訊息一再重投、沒有上限與出口，毒訊息反覆消耗 consumer。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>設 dead-letter policy（<code>max-delivery-attempts</code> + DLT），重投達上限轉 DLT</li>
<li>DLT 是另一個 topic，要有處理 / 告警流程（Mercari「送 DLT、後續訊息優先處理」）</li>
<li><code>max-delivery-attempts</code> 平衡暫時性失敗重試與毒訊息隔離</li>
<li>對照 <a href="/blog/backend/03-message-queue/vendors/aws-sqs/visibility-polling-lambda-cost/" data-link-title="AWS SQS：Visibility timeout、long polling 與 Lambda event source 的成本與失敗形狀" data-link-desc="SQS deep article：visibility timeout 對齊 consumer 處理時間（ChangeMessageVisibility）、long vs short polling 的 cost 取捨（WaitTimeSeconds）、SQS &#43; Lambda event source mapping（batch size / batch window / 並行 ramp-up）、DLQ &#43; redrive policy（maxReceiveCount）、message size 與 extended client、per-request cost 模型；含 5 個 production 故障演練（VT &lt; 處理時間 redelivery、polling 設定省成本、Lambda 部分失敗整批重投、DLQ maxReceiveCount、FIFO 吞吐上限）">SQS redrive</a>：兩者都是 managed 原生 DLQ/DLT、比自建省事</li>
</ol>
<h3 id="case-4flow-control-沒設consumer-一次拉太多撐爆">Case 4：flow control 沒設、consumer 一次拉太多撐爆</h3>
<p><strong>徵兆</strong>：consumer 記憶體暴增 / OOM，或一次拉太多把下游打爆。</p>
<p><strong>根因</strong>：pull subscription 沒設 flow control，client library 預設可能持有大量未 ack 訊息，consumer 端記憶體與下游壓力失控。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>設 <code>max_outstanding_messages</code> / <code>max_outstanding_bytes</code> 限制同時持有量</li>
<li>對齊 consumer 處理能力與下游容量（Mercari 對齊 LINE RPS）</li>
<li>監控 consumer 記憶體與未 ack 數，調 flow control 參數</li>
<li>flow control 是 pull 自我節流的核心，不設等於放棄背壓</li>
</ol>
<h3 id="case-5誤用-ordering-key吞吐受限">Case 5：誤用 ordering key、吞吐受限</h3>
<p><strong>徵兆</strong>：開了 message ordering 後吞吐明顯下降、特定 ordering key 的訊息處理變慢。</p>
<p><strong>根因</strong>：Pub/Sub 的順序保證是 per-ordering-key 的——同一個 ordering key 的訊息嚴格按序、必須序列處理（前一則 ack 才處理下一則）。把所有訊息塞同一個 ordering key 等於序列化整條流、吞吐崩。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>ordering key 用細粒度（per-entity，如 per-user），讓不同 key 可並行</li>
<li>不需要嚴格順序的就別開 ordering（預設無序、吞吐高）</li>
<li>評估順序需求的真實範圍——多數場景只需 per-entity 順序，不是全域</li>
<li>嚴格全域順序 + 高吞吐有本質衝突，重新審視需求或走 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka</a> 的 partition 模型</li>
</ol>
<h2 id="capacity--cost-邊界">Capacity / cost 邊界</h2>
<p>Pub/Sub 的容量判讀（managed、無 broker 運維）：</p>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>健康區間</th>
          <th>警戒與動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>subscription backlog（未 ack 數 / 最舊訊息 age）</td>
          <td>在 SLA 內</td>
          <td>持續成長 → consumer 跟不上、加 consumer / 調 flow control</td>
      </tr>
      <tr>
          <td>redelivery 率</td>
          <td>低</td>
          <td>偏高 → ack deadline 太短 / 下游失敗</td>
      </tr>
      <tr>
          <td>DLT 深度</td>
          <td>低且有處理流程</td>
          <td>成長 → 上游系統性失敗</td>
      </tr>
      <tr>
          <td>consumer 記憶體 / 未 ack 量</td>
          <td>在 flow control 限制內</td>
          <td>暴增 → flow control 沒設好</td>
      </tr>
      <tr>
          <td>訊息量（計費基礎）</td>
          <td>對齊預算</td>
          <td>暴增 → 評估 throughput 計費、batch / 壓縮</td>
      </tr>
  </tbody>
</table>
<p>撞牆後的路由判斷：</p>
<ul>
<li><strong>需要長期保留 + 任意 replay</strong>：Pub/Sub 有 retention（可設、seek 到時間點）但事件流長期 replay + 生態走 <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><strong>嚴格全域順序 + 高吞吐</strong>：Pub/Sub ordering 是 per-key 序列化，全域順序高吞吐走 Kafka partition 設計。</li>
<li><strong>不在 GCP 生態</strong>：Pub/Sub 綁 GCP，跨雲走 <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> 或對應雲的 managed（<a href="/blog/backend/03-message-queue/vendors/aws-sqs/" data-link-title="AWS SQS" data-link-desc="AWS managed queue、簡單可靠、無 ordering（standard）">SQS</a>）。</li>
<li><strong>複雜 routing（topic exchange 式）</strong>：Pub/Sub 是 topic→subscription 扇出，複雜 routing 規則走 <a href="/blog/backend/03-message-queue/vendors/rabbitmq/" data-link-title="RabbitMQ" data-link-desc="Classic message broker、AMQP routing 為主">RabbitMQ</a> exchange。</li>
</ul>
<h2 id="整合--下一步">整合 / 下一步</h2>
<p>push/pull 判讀與 ack 是 Pub/Sub 可靠消費的核心，它跟其他議題交織：</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、ack deadline、flow control 是 consumer 設計的具體選項。</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 + 重投要求消費冪等。</li>
<li><strong>跟 <a href="/blog/backend/03-message-queue/vendors/aws-sqs/visibility-polling-lambda-cost/" data-link-title="AWS SQS：Visibility timeout、long polling 與 Lambda event source 的成本與失敗形狀" data-link-desc="SQS deep article：visibility timeout 對齊 consumer 處理時間（ChangeMessageVisibility）、long vs short polling 的 cost 取捨（WaitTimeSeconds）、SQS &#43; Lambda event source mapping（batch size / batch window / 並行 ramp-up）、DLQ &#43; redrive policy（maxReceiveCount）、message size 與 extended client、per-request cost 模型；含 5 個 production 故障演練（VT &lt; 處理時間 redelivery、polling 設定省成本、Lambda 部分失敗整批重投、DLQ maxReceiveCount、FIFO 吞吐上限）">SQS visibility timeout</a></strong>：ack deadline 對應 visibility timeout、DLT 對應 redrive，兩個 managed queue 的可靠消費模型高度對位、可對照閱讀。</li>
<li><strong>跟 webhook buffer 模式</strong>：Pub/Sub topic 當 load-leveling buffer（Mercari）對應 <a href="/blog/backend/03-message-queue/cases/sqs-twilio-webhook-buffer/" data-link-title="3.C58 Twilio：SQS 緩衝高流量 webhook" data-link-desc="Twilio 教用 SQS 緩衝 SMS / status callback webhook、分 queue（SMS vs callback）、long polling 減 cost、FIFO 300 TPS 上限要分片。">SQS Twilio webhook buffer</a>——把不可控的外部 webhook 流量先緩衝再按自己節奏消化。</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li>上游 vendor 頁：<a href="/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Google Cloud Pub/Sub</a></li>
<li>對照 vendor：<a href="/blog/backend/03-message-queue/vendors/aws-sqs/visibility-polling-lambda-cost/" data-link-title="AWS SQS：Visibility timeout、long polling 與 Lambda event source 的成本與失敗形狀" data-link-desc="SQS deep article：visibility timeout 對齊 consumer 處理時間（ChangeMessageVisibility）、long vs short polling 的 cost 取捨（WaitTimeSeconds）、SQS &#43; Lambda event source mapping（batch size / batch window / 並行 ramp-up）、DLQ &#43; redrive policy（maxReceiveCount）、message size 與 extended client、per-request cost 模型；含 5 個 production 故障演練（VT &lt; 處理時間 redelivery、polling 設定省成本、Lambda 部分失敗整批重投、DLQ maxReceiveCount、FIFO 吞吐上限）">AWS SQS visibility timeout</a>、<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</a></li>
<li>對應案例：<a href="/blog/backend/03-message-queue/cases/pubsub-mercari-line-flow-control/" data-link-title="3.C65 Mercari LINE：Pull subscription 對齊外部 RPS" data-link-desc="Mercari LINE webhook 轉 Pub/Sub、worker pull subscription 精確控制 RPS、應 LINE API 限制。">3.C65 Mercari LINE flow control</a>、<a href="/blog/backend/03-message-queue/cases/pubsub-mercari-item-feed-dlt/" data-link-title="3.C64 Mercari Item Feed：DLT 防 poison message 阻塞" data-link-desc="Mercari 商品 feed 同步、ack 整批 / nack 重送、重試多次仍失敗送 DLT、topic 同時當 load-leveling buffer。">3.C64 Mercari item feed DLT</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></li>
</ul>
]]></content:encoded></item><item><title>Kafka Consumer Group Rebalance 與 Lag 診斷：從 protocol 到故障演練</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/consumer-rebalance-lag-diagnosis/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/consumer-rebalance-lag-diagnosis/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka&lt;/a> overview「進階主題」的 implementation-layer deep article，承接 overview「Consumer lag 暴增」與「Rebalance storm」兩段判讀原則的展開。Overview 給判讀方向，本文給 protocol 機制、診斷指令與故障演練。&lt;/p>&lt;/blockquote>
&lt;h2 id="rebalance-是-consumer-group-重新分配-partition-所有權的協調過程">Rebalance 是 consumer group 重新分配 partition 所有權的協調過程&lt;/h2>
&lt;p>Rebalance 是 consumer group coordinator 把 topic 的 partition 重新分配給 group 內 consumer 的協調動作，承擔「在成員數變動時維持每個 partition 恰好被一個 consumer 消費」這個責任。觸發條件是 group membership 改變：consumer 加入、consumer 離開、consumer 被判定失效，或 topic partition 數增加。Rebalance 完成前，受影響的 partition 暫停消費，這段空窗就是 rebalance 對 lag 的直接代價。&lt;/p>
&lt;p>Consumer group 是 Kafka 把「一份 event stream 分給多個 worker 平行處理」與「同一份 stream 給多個獨立應用各自 replay」兩種需求統一的抽象。同一個 group 內的 consumer 瓜分 partition、彼此不重複消費；不同 group 各自維護 offset、互不干擾。Rebalance 只在 group 內部發生，調整的是 group 內 partition 對 consumer 的 mapping。本文聚焦 group 內 rebalance 的機制與診斷，group 概念本身見 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer-group/" data-link-title="Consumer Group" data-link-desc="說明一組 consumer 如何共同分攤 stream 或 topic 的處理責任">consumer group 知識卡&lt;/a>。&lt;/p>
&lt;p>實機觀察 partition 如何在兩個 consumer 間分配：同一 group 起兩個 consumer，coordinator 把 3 個 partition 拆給它們。&lt;/p>





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





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





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




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





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




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





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




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





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




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">GROUP    COORDINATOR (ID)     ASSIGNMENT-STRATEGY  STATE    #MEMBERS
</span></span><span class="line"><span class="ln">2</span><span class="cl">live-cg  localhost:9092 (1)   range                Stable   2</span></span></code></pre></div><p>STATE 的取值是診斷訊號：<code>Stable</code> 代表分配已收斂正常消費；<code>PreparingRebalance</code> / <code>CompletingRebalance</code> 代表正在 rebalance；<code>Empty</code> 代表 group 沒有 active member（offset 還在但沒人消費），對應上面 lag 輸出裡 <code>CONSUMER-ID</code> 全是 <code>-</code> 的情況。看到 lag 持續累積又長期停在 rebalance 狀態，問題就在 rebalance 本身而非消費速度。</p>
<h2 id="lag-均勻分布與集中單一-partition-指向不同根因">Lag 均勻分布與集中單一 partition 指向不同根因</h2>
<p>Lag 的分布形狀是診斷的主軸：均勻分布指向消費總能力不足，集中在少數 partition 指向 key 分布或單 partition 的局部問題。同樣是 lag 高，這兩種形狀的修法完全相反，先讀分布再決定方向。</p>
<p>Lag 均勻分布在所有 partition，代表 consumer group 整體消費速度跟不上 producer 寫入速度。根因在消費側的總吞吐：consumer 數量不足、單 consumer 處理慢（CPU / GC / 下游 I/O）、或 producer 突發流量超過 group 設計容量。修法是擴消費能力：加 consumer（上限是 partition 數）、優化單筆處理、或對下游加 batch。如果 lag 隨時間線性成長且各 partition 同步成長，是穩態的容量不足，要重新評估 partition 數與 consumer 數。</p>
<p>Lag 集中在少數 partition、其餘 partition lag 接近零，代表負載不均，根因通常在 key 分布。Producer 用 key 決定 partition（<code>hash(key) % partition_count</code>），如果某些 key 是熱點（例如某個大客戶的 id、某個 null key 全落同一 partition），對應 partition 的訊息量遠高於其他，負責它的 consumer 再快也追不上，而其他 consumer 閒著。加 consumer 不解決這個問題，因為瓶頸 partition 仍只能被一個 consumer 消費。修法在 key 設計：拆熱點 key、加 salt 打散、或對熱點走獨立 topic。</p>
<p>Airbnb 的 logging pipeline 遇到的正是 partition 層 skew：event size 從幾百 bytes 到幾百 KB、QPS 跨數個量級，Spark 一個 partition 對一個 task，造成 data skew，catch-up 一個 4 小時 lag 要再花 4 小時（<a href="/blog/backend/03-message-queue/cases/kafka-airbnb-spark-streaming-rebalance/" data-link-title="3.C15 Airbnb：Spark Streaming Kafka reader rebalance" data-link-desc="Airbnb logging pipeline 解 partition-task 1:1 造成的 data skew、catch-up 4 小時 lag 要再花 4 小時的反效率。">3.C15</a>）。它的解法揭露一個關鍵判準：partition 數不該等同 consumer parallelism。當 lag 集中在少數重 partition，加 consumer 受限於 partition 數的天花板無效，要把 parallelism 從 partition 數解耦、按 event volume × size 重新分派 work。這把「lag 集中」的診斷從 key 分布延伸到了 work 分派模型本身。</p>
<table>
  <thead>
      <tr>
          <th>Lag 分布形狀</th>
          <th>根因方向</th>
          <th>修法</th>
          <th>加 consumer 是否有效</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>均勻分布、各 partition 相近</td>
          <td>消費總能力不足</td>
          <td>加 consumer、優化處理、batch 下游</td>
          <td>有效（上限 partition 數）</td>
      </tr>
      <tr>
          <td>集中少數 partition</td>
          <td>key 分布熱點 / data skew</td>
          <td>拆 key、salt、熱點獨立 topic、解耦 parallelism</td>
          <td>無效（瓶頸 partition 仍單線）</td>
      </tr>
  </tbody>
</table>
<p>判讀順序固定：先 <code>--describe</code> 看分布形狀，再決定往「擴容」還是「重分布」走。跳過分布判讀直接加 consumer，遇到熱點 partition 場景會白花資源還解不了 lag。</p>
<h2 id="production-故障演練">Production 故障演練</h2>
<h3 id="case-1consumer-處理慢被踢出-group-形成-rebalance-連環">Case 1：consumer 處理慢被踢出 group 形成 rebalance 連環</h3>
<p>徵兆：consumer log 反覆出現 <code>Member ... sending LeaveGroup request</code> 與 <code>Attempt to heartbeat failed since group is rebalancing</code>；lag 持續成長；group STATE 在 <code>Stable</code> 與 <code>PreparingRebalance</code> 之間反覆跳；同一批 partition 在不同 consumer 間反覆搬移。</p>
<p>根因：下游 I/O 變慢（DB 連線池打滿、外部 API 延遲升高），consumer 單批 <code>poll()</code> 後處理超過 <code>max.poll.interval.ms</code>（預設 5 分鐘），coordinator 判定該 consumer 失去處理能力、踢出 group、觸發 rebalance。partition 搬到另一個 consumer，後者面對同樣慢的下游、同樣超時被踢，rebalance 連環觸發，每次 rebalance 又讓所有 consumer 暫停消費，lag 加速惡化。</p>
<p>修法：</p>
<ol>
<li>確認瓶頸是處理慢而非 heartbeat 中斷：consumer log 若有正常 heartbeat 但仍被踢，問題在 <code>max.poll.interval.ms</code> 不是 <code>session.timeout.ms</code>。</li>
<li>縮小 <code>max.poll.records</code>：一次拉少一點，讓單批在 <code>max.poll.interval.ms</code> 內做完，這是不改下游就能止血的第一步。</li>
<li>拉高 <code>max.poll.interval.ms</code>：給單批更長處理時間，但這只是延後而非解決，要搭配下游修復。</li>
<li>修復下游根因：DB 連線池、外部 API 超時、batch 寫入策略，這才是消除連環 rebalance 的根本。</li>
</ol>
<h3 id="case-2lag-集中單一-partition加-consumer-無效">Case 2：lag 集中單一 partition、加 consumer 無效</h3>
<p>徵兆：<code>--describe</code> 顯示一兩個 partition lag 數十萬、其餘 partition lag 接近零；加了 consumer 之後 lag 不降，新 consumer 處於閒置（<code>--members</code> 顯示它分到的 partition 都沒 lag）。</p>
<p>根因：producer 的 key 分布有熱點，大量訊息落在同一 partition。Partition 是 Kafka 平行消費的最小單位，一個 partition 只能被 group 內一個 consumer 消費，熱點 partition 的消費速度被單 consumer 鎖死，加再多 consumer 都分不到這個 partition 的工作。</p>
<p>修法：</p>
<ol>
<li><code>--describe</code> 確認 lag 集中形狀，排除「整體容量不足」的均勻分布情境。</li>
<li>找出熱點 key：抽樣訊息看 key 分布，常見是 null key（全落同一 partition）或單一大租戶 id。</li>
<li>重設計 key：對熱點加 salt 打散到多 partition，或讓熱點走獨立 topic 用更多 partition。</li>
<li>若 work 本身有 skew（單筆訊息處理成本差異大），把 parallelism 從 partition 數解耦，按工作量重新分派，如 Airbnb 的 balanced reader（<a href="/blog/backend/03-message-queue/cases/kafka-airbnb-spark-streaming-rebalance/" data-link-title="3.C15 Airbnb：Spark Streaming Kafka reader rebalance" data-link-desc="Airbnb logging pipeline 解 partition-task 1:1 造成的 data skew、catch-up 4 小時 lag 要再花 4 小時的反效率。">3.C15</a>）。</li>
</ol>
<blockquote>
<p>key 重分布需要 producer 端配合改 key 策略，對既有 topic 是破壞性變更（舊訊息 key 不變），通常搭配新 topic 切換。本文未實機驗證 producer key 重設計的線上切換流程，依官方分區語義說明。</p></blockquote>
<h3 id="case-3deploy-每次都產生-lag-spike">Case 3：deploy 每次都產生 lag spike</h3>
<p>徵兆：每次滾動部署 consumer 服務，lag 在部署窗口內明顯上升、部署完成後緩慢回落；group STATE 在部署期間進入 rebalance；部署越頻繁，累積 lag 越明顯。</p>
<p>根因：每個 consumer pod 重啟，coordinator 看到舊 member 離開、新 member 加入，觸發 rebalance；若用 eager protocol，每次 rebalance 全 group 停止消費；滾動部署逐個重啟 N 個 pod 就觸發 N 次 rebalance，每次全停，lag 在這串全停窗口中累積。</p>
<p>修法：</p>
<ol>
<li>啟用 static membership：給每個 consumer 固定 <code>group.instance.id</code>，重啟時帶同一身分回來、不觸發 rebalance。</li>
<li>把 <code>session.timeout.ms</code> 設得比 pod 重啟時間長：涵蓋 K8s 重啟一個 pod 的 10-30 秒，否則 static membership 在窗口內失效。</li>
<li>切換到 cooperative incremental protocol：即使仍有 rebalance，只有換手 partition 中斷，未換手 partition 持續消費。</li>
<li>控制部署並行度：一次重啟太多 pod 會放大同時 rebalance 的影響，分批滾動。</li>
</ol>
<p>Walmart 在 25K+ consumer 規模下，正是 pod scaling / deploy / heartbeat fail 三類事件持續觸發 rebalance lag spike（<a href="/blog/backend/03-message-queue/cases/kafka-walmart-mps-rebalance/" data-link-title="3.C17 Walmart：Messaging Proxy Service 解 rebalance storm" data-link-desc="Walmart 每天 trillions of message、25K&#43; consumer 在 K8s、partition-consumer 1:1 模型撞到擴張極限。">3.C17</a>）；static membership 與 cooperative 降低單次代價，但它最終把 consumer 解耦成可獨立 auto-scale 的 stateless service，從架構層消除 rebalance 與 partition 數的綁定。</p>
<h3 id="case-4scale-to-zero-後冷啟動-lag">Case 4：scale-to-zero 後冷啟動 lag</h3>
<p>徵兆：低流量時段 consumer 被縮到 0，流量回來時 lag 已累積一批、需要一段 catch-up；autoscaler 若看 CPU / memory 反應遲鈍，因為 sink 多為 I/O bottleneck、CPU 平坦不觸發擴容。</p>
<p>根因：event-driven workload 的工作量是 backlog（lag）而非 resource usage。用 CPU / memory 當 scaling signal，在 I/O-bound 的 sink consumer 上失靈：訊息堆積但 CPU 不高，autoscaler 不動，lag 持續成長。</p>
<p>修法：</p>
<ol>
<li>用 consumer lag 當 scaling signal：lag 超過閾值就擴 consumer、lag 清空就縮，直接對齊工作量。</li>
<li>接受 scale-to-zero 的冷啟動 lag 為設計取捨：minReplicaCount=0 省下 idle 成本，代價是流量回來時的 catch-up 窗口，對非即時 sink 可接受。</li>
<li>設 lag 閾值與擴容步長：閾值太高 catch-up 久、太低頻繁擴縮，依 SLA 對 backlog 的容忍度設定。</li>
</ol>
<p>Trivago 跨 3 region 跑 50+ Kafka sink、每個 always-on 用 1 CPU + 1 GB，CPU/mem autoscaling 對 I/O-bound sink 無效；改用 KEDA 以 consumer lag 為 scaling signal、minReplicaCount=0 達到 scale-to-zero，daily replica-hour 從 50 降到 1-2（<a href="/blog/backend/03-message-queue/cases/kafka-trivago-keda-scale-to-zero/" data-link-title="3.C22 Trivago：KEDA scale-to-zero by Kafka lag" data-link-desc="Trivago 50&#43; Kafka sink、CPU/mem autoscaling 無效（I/O bottleneck）、KEDA 以 consumer lag 為訊號達到 scale-to-zero。">3.C22</a>）。這個案例的判準是 resource usage 不等於工作量，event-driven 場景該看 backlog signal。</p>
<h2 id="capacity-與-cost">Capacity 與 cost</h2>
<p>Rebalance 與 lag 的容量規劃圍繞三個變數：partition 數、consumer 數、單次 rebalance 的中斷成本。partition 數是消費平行度的天花板，consumer 數超過 partition 數時多出的 consumer 閒置，所以 partition 數要按峰值需要的平行度規劃，但 partition 過多會推高 metadata 壓力與 rebalance 計算成本。</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>估算</th>
          <th>警戒</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Consumer 數上限</td>
          <td>等於 partition 數，超出即閒置</td>
          <td>consumer = partition 仍跟不上要加 partition</td>
      </tr>
      <tr>
          <td>Eager rebalance 中斷</td>
          <td>全 group 停止消費直到分配收斂</td>
          <td>partition 多、group 大時窗口顯著</td>
      </tr>
      <tr>
          <td>Cooperative rebalance</td>
          <td>僅換手 partition 中斷，可能兩輪</td>
          <td>換手比例高時優勢縮小</td>
      </tr>
      <tr>
          <td>session.timeout.ms 窗口</td>
          <td>consumer 死亡到被踢出、partition 無人消費</td>
          <td>設太大則故障偵測慢、lag 累積</td>
      </tr>
      <tr>
          <td>加 partition 的代價</td>
          <td>提高平行度上限，但增加 rebalance 與 metadata 成本</td>
          <td>過度分區推高 controller 壓力</td>
      </tr>
  </tbody>
</table>
<p>實務 default：partition 數按峰值平行度設、保留成長餘量但不過度分區；consumer 數對齊 partition 數、用 lag 而非 CPU 當 autoscaling signal；rebalance 頻繁的環境優先 static membership + cooperative，再評估是否需要把 consumer 從 partition 解耦。加 partition 是單向操作（無法縮回），且改變既有 key 的 partition 對應，要在規劃期一次設足而非事後頻繁調整。</p>
<h2 id="整合與下一步">整合與下一步</h2>
<p>Rebalance 與 lag 診斷接在 consumer 設計與交付語義之上：commit 策略決定 lag 的計算基準與 rebalance 後的重複消費風險，交付語義決定 rebalance 中斷期間訊息是否可能丟失或重放。</p>
<h3 id="跟-consumer-設計對位">跟 consumer 設計對位</h3>
<p><a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計</a> 涵蓋 commit 策略（auto vs manual）、commit 時機與 partition 分配的整體設計。本文的 rebalance 是 consumer 設計在「成員變動」維度的展開，lag 是 commit 進度的可觀測量。commit 策略選錯會在 rebalance 後放大重複消費或丟失。</p>
<h3 id="跟交付與復原語義對位">跟交付與復原語義對位</h3>
<p><a href="/blog/backend/03-message-queue/processing-recovery-semantics/" data-link-title="3.6 Processing Semantics 與 Recovery Semantics" data-link-desc="說明投遞成功、處理成功與恢復成功為何是三個不同判斷。">3.6 processing 與 recovery 語義</a> 涵蓋 rebalance 中斷期間的 at-least-once / at-most-once 行為。rebalance revoke partition 時，未 commit 的進度會在新 consumer 接手後重放（at-least-once）；commit 太早則可能在 rebalance 中丟失（at-most-once）。idempotency 與 replay 的整體設計見 <a href="/blog/backend/06-reliability/idempotency-replay/" data-link-title="6.12 Idempotency 與 Replay 驗證" data-link-desc="把重試、重播與冪等性從口頭約定變成可驗證屬性">6.12 idempotency / replay</a>。</p>
<h3 id="相關案例">相關案例</h3>
<ul>
<li><a href="/blog/backend/03-message-queue/cases/kafka-airbnb-spark-streaming-rebalance/" data-link-title="3.C15 Airbnb：Spark Streaming Kafka reader rebalance" data-link-desc="Airbnb logging pipeline 解 partition-task 1:1 造成的 data skew、catch-up 4 小時 lag 要再花 4 小時的反效率。">3.C15 Airbnb Spark Streaming</a> — partition-task 1:1 造成 data skew、parallelism 從 partition 數解耦</li>
<li><a href="/blog/backend/03-message-queue/cases/kafka-walmart-mps-rebalance/" data-link-title="3.C17 Walmart：Messaging Proxy Service 解 rebalance storm" data-link-desc="Walmart 每天 trillions of message、25K&#43; consumer 在 K8s、partition-consumer 1:1 模型撞到擴張極限。">3.C17 Walmart MPS</a> — 25K+ consumer 在 K8s 的 rebalance storm、consumer 解耦成 stateless service</li>
<li><a href="/blog/backend/03-message-queue/cases/kafka-trivago-keda-scale-to-zero/" data-link-title="3.C22 Trivago：KEDA scale-to-zero by Kafka lag" data-link-desc="Trivago 50&#43; Kafka sink、CPU/mem autoscaling 無效（I/O bottleneck）、KEDA 以 consumer lag 為訊號達到 scale-to-zero。">3.C22 Trivago KEDA</a> — consumer lag 驅動 scale-to-zero、backlog signal 取代 resource usage</li>
</ul>
<h3 id="相關連結">相關連結</h3>
<ul>
<li>上游 vendor 頁：<a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Apache Kafka</a></li>
<li>知識卡：<a href="/blog/backend/knowledge-cards/consumer-lag/" data-link-title="Consumer Lag" data-link-desc="說明 consumer lag 如何反映訊息堆積、處理能力與容量風險">consumer lag</a>、<a href="/blog/backend/knowledge-cards/consumer-group/" data-link-title="Consumer Group" data-link-desc="說明一組 consumer 如何共同分攤 stream 或 topic 的處理責任">consumer group</a>、<a href="/blog/backend/knowledge-cards/partition/" data-link-title="Partition" data-link-desc="說明事件流如何切分成多個可並行處理的有序片段">partition</a></li>
<li>下游能力：<a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計</a>、<a href="/blog/backend/03-message-queue/processing-recovery-semantics/" data-link-title="3.6 Processing Semantics 與 Recovery Semantics" data-link-desc="說明投遞成功、處理成功與恢復成功為何是三個不同判斷。">3.6 processing 與 recovery 語義</a></li>
</ul>
]]></content:encoded></item><item><title>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>Pub/Sub Ordering Key、Dead-Letter Topic 與 Schema Enforcement：三道交付治理</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/google-pubsub/ordering-dlt-schema/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/google-pubsub/ordering-dlt-schema/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Google Cloud Pub/Sub&lt;/a> overview 的 implementation-layer deep article。Overview 回答「Pub/Sub 該不該選、跟 Kafka / SQS 差在哪」；本文回答「ordering key 怎麼設、DLT 怎麼擋 poison message、schema 怎麼守契約，各自踩哪些坑」。閱讀前可先讀 overview 的 ordering / DLT / schema 各段建立 context。&lt;/p>
&lt;p>文中 gcloud 指令的語法以 Pub/Sub emulator 實機驗證（topic / subscription / schema / ordering key / DLT / push 各操作均跑通），標準版的雲端配額、IAM 與計費行為依官方文件。&lt;/p>&lt;/blockquote>
&lt;h2 id="三道治理共用同一個交付骨架">三道治理共用同一個交付骨架&lt;/h2>
&lt;p>Pub/Sub 的 ordering key、dead-letter topic、schema enforcement 看似三個獨立功能，實際都掛在同一個交付骨架上：subscription 是消費進度的 first-class 抽象、訊息經 ackDeadline 控制重投、失敗訊息經投遞次數計數決定去留。理解這個骨架之後，三道治理只是骨架上的三個切面 — ordering 切的是「投遞順序」、DLT 切的是「投遞次數上限」、schema 切的是「投遞前的內容守門」。&lt;/p>
&lt;p>這條骨架跟 Kafka 思路不同。Kafka 的消費進度綁在 consumer group + partition offset；Pub/Sub 的 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic&lt;/a> 是 first-class，subscription 才是 consumer 抽象，一個 topic 可以掛 N 個 subscription、各自有獨立進度與獨立的 ackDeadline / DLT / ordering 設定。同一份 event 流，A subscription 可以開 ordering 嚴格有序、B subscription 可以不開 ordering 換吞吐，互不影響。&lt;/p>
&lt;p>把這三道治理寫進一篇的理由是：它們在 production 會互相牽制。Ordering key 開了之後 DLT 的隔離行為會變（有序流裡一則 poison message 會卡住整把 key 的後續訊息）；schema enforcement 擋下的不相容 publish 不會進 DLT（根本沒進 topic）。分開讀三個官方頁面看不到這層耦合。&lt;/p>
&lt;h2 id="subscription-是-first-classackdeadline-與-extension">subscription 是 first-class：ackDeadline 與 extension&lt;/h2>
&lt;p>subscription 承擔「這個消費者讀到哪、還有多少沒 ack」的責任。每則訊息投遞給 subscriber 後，Pub/Sub 啟動一個 ackDeadline 倒數；倒數內收到 ack 就移除訊息、倒數結束沒收到 ack 就重投。預設 ackDeadline 是 10 秒、上限 600 秒。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1"># subscription 的 ackDeadline 預設 10 秒、retention 預設 7 天&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">gcloud pubsub subscriptions describe demo-sub
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="c1"># ackDeadlineSeconds: 10&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="c1"># messageRetentionDuration: 604800s # 7 天&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="c1"># 建 subscription 時可顯式設更長的 ackDeadline 與更短的 retention&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">gcloud pubsub subscriptions create cfg-sub &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --topic&lt;span class="o">=&lt;/span>demo-topic &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --ack-deadline&lt;span class="o">=&lt;/span>&lt;span class="m">120&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --message-retention-duration&lt;span class="o">=&lt;/span>3d
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="c1"># ackDeadlineSeconds: 120&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="c1"># messageRetentionDuration: 259200s # 3 天&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>ackDeadline 是一道「處理時間預算」。設太短，處理還沒完訊息就被重投，consumer 會收到重複；設太長，consumer crash 後訊息要等滿 deadline 才重投，延遲拉高。長任務不靠把 ackDeadline 一次設到 600 秒解決，而是靠 ack deadline extension：consumer 在處理中週期性發 &lt;code>modifyAckDeadline&lt;/code> 把單則訊息的 deadline 往後延，處理完才 ack。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Google Cloud Pub/Sub</a> overview 的 implementation-layer deep article。Overview 回答「Pub/Sub 該不該選、跟 Kafka / SQS 差在哪」；本文回答「ordering key 怎麼設、DLT 怎麼擋 poison message、schema 怎麼守契約，各自踩哪些坑」。閱讀前可先讀 overview 的 ordering / DLT / schema 各段建立 context。</p>
<p>文中 gcloud 指令的語法以 Pub/Sub emulator 實機驗證（topic / subscription / schema / ordering key / DLT / push 各操作均跑通），標準版的雲端配額、IAM 與計費行為依官方文件。</p></blockquote>
<h2 id="三道治理共用同一個交付骨架">三道治理共用同一個交付骨架</h2>
<p>Pub/Sub 的 ordering key、dead-letter topic、schema enforcement 看似三個獨立功能，實際都掛在同一個交付骨架上：subscription 是消費進度的 first-class 抽象、訊息經 ackDeadline 控制重投、失敗訊息經投遞次數計數決定去留。理解這個骨架之後，三道治理只是骨架上的三個切面 — ordering 切的是「投遞順序」、DLT 切的是「投遞次數上限」、schema 切的是「投遞前的內容守門」。</p>
<p>這條骨架跟 Kafka 思路不同。Kafka 的消費進度綁在 consumer group + partition offset；Pub/Sub 的 <a href="/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic</a> 是 first-class，subscription 才是 consumer 抽象，一個 topic 可以掛 N 個 subscription、各自有獨立進度與獨立的 ackDeadline / DLT / ordering 設定。同一份 event 流，A subscription 可以開 ordering 嚴格有序、B subscription 可以不開 ordering 換吞吐，互不影響。</p>
<p>把這三道治理寫進一篇的理由是：它們在 production 會互相牽制。Ordering key 開了之後 DLT 的隔離行為會變（有序流裡一則 poison message 會卡住整把 key 的後續訊息）；schema enforcement 擋下的不相容 publish 不會進 DLT（根本沒進 topic）。分開讀三個官方頁面看不到這層耦合。</p>
<h2 id="subscription-是-first-classackdeadline-與-extension">subscription 是 first-class：ackDeadline 與 extension</h2>
<p>subscription 承擔「這個消費者讀到哪、還有多少沒 ack」的責任。每則訊息投遞給 subscriber 後，Pub/Sub 啟動一個 ackDeadline 倒數；倒數內收到 ack 就移除訊息、倒數結束沒收到 ack 就重投。預設 ackDeadline 是 10 秒、上限 600 秒。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># subscription 的 ackDeadline 預設 10 秒、retention 預設 7 天</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">gcloud pubsub subscriptions describe demo-sub
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"># ackDeadlineSeconds: 10</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"># messageRetentionDuration: 604800s   # 7 天</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"># 建 subscription 時可顯式設更長的 ackDeadline 與更短的 retention</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">gcloud pubsub subscriptions create cfg-sub <span class="se">\
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="se"></span>  --topic<span class="o">=</span>demo-topic <span class="se">\
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="se"></span>  --ack-deadline<span class="o">=</span><span class="m">120</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="se"></span>  --message-retention-duration<span class="o">=</span>3d
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"># ackDeadlineSeconds: 120</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"># messageRetentionDuration: 259200s   # 3 天</span></span></span></code></pre></div><p>ackDeadline 是一道「處理時間預算」。設太短，處理還沒完訊息就被重投，consumer 會收到重複；設太長，consumer crash 後訊息要等滿 deadline 才重投，延遲拉高。長任務不靠把 ackDeadline 一次設到 600 秒解決，而是靠 ack deadline extension：consumer 在處理中週期性發 <code>modifyAckDeadline</code> 把單則訊息的 deadline 往後延，處理完才 ack。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># pull 一則但不 auto-ack，拿到 ackId</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nv">ACKID</span><span class="o">=</span><span class="k">$(</span>gcloud pubsub subscriptions pull demo-sub --limit<span class="o">=</span><span class="m">1</span> --format<span class="o">=</span><span class="s1">&#39;value(ackId)&#39;</span><span class="k">)</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"># 處理中動態延長這則訊息的 ackDeadline 到 300 秒</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">gcloud pubsub subscriptions modify-message-ack-deadline demo-sub <span class="se">\
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="se"></span>  --ack-ids<span class="o">=</span><span class="s2">&#34;</span><span class="nv">$ACKID</span><span class="s2">&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="se"></span>  --ack-deadline<span class="o">=</span><span class="m">300</span></span></span></code></pre></div><p>實務上不手動發 <code>modifyAckDeadline</code>，而是用 client library 的自動 lease 管理：client 在背景對 outstanding 訊息週期性續約，直到 application code 回 ack / nack。這跟 SQS 的 visibility timeout 語意類似 — 都是「訊息正在被處理、暫時別重投」的租約 — 但 Pub/Sub 是 per-message lease + client 自動續約，SQS 是 per-receive visibility window + 手動 <code>ChangeMessageVisibility</code>。</p>
<blockquote>
<p>ackDeadline 的陷阱在 batch 邊界。client library 常以 batch 為單位 pull，但 ackDeadline lease 是 per-message。若 application 把整個 batch 當一個工作單元處理、處理時間超過單則 ackDeadline 且 client 未對每則續約，未 ack 的訊息會被重投。Mercari 的 actionable history pipeline 揭露的正是這個 client library 行為：ack deadline 以整批 batch 為粒度運作，同批只要有一則過期或被 nack，已 ack 的訊息會跟著一起重投（<a href="/blog/backend/03-message-queue/cases/pubsub-mercari-actionable-history/" data-link-title="3.C63 Mercari Actionable History：ack deadline 是 batch-level" data-link-desc="Merpay 支付流水帳用 Pub/Sub、ack deadline 是整批 batch 而非單訊息、acked 訊息會跟同批 expired 一起 redeliver。">3.C63</a>）。</p></blockquote>
<h2 id="pushpullstreaming-pull-與-flow-control">Push、Pull、Streaming Pull 與 flow control</h2>
<p>subscription 有兩種交付方向，pull 之下又分 unary pull 與 streaming pull。三者對應不同的下游承壓能力。</p>
<table>
  <thead>
      <tr>
          <th>交付模型</th>
          <th>機制</th>
          <th>適合場景</th>
          <th>flow control 由誰掌握</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Push</td>
          <td>Pub/Sub 主動 POST 到 HTTPS endpoint</td>
          <td>無狀態 worker、Cloud Run、Cloud Functions</td>
          <td>Pub/Sub（按 ack 動態調速）</td>
      </tr>
      <tr>
          <td>Unary Pull</td>
          <td>consumer 每次發一個 pull 請求拿一批</td>
          <td>低頻、批次拉取、簡單腳本</td>
          <td>consumer（自己控拉取頻率）</td>
      </tr>
      <tr>
          <td>Streaming Pull</td>
          <td>consumer 開長連線、Pub/Sub 持續推送到該連線</td>
          <td>高吞吐長 worker、需要精確 flow control</td>
          <td>consumer（client lib 設定）</td>
      </tr>
  </tbody>
</table>
<p>Push 把投遞節奏交給 Pub/Sub：endpoint 回 2xx 視為 ack、回非 2xx 或逾時視為 nack 並 backoff 重投。Pull 把節奏交給 consumer：consumer 想拉才拉、拉多少自己定。Streaming pull 是 production 高吞吐場景的主力 — client library 預設用它，因為它能在單一長連線上做精細的 flow control。</p>
<p>flow control 是 pull 的核心優勢：consumer 用 <code>max_outstanding_messages</code> 與 <code>max_outstanding_bytes</code> 設定「同時最多持有多少未 ack 訊息」，超過上限 client 就暫停從連線拉取，等 application ack 釋放額度才繼續。這讓 consumer 能把消費速率對齊到下游能吃的速率，而不是被 broker 灌爆。</p>
<blockquote>
<p>Push vs pull 不是實作偏好，是「下游能否接受 push 衝擊」的判讀。Mercari 把外部行銷 webhook（Braze）轉成 Pub/Sub event 後，下游 worker 刻意用 pull subscription 精確控制每秒處理訊息數，因為下游要呼叫的外部 LINE API 有 RPS 限制 — push 會把瞬間流量直接打到受限的外部 API（<a href="/blog/backend/03-message-queue/cases/pubsub-mercari-line-flow-control/" data-link-title="3.C65 Mercari LINE：Pull subscription 對齊外部 RPS" data-link-desc="Mercari LINE webhook 轉 Pub/Sub、worker pull subscription 精確控制 RPS、應 LINE API 限制。">3.C65</a>）。下游有硬性 RPS 上限時，pull + flow control 是讓消費速率可控的手段。</p></blockquote>
<h2 id="ordering-key有序的代價是吞吐">Ordering Key：有序的代價是吞吐</h2>
<p>Ordering key 讓「帶同一個 ordering key 的訊息，在 subscription 端按 publish 順序投遞」。它把全域無序的 Pub/Sub 變成 per-key 有序 — 不同 key 之間仍可並行、亂序，只有同 key 內部保證順序。要生效需要兩端配合：subscription 建立時開 <code>--enable-message-ordering</code>，publish 時帶 <code>--ordering-key</code>。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># subscription 端開啟 ordering</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">gcloud pubsub subscriptions create ord-sub <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --topic<span class="o">=</span>ord-topic <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --enable-message-ordering
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># describe 可見 enableMessageOrdering: true</span>
</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"><span class="c1"># publish 端帶 ordering key（同一 key 的訊息會保序）</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">gcloud pubsub topics publish ord-topic --message<span class="o">=</span>m1 --ordering-key<span class="o">=</span>user-123
</span></span><span class="line"><span class="ln">9</span><span class="cl">gcloud pubsub topics publish ord-topic --message<span class="o">=</span>m2 --ordering-key<span class="o">=</span>user-123</span></span></code></pre></div><p>Ordering key 的設計責任在於選對 key 的粒度。粒度太粗（例如所有訊息共用一個 key）會把整條 topic 退化成單線序列、吞吐崩塌；粒度太細（例如每則訊息一個 key）等於沒開 ordering。正確做法是按「需要保序的業務實體」選 key — 同一個 <code>user-123</code> 的事件要保序、不同 user 之間不需要 — 這樣並行度等於活躍 key 數，既保序又不犧牲整體吞吐。</p>
<p>跟 Kafka 對照能看清取捨。Kafka 用 partition + 同 key hash 到同 partition 達成保序，partition 數是固定預先規劃的並行上限；Pub/Sub 沒有顯式 partition，ordering key 的並行度是動態的、由活躍 key 數決定。代價是 Pub/Sub 的有序投遞要求同 key 訊息送到同一個內部處理單元，這個約束讓單一 ordering key 的吞吐有上限（官方標稱單 ordering key 約 1 MB/s）。</p>
<blockquote>
<p>Ordering 跟 DLT 在 production 會耦合：有序流裡若一則訊息反覆失敗、Pub/Sub 為維持順序不會跳過它去投後面的訊息，整把 key 的後續訊息全卡住，直到該訊息 ack 或送進 DLT。沒開 ordering 時 poison message 只卡自己；開了 ordering 後它卡住整條 key 序列。這是下一節 DLT 要解的問題在 ordering 場景下被放大的原因。</p></blockquote>
<h2 id="dead-letter-topic投遞次數上限決定隔離時機">Dead-Letter Topic：投遞次數上限決定隔離時機</h2>
<p>Dead-letter topic 是 <a href="/blog/backend/knowledge-cards/poison-message-quarantine/" data-link-title="Poison-Message Quarantine" data-link-desc="說明把毒訊息從主處理路徑隔離出來的機制，讓正常訊息繼續前進">poison-message quarantine</a> 在 Pub/Sub 的實作：subscription 對每則訊息計數投遞次數，超過 <code>max-delivery-attempts</code> 就把訊息轉發到另一個 topic（DLT），主 subscription 不再重投它，後續正常訊息得以前進。</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">gcloud pubsub topics create main-topic
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">gcloud pubsub topics create dl-topic
</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">gcloud pubsub subscriptions create main-sub <span class="se">\
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="se"></span>  --topic<span class="o">=</span>main-topic <span class="se">\
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="se"></span>  --dead-letter-topic<span class="o">=</span>dl-topic <span class="se">\
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="se"></span>  --max-delivery-attempts<span class="o">=</span><span class="m">5</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"># deadLetterPolicy:</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1">#   deadLetterTopic: projects/&lt;proj&gt;/topics/dl-topic</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1">#   maxDeliveryAttempts: 5</span></span></span></code></pre></div><p>DLT 是 topic 不是 queue，這是 Pub/Sub 跟 SQS DLQ 的關鍵差異。SQS 的 DLQ 是另一個 queue、消費者直接 receive；Pub/Sub 的 DLT 是 topic，要再掛一個 subscription 才能讀。好處是 DLT 上可以同時掛多個 subscription — 一個給人工檢視、一個給自動 replay、一個給長期歸檔 — fan-out 內建。代價是多一層 subscription 配置，且 DLT 也有自己的 retention（同樣預設 7 天，poison message 要在這之內處理掉）。</p>
<p><code>max-delivery-attempts</code> 設定的是「容忍多少次暫時性失敗」與「多快放棄」之間的平衡。設太低（例如 1-2 次），下游短暫抖動就把訊息丟進 DLT、誤殺可恢復的訊息；設太高（例如 50 次），一則真正壞掉的訊息會反覆重試半天、占用 consumer 資源、在有序流裡還會長時間卡住整條 key。官方允許範圍 5-100，常見起點是 5。</p>
<p>搭配 retry policy 的 backoff 能讓重投不至於太密集：</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">gcloud pubsub subscriptions create retry-sub <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --topic<span class="o">=</span>main-topic <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --min-retry-delay<span class="o">=</span>10s <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --max-retry-delay<span class="o">=</span>600s
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># retryPolicy:</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1">#   minimumBackoff: 10s</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1">#   maximumBackoff: 600s</span></span></span></code></pre></div><blockquote>
<p>啟用 DLT 需要把 Pub/Sub service account 授權對主 subscription 有 subscriber、對 DLT 有 publisher（emulator 不校驗 IAM，正式環境若漏授權，訊息超過 max attempts 後不會進 DLT、而是繼續留在主 subscription 重投，看起來像 DLT 沒生效）。授權細節依 GCP 官方 IAM 文件。</p></blockquote>
<p>Mercari 的商品 feed 同步示範了 DLT 的標準用法：pull subscription + 自家 batch requester、成功 ack 整批、失敗 nack 讓 Pub/Sub 重送、重試多次仍失敗送 DLT、後續訊息優先處理；同一個 topic 還兼當突發流量的 load-leveling buffer（<a href="/blog/backend/03-message-queue/cases/pubsub-mercari-item-feed-dlt/" data-link-title="3.C64 Mercari Item Feed：DLT 防 poison message 阻塞" data-link-desc="Mercari 商品 feed 同步、ack 整批 / nack 重送、重試多次仍失敗送 DLT、topic 同時當 load-leveling buffer。">3.C64</a>）。</p>
<h2 id="schema-enforcement投遞前的契約守門">Schema Enforcement：投遞前的契約守門</h2>
<p>Schema enforcement 把 <a href="/blog/backend/knowledge-cards/event-schema-compatibility/" data-link-title="Event Schema Compatibility" data-link-desc="說明 event schema 演進時，新舊 producer 與 consumer 能否互通的相容性等級">event schema compatibility</a> 從「應用層約定」提升到「broker 強制」。topic 綁定一個 Avro 或 Protobuf schema 後，不符 schema 的 publish 在進 topic 前就被拒絕 — 訊息根本不會被儲存、不會投遞、不會進 DLT。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 1. 建 schema（Avro，一個必填 string 欄位 id）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">gcloud pubsub schemas create order-schema <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --type<span class="o">=</span>avro <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --definition<span class="o">=</span><span class="s1">&#39;{&#34;type&#34;:&#34;record&#34;,&#34;name&#34;:&#34;Order&#34;,&#34;fields&#34;:[{&#34;name&#34;:&#34;id&#34;,&#34;type&#34;:&#34;string&#34;}]}&#39;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"># 2. topic 綁 schema + 指定 message encoding</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">gcloud pubsub topics create sch-topic <span class="se">\
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="se"></span>  --schema<span class="o">=</span>order-schema <span class="se">\
</span></span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="se"></span>  --message-encoding<span class="o">=</span>json</span></span></code></pre></div><p>綁定後的 publish 行為（emulator 實機驗證 enforce）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 符合 schema：通過</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">gcloud pubsub topics publish sch-topic --message<span class="o">=</span><span class="s1">&#39;{&#34;id&#34;:&#34;abc&#34;}&#39;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"># messageIds: [&#39;4&#39;]</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"># 欄位不符 schema：被拒</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">gcloud pubsub topics publish sch-topic --message<span class="o">=</span><span class="s1">&#39;{&#34;wrong&#34;:123}&#39;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"># ERROR: INVALID_ARGUMENT: Could not parse message</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="c1"># 非 JSON 垃圾：被拒</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">gcloud pubsub topics publish sch-topic --message<span class="o">=</span><span class="s1">&#39;not-json&#39;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"># ERROR: INVALID_ARGUMENT: Could not parse message</span></span></span></code></pre></div><p>schema 守門的價值在於把契約破壞擋在 producer 端、而不是 consumer 端。沒有 schema enforcement 時，producer 改了 payload 結構、不相容的訊息照樣進 topic、要到 consumer 解析失敗才爆 — 此時訊息已經在系統裡流動、可能已 fan-out 到多個 subscription、修復成本高。有 schema enforcement 時，不相容的 publish 在源頭就失敗，問題暴露在「誰送了壞訊息」而不是「誰收到壞訊息」。</p>
<p>schema evolution 要在「擋住破壞性改版」與「不阻塞合理演進」之間取捨。新增可選欄位或帶預設值的欄位維持相容、可以平滑演進；新增必填欄位、刪欄位、改型別是破壞性改版，會讓既有 producer 或 consumer 失效。設計上先定相容性等級（backward / forward / full）再演進，刪欄位分兩步（先停用再移除），避免一次破壞性改版打掛下游。</p>
<p>跟 Kafka Schema Registry 對照：Kafka 的 schema 校驗在 client 端（producer / consumer 各自向 Registry 查 schema、序列化時校驗），broker 本身不認識 schema；Pub/Sub 的 schema 綁在 topic、校驗在 broker 端 publish 路徑上。前者校驗點分散、靈活但要求所有 client 守規矩；後者校驗點集中在 broker、強制但耦合到 topic 配置。</p>
<h2 id="五個-production-故障演練">五個 Production 故障演練</h2>
<p>deep article 的差異化價值在故障演練。以下五個徵兆對應前述三道治理在 production 的典型失效。</p>
<h3 id="演練一ordering-key-把吞吐限到單線">演練一：Ordering key 把吞吐限到單線</h3>
<p><strong>徵兆</strong>：開了 ordering 後整條 topic 的吞吐從數萬 msg/s 掉到數百 msg/s，subscription backlog（<code>num_undelivered_messages</code>）持續攀升、<code>oldest_unacked_message_age</code> 越拉越長，但 consumer CPU 並不滿載 — consumer 在等訊息、不是在忙。</p>
<p><strong>根因</strong>：ordering key 粒度太粗。最常見是「所有訊息共用同一個 ordering key」（例如固定字串、或單一租戶 ID），整條 topic 退化成單一有序序列，並行度等於 1。單一 ordering key 的吞吐有上限（官方標稱約 1 MB/s），所有訊息擠進一個 key 就被這個上限封頂。</p>
<p><strong>判讀與修法</strong>：</p>
<ol>
<li>確認 ordering key 的基數（cardinality）。<code>gcloud pubsub topics publish</code> 帶的 <code>--ordering-key</code> 在 production 是業務欄位映射來的 — 檢查映射邏輯是否塌縮成低基數。</li>
<li>把 key 粒度對齊到「真正需要保序的業務實體」：同一筆訂單 / 同一個 user / 同一個 device 內要保序，跨實體不需要。粒度從「全域一個 key」改成「per-user 一個 key」，並行度從 1 拉到活躍 user 數。</li>
<li>評估是否真的需要 ordering。多數 pipeline 靠 consumer 端 idempotency + 版本號就能容忍亂序，不需要 broker 層保序 — 把保序成本從吞吐換成 consumer 設計（見 <a href="/blog/backend/03-message-queue/event-contract-replay-boundary/" data-link-title="3.7 Event Contract 與 Replay Boundary" data-link-desc="說明 event schema、idempotency key、replay window 與補償如何先於 broker 選型。">3.7 event contract</a> 的 idempotency key 段）。</li>
</ol>
<h3 id="演練二ack-deadline-太短導致重複投遞">演練二：Ack deadline 太短導致重複投遞</h3>
<p><strong>徵兆</strong>：consumer 處理邏輯正確、下游也成功，但同一則訊息被處理多次；<code>DELIVERY_ATTEMPT</code> 計數異常偏高、下游出現重複副作用（重複扣款 / 重複發信）。Backlog 不一定高，但「處理量」遠大於「publish 量」。</p>
<p><strong>根因</strong>：ackDeadline 比實際處理時間短。預設 10 秒對「呼叫一個慢的外部 API」「處理大 payload」這類任務不夠，訊息在 application 還沒 ack 前就過了 deadline、被 Pub/Sub 重投，於是同一則訊息有多個 consumer 副本在跑。若 client library 的自動 lease extension 沒生效（例如 application 阻塞在同步呼叫、background lease thread 餓死），重投更嚴重。</p>
<p><strong>判讀與修法</strong>：</p>
<ol>
<li>量測 p99 處理時間，把 ackDeadline 設到 p99 之上留 buffer，但不要不加判斷地設到 600 秒上限 — deadline 越長，consumer crash 後訊息重投的延遲越長。</li>
<li>長任務靠 lease extension 而非長 ackDeadline：確認 client library 的自動續約有在跑，application code 不要在處理迴圈裡阻塞到讓 background 續約 thread 餓死。</li>
<li>consumer 端做 idempotency：用 message 的 dedup key（<a href="/blog/backend/03-message-queue/event-contract-replay-boundary/" data-link-title="3.7 Event Contract 與 Replay Boundary" data-link-desc="說明 event schema、idempotency key、replay window 與補償如何先於 broker 選型。">3.7</a>）讓重複投遞變成無害 — at-least-once 交付下重複是常態，不靠調 ackDeadline 消除、靠 consumer 設計吸收。</li>
</ol>
<h3 id="演練三dlt-max-delivery-attempts-設定誤判">演練三：DLT max delivery attempts 設定誤判</h3>
<p><strong>徵兆</strong>：兩種反向徵兆。其一，DLT 堆滿了「其實能恢復」的訊息 — 下游一抖動就被丟進 DLT，DLT backlog 暴增、人工 replay 不完。其二，主 subscription 卡著一則壞訊息反覆重投半天都不進 DLT、後面訊息（尤其在 ordering 流裡）全堵住。</p>
<p><strong>根因</strong>：第一種是 <code>max-delivery-attempts</code> 設太低（1-2 次），暫時性失敗就被當成 poison。第二種是設太高（數十次）或根本沒設 DLT，真正的 poison message 反覆重試、占資源、卡序列。</p>
<p><strong>判讀與修法</strong>：</p>
<ol>
<li>區分「暫時性失敗」與「結構性失敗」。暫時性（下游超時、限流）需要重試容忍度，結構性（payload 解析不了、業務規則永久拒絕）越早隔離越好。</li>
<li><code>max-delivery-attempts</code> 起點設 5，搭配 retry policy backoff（<code>--min-retry-delay</code> / <code>--max-retry-delay</code>）讓重試之間有間隔、給下游恢復時間，而不是密集重打。</li>
<li>確認 DLT 真的接得到訊息：檢查 Pub/Sub service account 對 DLT 的 publisher 授權（漏授權會讓訊息超過 attempts 後繼續留在主 subscription、看起來像沒進 DLT）。</li>
<li>DLT 要掛 subscription 才讀得到 — DLT 是 topic 不是 queue，建完 DLT 還要建 DLT 的 subscription 並設好 retention，否則 poison message 在 DLT 裡放滿 7 天後一樣丟失。</li>
</ol>
<h3 id="演練四push-endpoint-500-觸發-retry-storm">演練四：Push endpoint 500 觸發 retry storm</h3>
<p><strong>徵兆</strong>：push subscription 的下游 HTTP endpoint 開始大量回 500，Pub/Sub backoff 重投、但 endpoint 仍 500，重投量隨 backlog 累積越滾越大；endpoint 一旦短暫恢復就被積壓的重投流量瞬間打回 500，形成「恢復即再掛」的震盪。</p>
<p><strong>根因</strong>：push 的 flow control 由 Pub/Sub 掌握、按 ack 動態調速 — endpoint 回 2xx 視為 ack、非 2xx 視為 nack 並重投。當 endpoint 因下游依賴（DB / 外部 API）掛掉而持續 500，Pub/Sub 的 backoff 重投跟累積的 backlog 疊加，恢復瞬間的流量遠超 endpoint 平時負載。這正是「下游能否接受 push 衝擊」的反面 — push 沒有 consumer 端的 flow control 閥門。</p>
<p><strong>判讀與修法</strong>：</p>
<ol>
<li>先判訊息毒性 vs endpoint 健康。若是 endpoint 整體掛（所有訊息都 500），是容量 / 依賴問題；若是特定訊息 500（多數成功、少數失敗），是 poison message，該走 DLT。</li>
<li>endpoint 整體掛的場景，push 不是好選擇 — 改 pull + flow control，讓 consumer 用 <code>max_outstanding_messages</code> 把消費速率對齊到下游能吃的速率，避免恢復瞬間被積壓流量打垮（對照 <a href="/blog/backend/03-message-queue/cases/pubsub-mercari-line-flow-control/" data-link-title="3.C65 Mercari LINE：Pull subscription 對齊外部 RPS" data-link-desc="Mercari LINE webhook 轉 Pub/Sub、worker pull subscription 精確控制 RPS、應 LINE API 限制。">3.C65</a> 的下游 RPS 限制場景）。</li>
<li>對 push 配 DLT，把反覆 500 的特定訊息隔離出去，避免單一 poison message 混在正常流量裡放大 retry。</li>
<li>endpoint 側對「Pub/Sub 重投」做 idempotency，因為 push 也是 at-least-once、500 後的重投會帶來重複。</li>
</ol>
<h3 id="演練五schema-enforcement-擋下不相容-publish">演練五：Schema enforcement 擋下不相容 publish</h3>
<p><strong>徵兆</strong>：某次 producer 部署後，該 service 的 publish 開始大量回 <code>INVALID_ARGUMENT: Could not parse message</code>，訊息發不出去；但 consumer 端風平浪靜、沒有任何解析錯誤、backlog 也沒異常。</p>
<p><strong>根因</strong>：這通常不是故障、是 schema enforcement 正常運作。producer 改了 payload 結構（加必填欄位 / 改型別 / 漏欄位），新 payload 不符 topic 綁定的 schema，broker 在 publish 路徑上擋下、訊息根本沒進 topic。徵兆出現在 producer 端（publish 失敗）而非 consumer 端（解析失敗），正是 schema 守門把問題前移到源頭的設計意圖。</p>
<p><strong>判讀與修法</strong>：</p>
<ol>
<li>先確認是「該擋」還是「誤擋」。對照 producer 的新 payload 與 topic schema：若是破壞性改版（加必填欄位 / 改型別），enforcement 擋對了 — 該回滾 producer 或先演進 schema。</li>
<li>用 <code>gcloud pubsub schemas validate-message</code> 在部署前 dry-run 校驗 payload 對 schema，把「不相容」暴露在 CI 而不是 production publish。</li>
<li>schema 演進走相容路徑：新增欄位帶預設或設可選、刪欄位分兩步、避免一次破壞性改版。先升 schema 再升 producer，順序反了就會出現這個徵兆。</li>
<li>區分 schema enforcement 失敗與 DLT：schema 擋下的訊息不進 topic、不進 DLT（DLT 隔離的是「進了 topic 但消費反覆失敗」的訊息）。兩者是交付管線的不同關卡，徵兆與修法都不同。</li>
</ol>
<h2 id="容量與選型邊界標準版-vs-pubsub-lite">容量與選型邊界：標準版 vs Pub/Sub Lite</h2>
<p>前述配置適用標準版 Pub/Sub。標準版的計費與容量模型偏向「全域路由內建、按用量計費、不需預先規劃容量」；當吞吐極高且 region 確定時，Pub/Sub Lite 的 partition-based / zonal 模型成本更低。</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>標準版 Pub/Sub</th>
          <th>Pub/Sub Lite</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>路由</td>
          <td>全域、無 region 概念</td>
          <td>zonal / regional、需指定</td>
      </tr>
      <tr>
          <td>容量模型</td>
          <td>自動擴縮、按用量計費</td>
          <td>partition-based、預先 provision throughput</td>
      </tr>
      <tr>
          <td>成本</td>
          <td>高吞吐時單位成本較高</td>
          <td>高吞吐 + 確定 region 時顯著較低</td>
      </tr>
      <tr>
          <td>CLI surface</td>
          <td><code>gcloud pubsub topics</code></td>
          <td><code>gcloud pubsub lite-topics</code>（獨立）</td>
      </tr>
      <tr>
          <td>適用</td>
          <td>全域分發、彈性流量、不想管容量</td>
          <td>已知高且穩定的吞吐、成本敏感、region 確定</td>
      </tr>
  </tbody>
</table>
<p>Pub/Sub Lite 是獨立的 CLI surface（<code>gcloud pubsub lite-topics</code> / <code>gcloud pubsub lite-subscriptions</code>），不是標準版的一個 flag。選 Lite 的代價是要自己 provision partition 數與 throughput capacity（回到接近 Kafka 的容量規劃），換來的是高吞吐穩定流量下顯著更低的成本。判準是吞吐「夠高且夠穩定到值得自己管容量」— 流量彈性大、或不想管 partition 的場景仍該留在標準版。</p>
<blockquote>
<p>Spotify 的 autoscaling 案例揭露 backlog 不等於 consumer healthy：下游 export 失敗時 consumer 不 ack 仍持續耗 CPU，autoscaling 把 CPU 越拉越高、反而擴出更多空轉 consumer；解法是 exponential backoff 抑制 CPU 消耗（<a href="/blog/backend/03-message-queue/cases/pubsub-spotify-autoscaling-consumers/" data-link-title="3.C61 Spotify：Autoscaling Pub/Sub consumer 反效果" data-link-desc="Spotify 下游失敗時 consumer 不 ack 仍耗 CPU、autoscaling 越拉越高、解法是 exponential backoff 抑制 CPU。">3.C61</a>）。容量規劃的 autoscale signal 要看「處理成功率」而非「CPU + backlog」，否則擴縮方向會反。</p></blockquote>
<h2 id="整合與下一步">整合與下一步</h2>
<h3 id="bigquery--cloud-storage-subscription免-consumer-的落地路徑">BigQuery / Cloud Storage subscription：免 consumer 的落地路徑</h3>
<p>標準版提供兩種「不需要自寫 consumer」的 subscription，直接把訊息落地到分析 / 儲存層：</p>
<ul>
<li><strong>BigQuery subscription</strong>（<code>--bigquery-table</code>）：訊息直接寫進 BQ table，免 Dataflow 中介，適合 streaming analytics。可搭配 <code>--use-topic-schema</code> 讓 BQ table schema 對齊 topic schema — schema enforcement 在這裡延伸成「落地結構也受契約約束」。</li>
<li><strong>Cloud Storage subscription</strong>（<code>--cloud-storage-bucket</code>）：訊息批次寫成 GCS object，適合 data lake / 歸檔。</li>
</ul>
<p>這兩種 subscription 把「event 流 → 分析 / 儲存」的常見管線收進 Pub/Sub 配置，省掉一層自管 consumer。它們仍受同一套 ackDeadline / DLT 骨架管轄。</p>
<h3 id="cross-link">Cross-link</h3>
<ul>
<li>上游 vendor 頁：<a href="/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Google Cloud Pub/Sub overview</a> — 選型層、跟 Kafka / SQS 取捨</li>
<li>契約與重播邊界：<a href="/blog/backend/03-message-queue/event-contract-replay-boundary/" data-link-title="3.7 Event Contract 與 Replay Boundary" data-link-desc="說明 event schema、idempotency key、replay window 與補償如何先於 broker 選型。">3.7 Event Contract 與 Replay Boundary</a> — schema / idempotency key / replay window 先於 broker 選型</li>
<li>知識卡：<a href="/blog/backend/knowledge-cards/event-schema-compatibility/" data-link-title="Event Schema Compatibility" data-link-desc="說明 event schema 演進時，新舊 producer 與 consumer 能否互通的相容性等級">Event Schema Compatibility</a>（schema enforcement 守的契約等級）、<a href="/blog/backend/knowledge-cards/poison-message-quarantine/" data-link-title="Poison-Message Quarantine" data-link-desc="說明把毒訊息從主處理路徑隔離出來的機制，讓正常訊息繼續前進">Poison-Message Quarantine</a>（DLT 的隔離機制）</li>
<li>對應 case：<a href="/blog/backend/03-message-queue/cases/pubsub-mercari-item-feed-dlt/" data-link-title="3.C64 Mercari Item Feed：DLT 防 poison message 阻塞" data-link-desc="Mercari 商品 feed 同步、ack 整批 / nack 重送、重試多次仍失敗送 DLT、topic 同時當 load-leveling buffer。">3.C64 Mercari Item Feed DLT</a>、<a href="/blog/backend/03-message-queue/cases/pubsub-mercari-line-flow-control/" data-link-title="3.C65 Mercari LINE：Pull subscription 對齊外部 RPS" data-link-desc="Mercari LINE webhook 轉 Pub/Sub、worker pull subscription 精確控制 RPS、應 LINE API 限制。">3.C65 Mercari LINE flow control</a>、<a href="/blog/backend/03-message-queue/cases/pubsub-spotify-autoscaling-consumers/" data-link-title="3.C61 Spotify：Autoscaling Pub/Sub consumer 反效果" data-link-desc="Spotify 下游失敗時 consumer 不 ack 仍耗 CPU、autoscaling 越拉越高、解法是 exponential backoff 抑制 CPU。">3.C61 Spotify autoscaling</a>、<a href="/blog/backend/03-message-queue/cases/pubsub-mercari-actionable-history/" data-link-title="3.C63 Mercari Actionable History：ack deadline 是 batch-level" data-link-desc="Merpay 支付流水帳用 Pub/Sub、ack deadline 是整批 batch 而非單訊息、acked 訊息會跟同批 expired 一起 redeliver。">3.C63 Mercari actionable history</a></li>
<li>方法論：<a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章的寫作方法論</a></li>
</ul>
<h3 id="何時-revisit">何時 revisit</h3>
<ul>
<li>ordering key 吞吐撞上單 key 上限、且無法再細分 key：評估改用 Kafka partition 模型，或把保序成本移到 consumer 端 idempotency</li>
<li>高吞吐穩定流量 + 成本壓力浮現：評估標準版 → Pub/Sub Lite，接受自管 partition 容量換成本</li>
<li>schema 需要跨多 vendor 共用契約（同一份 event 同時進 Pub/Sub 與 Kafka）：評估把 schema source of truth 抽到 broker 外的 registry</li>
</ul>
]]></content:encoded></item><item><title>RabbitMQ → Kafka：從『處理即承諾』到『寫入即承諾 + 可 replay』的 paradigm shift</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/rabbitmq/migrate-to-kafka/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/rabbitmq/migrate-to-kafka/</guid><description>&lt;blockquote>
&lt;p>本文是跨 vendor migration playbook、cross-link 到 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/rabbitmq/" data-link-title="RabbitMQ" data-link-desc="Classic message broker、AMQP routing 為主">RabbitMQ&lt;/a> 跟 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka&lt;/a>。跟同類產品的 drop-in 或 operational 遷移不同、本篇是 &lt;em>paradigm shift&lt;/em> — 兩端不是「同類 broker 的不同實作」、是 &lt;em>不同責任模型的 messaging system&lt;/em>：RabbitMQ 是「處理即承諾」的 work queue、Kafka 是「寫入即承諾、可長期 replay」的 event log。&lt;/p>&lt;/blockquote>
&lt;h2 id="rabbitmq--kafka-不是把-queue-換成-topic">RabbitMQ → Kafka 不是把 queue 換成 topic&lt;/h2>
&lt;p>RabbitMQ 跟 Kafka 都被歸在「message queue」這個傘狀詞下、但兩者承擔的責任不同。RabbitMQ 的可靠性建立在 &lt;em>consumer 處理完才 ack、未 ack 的訊息 broker 重新投遞&lt;/em>；訊息一旦被成功消費就從 queue 移除、broker 是「任務分派 + 重試」的中介。Kafka 的可靠性建立在 &lt;em>訊息寫進 partition log 就持久化、consumer 各自維護 offset&lt;/em>；訊息在 retention 期內一直留著、broker 是「事件儲存 + 多方各自讀取」的 log。&lt;/p>
&lt;p>把 RabbitMQ「migration」成 Kafka 的字面理解通常是：queue 對 topic、exchange 對 producer key、consumer 對 consumer group。這個對映在 transport 層成立、在責任層不成立。RabbitMQ 一個 message 被 ack 後就消失、Kafka 一個 message 寫進 log 後對所有 consumer group 都還在；RabbitMQ 的 routing 由 broker 端 exchange + binding 決定、Kafka 的「routing」由 producer 端 partition key 決定、broker 不做內容路由。先確認這層差異、再決定哪些 workload 值得遷。&lt;/p>
&lt;h2 id="6-維-diff-dimension-audit">6 維 diff dimension audit&lt;/h2>
&lt;p>跨 vendor 遷移前先盤點 source 跟 target 在六個維度的落差、用最大落差維度決定 playbook 結構、而不是反過來套既有模板。RabbitMQ → Kafka 的 audit 結果：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>維度&lt;/th>
 &lt;th>落差&lt;/th>
 &lt;th>說明&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Schema / API&lt;/td>
 &lt;td>中&lt;/td>
 &lt;td>AMQP client → Kafka client、wire protocol 全換、但都是 publish / consume 心智模型&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Operational model&lt;/td>
 &lt;td>中&lt;/td>
 &lt;td>單 broker + management UI → multi-broker + KRaft / Schema Registry / Connect、運維資產變重&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Abstraction/paradigm&lt;/td>
 &lt;td>&lt;strong>高&lt;/strong>&lt;/td>
 &lt;td>work queue「處理即承諾、ack 後即刪」→ event log「寫入即承諾、offset replay」、責任模型整個不同&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Number of components&lt;/td>
 &lt;td>低&lt;/td>
 &lt;td>兩端都是單一 messaging system、不是一站式拆多工具&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Application change&lt;/td>
 &lt;td>&lt;strong>高&lt;/strong>&lt;/td>
 &lt;td>consumer 要重設計（ack → offset commit）、producer 要重設計（exchange routing → partition key）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Data topology&lt;/td>
 &lt;td>&lt;strong>高&lt;/strong>&lt;/td>
 &lt;td>exchange + queue + binding 的 routing 拓樸 → topic + partition + key 的 log 拓樸、資料分佈邏輯不同&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>三個維度 High：paradigm、application change、data topology。其中 paradigm 是主導維度 —— application change 跟 data topology 的落差都是 paradigm 落差的下游結果。consumer 要重寫，是因為「ack 後即刪」變成「offset 不刪」；資料拓樸要重劃，是因為「broker 路由到 queue」變成「producer 決定 partition」。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是跨 vendor migration playbook、cross-link 到 <a href="/blog/backend/03-message-queue/vendors/rabbitmq/" data-link-title="RabbitMQ" data-link-desc="Classic message broker、AMQP routing 為主">RabbitMQ</a> 跟 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka</a>。跟同類產品的 drop-in 或 operational 遷移不同、本篇是 <em>paradigm shift</em> — 兩端不是「同類 broker 的不同實作」、是 <em>不同責任模型的 messaging system</em>：RabbitMQ 是「處理即承諾」的 work queue、Kafka 是「寫入即承諾、可長期 replay」的 event log。</p></blockquote>
<h2 id="rabbitmq--kafka-不是把-queue-換成-topic">RabbitMQ → Kafka 不是把 queue 換成 topic</h2>
<p>RabbitMQ 跟 Kafka 都被歸在「message queue」這個傘狀詞下、但兩者承擔的責任不同。RabbitMQ 的可靠性建立在 <em>consumer 處理完才 ack、未 ack 的訊息 broker 重新投遞</em>；訊息一旦被成功消費就從 queue 移除、broker 是「任務分派 + 重試」的中介。Kafka 的可靠性建立在 <em>訊息寫進 partition log 就持久化、consumer 各自維護 offset</em>；訊息在 retention 期內一直留著、broker 是「事件儲存 + 多方各自讀取」的 log。</p>
<p>把 RabbitMQ「migration」成 Kafka 的字面理解通常是：queue 對 topic、exchange 對 producer key、consumer 對 consumer group。這個對映在 transport 層成立、在責任層不成立。RabbitMQ 一個 message 被 ack 後就消失、Kafka 一個 message 寫進 log 後對所有 consumer group 都還在；RabbitMQ 的 routing 由 broker 端 exchange + binding 決定、Kafka 的「routing」由 producer 端 partition key 決定、broker 不做內容路由。先確認這層差異、再決定哪些 workload 值得遷。</p>
<h2 id="6-維-diff-dimension-audit">6 維 diff dimension audit</h2>
<p>跨 vendor 遷移前先盤點 source 跟 target 在六個維度的落差、用最大落差維度決定 playbook 結構、而不是反過來套既有模板。RabbitMQ → Kafka 的 audit 結果：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>落差</th>
          <th>說明</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Schema / API</td>
          <td>中</td>
          <td>AMQP client → Kafka client、wire protocol 全換、但都是 publish / consume 心智模型</td>
      </tr>
      <tr>
          <td>Operational model</td>
          <td>中</td>
          <td>單 broker + management UI → multi-broker + KRaft / Schema Registry / Connect、運維資產變重</td>
      </tr>
      <tr>
          <td>Abstraction/paradigm</td>
          <td><strong>高</strong></td>
          <td>work queue「處理即承諾、ack 後即刪」→ event log「寫入即承諾、offset replay」、責任模型整個不同</td>
      </tr>
      <tr>
          <td>Number of components</td>
          <td>低</td>
          <td>兩端都是單一 messaging system、不是一站式拆多工具</td>
      </tr>
      <tr>
          <td>Application change</td>
          <td><strong>高</strong></td>
          <td>consumer 要重設計（ack → offset commit）、producer 要重設計（exchange routing → partition key）</td>
      </tr>
      <tr>
          <td>Data topology</td>
          <td><strong>高</strong></td>
          <td>exchange + queue + binding 的 routing 拓樸 → topic + partition + key 的 log 拓樸、資料分佈邏輯不同</td>
      </tr>
  </tbody>
</table>
<p>三個維度 High：paradigm、application change、data topology。其中 paradigm 是主導維度 —— application change 跟 data topology 的落差都是 paradigm 落差的下游結果。consumer 要重寫，是因為「ack 後即刪」變成「offset 不刪」；資料拓樸要重劃，是因為「broker 路由到 queue」變成「producer 決定 partition」。</p>
<p>主導維度是 paradigm、對映 <em>Type E paradigm shift</em> 結構：先講「字面 migration 不成立」、再講適配度（什麼能遷什麼不能）、再講 application 重設計與部分 cutover、最後是長期混合架構。application change 跟 data topology 這兩個高維度不另起 playbook、而是落在 application 重設計段與故障演練段裡展開。</p>
<h3 id="為什麼-paradigm-是主導不是-application-change">為什麼 paradigm 是主導、不是 application change</h3>
<p>application change 看起來工作量最大（consumer / producer 都要改），直覺會把它當主導維度。但 application change 的方向跟難度是由 paradigm 決定的：如果只是 AMQP client 換 Kafka client、心智模型不變，那 application change 是機械式翻譯、屬於 Schema/API 維度。實際上 consumer 不只是換 SDK、是要把「處理完才 ack、失敗就 nack 重投」的設計改成「拉一批、處理、commit offset、失敗自己重試或寫 DLQ topic」—— 這是責任模型的改變，不是 API 的改變。所以主結構走 paradigm、application change 是它的展開。</p>
<h2 id="什麼-workload-真該遷什麼不該">什麼 workload 真該遷、什麼不該</h2>
<table>
  <thead>
      <tr>
          <th>Application 模式</th>
          <th>RabbitMQ 適配</th>
          <th>Kafka 適配</th>
          <th>遷移可行性</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>任務分派（寄信 / 轉檔 / webhook）</td>
          <td>強</td>
          <td>中（overkill）</td>
          <td>不該遷（保留 RabbitMQ）</td>
      </tr>
      <tr>
          <td>複雜 routing（topic exchange + binding）</td>
          <td>強</td>
          <td>弱（broker 不做路由）</td>
          <td>不該遷或要重新設計拓樸</td>
      </tr>
      <tr>
          <td>RPC over messaging（request-reply）</td>
          <td>強</td>
          <td>弱（不適合）</td>
          <td>不該遷</td>
      </tr>
      <tr>
          <td>Event sourcing（多 consumer 各自 replay）</td>
          <td>弱（ack 即刪）</td>
          <td>強</td>
          <td>該遷（這是 Kafka 的主場）</td>
      </tr>
      <tr>
          <td>CDC / 跨系統事件總線</td>
          <td>弱</td>
          <td>強</td>
          <td>該遷</td>
      </tr>
      <tr>
          <td>高吞吐事件流 + 長期 retention</td>
          <td>弱</td>
          <td>強</td>
          <td>該遷</td>
      </tr>
      <tr>
          <td>同一事件要被多個獨立團隊各自消費</td>
          <td>中（多 queue）</td>
          <td>強（多 consumer group）</td>
          <td>該遷</td>
      </tr>
  </tbody>
</table>
<p>判讀的核心問題是：<em>這個 workload 需要的是「處理一次就完成的任務」、還是「被多方各自讀取、可回放的事件」</em>。</p>
<p>任務分派場景不該遷。寄信、轉檔、生成縮圖這類 workload 的本質是「有一個工人池、把任務做完就結束」、RabbitMQ 的 manual ack + prefetch + DLX 對這條路徑是貼合的設計。把它搬到 Kafka 會引入不需要的複雜度：partition 數要規劃、consumer group rebalance 要管、offset commit 時機要自己設計、而換來的 replay 能力在「任務做完就丟」的場景根本用不到。單純 work queue 不需要 Kafka 是這篇 playbook 最該先說清楚的判讀。</p>
<p>事件流場景該遷。當同一份事件要被 analytics pipeline、search index sync、audit log、下游微服務各自消費、而且各自進度不同、偶爾要回放過去 N 天重算 —— RabbitMQ 的「ack 後即刪」就會逼出「為每個 consumer 複製一份 queue」的反模式，這正是 Kafka 的 consumer group + retention 要解的問題。</p>
<p>複雜 routing 場景要重新設計、不是平移。RabbitMQ 的 topic exchange 用 <code>order.*.created</code> 這種 binding pattern 在 broker 端做內容路由、consumer 訂閱 binding 就收到符合的訊息。Kafka broker 不做內容路由，要嘛把路由邏輯前移到 producer（按內容決定寫哪個 topic / partition key），要嘛 consumer 端全收後自己 filter。直接平移會發現 Kafka 沒有 exchange 這個概念，routing 拓樸必須重新設計。</p>
<h2 id="為什麼會考慮這個-paradigm-shift">為什麼會考慮這個 paradigm shift</h2>
<p>實務上從 RabbitMQ 評估遷往 Kafka 通常由三條 driver 觸發：</p>
<ol>
<li><strong>同一事件要 fan-out 給愈來愈多 consumer</strong>：初期一個 queue 一個 worker、後來下游團隊一個個來要「也給我一份」。RabbitMQ 要嘛加 fanout exchange + 每團隊一個 queue、要嘛 consumer 互搶。Kafka 的 consumer group 天然支援「N 個獨立團隊各自從頭讀」、這是最常見的 driver。</li>
<li><strong>需要 replay 重算</strong>：下游邏輯出 bug、要重跑過去 7 天的事件修資料；RabbitMQ ack 後訊息已刪、無從回放。Kafka retention 期內可以從任意 offset 重讀。</li>
<li><strong>吞吐量壓到 RabbitMQ 的設計邊界</strong>：單 queue 的 throughput 受限於單一 queue 的處理模型、量大時要拆 queue 手動分流；Kafka 的 partition 並行是 first-class。</li>
</ol>
<p>這三條 driver 都指向 event streaming 的特性、不是「Kafka 普遍比較好」。任務隊列場景套不上這三條 driver、就不該被這個評估帶著走。</p>
<h2 id="migration-結構application-重設計--部分-cutover--長期混合">Migration 結構：application 重設計 + 部分 cutover + 長期混合</h2>
<p>RabbitMQ → Kafka 不是一次性 cutover，是按 workload 拆分、漸進遷移、長期共存：</p>
<ol>
<li><strong>Phase 0：workload 盤點</strong> — 把現有 queue / exchange 逐一分類「適合 Kafka（event 性質）」vs「保留 RabbitMQ（task 性質）」。盤點輸出是清單，不是「全遷」。</li>
<li><strong>Phase 1：application code 重設計</strong> — 對判定要遷的 workload，重寫 producer（exchange routing → topic + partition key）跟 consumer（manual ack → offset commit + 自管重試 / DLQ）。這是 paradigm 翻譯，不是 SDK 替換。</li>
<li><strong>Phase 2：dual-write 並行</strong> — producer 同時寫 RabbitMQ 跟 Kafka、新 consumer 從 Kafka shadow consume 驗證行為對齊、舊 consumer 持續從 RabbitMQ 消費。</li>
<li><strong>Phase 3：cutover 個別 workload</strong> — shadow 驗證通過後、把該 workload 的真正消費切到 Kafka、停掉 RabbitMQ 端的對應 consumer 與 dual-write。</li>
<li><strong>Phase 4：長期混合</strong> — task 性質的 workload 永遠留在 RabbitMQ、event 性質的在 Kafka。兩者共存是終態、不是過渡。</li>
</ol>
<p>整體不是「把 RabbitMQ 換成 Kafka」、是「把適合 event log 的部分搬到 Kafka、其餘留在 RabbitMQ」。多數環境的終態是兩者並存。</p>
<h2 id="application-重設計範例manual-ack--offset-commit">Application 重設計範例：manual ack → offset commit</h2>
<p>RabbitMQ consumer 的核心是 <em>每個 message 處理完顯式 ack、broker 才認定投遞成功</em>；失敗就 nack、broker 重投或進 DLX。Kafka consumer 沒有 per-message ack 的概念、是 <em>批次拉取、處理、commit offset</em>；commit 的是「讀到哪了」、不是「哪幾條成功了」。</p>





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




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





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">[task 分派：寄信 / 轉檔 / webhook]        [event log：CDC / 事件總線 / replay]
</span></span><span class="line"><span class="ln">2</span><span class="cl">         RabbitMQ                                    Kafka
</span></span><span class="line"><span class="ln">3</span><span class="cl">         │                                            │
</span></span><span class="line"><span class="ln">4</span><span class="cl">         └──────── Bridge（Connect source / 自寫）────┘</span></span></code></pre></div><p>RabbitMQ 跑「處理即承諾」的任務隊列、Kafka 跑「寫入即承諾」的事件流。需要從任務流產生事件記錄時、用 Kafka Connect 的 RabbitMQ source connector 或自寫 bridge 把選定的訊息搬到 Kafka topic。</p>
<h3 id="跟-outbox-pattern-對位">跟 outbox pattern 對位</h3>
<p>從 RabbitMQ 遷往 Kafka 常伴隨 <em>資料庫交易與事件發布一致性</em> 的需求 —— 因為 event sourcing 場景要求事件不能丟。直接在交易中寫 Kafka 有雙寫一致性問題、應該走 <a href="/blog/backend/03-message-queue/outbox-pattern/" data-link-title="3.3 outbox pattern 與發佈一致性" data-link-desc="把 transaction 與 event publish 分離">3.3 outbox pattern</a>：交易內只寫 outbox 表、再由 <a href="/blog/backend/01-database/vendors/postgresql/logical-replication-debezium/" data-link-title="PostgreSQL Logical Replication &#43; Debezium CDC：replication slot × failure × recovery 對照" data-link-desc="PostgreSQL logical replication slot 跟 Debezium CDC 的失效模式對照表：slot lag 撐爆 primary disk / schema change 斷流 / 初始 COPY 鎖表 / zombie slot 不釋放 / replay storm 後 offset reset；publication / subscription / pgoutput 配置、跟 Kafka outbox pattern 整合">Debezium CDC</a> 把 outbox 變更發到 Kafka topic。</p>
<h3 id="跟其他-migration-結構的對照">跟其他 migration 結構的對照</h3>
<table>
  <thead>
      <tr>
          <th>篇</th>
          <th>Schema 差</th>
          <th>Operational 差</th>
          <th>Paradigm 差</th>
          <th>結構</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Kafka ↔ NATS</td>
          <td>中</td>
          <td>中</td>
          <td><strong>高</strong></td>
          <td>partial + 混合</td>
      </tr>
      <tr>
          <td>RabbitMQ → Kafka（本篇）</td>
          <td>中</td>
          <td>中</td>
          <td><strong>高</strong></td>
          <td>partial + 混合</td>
      </tr>
  </tbody>
</table>
<p>兩篇都是 paradigm shift、都是 partial migration + 長期混合。差別在落差的方向：Kafka ↔ NATS 是 log vs subject messaging 的抽象層差異、RabbitMQ → Kafka 是 work queue vs event log 的責任模型差異 —— 後者的核心翻譯是「處理即承諾」如何重新表達成「寫入即承諾 + offset replay」。</p>
<h2 id="相關連結">相關連結</h2>
<ul>
<li>Source / target vendor：<a href="/blog/backend/03-message-queue/vendors/rabbitmq/" data-link-title="RabbitMQ" data-link-desc="Classic message broker、AMQP routing 為主">RabbitMQ</a> / <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka</a></li>
<li>平行 vendor：<a href="/blog/backend/03-message-queue/vendors/nats/" data-link-title="NATS" data-link-desc="Lightweight messaging、JetStream 加持久化與 streams">NATS</a> / <a href="/blog/backend/03-message-queue/vendors/redis-streams/" data-link-title="Redis Streams" data-link-desc="Redis 生態內的 streams、append-only log &#43; consumer group">Redis Streams</a></li>
<li>平行 migration playbook：<a href="/blog/backend/03-message-queue/vendors/kafka/migrate-from-to-nats/" data-link-title="Kafka ↔ NATS：不是 migration、是 messaging paradigm 重設計" data-link-desc="Kafka 跟 NATS 不是同類產品（log-based event streaming vs subject-based messaging）、&#39;migration&#39; 字面上不成立；本文釐清兩家 paradigm 邊界、什麼情境真的能換、application 模式重設計的 5 個踩雷（consumer offset 觀念差 / retention model / exactly-once 假設 / schema registry 缺位 / fan-out 模式差）、跟 JetStream 對位 &#43; 混合架構">Kafka ↔ NATS</a> / <a href="/blog/backend/03-message-queue/vendors/kafka/migrate-to-msk/" data-link-title="Self-managed Kafka → AWS MSK：把 $15K/month operational cost 拆解到 managed" data-link-desc="Kafka self-managed → MSK 是 Type C operational redesign — protocol 完全相容、operational stack（ZooKeeper / brokers / monitoring / patching）全託管；本文用 cost 拆解開頭、5 個 production 踩雷（client connection pattern / version pinning / metric pipeline / IAM auth / cross-cluster mirror）">Kafka → MSK</a></li>
<li>關鍵概念卡：<a href="/blog/backend/knowledge-cards/partition/" data-link-title="Partition" data-link-desc="說明事件流如何切分成多個可並行處理的有序片段">Partition</a> / <a href="/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">Topic</a> / <a href="/blog/backend/knowledge-cards/consumer-group/" data-link-title="Consumer Group" data-link-desc="說明一組 consumer 如何共同分攤 stream 或 topic 的處理責任">Consumer group</a> / <a href="/blog/backend/knowledge-cards/dead-letter-queue/" data-link-title="Dead-Letter Queue" data-link-desc="說明 dead-letter queue 如何隔離多次處理失敗的訊息">Dead-letter queue</a> / <a href="/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">Ack/nack</a></li>
<li>下游能力：<a href="/blog/backend/03-message-queue/outbox-pattern/" data-link-title="3.3 outbox pattern 與發佈一致性" data-link-desc="把 transaction 與 event publish 分離">3.3 outbox pattern</a> / <a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計</a> / <a href="/blog/backend/06-reliability/idempotency-replay/" data-link-title="6.12 Idempotency 與 Replay 驗證" data-link-desc="把重試、重播與冪等性從口頭約定變成可驗證屬性">6.12 idempotency / replay</a></li>
<li>Methodology：<a href="/blog/posts/migration-playbook-%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84stage-0-variant-%E8%A6%8F%E5%8A%83%E6%8A%8A-collapse-%E7%8E%87%E5%BE%9E-60-%E9%99%8D%E5%88%B0-0/" data-link-title="Migration Playbook 方法論的演化紀錄：Stage 0 variant 規劃把 collapse 率從 60% 降到 0%" data-link-desc="跨 vendor migration playbook 需要獨立寫作方法論的依據，以及這套方法論從三輪 batch dogfood 中演化出來的驗證證據。">Migration Playbook 寫作方法論</a></li>
</ul>
]]></content:encoded></item><item><title>RabbitMQ DLQ 與分層 retry：別把失敗訊息 requeue 回隊首</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/rabbitmq/dlq-retry-escalation/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/rabbitmq/dlq-retry-escalation/</guid><description>&lt;blockquote>
&lt;p>本文是 &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> overview 的 implementation-layer deep article。選型層（RabbitMQ vs Kafka / SQS、何時選 RabbitMQ）見 overview；本文只處理「決定用 RabbitMQ 後，失敗訊息怎麼 retry 才不會卡死隊列」。DLX 拓樸實機驗證於 rabbitmq:3-management、最後檢查日 2026-06-16；機制以 &lt;a href="https://www.rabbitmq.com/docs/dlx">RabbitMQ DLX 官方文件&lt;/a> 為準。&lt;/p>&lt;/blockquote>
&lt;h2 id="失敗訊息-requeue-回隊首會卡住整條隊列">失敗訊息 requeue 回隊首，會卡住整條隊列&lt;/h2>
&lt;p>消費一則訊息失敗了——下游 API 超時、資料還沒就緒、暫時性錯誤。最直覺的處理是 &lt;code>nack&lt;/code> 加 &lt;code>requeue=true&lt;/code>，讓它重新排隊再試一次。問題是 RabbitMQ 的 requeue 把訊息放回&lt;strong>原隊列的隊首&lt;/strong>，於是它立刻又被同一個 consumer 取出、再次失敗、再 requeue……在「下游還沒恢復」的那段時間裡，這則訊息反覆佔據隊首，後面所有正常訊息全被卡住。這就是 head-of-line blocking：一則毒訊息（poison message）拖垮整條隊列。&lt;/p>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/rabbitmq-indeed-delay-dlq-escalation/" data-link-title="3.C25 Indeed：Delay queue &amp;#43; DLQ 三層 escalation" data-link-desc="Indeed 每天 35M&amp;#43; 職缺、設計 Requeue → Delay queue → DLQ 三層 escalation 避開 head-of-line blocking。">Indeed 每天處理 35M+ 職缺訊息&lt;/a>，原本的架構正是把失敗訊息 requeue 回隊首，造成阻塞。他們的解法是設計 &lt;strong>Requeue → Delay queue → Dead Letter Queue 三層 escalation&lt;/strong>：retry 幾次後讓訊息進延遲隊列（隔一段時間再試）、再失敗幾次才進 DLQ（停止重試、留待人工或專門處理）。這個案例揭露的核心原則是——&lt;strong>retry 策略要跟隊列拓樸一起設計，不是純 client 端的 backoff&lt;/strong>。&lt;/p>
&lt;p>本文展開 RabbitMQ 實現這套分層 retry 的機制（dead-letter exchange + TTL）、實機驗證的拓樸、以及把它寫成事故的踩坑。&lt;/p>
&lt;h2 id="核心概念dead-letter-exchange-的求值模型">核心概念：dead-letter exchange 的求值模型&lt;/h2>
&lt;p>RabbitMQ 的失敗訊息處理建立在 dead-letter exchange（DLX）上。理解它要抓住「訊息在什麼條件下被 dead-letter、去哪裡」。&lt;/p>
&lt;p>&lt;strong>訊息在三種情況被 dead-letter&lt;/strong>。一則訊息會從它所在的隊列被轉送到該隊列設定的 DLX：(1) 被 consumer &lt;code>nack&lt;/code> / &lt;code>reject&lt;/code> 且 &lt;code>requeue=false&lt;/code>；(2) 訊息 TTL 到期（&lt;code>x-message-ttl&lt;/code> 或 per-message expiration）；(3) 隊列達到長度上限（&lt;code>x-max-length&lt;/code>）被擠掉。這三種 reason 會記在訊息的 &lt;code>x-death&lt;/code> header 裡。&lt;/p>
&lt;p>&lt;strong>DLX 是隊列的屬性、不是訊息的&lt;/strong>。在宣告隊列時用 &lt;code>x-dead-letter-exchange&lt;/code> 指定這個隊列的「死信要送去哪個 exchange」，搭配 &lt;code>x-dead-letter-routing-key&lt;/code> 指定送過去時用什麼 routing key。死信被當成一則新訊息發布到那個 exchange，再依綁定路由到 DLQ。&lt;/p>
&lt;p>&lt;strong>TTL + DLX 組出「延遲隊列」&lt;/strong>。RabbitMQ 沒有原生的延遲投遞，但可以用「一個沒有 consumer、只設 TTL + DLX 的隊列」模擬：訊息進這個隊列、躺到 TTL 到期、被 dead-letter 回工作 exchange——等於延遲了 TTL 那麼久才重新可被消費。這是分層 retry 的關鍵積木。&lt;/p>
&lt;p>&lt;strong>&lt;code>x-death&lt;/code> header 累積重試歷史&lt;/strong>。每次 dead-letter，RabbitMQ 在 &lt;code>x-death&lt;/code> header 追加一筆記錄（哪個隊列、什麼 reason、次數 count）。消費端讀這個 count 就能判斷「這則訊息重試幾次了」，決定要再延遲還是進 DLQ。這是實現「retry n 次後升級」的依據。&lt;/p>
&lt;h2 id="配置work--delay--dlq-三層拓樸">配置：work → delay → DLQ 三層拓樸&lt;/h2>
&lt;p>實機驗證的最小 DLX 拓樸（工作隊列的訊息 TTL 到期後 dead-letter 到 DLQ）：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 宣告 DLX exchange 與 DLQ&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">rabbitmqadmin &lt;span class="nb">declare&lt;/span> exchange &lt;span class="nv">name&lt;/span>&lt;span class="o">=&lt;/span>dlx &lt;span class="nv">type&lt;/span>&lt;span class="o">=&lt;/span>direct
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">rabbitmqadmin &lt;span class="nb">declare&lt;/span> queue &lt;span class="nv">name&lt;/span>&lt;span class="o">=&lt;/span>dlq
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">rabbitmqadmin &lt;span class="nb">declare&lt;/span> binding &lt;span class="nv">source&lt;/span>&lt;span class="o">=&lt;/span>dlx &lt;span class="nv">destination&lt;/span>&lt;span class="o">=&lt;/span>dlq &lt;span class="nv">routing_key&lt;/span>&lt;span class="o">=&lt;/span>app.work
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="c1"># 工作隊列：設 TTL + 指向 DLX（TTL 到期或 nack(requeue=false) 都會 dead-letter）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">rabbitmqadmin &lt;span class="nb">declare&lt;/span> queue &lt;span class="nv">name&lt;/span>&lt;span class="o">=&lt;/span>app.work &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> &lt;span class="nv">arguments&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s1">&amp;#39;{&amp;#34;x-message-ttl&amp;#34;:2000,&amp;#34;x-dead-letter-exchange&amp;#34;:&amp;#34;dlx&amp;#34;,&amp;#34;x-dead-letter-routing-key&amp;#34;:&amp;#34;app.work&amp;#34;}&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="c1"># 驗證：發一則、等 2s TTL 到期、它從 app.work 搬到 dlq&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">rabbitmqadmin publish &lt;span class="nv">routing_key&lt;/span>&lt;span class="o">=&lt;/span>app.work &lt;span class="nv">payload&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;poison-msg&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="c1"># 等 TTL（2s）過期後（實測等 4s 確保）：&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">rabbitmqctl list_queues name messages
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="c1"># app.work 0 ← TTL 到期被搬走&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">&lt;span class="c1"># dlq 1 ← 落到 DLQ（訊息帶 x-death header、reason=expired）&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>實機驗證於 rabbitmq:3-management（最後檢查日 2026-06-16）：publish 後等 TTL 過期，&lt;code>app.work&lt;/code> 歸零、&lt;code>dlq&lt;/code> 出現該訊息。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/03-message-queue/vendors/rabbitmq/" data-link-title="RabbitMQ" data-link-desc="Classic message broker、AMQP routing 為主">RabbitMQ</a> overview 的 implementation-layer deep article。選型層（RabbitMQ vs Kafka / SQS、何時選 RabbitMQ）見 overview；本文只處理「決定用 RabbitMQ 後，失敗訊息怎麼 retry 才不會卡死隊列」。DLX 拓樸實機驗證於 rabbitmq:3-management、最後檢查日 2026-06-16；機制以 <a href="https://www.rabbitmq.com/docs/dlx">RabbitMQ DLX 官方文件</a> 為準。</p></blockquote>
<h2 id="失敗訊息-requeue-回隊首會卡住整條隊列">失敗訊息 requeue 回隊首，會卡住整條隊列</h2>
<p>消費一則訊息失敗了——下游 API 超時、資料還沒就緒、暫時性錯誤。最直覺的處理是 <code>nack</code> 加 <code>requeue=true</code>，讓它重新排隊再試一次。問題是 RabbitMQ 的 requeue 把訊息放回<strong>原隊列的隊首</strong>，於是它立刻又被同一個 consumer 取出、再次失敗、再 requeue……在「下游還沒恢復」的那段時間裡，這則訊息反覆佔據隊首，後面所有正常訊息全被卡住。這就是 head-of-line blocking：一則毒訊息（poison message）拖垮整條隊列。</p>
<p><a href="/blog/backend/03-message-queue/cases/rabbitmq-indeed-delay-dlq-escalation/" data-link-title="3.C25 Indeed：Delay queue &#43; DLQ 三層 escalation" data-link-desc="Indeed 每天 35M&#43; 職缺、設計 Requeue → Delay queue → DLQ 三層 escalation 避開 head-of-line blocking。">Indeed 每天處理 35M+ 職缺訊息</a>，原本的架構正是把失敗訊息 requeue 回隊首，造成阻塞。他們的解法是設計 <strong>Requeue → Delay queue → Dead Letter Queue 三層 escalation</strong>：retry 幾次後讓訊息進延遲隊列（隔一段時間再試）、再失敗幾次才進 DLQ（停止重試、留待人工或專門處理）。這個案例揭露的核心原則是——<strong>retry 策略要跟隊列拓樸一起設計，不是純 client 端的 backoff</strong>。</p>
<p>本文展開 RabbitMQ 實現這套分層 retry 的機制（dead-letter exchange + TTL）、實機驗證的拓樸、以及把它寫成事故的踩坑。</p>
<h2 id="核心概念dead-letter-exchange-的求值模型">核心概念：dead-letter exchange 的求值模型</h2>
<p>RabbitMQ 的失敗訊息處理建立在 dead-letter exchange（DLX）上。理解它要抓住「訊息在什麼條件下被 dead-letter、去哪裡」。</p>
<p><strong>訊息在三種情況被 dead-letter</strong>。一則訊息會從它所在的隊列被轉送到該隊列設定的 DLX：(1) 被 consumer <code>nack</code> / <code>reject</code> 且 <code>requeue=false</code>；(2) 訊息 TTL 到期（<code>x-message-ttl</code> 或 per-message expiration）；(3) 隊列達到長度上限（<code>x-max-length</code>）被擠掉。這三種 reason 會記在訊息的 <code>x-death</code> header 裡。</p>
<p><strong>DLX 是隊列的屬性、不是訊息的</strong>。在宣告隊列時用 <code>x-dead-letter-exchange</code> 指定這個隊列的「死信要送去哪個 exchange」，搭配 <code>x-dead-letter-routing-key</code> 指定送過去時用什麼 routing key。死信被當成一則新訊息發布到那個 exchange，再依綁定路由到 DLQ。</p>
<p><strong>TTL + DLX 組出「延遲隊列」</strong>。RabbitMQ 沒有原生的延遲投遞，但可以用「一個沒有 consumer、只設 TTL + DLX 的隊列」模擬：訊息進這個隊列、躺到 TTL 到期、被 dead-letter 回工作 exchange——等於延遲了 TTL 那麼久才重新可被消費。這是分層 retry 的關鍵積木。</p>
<p><strong><code>x-death</code> header 累積重試歷史</strong>。每次 dead-letter，RabbitMQ 在 <code>x-death</code> header 追加一筆記錄（哪個隊列、什麼 reason、次數 count）。消費端讀這個 count 就能判斷「這則訊息重試幾次了」，決定要再延遲還是進 DLQ。這是實現「retry n 次後升級」的依據。</p>
<h2 id="配置work--delay--dlq-三層拓樸">配置：work → delay → DLQ 三層拓樸</h2>
<p>實機驗證的最小 DLX 拓樸（工作隊列的訊息 TTL 到期後 dead-letter 到 DLQ）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 宣告 DLX exchange 與 DLQ</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">rabbitmqadmin <span class="nb">declare</span> exchange <span class="nv">name</span><span class="o">=</span>dlx <span class="nv">type</span><span class="o">=</span>direct
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">rabbitmqadmin <span class="nb">declare</span> queue <span class="nv">name</span><span class="o">=</span>dlq
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">rabbitmqadmin <span class="nb">declare</span> binding <span class="nv">source</span><span class="o">=</span>dlx <span class="nv">destination</span><span class="o">=</span>dlq <span class="nv">routing_key</span><span class="o">=</span>app.work
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"># 工作隊列：設 TTL + 指向 DLX（TTL 到期或 nack(requeue=false) 都會 dead-letter）</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">rabbitmqadmin <span class="nb">declare</span> queue <span class="nv">name</span><span class="o">=</span>app.work <span class="se">\
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="se"></span>  <span class="nv">arguments</span><span class="o">=</span><span class="s1">&#39;{&#34;x-message-ttl&#34;:2000,&#34;x-dead-letter-exchange&#34;:&#34;dlx&#34;,&#34;x-dead-letter-routing-key&#34;:&#34;app.work&#34;}&#39;</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="c1"># 驗證：發一則、等 2s TTL 到期、它從 app.work 搬到 dlq</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">rabbitmqadmin publish <span class="nv">routing_key</span><span class="o">=</span>app.work <span class="nv">payload</span><span class="o">=</span><span class="s2">&#34;poison-msg&#34;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"># 等 TTL（2s）過期後（實測等 4s 確保）：</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">rabbitmqctl list_queues name messages
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1"># app.work   0     ← TTL 到期被搬走</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="c1"># dlq        1     ← 落到 DLQ（訊息帶 x-death header、reason=expired）</span></span></span></code></pre></div><p>實機驗證於 rabbitmq:3-management（最後檢查日 2026-06-16）：publish 後等 TTL 過期，<code>app.work</code> 歸零、<code>dlq</code> 出現該訊息。</p>
<p>三層 escalation 的完整拓樸（對應 Indeed 模式）：</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">app.work（主工作隊列）
</span></span><span class="line"><span class="ln">2</span><span class="cl">  └─ consumer nack(requeue=false) 或處理失敗
</span></span><span class="line"><span class="ln">3</span><span class="cl">       ↓ dead-letter 到
</span></span><span class="line"><span class="ln">4</span><span class="cl">app.retry（延遲隊列：x-message-ttl=30s、無 consumer、DLX 指回 app.work）
</span></span><span class="line"><span class="ln">5</span><span class="cl">  └─ TTL 到期
</span></span><span class="line"><span class="ln">6</span><span class="cl">       ↓ dead-letter 回
</span></span><span class="line"><span class="ln">7</span><span class="cl">app.work（再次嘗試；消費端讀 x-death count）
</span></span><span class="line"><span class="ln">8</span><span class="cl">  └─ 重試達上限（例如 count &gt;= 3）→ 消費端主動 nack 到
</span></span><span class="line"><span class="ln">9</span><span class="cl">app.dlq（死信終點：無自動重試、人工 / 專門 consumer 處理）</span></span></code></pre></div><p>判讀：</p>
<ul>
<li>延遲時間靠 <code>app.retry</code> 的 TTL 控制；要指數退避就設多個不同 TTL 的 delay 隊列（30s / 5m / 1h）逐層升級</li>
<li>「重試幾次」由消費端讀 <code>x-death</code> 的 count 判斷、達上限才送終點 DLQ</li>
<li>DLQ 不該有自動重試的 consumer（否則又是迴圈）；它是給人看的、或給冪等的專門修復流程</li>
</ul>
<h2 id="production-故障演練">Production 故障演練</h2>
<h3 id="case-1requeue-回隊首毒訊息卡死整條隊列">Case 1：requeue 回隊首、毒訊息卡死整條隊列</h3>
<p><strong>徵兆</strong>：下游短暫故障期間，整條隊列的消費停滯、consumer CPU 衝高但吞吐歸零，恢復後發現大量正常訊息延遲。</p>
<p><strong>根因</strong>：失敗時用 <code>nack(requeue=true)</code>，訊息回到隊首被立刻重取、反覆失敗，head-of-line blocking。下游故障越久，毒訊息霸佔隊首越久。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>失敗一律 <code>nack(requeue=false)</code> 走 DLX，不要 requeue 回原隊列</li>
<li>用 delay 隊列（TTL + DLX）讓重試隔一段時間，給下游恢復時間</li>
<li>重試有上限，達上限進終點 DLQ，停止自動重試</li>
<li>這正是 <a href="/blog/backend/03-message-queue/cases/rabbitmq-indeed-delay-dlq-escalation/" data-link-title="3.C25 Indeed：Delay queue &#43; DLQ 三層 escalation" data-link-desc="Indeed 每天 35M&#43; 職缺、設計 Requeue → Delay queue → DLQ 三層 escalation 避開 head-of-line blocking。">Indeed 案例</a> 的核心教訓：retry 拓樸化，不要 requeue-to-head</li>
</ol>
<h3 id="case-2delay-隊列綁錯retry-變無限迴圈">Case 2：delay 隊列綁錯、retry 變無限迴圈</h3>
<p><strong>徵兆</strong>：某些訊息永遠在重試、<code>x-death</code> count 累積到幾百次，DLQ 卻一直是空的。</p>
<p><strong>根因</strong>：delay 隊列的 DLX 指回工作隊列，但消費端沒有檢查 <code>x-death</code> count、或上限判斷寫錯，訊息在 work ↔ retry 之間無限往返、永遠到不了終點 DLQ。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>消費端每次處理前讀 <code>x-death</code> 的 count，超過上限就主動投遞到終點 DLQ（不再走 retry）</li>
<li>上限判斷要涵蓋所有 retry 路徑，不要漏掉某條</li>
<li>監控 <code>x-death</code> count 分布，出現高 count 訊息代表升級邏輯漏了</li>
<li>終點 DLQ 絕對不要接會 nack-to-DLX 的 consumer，否則迴圈</li>
</ol>
<h3 id="case-3per-queue-ttl-的隊首阻塞陷阱">Case 3：per-queue TTL 的隊首阻塞陷阱</h3>
<p><strong>徵兆</strong>：用 <code>x-message-ttl</code> 設隊列級 TTL 做延遲，但發現訊息沒有按預期時間 dead-letter，延遲時間忽長忽短。</p>
<p><strong>根因</strong>：隊列級 TTL（<code>x-message-ttl</code>）只在訊息到達隊首時才檢查是否過期。如果用 per-message TTL 且不同訊息 TTL 不同，前面一則長 TTL 的訊息會擋住後面短 TTL 的——後者明明過期了卻因為不在隊首而沒被 dead-letter。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>delay 隊列用統一的隊列級 TTL（同一個 delay 隊列裡所有訊息延遲時間相同），不要在同隊列混用 per-message TTL</li>
<li>要多種延遲時間就開多個 delay 隊列（每個固定 TTL），不要靠 per-message TTL</li>
<li>理解 TTL 是「到隊首才檢查」的惰性求值，不是精準定時器</li>
<li>需要精準排程的延遲用專門的 delay 機制（rabbitmq-delayed-message-exchange plugin），不靠 TTL 模擬</li>
</ol>
<h3 id="case-4dlx-沒綁好死信靜默消失">Case 4：DLX 沒綁好、死信靜默消失</h3>
<p><strong>徵兆</strong>：訊息明明該 dead-letter，但 DLQ 一直收不到，訊息憑空消失。</p>
<p><strong>根因</strong>：DLX exchange 存在、隊列也設了 <code>x-dead-letter-exchange</code>，但 DLX 到 DLQ 的 binding 不存在或 routing key 對不上。死信被發布到 DLX 後沒有任何隊列接收（unroutable），直接被丟棄。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>確認 DLX → DLQ 的 binding 存在且 routing key 匹配（<code>x-dead-letter-routing-key</code> 對上 binding key）</li>
<li>沒設 <code>x-dead-letter-routing-key</code> 時死信沿用原 routing key，binding 要對應原 key</li>
<li>給 DLX 設 alternate exchange 或在 DLX 上掛一個 catch-all 隊列，避免 unroutable 死信靜默消失</li>
<li>監控 DLX 的 unroutable / drop 指標，死信消失是嚴重的資料遺失</li>
</ol>
<h3 id="case-5dlq-無上限成長變成第二個問題">Case 5：DLQ 無上限成長、變成第二個問題</h3>
<p><strong>徵兆</strong>：DLQ 累積到幾十萬則訊息、記憶體吃緊，沒人處理。</p>
<p><strong>根因</strong>：DLQ 是終點但沒有處理流程——訊息一直進、沒人消費，DLQ 變成一個越長越大的垃圾堆，最終吃光 broker 記憶體（classic queue 訊息在記憶體）。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>DLQ 要有處理流程：告警 + 人工 / 自動修復 consumer（冪等地重新投遞或記錄）</li>
<li>DLQ 設 <code>x-max-length</code> 或自己的 TTL，避免無限成長（但要先確認丟棄可接受）</li>
<li>監控 DLQ 深度與成長速率，持續成長代表上游有系統性失敗、要根治而非堆 DLQ</li>
<li>quorum queue 對 DLQ 是合理選擇（持久、不純靠記憶體），見 <a href="/blog/backend/03-message-queue/vendors/rabbitmq/queue-types-classic-quorum-stream/" data-link-title="RabbitMQ Queue Type 選型：Classic、Quorum、Stream 的責任邊界與容量取捨" data-link-desc="RabbitMQ 3.x 三種 queue type 的選型 deep article — classic queue（mirrored 已 deprecated）、quorum queue（Raft 一致性、取代 mirrored）、stream（3.9&#43; append-only log、可重複消費）。涵蓋三種模型在 throughput / retention / replay / 記憶體成本的判讀、宣告語意差異（實機驗證）、4 個 production 故障演練（mirrored 網路放大 / quorum loss / stream retention 超量 / classic→quorum in-flight message），與容量規劃。">quorum vs mirrored queue deep article</a></li>
</ol>
<h2 id="capacity--cost-邊界">Capacity / cost 邊界</h2>
<p>分層 retry 拓樸的容量判讀：</p>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>健康區間</th>
          <th>警戒與動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>主隊列消費吞吐</td>
          <td>穩定、無停滯</td>
          <td>歸零但有積壓 → 毒訊息 head-of-line blocking</td>
      </tr>
      <tr>
          <td><code>x-death</code> count 分布</td>
          <td>多數低（1-2 次成功）</td>
          <td>高 count 訊息多 → 下游系統性故障 / 升級邏輯漏</td>
      </tr>
      <tr>
          <td>DLQ 深度</td>
          <td>低且有處理流程</td>
          <td>持續成長 → 無人處理、會吃光記憶體</td>
      </tr>
      <tr>
          <td>delay 隊列堆積</td>
          <td>隨重試量波動、可消化</td>
          <td>持續堆高 → 重試量超過下游恢復速度</td>
      </tr>
      <tr>
          <td>unroutable 死信</td>
          <td>0</td>
          <td>&gt; 0 → DLX binding 錯、死信靜默遺失</td>
      </tr>
  </tbody>
</table>
<p>撞牆後的路由判斷：</p>
<ul>
<li><strong>重試量大、delay 隊列堆積</strong>：重試治標、下游系統性故障要根治；考慮 circuit breaker 在上游擋住而非無限重試。</li>
<li><strong>需要精準延遲排程</strong>：TTL 模擬的延遲不精準（惰性求值），用 rabbitmq-delayed-message-exchange plugin。</li>
<li><strong>DLQ / 隊列要持久可靠</strong>：classic queue 靠記憶體 + 鏡像，大量積壓有風險；用 <a href="/blog/backend/03-message-queue/vendors/rabbitmq/queue-types-classic-quorum-stream/" data-link-title="RabbitMQ Queue Type 選型：Classic、Quorum、Stream 的責任邊界與容量取捨" data-link-desc="RabbitMQ 3.x 三種 queue type 的選型 deep article — classic queue（mirrored 已 deprecated）、quorum queue（Raft 一致性、取代 mirrored）、stream（3.9&#43; append-only log、可重複消費）。涵蓋三種模型在 throughput / retention / replay / 記憶體成本的判讀、宣告語意差異（實機驗證）、4 個 production 故障演練（mirrored 網路放大 / quorum loss / stream retention 超量 / classic→quorum in-flight message），與容量規劃。">quorum queue</a>（Raft 持久）。</li>
<li><strong>吞吐 / 保留需求超過 RabbitMQ</strong>：retry / replay 是 log-based broker 的強項，大規模 replay 走 <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>（consumer 各自 offset、可重讀）。</li>
</ul>
<h2 id="整合--下一步">整合 / 下一步</h2>
<p>分層 retry 是 RabbitMQ 可靠消費的核心，它跟其他議題交織：</p>
<ul>
<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>：DLQ 要持久才不會在 broker 重啟時丟失死信。</li>
<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>：prefetch / ack 策略決定毒訊息影響範圍，跟 retry 拓樸一起設計。</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>：retry 與 DLQ 重新投遞都要求消費冪等，否則重試造成重複副作用。</li>
<li><strong>跟 <a href="/blog/backend/03-message-queue/vendors/rabbitmq/queue-types-classic-quorum-stream/" data-link-title="RabbitMQ Queue Type 選型：Classic、Quorum、Stream 的責任邊界與容量取捨" data-link-desc="RabbitMQ 3.x 三種 queue type 的選型 deep article — classic queue（mirrored 已 deprecated）、quorum queue（Raft 一致性、取代 mirrored）、stream（3.9&#43; append-only log、可重複消費）。涵蓋三種模型在 throughput / retention / replay / 記憶體成本的判讀、宣告語意差異（實機驗證）、4 個 production 故障演練（mirrored 網路放大 / quorum loss / stream retention 超量 / classic→quorum in-flight message），與容量規劃。">quorum vs mirrored queue</a></strong>：DLQ 與重試隊列的持久性選 quorum queue，避開 mirrored queue 的網路成本。</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<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></li>
<li>同 vendor deep article：<a href="/blog/backend/03-message-queue/vendors/rabbitmq/queue-types-classic-quorum-stream/" data-link-title="RabbitMQ Queue Type 選型：Classic、Quorum、Stream 的責任邊界與容量取捨" data-link-desc="RabbitMQ 3.x 三種 queue type 的選型 deep article — classic queue（mirrored 已 deprecated）、quorum queue（Raft 一致性、取代 mirrored）、stream（3.9&#43; append-only log、可重複消費）。涵蓋三種模型在 throughput / retention / replay / 記憶體成本的判讀、宣告語意差異（實機驗證）、4 個 production 故障演練（mirrored 網路放大 / quorum loss / stream retention 超量 / classic→quorum in-flight message），與容量規劃。">quorum vs mirrored queue</a></li>
<li>對應案例：<a href="/blog/backend/03-message-queue/cases/rabbitmq-indeed-delay-dlq-escalation/" data-link-title="3.C25 Indeed：Delay queue &#43; DLQ 三層 escalation" data-link-desc="Indeed 每天 35M&#43; 職缺、設計 Requeue → Delay queue → DLQ 三層 escalation 避開 head-of-line blocking。">3.C25 Indeed delay queue + DLQ 三層 escalation</a></li>
<li>上游概念：<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>、<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></li>
</ul>
]]></content:encoded></item><item><title>Redis Streams → Kafka：從 embedded stream 長成 dedicated event streaming</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/redis-streams/migrate-to-kafka/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/redis-streams/migrate-to-kafka/</guid><description>&lt;blockquote>
&lt;p>本文是跨 vendor migration playbook、cross-link 到 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/redis-streams/" data-link-title="Redis Streams" data-link-desc="Redis 生態內的 streams、append-only log &amp;#43; consumer group">Redis Streams&lt;/a> 跟 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka&lt;/a>。對位 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/migrate-from-to-nats/" data-link-title="Kafka ↔ NATS：不是 migration、是 messaging paradigm 重設計" data-link-desc="Kafka 跟 NATS 不是同類產品（log-based event streaming vs subject-based messaging）、&amp;#39;migration&amp;#39; 字面上不成立；本文釐清兩家 paradigm 邊界、什麼情境真的能換、application 模式重設計的 5 個踩雷（consumer offset 觀念差 / retention model / exactly-once 假設 / schema registry 缺位 / fan-out 模式差）、跟 JetStream 對位 &amp;#43; 混合架構">Kafka ↔ NATS&lt;/a> 的 &lt;em>paradigm shift&lt;/em> 模板 — 兩端不是同類產品的不同實作、是不同抽象層的系統：一個是 Redis 行程內的 append-only log data structure、一個是專用的 distributed event streaming platform。&lt;/p>&lt;/blockquote>
&lt;h2 id="redis-streams-跟-kafka-是不同抽象層的東西">Redis Streams 跟 Kafka 是不同抽象層的東西&lt;/h2>
&lt;p>Redis Streams 是 Redis 行程內的一個 data structure、Kafka 是一整套獨立的 distributed event streaming platform。這個區別決定整趟遷移的性質：要把 messaging 能力從「既有 Redis 行程的一塊記憶體」搬到「自成一格、要獨立運維的多節點叢集」，遠超過換個相容 broker 的工作量。&lt;/p>
&lt;p>Redis Streams 的責任邊界是「在已經跑著的 Redis 裡多一個 append-only log」。它共用 Redis 的記憶體、持久化（AOF / RDB）、failover（Sentinel / Cluster）跟運維團隊。寫入用 &lt;code>XADD&lt;/code>、消費用 &lt;code>XREADGROUP&lt;/code>，consumer group 跟 pending entries list（PEL）都活在同一個 Redis 行程。它的設計取捨偏向「低延遲、低運維增量、跟 Redis 生命週期綁定」。&lt;/p>
&lt;p>Kafka 的責任邊界是「成為跨系統的事件總線」。它把訊息寫成 partition 化的 log、落在獨立 broker 的磁碟、用 replication 保護、用 consumer group offset 追蹤各 consumer 進度，可長期保留並隨意 replay。它的設計取捨偏向「寫入即承諾、磁碟級長期保留、多 consumer 各自重播、水平擴展吞吐」。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>維度&lt;/th>
 &lt;th>Redis Streams&lt;/th>
 &lt;th>Kafka&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>部署形態&lt;/td>
 &lt;td>Redis 行程內的 data structure&lt;/td>
 &lt;td>獨立 broker 叢集（3-5 broker + KRaft）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>儲存後端&lt;/td>
 &lt;td>RAM-bound（受 &lt;code>maxmemory&lt;/code> 限制）&lt;/td>
 &lt;td>Broker 本地磁碟（可加 tiered storage to S3）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>拓樸單位&lt;/td>
 &lt;td>單一 stream key（綁單一 shard）&lt;/td>
 &lt;td>Topic + 多 partition（跨 broker 分布）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Retention 機制&lt;/td>
 &lt;td>&lt;code>MAXLEN&lt;/code> / &lt;code>MINID&lt;/code>、application 主動 trim&lt;/td>
 &lt;td>Broker 端 retention policy（time / size）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>消費進度&lt;/td>
 &lt;td>PEL + &lt;code>XACK&lt;/code>（broker 維護待 ack 集合）&lt;/td>
 &lt;td>Consumer offset commit（per partition）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>失敗接管&lt;/td>
 &lt;td>&lt;code>XCLAIM&lt;/code> / &lt;code>XAUTOCLAIM&lt;/code>（手動 / 半自動）&lt;/td>
 &lt;td>Rebalance protocol（broker 協調自動分配）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Replay&lt;/td>
 &lt;td>從 entry ID 重讀（受 retention 內資料限制）&lt;/td>
 &lt;td>從任意 offset 重讀（受磁碟 retention 限制）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>延遲&lt;/td>
 &lt;td>亞毫秒（記憶體操作）&lt;/td>
 &lt;td>5-50ms&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>運維增量&lt;/td>
 &lt;td>近乎零（沿用 Redis）&lt;/td>
 &lt;td>顯著（多養一套叢集 + schema / connect 生態）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>關鍵在「拓樸單位」這列。Redis Streams 的一個 stream key 只能落在單一 shard、沒有 partition 概念，吞吐與資料量受單 shard 的記憶體與單執行緒處理能力封頂。Kafka 的 topic 天然切成多 partition、分散到多 broker，這是兩者在規模上的分水嶺，也是後面所有對位與故障演練的根。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是跨 vendor migration playbook、cross-link 到 <a href="/blog/backend/03-message-queue/vendors/redis-streams/" data-link-title="Redis Streams" data-link-desc="Redis 生態內的 streams、append-only log &#43; consumer group">Redis Streams</a> 跟 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka</a>。對位 <a href="/blog/backend/03-message-queue/vendors/kafka/migrate-from-to-nats/" data-link-title="Kafka ↔ NATS：不是 migration、是 messaging paradigm 重設計" data-link-desc="Kafka 跟 NATS 不是同類產品（log-based event streaming vs subject-based messaging）、&#39;migration&#39; 字面上不成立；本文釐清兩家 paradigm 邊界、什麼情境真的能換、application 模式重設計的 5 個踩雷（consumer offset 觀念差 / retention model / exactly-once 假設 / schema registry 缺位 / fan-out 模式差）、跟 JetStream 對位 &#43; 混合架構">Kafka ↔ NATS</a> 的 <em>paradigm shift</em> 模板 — 兩端不是同類產品的不同實作、是不同抽象層的系統：一個是 Redis 行程內的 append-only log data structure、一個是專用的 distributed event streaming platform。</p></blockquote>
<h2 id="redis-streams-跟-kafka-是不同抽象層的東西">Redis Streams 跟 Kafka 是不同抽象層的東西</h2>
<p>Redis Streams 是 Redis 行程內的一個 data structure、Kafka 是一整套獨立的 distributed event streaming platform。這個區別決定整趟遷移的性質：要把 messaging 能力從「既有 Redis 行程的一塊記憶體」搬到「自成一格、要獨立運維的多節點叢集」，遠超過換個相容 broker 的工作量。</p>
<p>Redis Streams 的責任邊界是「在已經跑著的 Redis 裡多一個 append-only log」。它共用 Redis 的記憶體、持久化（AOF / RDB）、failover（Sentinel / Cluster）跟運維團隊。寫入用 <code>XADD</code>、消費用 <code>XREADGROUP</code>，consumer group 跟 pending entries list（PEL）都活在同一個 Redis 行程。它的設計取捨偏向「低延遲、低運維增量、跟 Redis 生命週期綁定」。</p>
<p>Kafka 的責任邊界是「成為跨系統的事件總線」。它把訊息寫成 partition 化的 log、落在獨立 broker 的磁碟、用 replication 保護、用 consumer group offset 追蹤各 consumer 進度，可長期保留並隨意 replay。它的設計取捨偏向「寫入即承諾、磁碟級長期保留、多 consumer 各自重播、水平擴展吞吐」。</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>Redis Streams</th>
          <th>Kafka</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>部署形態</td>
          <td>Redis 行程內的 data structure</td>
          <td>獨立 broker 叢集（3-5 broker + KRaft）</td>
      </tr>
      <tr>
          <td>儲存後端</td>
          <td>RAM-bound（受 <code>maxmemory</code> 限制）</td>
          <td>Broker 本地磁碟（可加 tiered storage to S3）</td>
      </tr>
      <tr>
          <td>拓樸單位</td>
          <td>單一 stream key（綁單一 shard）</td>
          <td>Topic + 多 partition（跨 broker 分布）</td>
      </tr>
      <tr>
          <td>Retention 機制</td>
          <td><code>MAXLEN</code> / <code>MINID</code>、application 主動 trim</td>
          <td>Broker 端 retention policy（time / size）</td>
      </tr>
      <tr>
          <td>消費進度</td>
          <td>PEL + <code>XACK</code>（broker 維護待 ack 集合）</td>
          <td>Consumer offset commit（per partition）</td>
      </tr>
      <tr>
          <td>失敗接管</td>
          <td><code>XCLAIM</code> / <code>XAUTOCLAIM</code>（手動 / 半自動）</td>
          <td>Rebalance protocol（broker 協調自動分配）</td>
      </tr>
      <tr>
          <td>Replay</td>
          <td>從 entry ID 重讀（受 retention 內資料限制）</td>
          <td>從任意 offset 重讀（受磁碟 retention 限制）</td>
      </tr>
      <tr>
          <td>延遲</td>
          <td>亞毫秒（記憶體操作）</td>
          <td>5-50ms</td>
      </tr>
      <tr>
          <td>運維增量</td>
          <td>近乎零（沿用 Redis）</td>
          <td>顯著（多養一套叢集 + schema / connect 生態）</td>
      </tr>
  </tbody>
</table>
<p>關鍵在「拓樸單位」這列。Redis Streams 的一個 stream key 只能落在單一 shard、沒有 partition 概念，吞吐與資料量受單 shard 的記憶體與單執行緒處理能力封頂。Kafka 的 topic 天然切成多 partition、分散到多 broker，這是兩者在規模上的分水嶺，也是後面所有對位與故障演練的根。</p>
<h2 id="先確認是不是真的該遷多數中小規模不該遷">先確認是不是真的該遷：多數中小規模不該遷</h2>
<p>決定遷移前先做反向確認：在中小規模、且團隊已熟 Redis 的情境，Redis Streams 往往已經夠用，把它換成 Kafka 多半是引入運維負擔而非解決問題。遷移的正當理由來自規模或保留需求真的超出 Redis Streams 的能力邊界，而不是 Kafka 更主流。</p>
<p><a href="/blog/backend/03-message-queue/cases/redis-streams-arcjet-replace-kafka/" data-link-title="3.C43 Arcjet：Redis Streams 取代 Kafka 省 6 位數 $" data-link-desc="Arcjet security 平台、Kafka managed 6 位數 $/yr、用 Redis Streams 約 $1k/yr、自寫 Janitor 監控 retention。">Arcjet</a> 的方向恰好相反、值得當反向參照。Arcjet 的 security / bot detection 平台需要低延遲請求處理，原本評估 Kafka，發現 managed Kafka 要六位數美元年費、自管運維難度也高；他們把既有的 Redis cache 層升級成 Streams，總成本掉到約一千美元年費。代價是 Redis Streams 沒有自動 retention，他們自寫一個 Janitor process，依約每分鐘一百則的實際處理速度監測 stream 長度跟 consumer group 狀態、selectively trim。</p>
<p>Arcjet 的判讀對遷移方向的啟示：當 workload 是低延遲、資料量留在記憶體可承受的範圍、團隊本來就在跑 Redis，Redis Streams 是務實且便宜的選擇；願意自寫 retention 工具就能補上它缺的治理能力。這條路成立時，遷去 Kafka 是用六位數年費跟一整套叢集運維，去換一個現有方案已能覆蓋的需求。</p>
<p><a href="/blog/backend/03-message-queue/cases/redis-streams-bitso-reliable-streams/" data-link-title="3.C42 Bitso：Reliable Redis Streams 抽象 &#43; 自建 DLQ" data-link-desc="Bitso 加密交易所、千 msg/sec/stream &#43; 亞毫秒延遲、自建 Reliable Streams 封裝 PEL &#43; retry &#43; DLQ、idempotent processing。">Bitso</a> 是另一個 Redis Streams 站得住的高壓案例。Bitso 的撮合引擎微服務要扛每秒上千則訊息、亞毫秒延遲、撐住 BTC 價格暴動的尖峰；他們先後評估 Kafka（延遲不符）跟 SQS（vendor lock-in + 延遲）後選 Redis Streams，自建一層 Reliable Streams 抽象封裝 PEL + retry + DLQ，走 idempotent processing 接受重複勝過遺失。Bitso 揭露 Redis Streams 是「資料結構」而非「broker 系統」，可靠性責任在 application 層；但在亞毫秒延遲是硬指標的撮合場景，這個取捨反而讓 Redis Streams 勝過 Kafka。</p>
<p>兩個案例共同點：當延遲是硬指標、資料量在 RAM 可承受範圍、團隊能自建缺的治理層，Redis Streams 就站得住。遷去 Kafka 的決策該建立在這些前提不再成立之上，而不是建立在 Kafka 更有名之上。</p>
<h2 id="真正該遷的訊號">真正該遷的訊號</h2>
<p>決定遷移的依據是 Redis Streams 的三個能力邊界被實際 workload 突破：retention 需求超出 RAM 的成本曲線、需要長期 replay、consumer group 或 partition 規模超出單一 Redis 行程。三個訊號中任一個被觸發、且自建工具補不回來時，遷去 Kafka 才划算。</p>
<p>第一個訊號是 retention 超出 RAM 的成本翻轉。Redis Streams 的資料活在記憶體，保留越久、stream 越長、佔的 RAM 越多，而 RAM 是 Redis 叢集裡最貴的資源。當 retention 需求從「幾小時的緩衝」長到「數天到數週的事件保留」，把這些資料留在 RAM 的成本會快速超過 Kafka 把同樣資料留在 broker 磁碟（甚至 tiered storage 到 S3）的成本。<a href="/blog/backend/03-message-queue/cases/redis-streams-learning-com-event-source-retreat/" data-link-title="3.C46 Learning.com：Redis 事件源退場（反例）" data-link-desc="Learning.com 把 microservice event store 放 Redis、1 年累積 GB/週、AOF&#43;EBS 變 latency 痛點、退到 PostgreSQL。">Learning.com 退場案例</a>就是這條線被突破的反例 — 把 Redis 當長期事件儲存（Stream 是其中一塊），事件量每週以 GB 成長、AOF fsync 與 EBS I/O 變成 latency 痛點，最終退回 PostgreSQL。成本曲線翻轉是最常見、也最該觸發遷移的訊號。</p>
<p>第二個訊號是需要長期 replay。事件溯源（event sourcing）或合規稽核場景，需要保留並重播數週、數月甚至數年的歷史事件。Redis Streams 的 replay 只能重讀 retention 內還在的資料，而 retention 受 RAM 限制無法拉得很長；Kafka 的磁碟保留加 tiered storage 讓長期 replay 變成 first-class 能力。當 replay 視窗的需求超出 RAM 能承受的 retention，這個訊號成立。</p>
<p>第三個訊號是 consumer group 或 partition 規模超出單一 Redis。Redis Streams 的單一 stream key 綁在單一 shard，吞吐受單 shard 封頂、沒有 partition 可以水平拆分並行度；要跨 shard 只能手動用 hash tag 切成多個獨立 stream，application 自己路由。當單一邏輯 stream 的吞吐需求、或 consumer 並行度需求超過單 shard 能給的，且手動切 stream 的複雜度已經失控，Kafka 的原生 partition 才值得換。</p>
<p>這三個訊號之外，還有一個放大條件：是否需要 Kafka 生態（Schema Registry、Connect / Debezium CDC、Streams 流處理）。如果遷移同時要接上 CDC pipeline 或 schema 強制治理，那 Kafka 帶來的不只是 retention 跟 partition、而是整套生態，這會讓遷移的價值天平更傾向 Kafka。但若只是想要更長 retention、生態用不到，先評估 Redis tiered 方案或自建 Janitor 是否更便宜。</p>
<h2 id="概念對位xaddxreadgroupxackmaxlenxclaim">概念對位：XADD/XREADGROUP/XACK/MAXLEN/XCLAIM</h2>
<p>遷移的核心工作是把 Redis Streams 的五個核心操作對應到 Kafka 的等價概念、並理解每個對位背後語意的偏移，這比換 SDK 重得多。直接照字面搬會在 retention、消費進度、失敗接管三處踩雷，這三處正是後面故障演練的來源。</p>
<table>
  <thead>
      <tr>
          <th>Redis Streams 操作</th>
          <th>Kafka 等價</th>
          <th>語意偏移</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>XADD stream * field val</code></td>
          <td><code>producer.send(topic, key, val)</code></td>
          <td>Kafka 用 key 決定 partition、Redis 單 stream 無 partition</td>
      </tr>
      <tr>
          <td><code>XREADGROUP GROUP g c</code></td>
          <td>consumer group + <code>poll()</code></td>
          <td>Kafka rebalance 自動分配 partition、Redis 要手動 <code>XCLAIM</code></td>
      </tr>
      <tr>
          <td><code>XACK stream g id</code></td>
          <td>offset commit</td>
          <td>PEL 是逐則待 ack 集合、offset 是單調位移、語意不同</td>
      </tr>
      <tr>
          <td><code>MAXLEN</code> / <code>MINID</code> / <code>XTRIM</code></td>
          <td>retention policy（time / size）</td>
          <td>application 主動 trim → broker 端被動 retention</td>
      </tr>
      <tr>
          <td><code>XCLAIM</code> / <code>XAUTOCLAIM</code></td>
          <td>rebalance protocol</td>
          <td>手動 / 半自動接管 → broker 協調自動 reassign</td>
      </tr>
  </tbody>
</table>
<p><code>XADD</code> 對 <code>producer.send</code> 的最大偏移是 partition key。Redis 的單一 stream key 沒有 partition，所有 entry 都在同一條序列上嚴格有序；Kafka 把訊息依 key 雜湊分到不同 partition，只有同一 partition 內保證有序。遷移時要決定哪個欄位當 partition key、這個決定同時決定了 ordering 的範圍跟 hot partition 的風險。</p>
<p><code>XREADGROUP</code> 對 consumer group 的偏移在 rebalance。Redis consumer group 沒有自動 rebalance，consumer 掛掉後它名下未 ack 的訊息留在 PEL，要靠其他 consumer 主動 <code>XCLAIM</code> 接管；Kafka 的 consumer group 有 rebalance protocol，consumer 加入或離開時 broker 自動把 partition 重新分配。從手動接管搬到自動 rebalance，application 端負責接管的那段邏輯可以刪掉、但要改成理解 rebalance 行為。</p>
<p><code>XACK</code> 對 offset commit 是最容易誤用的一處，獨立成下一節的故障演練。<code>MAXLEN</code> 對 retention policy 是成本模型翻轉的核心，也獨立成故障演練。</p>
<h2 id="production-故障演練">Production 故障演練</h2>
<h3 id="case-1retention-模型從-ram-限制翻成-log-成本磁碟與成本失準">Case 1：Retention 模型從 RAM 限制翻成 log 成本，磁碟與成本失準</h3>
<p><strong>徵兆</strong>：團隊把 Redis Streams 的 <code>MAXLEN 100000</code>（保留最近十萬則、控制 RAM）習慣直接對映成 Kafka 的某個數字，結果 cutover 後不是 broker 磁碟暴漲超出預期、就是資料保留遠短於業務需要、replay 視窗對不上。</p>
<p><strong>根因</strong>：Redis Streams 的 <code>MAXLEN</code> 是 application 在每次 <code>XADD</code> 主動修剪的「條數上限」，目的是壓住 RAM 佔用，是一個 count-based 的記憶體預算旋鈕。Kafka 的 retention 是 broker 端被動執行的 policy、預設是 time-based（<code>retention.ms</code>）或 size-based（<code>retention.bytes</code>），目的是控制磁碟保留窗，而磁碟比 RAM 便宜一到兩個數量級。兩者的單位、執行主體、成本曲線都不同 — 把「保留十萬則以省 RAM」直接搬成 Kafka 設定，會錯估磁碟用量，也會把 Redis 時代「為了省 RAM 而被迫短保留」的限制錯誤地帶進一個本來就能長保留的系統。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>從業務需求重算 retention、不沿用 Redis 的 RAM 預算</strong>：Redis 的 <code>MAXLEN</code> 數字是 RAM 成本的妥協、不是業務的真實保留需求；遷移時回到「業務需要 replay 多久」重新算 <code>retention.ms</code>，這正是遷移要解鎖的能力。</li>
<li><strong>改用 time-based 為主、size-based 當保險絲</strong>：Kafka 設 <code>retention.ms</code> 對齊業務 replay 窗、再設 <code>retention.bytes</code> 防單 partition 磁碟失控。</li>
<li><strong>長保留接 tiered storage</strong>：retention 需求拉到數週數月時，把冷資料分層到 S3、熱資料留本地磁碟，成本曲線進一步壓平，而這在 Redis 的 RAM 模型下做不到。</li>
</ol>
<h3 id="case-2pel-觀念被帶進-offset造成重複或漏消費">Case 2：PEL 觀念被帶進 offset，造成重複或漏消費</h3>
<p><strong>徵兆</strong>：遷移後 consumer 出現「明明處理過的訊息又被重新消費」或「某些訊息整批沒被處理」；團隊照 Redis 時代「逐則 <code>XACK</code>」的心智模型管理 Kafka offset commit，結果對不上。</p>
<p><strong>根因</strong>：PEL 跟 offset 是兩個不同的進度模型。Redis Streams 的 PEL 是 broker 維護的「逐則待 ack 集合」，每則訊息獨立追蹤是否已 ack，consumer 可以亂序 ack 某幾則、其他留在 PEL；<code>XACK</code> 是針對特定 entry ID 的點狀確認。Kafka 的 offset 是 per partition 的單調位移、代表「這個位置之前都算消費完」，commit offset N 意味著 0 到 N-1 全部視為已處理。把 PEL 的逐則語意套到 offset 上會出兩種錯：一是處理完亂序的訊息後 commit 了較大的 offset，中間沒處理完的訊息被當成已消費而漏掉；二是 commit 時機錯置（auto-commit 在處理前就 commit），crash 後從錯誤位置重讀造成重複。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>理解 offset 是區間承諾、不是逐則確認</strong>：commit offset 前確保該 offset 之前的訊息都已處理完、不要對亂序處理的批次 commit 最大 offset。</li>
<li><strong>關 auto-commit、改 manual commit 在處理之後</strong>：<code>enable.auto.commit=false</code>，處理完一批再 commit，對齊 at-least-once。</li>
<li><strong>保留 application 端 idempotency</strong>：這點從 Redis 時代就該有、遷到 Kafka 仍成立 — at-least-once 下重複難免，用 message ID + dedup store 顯式去重，對位 <a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency 卡</a>跟 <a href="/blog/backend/03-message-queue/cases/redis-streams-bitso-reliable-streams/" data-link-title="3.C42 Bitso：Reliable Redis Streams 抽象 &#43; 自建 DLQ" data-link-desc="Bitso 加密交易所、千 msg/sec/stream &#43; 亞毫秒延遲、自建 Reliable Streams 封裝 PEL &#43; retry &#43; DLQ、idempotent processing。">Bitso 的 idempotent processing</a>。</li>
</ol>
<h3 id="case-3單-stream-key-換成多-partitionordering-假設破裂">Case 3：單 stream key 換成多 partition，ordering 假設破裂</h3>
<p><strong>徵兆</strong>：遷移前所有事件在單一 Redis stream 上嚴格有序、downstream 依賴這個順序（例如同一筆訂單的 created → paid → shipped）；切到 Kafka 多 partition 後，同一筆訂單的事件被分到不同 partition、處理順序錯亂。</p>
<p><strong>根因</strong>：Redis Streams 的單一 stream key 綁單一 shard、所有 entry 在一條序列上全域有序，application 不需要思考 ordering 範圍就免費得到全序。Kafka 把 topic 切成多 partition 來換取水平吞吐，代價是只保證 <em>同一 partition 內</em> 有序、partition 之間無序。遷移時若沒指定 partition key、訊息會被 round-robin 或依預設雜湊散開，同一個業務實體（訂單、帳戶、裝置）的事件落到不同 partition，全序假設就破了。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>用業務實體當 partition key</strong>：把需要保序的實體 ID（訂單 ID、帳戶 ID）當 Kafka message key，同 key 雜湊到同 partition、partition 內保序，把「全域有序」收斂成「per-entity 有序」這個多數業務真正需要的粒度。</li>
<li><strong>辨識哪些流真的需要全序</strong>：若某條流真的需要全域嚴格有序且無法拆成 per-entity，設單 partition topic（犧牲該 topic 的水平吞吐）；這也是個訊號 — 若大量流都需要全序，遷 Kafka 的吞吐優勢用不上、該重新評估遷移。</li>
<li><strong>規劃 partition 數對齊並行度跟 hot key</strong>：partition 數決定 consumer 並行上限，同時注意熱門 key 造成的 hot partition，對位 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka topic 設計</a>的 key 策略段。</li>
</ol>
<h3 id="case-4redis-既有低延遲被-kafka-吞吐換掉延遲敏感路徑受傷">Case 4：Redis 既有低延遲被 Kafka 吞吐換掉，延遲敏感路徑受傷</h3>
<p><strong>徵兆</strong>：遷移後某些原本靠 Redis Streams 亞毫秒延遲的路徑（即時風控判斷、撮合前置）延遲跳到數十毫秒，下游 SLA 破線。</p>
<p><strong>根因</strong>：Redis Streams 的亞毫秒延遲來自記憶體操作 + 行程內 data structure；Kafka 為了長期保留跟高吞吐，訊息要落磁碟、過 replication、走網路到獨立 broker，單則訊息延遲落在 5-50ms 區間，這是它換吞吐跟持久性付出的代價。把延遲敏感路徑無差別搬上 Kafka，等於用一個為吞吐優化的系統去服務一個為延遲優化的需求。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>按延遲需求分流、不要全遷</strong>：把延遲敏感的即時路徑留在 Redis Streams（或 Redis 其他結構）、把需要長保留 / 高吞吐 / replay 的事件流遷到 Kafka，這正是 Bitso 在撮合場景堅持 Redis Streams 的理由。</li>
<li><strong>接受混合架構是常態</strong>：Redis Streams 跟 Kafka 共存、各自服務適配的 workload，不追求「全部統一到 Kafka」；對位 <a href="/blog/backend/03-message-queue/vendors/kafka/migrate-from-to-nats/" data-link-title="Kafka ↔ NATS：不是 migration、是 messaging paradigm 重設計" data-link-desc="Kafka 跟 NATS 不是同類產品（log-based event streaming vs subject-based messaging）、&#39;migration&#39; 字面上不成立；本文釐清兩家 paradigm 邊界、什麼情境真的能換、application 模式重設計的 5 個踩雷（consumer offset 觀念差 / retention model / exactly-once 假設 / schema registry 缺位 / fan-out 模式差）、跟 JetStream 對位 &#43; 混合架構">Kafka ↔ NATS 的混合架構是 long-term default</a> 思路。</li>
<li><strong>若 Kafka 延遲必須壓低</strong>：調 producer <code>linger.ms=0</code> + <code>acks=1</code>、consumer <code>fetch.min.bytes=1</code> 換取較低延遲，但這會犧牲吞吐與部分可靠性、是 trade-off 不是免費午餐。</li>
</ol>
<h2 id="migration-結構漸進-cutover--長期混合">Migration 結構：漸進 cutover + 長期混合</h2>
<p>這趟遷移的結構是漸進拆分而非一次性切換：先按 workload 性質分流、再對需要遷的事件流做 dual-write 並行、逐流 cutover、最終留下 Redis Streams 跟 Kafka 共存的混合架構。一次性把所有 stream 搬上 Kafka 既無必要、也會把延遲敏感路徑拖下水。</p>
<ol>
<li><strong>Phase 0：scope 分流</strong> — 對每條 stream 跑前面三個訊號的判讀，分成「該遷 Kafka」（retention / replay / 規模超界）跟「留 Redis Streams」（延遲敏感 / 規模在範圍內）兩類。這一步直接決定後續工作量、也避免無差別遷移。</li>
<li><strong>Phase 1：Kafka 叢集與 topic 設計</strong> — 建 broker 叢集、依 Case 3 的 partition key 設計建 topic、依 Case 1 的業務需求設 retention，這時做的是基礎設施準備、還沒碰流量。</li>
<li><strong>Phase 2：dual-write 並行</strong> — producer 同時寫 Redis Streams 跟 Kafka、新 consumer 接 Kafka 驗證正確性、舊 consumer 持續吃 Redis Streams，這是可逆階段、出問題退回只讀 Redis 即可。</li>
<li><strong>Phase 3：逐流 cutover</strong> — 逐條 stream 把流量切到 Kafka、確認 consumer 進度（offset）跟 idempotency 都對、再停掉該 stream 的 Redis 端寫入；cutover 以 stream 為單位、不是整批。</li>
<li><strong>Phase 4：長期混合</strong> — 留在 Redis Streams 的延遲敏感流跟遷到 Kafka 的事件流共存、各自運維；需要時用 bridge（消費 Redis Streams 寫入 Kafka、或反向）同步必要資料。</li>
</ol>
<p>dual-write 階段的可逆性是這個結構的安全邊界：在 Phase 2 之前一切可退回純 Redis、Phase 3 逐流 cutover 把不可逆動作（停 Redis 寫入）切到最小粒度，單條 stream 出問題不影響其他流。</p>
<h2 id="capacity--cost-對照">Capacity / cost 對照</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>Redis Streams（既有 Redis 內）</th>
          <th>Kafka（self-managed）</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>部署增量</td>
          <td>近乎零（沿用 Redis 行程）</td>
          <td>3-5 broker + KRaft、獨立叢集</td>
      </tr>
      <tr>
          <td>儲存成本曲線</td>
          <td>RAM-bound（最貴的資源）</td>
          <td>磁碟為主（便宜 1-2 數量級）+ tiered to S3</td>
      </tr>
      <tr>
          <td>Retention 上限</td>
          <td>受 <code>maxmemory</code> 限制、實務數小時到數天</td>
          <td>數週到數月（磁碟）、數年（tiered storage）</td>
      </tr>
      <tr>
          <td>吞吐 / 單邏輯 stream</td>
          <td>受單 shard 封頂</td>
          <td>多 partition 水平擴展</td>
      </tr>
      <tr>
          <td>延遲</td>
          <td>亞毫秒</td>
          <td>5-50ms</td>
      </tr>
      <tr>
          <td>運維 FTE 增量</td>
          <td>近乎零</td>
          <td>0.5-2 FTE（含 schema / connect 生態）</td>
      </tr>
      <tr>
          <td>Replay 能力</td>
          <td>retention 內重讀（受 RAM 限制）</td>
          <td>任意 offset 重讀（受磁碟 retention 限制）</td>
      </tr>
      <tr>
          <td>生態</td>
          <td>Redis 工具鏈</td>
          <td>Schema Registry / Connect / Streams</td>
      </tr>
  </tbody>
</table>
<p><strong>判讀</strong>：成本的核心翻轉在「儲存成本曲線」這列。Redis Streams 把資料壓在最貴的 RAM、retention 越長越貴，所以實務上被迫短保留；Kafka 把資料攤到便宜的磁碟、再分層到 S3，讓長保留變得可負擔。但這個翻轉只在「retention 需求真的長」時成立 — 若 retention 只需數小時、資料量小，Redis Streams 沒有獨立叢集跟 0.5-2 FTE 的運維增量，總成本反而低，這正是 Arcjet 的處境。遷移划不划算取決於 retention 跟規模需求落在這條曲線的哪一段。</p>
<h2 id="整合--下一步">整合 / 下一步</h2>
<h3 id="混合架構是常見終態">混合架構是常見終態</h3>
<p>多數從 Redis Streams 起步、因規模長出 Kafka 需求的系統，終態是兩者共存而非取代：</p>





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





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




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





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





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





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># Kafka consumer pattern</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="k">for</span> <span class="n">msg</span> <span class="ow">in</span> <span class="n">kafka_consumer</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="n">process</span><span class="p">(</span><span class="n">msg</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="n">kafka_consumer</span><span class="o">.</span><span class="n">commit</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"># Pub/Sub pull subscriber pattern</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="k">def</span> <span class="nf">callback</span><span class="p">(</span><span class="n">message</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="n">process</span><span class="p">(</span><span class="n">message</span><span class="o">.</span><span class="n">data</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="n">message</span><span class="o">.</span><span class="n">ack</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">except</span> <span class="ne">Exception</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="n">message</span><span class="o">.</span><span class="n">nack</span><span class="p">()</span>  <span class="c1"># 會被重新投遞</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="n">subscriber</span><span class="o">.</span><span class="n">subscribe</span><span class="p">(</span><span class="s2">&#34;projects/my-project/subscriptions/order-processor&#34;</span><span class="p">,</span> <span class="n">callback</span><span class="o">=</span><span class="n">callback</span><span class="p">)</span></span></span></code></pre></div><p><strong>Idempotency 更重要</strong>：Pub/Sub 的 at-least-once delivery 加上 ack deadline 機制，redelivery 比 Kafka 更容易觸發（ack deadline 內沒 ack 就重投）。Consumer 的 <a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a> 設計要比 Kafka 時更嚴格。</p>
<p><strong>Flow control</strong>：Pub/Sub client library 支援 <code>max_outstanding_messages</code> 和 <code>max_outstanding_bytes</code> 做 <a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a> 控制，對應 Kafka 的 <code>max.poll.records</code>。</p>
<h3 id="切換順序">切換順序</h3>
<p>依 consumer 的重要度和複雜度排序：</p>
<ol>
<li>先切 stateless consumer（log pipeline、metrics aggregation）— 低風險</li>
<li>再切有 side effect 但 idempotent 的 consumer（search index sync、notification）</li>
<li>最後切核心 consumer（payment processing、inventory update）— 需要完整 idempotency 驗證</li>
</ol>
<p>每切一組 consumer：</p>
<ol>
<li>建立對應的 Pub/Sub subscription</li>
<li>部署新 consumer（讀 Pub/Sub）</li>
<li>驗證處理正確性（比對 Kafka consumer 和 Pub/Sub consumer 的輸出）</li>
<li>停止舊 Kafka consumer</li>
<li>觀察 7 天無異常</li>
</ol>
<h2 id="階段三停止雙寫">階段三：停止雙寫</h2>
<p>所有 consumer 切完後：</p>
<ol>
<li>停止 Kafka producer（移除雙寫邏輯）</li>
<li>觀察 Kafka topic 不再有新訊息</li>
<li>等 Kafka retention 過期</li>
<li>下線 Kafka cluster</li>
</ol>
<p>Kafka cluster 不要在 consumer 切完後立即下線。保留 retention period + 7 天作為回退保險。</p>
<h2 id="回退路徑">回退路徑</h2>
<p>Type E 遷移的回退要在每個階段都設計：</p>
<ul>
<li><strong>階段一回退</strong>：移除 Pub/Sub publish 邏輯，Kafka 路徑不受影響</li>
<li><strong>階段二回退</strong>：重啟 Kafka consumer、停止 Pub/Sub subscriber。Kafka 的 offset 要確認是否仍在 retention 內</li>
<li><strong>階段三回退</strong>：如果 Kafka 已下線，需要重新建 cluster 並從 Pub/Sub 反向雙寫回 Kafka — 成本高，所以階段三前要確認穩定</li>
</ul>
<p>回退的關鍵指標：consumer lag（Pub/Sub 的 <code>oldest_unacked_message_age</code>）持續上升、error rate 上升、或 redelivery rate 異常。</p>
<h2 id="遷移後的監控對照">遷移後的監控對照</h2>
<table>
  <thead>
      <tr>
          <th>Kafka 監控指標</th>
          <th>Pub/Sub 對應指標</th>
          <th>來源</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Consumer lag (offset)</td>
          <td><code>subscription/oldest_unacked_message_age</code></td>
          <td>Cloud Monitoring</td>
      </tr>
      <tr>
          <td>Produce rate</td>
          <td><code>topic/send_message_operation_count</code></td>
          <td>Cloud Monitoring</td>
      </tr>
      <tr>
          <td>Consume rate</td>
          <td><code>subscription/pull_message_operation_count</code></td>
          <td>Cloud Monitoring</td>
      </tr>
      <tr>
          <td>Redelivery count</td>
          <td><code>subscription/dead_letter_message_count</code> + nack rate</td>
          <td>Cloud Monitoring</td>
      </tr>
      <tr>
          <td>Broker disk usage</td>
          <td>無需關注（fully managed）</td>
          <td>N/A</td>
      </tr>
      <tr>
          <td>Rebalance events</td>
          <td>無（Pub/Sub 自動分散）</td>
          <td>N/A</td>
      </tr>
  </tbody>
</table>
<h2 id="不適合遷移的場景">不適合遷移的場景</h2>
<p>以下場景 Kafka → Pub/Sub 的 ROI 不成立：</p>
<ul>
<li><strong>需要 exactly-once semantics</strong>：Kafka 的 transactional producer + idempotent producer 提供 exactly-once；Pub/Sub 是 at-least-once，application 層做 dedup</li>
<li><strong>需要長期 replay</strong>：Kafka retention 可設數月甚至永久（tiered storage）；Pub/Sub message retention 最長 31 天（若需超過 31 天的 replay，可用 BigQuery subscription 做長期歸檔，但查詢模式不同於 Kafka 的 offset-based replay）</li>
<li><strong>大量 ordering 依賴</strong>：如果 Kafka topology 重度依賴 partition ordering 且 key cardinality 低，Pub/Sub ordering key 的並行度會比 Kafka 差</li>
<li><strong>使用 Kafka Streams / ksqlDB 做 stateful processing</strong>：stream processing 邏輯跟 Kafka 綁定（state store backed by changelog topic），遷到 Pub/Sub 要同時遷移 processing 框架（→ Dataflow / Beam），工程量額外翻倍且 API 完全不同</li>
<li><strong>多雲 / 非 GCP 環境</strong>：Pub/Sub 是 GCP-only，跨雲場景反而讓 Kafka 更合理</li>
</ul>
<h2 id="交接路由">交接路由</h2>
<ul>
<li>Source vendor overview：<a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Apache Kafka</a></li>
<li>Target vendor overview：<a href="/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Google Cloud Pub/Sub</a></li>
<li>Pub/Sub 操作細節：<a href="/blog/backend/03-message-queue/vendors/google-pubsub/push-pull-ack-flow-control/" data-link-title="Google Pub/Sub push vs pull：不是實作偏好，是下游容量的判讀" data-link-desc="Pub/Sub 的 push 與 pull subscription 常被當成實作偏好二選一，但它其實是一個容量判讀：push 把流量瞬間打到 endpoint，pull 讓 consumer 自己節流。下游有 RPS 限制就只能 pull。本文展開 subscription 模型、ack deadline、flow control 與 dead-letter topic，5 個把 push/pull 與 ack deadline 寫成下游打爆與重投的 production 踩坑">Push / Pull / Ack Flow Control</a>、<a href="/blog/backend/03-message-queue/vendors/google-pubsub/ordering-dlt-schema/" data-link-title="Pub/Sub Ordering Key、Dead-Letter Topic 與 Schema Enforcement：三道交付治理" data-link-desc="Pub/Sub overview 之下的 implementation-layer deep article — 把 ordering key 的有序代價、dead-letter topic 的 poison message 隔離、schema enforcement 的契約守門三件事寫到可操作：subscription 是 first-class、ackDeadline 與 extension、push vs pull vs streaming pull &#43; flow control、Avro / Protobuf schema、Pub/Sub Lite 與標準版差異、BigQuery / Cloud Storage subscription，含 5 個 production 故障演練（ordering 限流 / ack deadline 太短重投 / DLT max delivery attempts / push 500 retry storm / schema 擋下不相容 publish）">Ordering / DLT / Schema</a></li>
<li>Consumer idempotency：<a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 Consumer Design</a>、<a href="/blog/backend/03-message-queue/processing-recovery-semantics/" data-link-title="3.6 Processing Semantics 與 Recovery Semantics" data-link-desc="說明投遞成功、處理成功與恢復成功為何是三個不同判斷。">3.6 Processing Recovery Semantics</a></li>
<li>反向路徑（SQS → Pub/Sub）：<a href="/blog/backend/03-message-queue/vendors/aws-sqs/migrate-to-google-pubsub/" data-link-title="AWS SQS → Google Pub/Sub：queue 模型搬到 topic &#43; subscription 模型的跨雲遷移" data-link-desc="SQS 是單一 region-scoped pull queue、Pub/Sub 是 global topic &#43; first-class subscription 的 pub/sub 模型；這篇跨雲 migration playbook 走 6 維 diff dimension audit（components / data topology 軸 High）、對位 visibility timeout → ack deadline、maxReceiveCount → dead-letter topic、long polling → streaming pull、IAM policy → Service Account、SQS-to-many-consumer 要重設計成 topic fan-out；含 5 個 production 故障演練（fan-out 行為差 / ack deadline 太短重投 / ordering key vs FIFO / 跨雲網路成本 / DLT 設定差）跟 dual-publish 漸進 cutover">AWS SQS → Google Pub/Sub</a></li>
</ul>
]]></content:encoded></item><item><title>AWS SQS：Visibility timeout、long polling 與 Lambda event source 的成本與失敗形狀</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/aws-sqs/visibility-polling-lambda-cost/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/aws-sqs/visibility-polling-lambda-cost/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/aws-sqs/" data-link-title="AWS SQS" data-link-desc="AWS managed queue、簡單可靠、無 ordering（standard）">AWS SQS&lt;/a> overview 的 implementation-layer deep article。本文的 CLI 指令語法經 LocalStack round-trip 驗證、真實 AWS 的 scaling 行為、Lambda event source mapping 細節與計費數字依 AWS 官方文件。&lt;/p>&lt;/blockquote>
&lt;h2 id="sqs-沒有-broker-ackdelivery-控制全靠-visibility-timeout">SQS 沒有 broker ACK，delivery 控制全靠 visibility timeout&lt;/h2>
&lt;p>SQS 跟自管 broker（RabbitMQ / Kafka）最大的操作差異是：consumer 不會跟 broker 維持一條長連線、也沒有 channel-level 的 ack / nack 協議。SQS 的整個 delivery 保證建立在一個計時器上 — visibility timeout。訊息被 &lt;code>ReceiveMessage&lt;/code> 拉走後進入 in-flight 狀態、在 timeout 視窗內對其他 consumer 不可見；consumer 處理成功就呼叫 &lt;code>DeleteMessage&lt;/code> 把它移除、處理失敗或當機則什麼都不做、等 timeout 到期訊息自動回到 queue 重新可見。&lt;/p>
&lt;p>這個設計把「確認處理完成」的責任從 broker 連線狀態轉移到 consumer 的主動刪除。好處是 consumer 可以隨時死掉、重啟、水平擴縮、不需要維持任何 session 狀態 — 訊息不會因為連線斷掉而遺失。代價是 visibility timeout 這個數字變成最容易設錯、後果最隱蔽的參數：設太短訊息會在 consumer 還在處理時就重新可見、被另一個 consumer 重複領走；設太長則 consumer 當機後訊息要等很久才回到 queue、retry 延遲拉長。&lt;/p>
&lt;p>實機建立一個 queue 並查 default、可以確認這個視窗的起點。新建 queue 的 &lt;code>VisibilityTimeout&lt;/code> 預設 30 秒：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 不帶任何 attribute 建 queue&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">aws sqs create-queue --queue-name demo-default
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1"># 查 default visibility timeout&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">aws sqs get-queue-attributes &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --queue-url &amp;lt;url&amp;gt; &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --attribute-names VisibilityTimeout
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="c1"># =&amp;gt; &amp;#34;VisibilityTimeout&amp;#34;: &amp;#34;30&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>30 秒對「處理時間穩定在數百毫秒」的 task 綽綽有餘、對「呼叫第三方 API、跑批次轉檔、寫多個下游」的 task 則經常不夠。下一節先把這個參數設對，後面的故障演練再展開它設錯時的具體徵兆。&lt;/p>
&lt;h2 id="對齊-visibility-timeout-與-consumer-處理時間">對齊 visibility timeout 與 consumer 處理時間&lt;/h2>
&lt;p>設定 visibility timeout 的判準是「略高於 consumer 處理單則訊息的最大時間」、不是平均時間。Capital One 的官方 tech blog 在講 SQS + Lambda 時明示這條原則：visibility timeout 應比最大處理時間略高 — 因為決定 redelivery 的是尾端那幾則最慢的訊息、不是中位數。處理時間 p50 是 2 秒、p99 是 25 秒時、visibility timeout 要對齊 p99 加緩衝、設到 30-40 秒、而不是看 p50 設 10 秒。&lt;/p>
&lt;p>建 queue 時直接帶 &lt;code>VisibilityTimeout&lt;/code> attribute，或對既有 queue 用 &lt;code>set-queue-attributes&lt;/code> 調整：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 建立時指定（單位：秒；上限 12 小時 = 43200）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">aws sqs create-queue &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --queue-name demo &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --attributes &lt;span class="nv">VisibilityTimeout&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">60&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="c1"># 對既有 queue 調整&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">aws sqs set-queue-attributes &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --queue-url &amp;lt;url&amp;gt; &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --attributes &lt;span class="nv">VisibilityTimeout&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">120&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>處理時間本身不可預測的場景（例如轉檔大小差異大、下游 API 偶發慢）、用一個固定的 queue-level visibility timeout 會兩頭不討好：對齊最壞情況會讓正常訊息當機後 retry 太慢、對齊正常情況會讓慢訊息 redelivery。SQS 給的工具是 &lt;code>ChangeMessageVisibility&lt;/code> — consumer 在處理過程中發現這則會花更久時，主動延長這一則訊息的 visibility timeout，而不影響 queue default：&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/03-message-queue/vendors/aws-sqs/" data-link-title="AWS SQS" data-link-desc="AWS managed queue、簡單可靠、無 ordering（standard）">AWS SQS</a> overview 的 implementation-layer deep article。本文的 CLI 指令語法經 LocalStack round-trip 驗證、真實 AWS 的 scaling 行為、Lambda event source mapping 細節與計費數字依 AWS 官方文件。</p></blockquote>
<h2 id="sqs-沒有-broker-ackdelivery-控制全靠-visibility-timeout">SQS 沒有 broker ACK，delivery 控制全靠 visibility timeout</h2>
<p>SQS 跟自管 broker（RabbitMQ / Kafka）最大的操作差異是：consumer 不會跟 broker 維持一條長連線、也沒有 channel-level 的 ack / nack 協議。SQS 的整個 delivery 保證建立在一個計時器上 — visibility timeout。訊息被 <code>ReceiveMessage</code> 拉走後進入 in-flight 狀態、在 timeout 視窗內對其他 consumer 不可見；consumer 處理成功就呼叫 <code>DeleteMessage</code> 把它移除、處理失敗或當機則什麼都不做、等 timeout 到期訊息自動回到 queue 重新可見。</p>
<p>這個設計把「確認處理完成」的責任從 broker 連線狀態轉移到 consumer 的主動刪除。好處是 consumer 可以隨時死掉、重啟、水平擴縮、不需要維持任何 session 狀態 — 訊息不會因為連線斷掉而遺失。代價是 visibility timeout 這個數字變成最容易設錯、後果最隱蔽的參數：設太短訊息會在 consumer 還在處理時就重新可見、被另一個 consumer 重複領走；設太長則 consumer 當機後訊息要等很久才回到 queue、retry 延遲拉長。</p>
<p>實機建立一個 queue 並查 default、可以確認這個視窗的起點。新建 queue 的 <code>VisibilityTimeout</code> 預設 30 秒：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 不帶任何 attribute 建 queue</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">aws sqs create-queue --queue-name demo-default
</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"># 查 default visibility timeout</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">aws sqs get-queue-attributes <span class="se">\
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="se"></span>  --queue-url &lt;url&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="se"></span>  --attribute-names VisibilityTimeout
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1"># =&gt; &#34;VisibilityTimeout&#34;: &#34;30&#34;</span></span></span></code></pre></div><p>30 秒對「處理時間穩定在數百毫秒」的 task 綽綽有餘、對「呼叫第三方 API、跑批次轉檔、寫多個下游」的 task 則經常不夠。下一節先把這個參數設對，後面的故障演練再展開它設錯時的具體徵兆。</p>
<h2 id="對齊-visibility-timeout-與-consumer-處理時間">對齊 visibility timeout 與 consumer 處理時間</h2>
<p>設定 visibility timeout 的判準是「略高於 consumer 處理單則訊息的最大時間」、不是平均時間。Capital One 的官方 tech blog 在講 SQS + Lambda 時明示這條原則：visibility timeout 應比最大處理時間略高 — 因為決定 redelivery 的是尾端那幾則最慢的訊息、不是中位數。處理時間 p50 是 2 秒、p99 是 25 秒時、visibility timeout 要對齊 p99 加緩衝、設到 30-40 秒、而不是看 p50 設 10 秒。</p>
<p>建 queue 時直接帶 <code>VisibilityTimeout</code> attribute，或對既有 queue 用 <code>set-queue-attributes</code> 調整：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 建立時指定（單位：秒；上限 12 小時 = 43200）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">aws sqs create-queue <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --queue-name demo <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --attributes <span class="nv">VisibilityTimeout</span><span class="o">=</span><span class="m">60</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"># 對既有 queue 調整</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">aws sqs set-queue-attributes <span class="se">\
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="se"></span>  --queue-url &lt;url&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="se"></span>  --attributes <span class="nv">VisibilityTimeout</span><span class="o">=</span><span class="m">120</span></span></span></code></pre></div><p>處理時間本身不可預測的場景（例如轉檔大小差異大、下游 API 偶發慢）、用一個固定的 queue-level visibility timeout 會兩頭不討好：對齊最壞情況會讓正常訊息當機後 retry 太慢、對齊正常情況會讓慢訊息 redelivery。SQS 給的工具是 <code>ChangeMessageVisibility</code> — consumer 在處理過程中發現這則會花更久時，主動延長這一則訊息的 visibility timeout，而不影響 queue default：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># consumer 拿到 ReceiptHandle 後，動態把這則延長到 120 秒</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">aws sqs change-message-visibility <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --queue-url &lt;url&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --receipt-handle &lt;receipt-handle&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  --visibility-timeout <span class="m">120</span></span></span></code></pre></div><p>實務上長任務 consumer 的常見寫法是「heartbeat extension」：每處理一段就呼叫一次 <code>ChangeMessageVisibility</code> 往後推、形成一個續命迴圈、直到處理完成才 <code>DeleteMessage</code>。這把「我還活著、還在處理這則」的訊號明確化、避免用一個保守的 queue-level 大數字一刀切。<code>ReceiptHandle</code> 是每次 <code>ReceiveMessage</code> 回傳的一次性 token、不是 message id — 同一則訊息被重新領取後 ReceiptHandle 會變、延長操作必須用當次領取拿到的那一個。</p>
<h2 id="long-polling-決定空輪詢成本short-polling-是預設陷阱">Long polling 決定空輪詢成本，short polling 是預設陷阱</h2>
<p>Polling 模式直接決定 SQS 的 request 帳單，因為 SQS 按 request 數計費、而 <code>ReceiveMessage</code> 即使沒拿到訊息也算一次 request。Short polling（預設、<code>WaitTimeSeconds=0</code>）的行為是「立即回應」：consumer 發 <code>ReceiveMessage</code>、SQS 抽樣一部分 server 立刻回、queue 空的時候回一個空 response。Consumer 為了即時拿到訊息會緊接著再發一次、形成高頻空輪詢 — 在低流量 queue 上、絕大多數 request 都是空回、帳單全花在「問有沒有訊息」上。</p>
<p>Long polling（<code>WaitTimeSeconds</code> 設 1-20 秒）改變這個行為：SQS 收到 <code>ReceiveMessage</code> 後、若 queue 當下沒訊息、會 hold 住這條連線最多 <code>WaitTimeSeconds</code> 秒、期間一有訊息到達就立刻回傳、整段時間都沒訊息才回空。對 consumer 端來說一個 20 秒的 long poll 取代了 20 秒內可能發出的數十次 short poll、空 request 數量大幅下降。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># long polling：等到有訊息或最多 20 秒才回</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">aws sqs receive-message <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --queue-url &lt;url&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --wait-time-seconds <span class="m">20</span></span></span></code></pre></div><p>設定 long polling 有兩個位置：per-request 帶 <code>--wait-time-seconds</code>、或 queue-level 設 <code>ReceiveMessageWaitTimeSeconds</code> attribute 讓所有 receive 預設走 long polling。後者更穩、不依賴每個 consumer 都記得帶參數。20 秒幾乎總是對的選擇：它把空輪詢壓到最低、而 latency 代價只在「queue 剛好空、訊息在 poll 結束後才到」這個邊界出現 — 大多數有持續流量的 queue 根本碰不到 20 秒上限。唯一要留意的是 consumer 的 socket timeout 必須大於 <code>WaitTimeSeconds</code>、否則 client 會在 SQS 還在 hold 連線時自己先 timeout 斷線。</p>
<h2 id="sqs--lambdaevent-source-mapping-把-polling-交給-aws">SQS + Lambda：event source mapping 把 polling 交給 AWS</h2>
<p>把 SQS 接上 Lambda 時、polling 這件事整個從應用程式碼消失、改由 Lambda 的 event source mapping 接管。Event source mapping 是 Lambda service 內部一組 managed poller、持續對 queue 做 long polling、把拉到的訊息打包成 batch 同步 invoke 函式、函式正常返回就由 service 代為 <code>DeleteMessage</code>。Consumer 端不再寫 receive / delete 迴圈、只寫處理單一 batch 的 handler。</p>
<p>這套 managed poller 的 scaling 不是線性的、有 ramp-up 上限。Capital One 觀察到的行為是：Lambda 初始開 5 個並行的 long polling 連線、隨 queue 累積每分鐘最多增加 60 個 instance、standard queue 的並行 batch 上限到 1000。這意味著 queue 突然湧入大量訊息時、Lambda 不會瞬間炸開到滿並行、而是分鐘級爬升 — 容量規劃時要把這段 ramp-up 期算進 backlog 消化時間、不能假設「訊息一到就有足夠 consumer」。</p>
<p>兩個核心參數決定每次 invoke 的形狀：</p>
<table>
  <thead>
      <tr>
          <th>參數</th>
          <th>作用</th>
          <th>取捨</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Batch size</td>
          <td>一次 invoke 最多打包幾則訊息（standard 上限 10000、FIFO 上限 10）</td>
          <td>大 batch 省 invoke 數與成本、但放大「部分失敗整批重投」風險</td>
      </tr>
      <tr>
          <td>Batch window</td>
          <td>累積訊息的最長等待時間（<code>MaximumBatchingWindowInSeconds</code>、0-300 秒）</td>
          <td>拉長視窗讓 batch 更滿、代價是 latency；流量稀疏時尤其明顯</td>
      </tr>
  </tbody>
</table>
<p>Batch size 拉大表面上省錢 — invoke 次數少、每則訊息分攤的 request 成本低。但它跟下一節的部分失敗處理直接耦合：batch 越大、一則毒訊息拖累整批重投的範圍越大。Batch window 則是流量稀疏時讓 batch 攢滿的手段、流量本來就密集時設不設都差不多、反而會引入不必要的 latency。</p>
<h2 id="dlq-與-redrive-policy用-maxreceivecount-隔離毒訊息">DLQ 與 redrive policy：用 maxReceiveCount 隔離毒訊息</h2>
<p>毒訊息（永遠處理失敗的訊息 — 格式損壞、引用了已刪除的資源、觸發 consumer 確定性 bug）會在 visibility timeout 機制下無限重投：處理失敗、timeout 到期、重新可見、再次被領取、再次失敗。沒有上限的話這則訊息會永遠佔用 consumer 資源、且其他正常訊息的處理被它反覆插隊。Dead-letter queue（DLQ）加 <code>maxReceiveCount</code> 是 SQS 對這個問題的標準解 — 訊息被接收超過 N 次後、SQS 自動把它移到另一個指定的 queue（DLQ）、主 queue 不再被它卡住。</p>
<p>設定分兩步：先建一個普通 queue 當 DLQ、取它的 ARN、再對主 queue 設 redrive policy 指向這個 ARN 並設 <code>maxReceiveCount</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 1. 建 DLQ 並取得 ARN</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">aws sqs create-queue --queue-name demo-dlq
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">aws sqs get-queue-attributes <span class="se">\
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="se"></span>  --queue-url &lt;dlq-url&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="se"></span>  --attribute-names QueueArn
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"># =&gt; &#34;QueueArn&#34;: &#34;arn:aws:sqs:us-east-1:000000000000:demo-dlq&#34;</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. 對主 queue 設 redrive policy（被接收 5 次後送 DLQ）</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">aws sqs set-queue-attributes <span class="se">\
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="se"></span>  --queue-url &lt;main-url&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="se"></span>  --attributes <span class="s1">&#39;{&#34;RedrivePolicy&#34;:&#34;{\&#34;deadLetterTargetArn\&#34;:\&#34;arn:aws:sqs:us-east-1:000000000000:demo-dlq\&#34;,\&#34;maxReceiveCount\&#34;:\&#34;5\&#34;}&#34;}&#39;</span></span></span></code></pre></div><p>DLQ 不是訊息的墳場、是待診斷的暫存區。對應 <a href="/blog/backend/knowledge-cards/poison-message-quarantine/" data-link-title="Poison-Message Quarantine" data-link-desc="說明把毒訊息從主處理路徑隔離出來的機制，讓正常訊息繼續前進">poison message quarantine</a> 的思路、DLQ 累積要分兩種根因處理：訊息格式錯（永遠失敗、需要修 producer 或人工丟棄）vs 下游服務暫時 down（訊息本身沒問題、修好下游後可以重放）。後者用 redrive 把訊息從 DLQ 批次放回主 queue 重新處理、對應 <a href="/blog/backend/knowledge-cards/dlq-drain/" data-link-title="DLQ Drain" data-link-desc="說明把 dead-letter queue 累積的訊息重新處理或排空的受控流程">dlq drain</a> 的排空流程。判斷之前先看 DLQ 裡訊息的內容、不要不加判斷地 redrive — 把毒訊息 redrive 回去只會再走一輪 maxReceiveCount 又回到 DLQ。</p>
<p><code>maxReceiveCount</code> 設多少是取捨：太小（例如 1-2）會讓「下游短暫抖動」這種暫時性失敗被誤判成毒訊息、過早送進 DLQ；太大（例如 100）會讓真正的毒訊息浪費大量 consumer 重試。多數 task queue 設 3-5 是合理起點 — 足以吸收幾次暫時性失敗、又不至於讓確定性失敗的訊息空轉太久。</p>
<h2 id="message-size-限制與-extended-client">Message size 限制與 extended client</h2>
<p>SQS 單則訊息上限是 256 KB（含 message body 與 attributes）。這對純事件通知、id 引用、小型 payload 足夠、但對「訊息本身要攜帶大檔案內容」的場景不夠 — 例如要傳一份報表、一張圖、一段長文字。直接的反模式是把大內容塞進 message body、撞上 256 KB 限制後 <code>SendMessage</code> 直接報錯。</p>
<p>標準解是 claim-check 模式：大 payload 寫到 S3、訊息只攜帶 S3 的物件引用（bucket + key）、consumer 收到訊息後再去 S3 取內容。AWS 提供的 Extended Client Library（Java / Python 等 SDK）把這個模式封裝起來 — <code>SendMessage</code> 時若 payload 超過門檻、library 自動把內容寫 S3、訊息只帶 pointer；consumer 端 <code>ReceiveMessage</code> 時 library 自動從 S3 取回、對應用程式碼透明。</p>
<p>選擇門檻時要把 S3 的 request 成本與 latency 算進來：每則大訊息變成「一次 S3 PUT + 一次 SQS Send」、consumer 端「一次 SQS Receive + 一次 S3 GET」。對大多數 payload 都超過 256 KB 的 queue、這是必要成本；對 payload 多數很小、偶爾爆量的 queue、extended client 只在超門檻時走 S3、混合成本可接受。Payload 普遍很大且高頻的場景、要重新評估 SQS 是否適合 — 可能該改用 streaming（Kinesis / Kafka）或乾脆讓 producer / consumer 直接交換 S3 引用、SQS 只傳通知。</p>
<h2 id="cost按-request-計費每一次操作都是一個-request">Cost：按 request 計費，每一次操作都是一個 request</h2>
<p>SQS 的計費模型是 per-request、不是 per-message-stored、也沒有固定月費。每一次 API call — <code>SendMessage</code>、<code>ReceiveMessage</code>（含空回）、<code>DeleteMessage</code>、<code>ChangeMessageVisibility</code> — 都算一個 request。這個模型對成本估算的影響是：帳單由「操作次數」驅動、而非「訊息量」或「儲存時長」。一則訊息從 producer 到 consumer 的最小生命週期是 send（1）+ receive（1）+ delete（1）= 3 個 request；空輪詢、retry、visibility 延長都會額外加 request。</p>
<p>兩個降低 request 數的主要手段：</p>
<p>第一是 batch 操作。<code>SendMessageBatch</code> 與 <code>DeleteMessageBatch</code> 一次最多打包 10 則、而 SQS 把一個 batch call 算作一個 request（實際計費以 64 KB 為一個 request 單位、一個 batch 在此範圍內仍是少數 request）。把 10 則訊息的 send 從 10 個 request 壓成 1 個 batch request、在高頻 queue 上是數量級的成本差異：</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">aws sqs send-message-batch <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --queue-url &lt;url&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --entries <span class="s1">&#39;Id=m1,MessageBody=a&#39;</span> <span class="s1">&#39;Id=m2,MessageBody=b&#39;</span></span></span></code></pre></div><p>第二是 long polling 消滅空 request — 前面 polling 段已經展開。低流量 queue 的帳單若異常高、第一個要查的就是有沒有開 long polling、consumer 是不是在 short polling 下高頻空轉。</p>
<p>Data transfer cost 只在跨 region 時出現 — 同 region 內 producer / consumer 與 SQS 之間的傳輸不計流量費。把 producer、consumer、queue 放在同一個 region 是預設、跨 region 設計要把 egress 成本明確算進來。FIFO queue 的 per-request 單價比 standard 高、是用成本換 ordering 與去重保證 — 不需要嚴格順序的場景用 standard、把這筆溢價省下來。</p>
<p>Rapid7 的規模參考點說明這個計費模型在極端規模下的份量：Rapid7 公開引述 SQS 撐住「每天數十億則訊息」。在這個量級、per-request 計費乘以訊息數是一筆需要認真建模的成本 — batch、long polling、避免不必要的 visibility 延長、控制 retry 次數、每一項節省都被訊息量放大。SQS 在數十億級可用、但成本結構必須被當作架構參數對待、不是事後才看帳單。</p>
<h2 id="production-故障演練">Production 故障演練</h2>
<h3 id="故障一visibility-timeout-短於處理時間訊息被重複處理">故障一：visibility timeout 短於處理時間，訊息被重複處理</h3>
<p><strong>徵兆</strong>：consumer log 顯示同一個 message id 在短時間內被處理多次、下游出現重複的副作用（重複扣款、重複寄信、重複寫入）；CloudWatch 的 <code>ApproximateNumberOfMessagesNotVisible</code>（in-flight 數）異常高、<code>NumberOfMessagesReceived</code> 遠大於 <code>NumberOfMessagesDeleted</code>。</p>
<p><strong>根因</strong>：visibility timeout 設定值低於 consumer 實際處理單則訊息的時間。訊息在 consumer 還沒處理完、還沒呼叫 <code>DeleteMessage</code> 之前、timeout 就到期、訊息重新可見、被另一個 consumer（或同一個 consumer 的下一輪 poll）領走。新建 queue 的 default 是 30 秒 — 處理時間長於此就會踩到：</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">aws sqs get-queue-attributes <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --queue-url &lt;url&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --attribute-names VisibilityTimeout
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 看到 30 而 consumer 處理時間 &gt; 30s，就是這個問題</span></span></span></code></pre></div><p><strong>修法</strong>：把 visibility timeout 對齊 consumer 處理時間的 p99 加緩衝、用 <code>set-queue-attributes</code> 調高；處理時間變異大的長任務改用 <code>ChangeMessageVisibility</code> heartbeat 在處理中動態延長。同時、因為 SQS standard 是 at-least-once、重複投遞在故障與 retry 下本來就會發生、consumer 的處理邏輯必須冪等 — 對齊 visibility timeout 降低重複頻率、冪等性才是真正消除重複副作用的防線。</p>
<h3 id="故障二short-polling-預設導致低流量-queue-帳單異常">故障二：short polling 預設導致低流量 queue 帳單異常</h3>
<p><strong>徵兆</strong>：一個訊息量很低的 queue、月度 SQS 帳單卻很高；CloudWatch 顯示 <code>NumberOfEmptyReceives</code> 佔 <code>ReceiveMessage</code> 總數的絕大比例 — 大量 request 是空回。</p>
<p><strong>根因</strong>：consumer 走 short polling（<code>WaitTimeSeconds=0</code>、預設值）、在 queue 空的時候緊密地反覆發 <code>ReceiveMessage</code>、每次都立即空回、每次都計一個 request。流量越低、空回比例越高、帳單越是花在「問有沒有訊息」上。</p>
<p><strong>修法</strong>：在 queue-level 設 <code>ReceiveMessageWaitTimeSeconds=20</code> 讓所有 receive 預設走 long polling、或在每個 <code>ReceiveMessage</code> 帶 <code>--wait-time-seconds 20</code>。Queue-level 設定更穩、不依賴每個 consumer 記得帶參數。設定後 consumer 在 queue 空時會 hold 住連線最多 20 秒、空 request 數量級下降、帳單同步下降。同時確認 consumer 的 socket timeout 大於 20 秒、避免 client 先於 SQS 斷線。</p>
<h3 id="故障三lambda-batch-部分失敗整批訊息被重投">故障三：Lambda batch 部分失敗，整批訊息被重投</h3>
<p><strong>徵兆</strong>：一個 batch 裡只有少數訊息處理失敗、但整批訊息（含已成功的）全部回到 queue 重新處理；下游對已成功的訊息出現重複副作用；DLQ 累積速度遠超實際毒訊息數量。</p>
<p><strong>根因</strong>：Lambda event source mapping 的 default 行為是「整批成敗一體」— 函式只要拋出錯誤、整個 batch 被視為失敗、所有訊息（包含已經處理成功的）都不會被刪除、全部重新可見重投。Batch size 越大、一則失敗拖累的成功訊息越多。</p>
<p><strong>修法</strong>：啟用 partial batch response — event source mapping 設 <code>ReportBatchItemFailures</code>、handler 返回時只回報失敗的 message id 清單、SQS 只把這些重投、已成功的正常刪除。這把失敗的爆炸半徑從「整批」縮到「真正失敗的那幾則」。配合縮小 batch size 進一步降低單批風險、並確保 handler 冪等以承受不可避免的重投。Handler 必須正確實作 partial response 的返回格式 — 漏回報某則失敗會讓它被當成成功刪除、訊息靜默遺失。</p>
<h3 id="故障四maxreceivecount-設定不當毒訊息空轉或誤判">故障四：maxReceiveCount 設定不當，毒訊息空轉或誤判</h3>
<p><strong>徵兆</strong>：兩種相反的故障形狀。一是 DLQ 幾乎為空但主 queue 有訊息反覆重試數十次、consumer log 同一 message id 重複出現、佔用處理容量 — maxReceiveCount 設太大。二是 DLQ 快速累積大量其實沒問題的訊息、redrive 回去又能正常處理 — maxReceiveCount 設太小、把下游短暫抖動誤判成毒訊息。</p>
<p><strong>根因</strong>：redrive policy 沒設、或 <code>maxReceiveCount</code> 與「暫時性失敗的正常重試次數」不匹配。沒設 redrive policy 時毒訊息無限重投；設太大時毒訊息空轉太久才進 DLQ；設太小時正常訊息在下游抖動期間被過早判死。</p>
<p><strong>修法</strong>：對主 queue 設 redrive policy、<code>maxReceiveCount</code> 取 3-5 作為起點 — 足以吸收幾次暫時性失敗、又不讓確定性失敗的訊息空轉太久。觀察 DLQ 的累積模式再微調：DLQ 累積的多是「下游修好後 redrive 能成功」的訊息就調高、累積的多是「redrive 回去又進 DLQ」的真毒訊息就維持或調低。對 DLQ 設 CloudWatch alarm 監控 <code>ApproximateNumberOfMessagesVisible</code>、累積超過閾值就告警人工介入、區分 redrive vs 丟棄。</p>
<h3 id="故障五fifo-queue-撞上吞吐上限">故障五：FIFO queue 撞上吞吐上限</h3>
<p><strong>徵兆</strong>：把 standard queue 換成 FIFO 取得 ordering 後、高峰流量下 producer 端開始收到 throttling、訊息積壓、<code>SendMessage</code> 報限流錯誤；吞吐怎麼加 consumer 都上不去。</p>
<p><strong>根因</strong>：FIFO queue 為了維持順序與去重、吞吐遠低於 standard。FIFO 的基礎吞吐是每秒 300 則訊息（API call）、開啟 batching 後到每秒 3000 則。更關鍵的是順序保證的粒度在 <code>MessageGroupId</code> — 同一個 group 內的訊息嚴格串行處理、跨 group 才能並行。若所有訊息共用一個 group id、實際並行度退化成 1、無論加多少 consumer 都無法並行消化。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># FIFO send 必須帶 MessageGroupId（決定順序與並行粒度）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">aws sqs send-message <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --queue-url &lt;fifo-url&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --message-body <span class="s2">&#34;ordered-1&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  --message-group-id <span class="s2">&#34;group-a&#34;</span></span></span></code></pre></div><p><strong>修法</strong>：先確認是否真的需要全域順序 — 多數場景只需要「同一個實體（同一用戶、同一訂單）內部有序」、不需要跨實體有序。把 <code>MessageGroupId</code> 設成業務實體 id（用戶 id、訂單 id）、讓不同實體的訊息能跨 group 並行、吞吐隨 group 數量擴展。確定需要嚴格全域順序且吞吐撞頂的場景、FIFO 的設計上限就是天花板 — 此時要重新評估是否該換成 streaming（Kafka 的 partition 模型在 per-key 有序下提供更高並行）、或拆分 queue。不需要任何順序保證的場景、退回 standard queue、把 FIFO 的吞吐限制與成本溢價一起省掉。</p>
<h2 id="整合與下一步">整合與下一步</h2>
<h3 id="跟-consumer-設計能力對接">跟 consumer 設計能力對接</h3>
<p>本文的 visibility timeout heartbeat、partial batch response、冪等處理都是 <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> 的具體落地 — consumer-design 講語言無關的 consumer 模式、本文是 SQS 上的實作形狀。retry 與 replay 的交接路徑見 <a href="/blog/backend/03-message-queue/queue-consumer-retry-replay-handoff/" data-link-title="3.8 Queue Consumer Retry 與 Replay Handoff（實作示範）" data-link-desc="以 order_created consumer 示範 queue 路徑如何交付 idempotency evidence、DLQ handling、replay runbook 與 incident decision log。">queue consumer retry replay handoff</a>。</p>
<h3 id="跟知識卡對位">跟知識卡對位</h3>
<p>DLQ 段對應 <a href="/blog/backend/knowledge-cards/poison-message-quarantine/" data-link-title="Poison-Message Quarantine" data-link-desc="說明把毒訊息從主處理路徑隔離出來的機制，讓正常訊息繼續前進">poison message quarantine</a>（毒訊息隔離）與 <a href="/blog/backend/knowledge-cards/dlq-drain/" data-link-title="DLQ Drain" data-link-desc="說明把 dead-letter queue 累積的訊息重新處理或排空的受控流程">dlq drain</a>（DLQ 排空）兩張卡 — SQS 的 redrive policy + maxReceiveCount 是這兩個概念在 managed queue 上的具體機制。visibility timeout 的 in-flight 概念見 <a href="/blog/backend/knowledge-cards/in-flight/" data-link-title="In-Flight Work" data-link-desc="目前已接收但尚未完成處理的工作量">in-flight</a>。</p>
<h3 id="跟-case-對位">跟 case 對位</h3>
<p>visibility timeout 與 Lambda event source 的 ramp-up 行為來自 <a href="/blog/backend/03-message-queue/cases/sqs-capital-one-visibility-timeout/" data-link-title="3.C50 Capital One：Visibility timeout 設計與 Lambda event source" data-link-desc="Capital One tech blog 講 SQS &#43; Lambda：visibility timeout 應略高於最大處理時間、Lambda 初 5 個 long polling、可擴 60/min。">3.C50 Capital One</a>；at-least-once + DLQ 在工作排程的取捨來自 <a href="/blog/backend/03-message-queue/cases/sqs-airbnb-dynein-delayed-jobs/" data-link-title="3.C48 Airbnb Dynein：SQS 分散式延遲任務排程" data-link-desc="Airbnb 用 SQS at-least-once &#43; DLQ 取代 Resque 單 Redis 限制、每 scheduler 1000 QPS、SQS wrap DynamoDB 處理 &gt; 15 分鐘 delay。">3.C48 Airbnb Dynein</a>；per-request cost 在極端規模的份量來自 <a href="/blog/backend/03-message-queue/cases/sqs-rapid7-scale-billion-messages/" data-link-title="3.C59 Rapid7：SQS 100 億 message/day 規模" data-link-desc="Rapid7 公開引述：SQS 撐 10s of billions of messages per day、是架構關鍵元件、scale 量級的具體參考。">3.C59 Rapid7</a>。</p>
<h3 id="何時-revisit">何時 revisit</h3>
<p>FIFO 吞吐撞頂、需要 replay / streaming、或 cost 在 streaming 模型下更划算時、回 <a href="/blog/backend/03-message-queue/vendors/aws-sqs/" data-link-title="AWS SQS" data-link-desc="AWS managed queue、簡單可靠、無 ordering（standard）">SQS overview 的「何時改走其他服務」</a> 重新選型。跨雲 managed queue 的對照見 <a href="/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Google Pub/Sub</a>。</p>
]]></content:encoded></item><item><title>Kafka Replication、ISR 與 exactly-once：從 acks 到端到端不重不漏</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/replication-isr-exactly-once/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/replication-isr-exactly-once/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka&lt;/a> overview「Replication 與 exactly-once 升級」段的 implementation-layer deep article。Overview 已給出 partition / replication 的選型定位、本文展開 &lt;em>寫入承諾&lt;/em> 跟 &lt;em>處理語義&lt;/em> 兩條獨立軸線怎麼設、邊界在哪、成本是什麼。對應反例 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9 Queue 語義誤配&lt;/a>。&lt;/p>&lt;/blockquote>
&lt;h2 id="寫入承諾與處理語義是兩條獨立軸線">寫入承諾與處理語義是兩條獨立軸線&lt;/h2>
&lt;p>Kafka 的可靠性拆成兩個彼此正交的問題、混在一起談是多數誤配的起點。第一條軸線是 &lt;em>寫入承諾&lt;/em>：一筆訊息寫進 broker 後、在多少 replica 落地才算「成功」、broker 掛掉時這筆訊息會不會消失。這條軸線由 replication factor、ISR、&lt;code>acks&lt;/code> 與 &lt;code>min.insync.replicas&lt;/code> 共同決定、屬於 broker 端的耐久性保證。第二條軸線是 &lt;em>處理語義&lt;/em>：同一筆訊息在 producer 重送、consumer 重啟、partition rebalance 等情境下、會不會被寫進去兩次或被處理兩次。這條軸線由 producer idempotence、transaction 與 consumer 端的 commit 設計決定、屬於端到端的正確性保證。&lt;/p>
&lt;p>兩條軸線可以獨立調整：可以有「寫入承諾很強但處理語義是 at-least-once」的配置（acks=all + 非冪等 consumer）、也可以有「寫入承諾較弱但已開冪等」的配置。把 exactly-once 當成單一開關去找、是因為沒看出這兩條軸線存在。本文先講第一條（replication / ISR / acks）、再講第二條（idempotence / transaction）、最後談兩者疊起來能達成什麼、達不成什麼。&lt;/p>
&lt;p>這個拆分對映 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/delivery-semantics/" data-link-title="Delivery Semantics" data-link-desc="說明事件投遞語意如何定義遺失、重複、順序與補償策略">delivery semantics&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency&lt;/a> 兩張知識卡：前者描述 broker 承諾的送達次數、後者描述處理端怎麼讓「送達多次」不等於「生效多次」。&lt;/p>
&lt;h2 id="isr誰算跟得上的副本">ISR：誰算「跟得上」的副本&lt;/h2>
&lt;p>ISR（in-sync replica、同步副本集）是一個 partition 當前「跟得上 leader」的 replica 集合、是 Kafka 把 replication factor 這個 &lt;em>靜態配置&lt;/em> 轉成 &lt;em>動態保證&lt;/em> 的關鍵概念。Replication factor = 3 只說明這個 partition 有 3 份 replica；但任一時刻真正跟得上 leader 的可能只有 2 份或 1 份。ISR 就是這個「當前實際同步」的集合、寫入承諾的判斷都基於 ISR、不是基於 replication factor。&lt;/p>
&lt;p>一個 follower 留在 ISR 內的條件是：它在 &lt;code>replica.lag.time.max.ms&lt;/code>（預設 30 秒）內持續向 leader 拉取資料、且追上 leader 的 log end offset。當 follower 因為 broker 慢、網路抖動、GC 停頓或 disk 壓力而落後超過這個時間窗、leader 會把它移出 ISR — 這就是 ISR shrink（收縮）。當它恢復、重新追上、再被加回 ISR — 這是 ISR expand（擴張）。&lt;/p>
&lt;p>ISR 收縮本身不是故障、是 Kafka 對「這個 follower 暫時不可信」的誠實表態。真正的風險在於：ISR 收縮到某個程度後、&lt;code>acks=all&lt;/code> 的寫入承諾會無法滿足 &lt;code>min.insync.replicas&lt;/code> 而開始拒絕寫入。下一段的 acks 取捨直接建立在 ISR 這個概念上。&lt;/p>
&lt;p>實機看 ISR 的方式是 &lt;code>kafka-topics.sh --describe&lt;/code>、Isr 欄位列出當前同步的 broker id：&lt;/p>





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





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





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





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





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





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





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





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





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">Topic: repl-demo  Partition: 0  Leader: 2  Replicas: 2,3,1  Isr: 1,2,3</span></span></code></pre></div><blockquote>
<p>附帶觀察：在 KRaft 模式下、controller 也是 quorum（本演練三台都兼任 controller）。同時停掉兩台、controller quorum 失去多數、<code>kafka-topics.sh --describe</code> 對 metadata 的查詢會 timeout（DisconnectException）。production 叢集應把 controller 數量與 broker 故障域分開規劃、避免 broker 故障連帶打垮 metadata 平面。</p></blockquote>
<h3 id="unclean-leader-election-的取捨">Unclean leader election 的取捨</h3>
<p>當一個 partition 的所有 ISR replica 都不可用、只剩一個 <em>曾經落後、已被踢出 ISR</em> 的 replica 還活著、Kafka 面臨一個無法兩全的選擇。<code>unclean.leader.election.enable=false</code>（預設）會選擇 <em>不選 leader</em>：這個 partition 進入不可用狀態、拒絕讀寫、直到某個 ISR replica 恢復。<code>unclean.leader.election.enable=true</code> 會選擇 <em>把那個落後的 replica 提為 leader</em>：partition 立刻恢復可用、代價是那個 replica 上缺失的訊息（leader 掛掉前已 commit 但它還沒同步到的部分）永久遺失。</p>
<p><strong>判讀</strong>：這是一個 <em>可用性 vs 耐久性</em> 的直接取捨、沒有正確答案、只有對映業務的選擇。對金流、訂單、審計這類「丟一筆都不行」的 topic、保持 false、寧可 partition 短暫不可用也不接受靜默資料遺失。對 metric、log、可重算的衍生資料、開 true 換可用性、丟幾筆可接受。預設 false 是合理的安全預設、但要意識到它的代價是「所有 replica 都不在 ISR 時、partition 會卡住不可用」、這在多 broker 同時故障時會發生。</p>
<h3 id="idempotent-producer-對重送去重">Idempotent producer 對重送去重</h3>
<p><strong>演練</strong>：producer 開 <code>enable.idempotence=true</code>、acks=all、模擬 ack 丟失導致的重送。</p>
<p><strong>判讀</strong>：冪等開啟後、producer 因網路超時重送的訊息帶相同 (PID, partition, sequence)、broker 認出 sequence 重複、丟棄重送並回成功、partition 內不出現重複。實機上 <code>enable.idempotence=true</code> 的 produce 寫入正常（exit=0）、消費端讀回的訊息數等於實際送出的邏輯訊息數、重送不放大。要記住的邊界仍是：這只覆蓋單一 producer session；producer 重啟換 PID 後、跨 session 的重複要靠 transaction 或下游冪等鍵處理。</p>
<h3 id="transaction-中途失敗的-read_committed-隔離">Transaction 中途失敗的 read_committed 隔離</h3>
<p><strong>演練</strong>：transactional producer 在 beginTransaction 後寫入若干訊息、然後 abortTransaction（模擬處理中途失敗）；consumer 分別用 read_uncommitted 與 read_committed 讀取。</p>
<p><strong>判讀</strong>：read_committed 的 consumer 看不到被 abort 的訊息 — 中途失敗的 transaction 對它等於沒發生過、不會讀到「處理一半的髒資料」。read_uncommitted 的 consumer 則會讀到這些最終被 abort 的訊息、若據此處理就產生了不該發生的副作用。這是 transaction 隔離的核心價值：把「transaction 失敗」的可見性控制在 commit 邊界內。</p>
<blockquote>
<p>本段的 abort 行為依官方 transaction 語義描述。本地以 <code>kafka-console-consumer.sh --isolation-level read_committed</code> 驗證了隔離級別參數可用、且對已 commit 的普通訊息 read_committed 與 read_uncommitted 輸出一致（普通訊息一律可見、隔離級別只對 transactional 訊息產生差異）；完整的 begin/abort transaction 生命週期需 client code、未用 console 工具跑通。</p></blockquote>
<h2 id="capacity--cost">Capacity / cost</h2>
<p>各配置的容量與成本影響、決定它適用的規模與 topic 類別：</p>
<table>
  <thead>
      <tr>
          <th>配置</th>
          <th>吞吐 / 延遲影響</th>
          <th>適用</th>
          <th>警戒</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>acks=0</td>
          <td>最低延遲、最高吞吐</td>
          <td>可丟的 metric / log shipping</td>
          <td>任何狀態變更類訊息不可用</td>
      </tr>
      <tr>
          <td>acks=1</td>
          <td>中等、單次往返</td>
          <td>容忍極少量遺失的衍生資料</td>
          <td>誤當安全選項、broker 故障窗口會遺失</td>
      </tr>
      <tr>
          <td>acks=all + min.insync=2 + RF=3</td>
          <td>延遲 +1 次跨 broker 往返、吞吐略降</td>
          <td>不能丟的業務訊息</td>
          <td>min.insync 沒設則 acks=all 在 ISR=1 時失效</td>
      </tr>
      <tr>
          <td>enable.idempotence=true</td>
          <td>幾乎無額外成本</td>
          <td>所有 producer 預設開</td>
          <td>只覆蓋單一 session</td>
      </tr>
      <tr>
          <td>transaction + read_committed</td>
          <td>begin/commit overhead、read 端 LSO 等待延遲</td>
          <td>Kafka-to-Kafka stream processing 封閉迴圈</td>
          <td>跨外部系統不成立、coordinator 成新關鍵路徑</td>
      </tr>
  </tbody>
</table>
<p>務實 default：</p>
<ul>
<li>業務 topic 一律 RF=3 + acks=all + min.insync.replicas=2、idempotence 預設開</li>
<li>容忍度算術 <code>RF - min.insync.replicas</code> 要 ≥ 1、否則單台 broker 維護就會中斷寫入</li>
<li>完整 transaction exactly-once 只給 Kafka-to-Kafka pipeline；跨系統用 at-least-once + 下游冪等鍵</li>
<li>unclean.leader.election 保持 false、除非該 topic 明確可丟資料換可用性</li>
</ul>
<h2 id="整合--下一步">整合 / 下一步</h2>
<h3 id="跟-processing-recovery-semantics-對位">跟 processing-recovery-semantics 對位</h3>
<p>寫入承諾保證訊息留在 broker、但 <em>處理</em> 的不重不漏在 consumer 端。<a href="/blog/backend/03-message-queue/processing-recovery-semantics/" data-link-title="3.6 Processing Semantics 與 Recovery Semantics" data-link-desc="說明投遞成功、處理成功與恢復成功為何是三個不同判斷。">3.6 processing-recovery-semantics</a> 展開 consumer 的 commit 時機、崩潰恢復的 replay 範圍、以及「冪等放在哪一層」的判讀 — 跟本文的 transaction exactly-once 邊界互補：本文界定 Kafka 能送什麼、那篇界定處理端怎麼接才不放大重複。</p>
<h3 id="跟-event-contract-replay-boundary-對位">跟 event-contract-replay-boundary 對位</h3>
<p>Exactly-once 的封閉迴圈假設訊息格式穩定、replay 可重現。<a href="/blog/backend/03-message-queue/event-contract-replay-boundary/" data-link-title="3.7 Event Contract 與 Replay Boundary" data-link-desc="說明 event schema、idempotency key、replay window 與補償如何先於 broker 選型。">3.7 event-contract-replay-boundary</a> 展開 schema 演進與 replay 邊界 — 當 transaction 提供的原子性遇上 schema 變更、replay 舊訊息的可重現性會受 contract 影響、是 exactly-once 在時間維度上的延伸限制。</p>
<h3 id="對應反例-3c9">對應反例 3.C9</h3>
<p><a href="/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9 Queue 語義誤配</a> 是本文兩條軸線混淆的真實後果：broker 遷移後「名稱上相近的 delivery semantics」在失敗重播時產生不同結果、出現重複扣款與狀態漏更新。判讀路徑正是本文的拆分 — 先確認是寫入承諾（acks / ISR）還是處理語義（idempotence / commit 時機）出問題、不要用 queue depth 這種寫入承諾層的指標去判斷處理語義層的故障。</p>
<h3 id="對應案例-3c21-goldman-sachs-msk-遷移">對應案例 3.C21 Goldman Sachs MSK 遷移</h3>
<p><a href="/blog/backend/03-message-queue/cases/kafka-goldman-sachs-msk-migration/" data-link-title="3.C21 Goldman Sachs：MSK 遷移 with MirrorMaker 2" data-link-desc="Goldman Sachs Global Investment Research 從 on-prem Kafka 遷到 MSK、用 MM2 同步 topic/ACL/offset、atomic cutover 7 小時完成。">3.C21 Goldman Sachs MSK 遷移</a> 揭露遷移時可靠性配置的細節風險集中在 client 端的 timeout / flush / LB 配置、而非 broker 本身。本文的 acks=all 在 ISR 不足時拒絕寫入、若 client 端的 retry 與 timeout 沒對齊（如 flush timeout 太短）、會把「broker 正常的 backpressure」誤判成「遷移失敗」。可靠性配置與 client 容錯參數要一起驗證。</p>
<h3 id="下一步路由">下一步路由</h3>
<ul>
<li>上游概念：<a href="/blog/backend/knowledge-cards/delivery-semantics/" data-link-title="Delivery Semantics" data-link-desc="說明事件投遞語意如何定義遺失、重複、順序與補償策略">delivery semantics</a>、<a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a> 知識卡</li>
<li>同 vendor：<a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka overview</a> 的 producer / consumer 設計段</li>
<li>下游能力：<a href="/blog/backend/03-message-queue/processing-recovery-semantics/" data-link-title="3.6 Processing Semantics 與 Recovery Semantics" data-link-desc="說明投遞成功、處理成功與恢復成功為何是三個不同判斷。">3.6 processing-recovery-semantics</a>、<a href="/blog/backend/03-message-queue/event-contract-replay-boundary/" data-link-title="3.7 Event Contract 與 Replay Boundary" data-link-desc="說明 event schema、idempotency key、replay window 與補償如何先於 broker 選型。">3.7 event-contract-replay-boundary</a>、<a href="/blog/backend/06-reliability/idempotency-replay/" data-link-title="6.12 Idempotency 與 Replay 驗證" data-link-desc="把重試、重播與冪等性從口頭約定變成可驗證屬性">6.12 idempotency / replay</a></li>
<li>方法論：<a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章的寫作方法論</a></li>
</ul>
]]></content:encoded></item><item><title>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><item><title>RabbitMQ → AWS SQS：交出 broker 維運、把 routing 收斂進 application</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/rabbitmq/migrate-to-aws-sqs/</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-aws-sqs/</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/aws-sqs/" data-link-title="AWS SQS" data-link-desc="AWS managed queue、簡單可靠、無 ordering（standard）">AWS SQS&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> 的 paradigm shift、本篇主導差異維度是 &lt;em>operational model&lt;/em>：source 跟 target 都是任務隊列、能力大致對得上、但運維責任從「自管 broker 叢集」整批交給 AWS managed 服務。&lt;/p>&lt;/blockquote>
&lt;p>RabbitMQ → AWS SQS 的核心是把 broker 運維責任轉移給 managed 服務、同時接受 SQS 沒有 exchange routing 這個事實、把路由邏輯收斂回 application 或改用 SNS fan-out。這個遷移不是 protocol drop-in（AMQP client 不能直接連 SQS）、application 端需要改 delivery 控制機制（manual ack → visibility timeout + delete）；但它也不是 paradigm shift（兩端都是 at-least-once 任務隊列、DLQ / 重試 / 解耦的語意一致）。主導差異落在 operational 維度、所以本文走 Type C operational redesign hybrid 結構。&lt;/p>
&lt;h2 id="為什麼遷不想再養-rabbitmq-叢集">為什麼遷：不想再養 RabbitMQ 叢集&lt;/h2>
&lt;p>觸發評估 SQS 的最常見壓力是 broker 維運成本、不是功能缺口。自管 RabbitMQ 叢集要承擔的運維責任包含 Erlang cluster 拓樸維護、network partition（腦裂）處理、quorum queue 的 Raft 一致性調校、disk / memory alarm 的容量規劃、版本升級的 rolling restart。這些責任需要至少 0.5-1 FTE 的持續投入、且在 &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 為主">network partition&lt;/a> 這類事故發生時需要熟悉 Erlang runtime 的人即時介入。&lt;/p>
&lt;p>SQS 把這整層責任移除。沒有 broker 實例、沒有 cluster 拓樸、沒有 disk / memory watermark、沒有版本升級。換來的代價是 routing 能力消失（SQS 沒有 exchange）、application 要改 delivery 控制機制、以及 AWS 生態綁定。這個交換在三種情境下成立：&lt;/p>
&lt;p>第一種是 AWS 生態原生服務。若 producer / consumer 已經跑在 Lambda、ECS、EKS 上、SQS 的 event source mapping 跟 IAM 整合讓 application 不必自管連線池跟認證。RabbitMQ 在 AWS 上要嘛自管 EC2 叢集、要嘛用 Amazon MQ（仍是 broker 模型、運維責任只是部分轉移）、都不如 SQS 的 serverless 整合直接。&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/aws-sqs/" data-link-title="AWS SQS" data-link-desc="AWS managed queue、簡單可靠、無 ordering（standard）">AWS SQS</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> 的 paradigm shift、本篇主導差異維度是 <em>operational model</em>：source 跟 target 都是任務隊列、能力大致對得上、但運維責任從「自管 broker 叢集」整批交給 AWS managed 服務。</p></blockquote>
<p>RabbitMQ → AWS SQS 的核心是把 broker 運維責任轉移給 managed 服務、同時接受 SQS 沒有 exchange routing 這個事實、把路由邏輯收斂回 application 或改用 SNS fan-out。這個遷移不是 protocol drop-in（AMQP client 不能直接連 SQS）、application 端需要改 delivery 控制機制（manual ack → visibility timeout + delete）；但它也不是 paradigm shift（兩端都是 at-least-once 任務隊列、DLQ / 重試 / 解耦的語意一致）。主導差異落在 operational 維度、所以本文走 Type C operational redesign hybrid 結構。</p>
<h2 id="為什麼遷不想再養-rabbitmq-叢集">為什麼遷：不想再養 RabbitMQ 叢集</h2>
<p>觸發評估 SQS 的最常見壓力是 broker 維運成本、不是功能缺口。自管 RabbitMQ 叢集要承擔的運維責任包含 Erlang cluster 拓樸維護、network partition（腦裂）處理、quorum queue 的 Raft 一致性調校、disk / memory alarm 的容量規劃、版本升級的 rolling restart。這些責任需要至少 0.5-1 FTE 的持續投入、且在 <a href="/blog/backend/03-message-queue/vendors/rabbitmq/" data-link-title="RabbitMQ" data-link-desc="Classic message broker、AMQP routing 為主">network partition</a> 這類事故發生時需要熟悉 Erlang runtime 的人即時介入。</p>
<p>SQS 把這整層責任移除。沒有 broker 實例、沒有 cluster 拓樸、沒有 disk / memory watermark、沒有版本升級。換來的代價是 routing 能力消失（SQS 沒有 exchange）、application 要改 delivery 控制機制、以及 AWS 生態綁定。這個交換在三種情境下成立：</p>
<p>第一種是 AWS 生態原生服務。若 producer / consumer 已經跑在 Lambda、ECS、EKS 上、SQS 的 event source mapping 跟 IAM 整合讓 application 不必自管連線池跟認證。RabbitMQ 在 AWS 上要嘛自管 EC2 叢集、要嘛用 Amazon MQ（仍是 broker 模型、運維責任只是部分轉移）、都不如 SQS 的 serverless 整合直接。</p>
<p>第二種是 routing 邏輯本來就簡單。若 RabbitMQ 的用法是 direct exchange + 少數固定 routing key、或單純 worker pool 消費單一 queue、那 exchange 的靈活性本來就沒被用到、遷到 SQS 不損失能力。Airbnb 的 Dynein 分散式延遲任務系統就是這個形狀：用 SQS at-least-once + DLQ 取代原本受限於單 Redis 的 Resque、每 scheduler instance 達約 1000 QPS、水平擴展（見 <a href="/blog/backend/03-message-queue/cases/sqs-airbnb-dynein-delayed-jobs/" data-link-title="3.C48 Airbnb Dynein：SQS 分散式延遲任務排程" data-link-desc="Airbnb 用 SQS at-least-once &#43; DLQ 取代 Resque 單 Redis 限制、每 scheduler 1000 QPS、SQS wrap DynamoDB 處理 &gt; 15 分鐘 delay。">3.C48 Airbnb Dynein</a>）。任務排程對「不丟資料」的需求 at-least-once 足夠、不需要 broker 級 routing。</p>
<p>第三種是團隊規模不支撐 broker 專業。小團隊養一套 RabbitMQ 叢集、真正用到的是「可靠的任務隊列 + DLQ」、但要付出整套 Erlang 運維學習曲線。把這層交給 SQS、團隊把精力放回 application 邏輯。</p>
<h2 id="6-維-diff-dimension-audit">6 維 diff dimension audit</h2>
<p>遷移前先跑 <a href="/blog/posts/migration-playbook-%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84stage-0-variant-%E8%A6%8F%E5%8A%83%E6%8A%8A-collapse-%E7%8E%87%E5%BE%9E-60-%E9%99%8D%E5%88%B0-0/" data-link-title="Migration Playbook 方法論的演化紀錄：Stage 0 variant 規劃把 collapse 率從 60% 降到 0%" data-link-desc="跨 vendor migration playbook 需要獨立寫作方法論的依據，以及這套方法論從三輪 batch dogfood 中演化出來的驗證證據。">diff dimension audit</a>、對每個維度評估 source 跟 target 的差異程度、決定主導維度跟結構：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>RabbitMQ（self-managed）</th>
          <th>AWS SQS（managed）</th>
          <th>差異</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Schema / API</td>
          <td>AMQP 0-9-1 協議、exchange / queue</td>
          <td>HTTP API、SendMessage / ReceiveMessage</td>
          <td>中</td>
      </tr>
      <tr>
          <td>Operational model</td>
          <td>自管 Erlang 叢集、cluster / disk / 升級</td>
          <td>Fully managed、無實例、無版本</td>
          <td>高</td>
      </tr>
      <tr>
          <td>Abstraction / paradigm</td>
          <td>任務隊列 + 重試 + DLQ</td>
          <td>任務隊列 + 重試 + DLQ</td>
          <td>低</td>
      </tr>
      <tr>
          <td>Components（1 vs N）</td>
          <td>broker 一站式（routing 內建）</td>
          <td>SQS + 需要 SNS 補 fan-out routing</td>
          <td>中</td>
      </tr>
      <tr>
          <td>Application change</td>
          <td>manual ack / nack、prefetch、AMQP client</td>
          <td>visibility timeout + delete、batch、SDK</td>
          <td>中高</td>
      </tr>
      <tr>
          <td>Data topology</td>
          <td>單叢集 / federation 拓樸</td>
          <td>region-scoped queue、無拓樸概念</td>
          <td>低</td>
      </tr>
  </tbody>
</table>
<p><strong>主導維度是 operational（高）</strong>：遷移的核心價值跟核心風險都在「broker 運維責任整批轉移」。Application change 維度評中高、因為 delivery 控制機制要改、但這是受控的 SDK 層改寫、不是 paradigm 重設計。Components 維度評中、因為 exchange routing 在 SQS 沒有對等物、要靠 SNS fan-out 或多 queue 補回來。其餘三維度低或中。</p>
<p>主導維度落在 operational、所以主結構走 Type C：以 operational redesign 對位開頭、phased 執行、故障演練聚焦在「以為對等其實不對等」的運維陷阱。Application change 跟 Components 兩個次高維度不硬塞進主結構、各自抽出獨立段（下面「application 改寫」跟「routing 收斂」兩段）。</p>
<h3 id="operational-redesign-對位">Operational redesign 對位</h3>
<p>Operational 維度差異最大、先逐項對位「原本自己做的事、現在誰做、怎麼做」：</p>
<table>
  <thead>
      <tr>
          <th>運維責任</th>
          <th>RabbitMQ（自己做）</th>
          <th>SQS（managed / application）</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>高可用</td>
          <td>quorum queue + cluster + partition 處理</td>
          <td>AWS 跨 AZ 自動冗餘、無需配置</td>
      </tr>
      <tr>
          <td>容量規劃</td>
          <td>disk / memory watermark、queue length 限</td>
          <td>自動擴展、無實例容量概念</td>
      </tr>
      <tr>
          <td>版本升級</td>
          <td>rolling restart、相容性驗證</td>
          <td>無、AWS 維護</td>
      </tr>
      <tr>
          <td>監控</td>
          <td>Management UI + Prometheus exporter</td>
          <td>CloudWatch metric（depth / age）</td>
      </tr>
      <tr>
          <td>Delivery 控制</td>
          <td>broker-side ack / nack 狀態機</td>
          <td>client-side visibility timeout + delete</td>
      </tr>
      <tr>
          <td>重試 / DLQ</td>
          <td>DLX + dead-letter routing key</td>
          <td>redrive policy + maxReceiveCount</td>
      </tr>
      <tr>
          <td>Routing</td>
          <td>exchange + binding（broker 內建）</td>
          <td>application 或 SNS（broker 外）</td>
      </tr>
  </tbody>
</table>
<p>前四列是純收益：責任消失、不需要對等實作。後三列是責任轉移、不是消失 — delivery 控制從 broker 移到 client、重試從 DLX 移到 redrive policy、routing 從 broker 移到 application。這三列正是故障演練聚焦的地方、因為「以為功能還在、其實機制換了」是這類遷移的主要事故來源。</p>
<p>監控這列值得展開。RabbitMQ 的 queue depth、unacked、consumer 數量是從 broker 直接讀；SQS 改看 CloudWatch 的 <code>ApproximateNumberOfMessagesVisible</code>（queue depth）跟 <code>ApproximateAgeOfOldestMessage</code>（lag 訊號）。差異在於 SQS 的 metric 是 approximate、且有分鐘級延遲、不適合用來做秒級的 backpressure 決策。原本靠 RabbitMQ Management UI 即時看 queue 狀態的 runbook 要改寫成 CloudWatch alarm 驅動。</p>
<h2 id="application-改寫manual-ack--visibility-timeout--delete">Application 改寫：manual ack → visibility timeout + delete</h2>
<p>Application change 維度的核心是 delivery 控制機制換了一套模型。RabbitMQ 是 broker-side 維護訊息狀態、consumer 用 <a href="/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">ack/nack</a> 回報處理結果；SQS 是 client-side 用 <a href="/blog/backend/knowledge-cards/in-flight/" data-link-title="In-Flight Work" data-link-desc="目前已接收但尚未完成處理的工作量">visibility timeout</a> + 顯式 delete、broker 不維護「處理中」以外的狀態。</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 pattern</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 class="c1"># 一次最多領 10 條未 ack</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">callback</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"># nack + requeue，或丟 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">callback</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"># SQS 端：visibility timeout + delete pattern</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="k">while</span> <span class="kc">True</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="n">resp</span> <span class="o">=</span> <span class="n">sqs</span><span class="o">.</span><span class="n">receive_message</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="n">QueueUrl</span><span class="o">=</span><span class="n">queue_url</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="n">MaxNumberOfMessages</span><span class="o">=</span><span class="mi">10</span><span class="p">,</span>        <span class="c1"># batch、對應 prefetch</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="n">WaitTimeSeconds</span><span class="o">=</span><span class="mi">20</span><span class="p">,</span>            <span class="c1"># long polling</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="n">VisibilityTimeout</span><span class="o">=</span><span class="mi">60</span><span class="p">,</span>          <span class="c1"># 處理中對其他 consumer 隱藏</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 class="k">for</span> <span class="n">msg</span> <span class="ow">in</span> <span class="n">resp</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;Messages&#34;</span><span class="p">,</span> <span class="p">[]):</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">            <span class="n">process</span><span class="p">(</span><span class="n">msg</span><span class="p">[</span><span class="s2">&#34;Body&#34;</span><span class="p">])</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">            <span class="n">sqs</span><span class="o">.</span><span class="n">delete_message</span><span class="p">(</span>           <span class="c1"># 顯式 delete = ack</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">                <span class="n">QueueUrl</span><span class="o">=</span><span class="n">queue_url</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">                <span class="n">ReceiptHandle</span><span class="o">=</span><span class="n">msg</span><span class="p">[</span><span class="s2">&#34;ReceiptHandle&#34;</span><span class="p">],</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">            <span class="p">)</span>
</span></span><span class="line"><span class="ln">16</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">17</span><span class="cl">            <span class="k">pass</span>  <span class="c1"># 不 delete、visibility timeout 後自動回 queue 重試</span></span></span></code></pre></div><p>對應關係：</p>
<ul>
<li>RabbitMQ <code>basic_ack</code> → SQS <code>delete_message</code>：處理成功的訊息要顯式刪除、否則 visibility timeout 後重新可見。「不做事」在 SQS 等於「重試」、在 RabbitMQ 等於「卡住 unacked」。</li>
<li>RabbitMQ <code>prefetch_count</code> → SQS <code>MaxNumberOfMessages</code>（上限 10）+ visibility timeout：併發控制從「broker 限制未 ack 數量」變成「一次 receive 的 batch 大小 + 隱藏時間窗」。</li>
<li>RabbitMQ <code>basic_nack(requeue=False)</code>（丟 DLX）→ SQS redrive policy：失敗不再是 application 主動丟 DLX、而是「達到 maxReceiveCount 次數後 SQS 自動送 DLQ」。</li>
<li>RabbitMQ push 模型（broker 主動推給 consumer）→ SQS pull 模型（consumer 主動 long polling）：consumer loop 結構不同、SQS 沒有 broker 主動推送、要嘛自己 poll、要嘛交給 Lambda event source mapping 代 poll。</li>
</ul>
<p>application 邏輯改動集中在 consumer 的 receive / ack / 重試三段、producer 端從 <code>basic_publish</code> 改成 <code>send_message</code> 相對單純。整體改動量取決於原本用了多少 AMQP 特性、典型情境是 consumer 端 20-40% 改寫。</p>
<h2 id="routing-收斂exchange-沒了靠-sns-fan-out-或多-queue">Routing 收斂：exchange 沒了、靠 SNS fan-out 或多 queue</h2>
<p>Components 維度的核心是 SQS 沒有 exchange、RabbitMQ 的 routing 能力要在 broker 外重建。RabbitMQ 的 <a href="/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">exchange</a> 在 broker 內承擔分流：一條訊息經 routing key 跟 binding 決定進哪些 queue。SQS 是裸 queue、producer 直接指定 queue、沒有中間分流層。</p>
<table>
  <thead>
      <tr>
          <th>RabbitMQ routing 模式</th>
          <th>SQS 對應方案</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Direct（固定 key）</td>
          <td>直接 send 到對應 queue、routing 收斂進 producer 程式碼</td>
      </tr>
      <tr>
          <td>Fanout（廣播）</td>
          <td>SNS topic → 多個 SQS queue 訂閱（SNS-to-SQS fan-out）</td>
      </tr>
      <tr>
          <td>Topic（層級 key 匹配）</td>
          <td>SNS + message filtering（subscription filter policy）</td>
      </tr>
      <tr>
          <td>Headers</td>
          <td>SNS message attribute filtering</td>
      </tr>
  </tbody>
</table>
<p>判讀：</p>
<ul>
<li><strong>Direct exchange + 少數固定 key</strong>：最容易遷。routing 邏輯本來就是「key X 進 queue X」、改成 producer 直接 <code>send_message</code> 到對應 queue url。routing 從 broker 收斂進 application、程式碼多幾行 if/else 或 map 查表。</li>
<li><strong>Fanout（一條訊息給多個 downstream）</strong>：用 SNS-to-SQS。SNS topic 當 fan-out 點、每個 downstream 訂閱一個自己的 SQS queue。Twitch EventSub 就是這個形狀（見 <a href="/blog/backend/03-message-queue/cases/sqs-twitch-eventsub-fanout/" data-link-title="3.C54 Twitch EventSub：SNS&#43;SQS fan-out 給第三方" data-link-desc="Twitch Event Bus ~1660 events/sec 進 SNS、EventSub 用 SQS 接收 &#43; Dispatcher fan-out 給訂閱者。">3.C54 Twitch EventSub</a>）：SNS fan-out 到多個 SQS、各 consumer 獨立消費。這比 RabbitMQ fanout exchange 多一層 SNS、但換來 managed 運維。</li>
<li><strong>Topic exchange（複雜層級匹配）</strong>：SNS 的 subscription filter policy 能做 attribute-based 過濾、但表達力不如 AMQP topic 的 <code>*</code> / <code>#</code> 通配。複雜 topic routing 是「不該遷」的訊號（見下節）。</li>
</ul>
<p>關鍵取捨：SQS + SNS 把 RabbitMQ 的單一 broker（routing 內建）拆成兩個 managed 服務（SQS 排隊 + SNS 分流）。好處是各自 managed、壞處是 routing 從宣告式 binding 變成要管 SNS topic + subscription + filter policy 的組合、跨服務除錯多一層。</p>
<h2 id="什麼不該遷保留-rabbitmq-的訊號">什麼不該遷：保留 RabbitMQ 的訊號</h2>
<p>SQS 的 managed 簡潔有代價、三類用法遷過去會損失能力或增加複雜度：</p>
<p><strong>複雜 topic routing</strong>。若 RabbitMQ 重度使用 topic exchange 的 <code>*</code> / <code>#</code> 層級通配、binding 規則數十條、那 routing 的表達力是核心價值。SNS subscription filter 的 attribute 匹配做不到對等表達、勉強遷會把 broker 內的宣告式 routing 拆成散落在 SNS filter policy + application 程式碼的命令式邏輯、維護成本反而上升。GoCardless 用單一 topic exchange 當服務 mesh（見 <a href="/blog/backend/03-message-queue/cases/rabbitmq-gocardless-hutch-service-mesh/" data-link-title="3.C26 GoCardless：Hutch &#43; 單一 topic exchange service mesh" data-link-desc="GoCardless 單一 RabbitMQ cluster 作所有 service 通訊中樞、routing key 用 service.subject.action 格式、JSON 多語言可讀。">3.C26 GoCardless Hutch</a>）這類設計、routing 就是架構本身、不該拆。</p>
<p><strong>需要 broker 級 ordering</strong>。RabbitMQ 單 queue 預設 FIFO、consistent hash exchange 還能做 per-key ordering（見 <a href="/blog/backend/03-message-queue/cases/rabbitmq-wework-consistent-hash-ordering/" data-link-title="3.C28 WeWork：Consistent hash exchange 保證帳戶順序" data-link-desc="WeWork 固定數量 queue &#43; account ID hash 路由、每 queue 一個 worker &#43; exclusive consumer 保 partition-level ordering。">3.C28 WeWork hash ordering</a>）。SQS standard queue <em>無 ordering</em>；要 ordering 只能用 FIFO queue、而 FIFO 吞吐受限（每 MessageGroupId 有序、整體 3000 msg/sec with batching）。若 workload 同時要高吞吐跟嚴格 ordering、SQS FIFO 兩者不可兼得、RabbitMQ 反而更適合。</p>
<p><strong>RPC over messaging（request-reply）</strong>。RabbitMQ 的 reply-to + correlation-id 做同步 RPC 模式、SQS 沒有原生 request-reply、要自己用兩條 queue + correlation 拼、延遲也不適合（SQS 是 task queue 不是低延遲傳輸）。這類用法該考慮 <a href="/blog/backend/03-message-queue/vendors/nats/" data-link-title="NATS" data-link-desc="Lightweight messaging、JetStream 加持久化與 streams">NATS</a> 的 request-reply 或直接 HTTP。</p>
<h2 id="migration-結構漸進-cutover">Migration 結構：漸進 cutover</h2>
<p>operational redesign 的 cutover 走 dual-run、按 queue（不是按整個叢集）漸進切、每步都保留回退邊界：</p>
<ol>
<li><strong>Phase 0：scope 盤點</strong> — 列出所有 exchange / queue / binding、標註 routing 模式（direct / fanout / topic）跟 ordering 需求。判斷哪些 queue 適合遷（簡單 routing、at-least-once 夠用）、哪些保留（複雜 topic、需 broker ordering、RPC）。</li>
<li><strong>Phase 1：SQS / SNS 基礎建設</strong> — 對適合遷的 queue 建對應 SQS queue + DLQ（設 redrive policy + maxReceiveCount）、fanout 場景建 SNS topic + subscription。設好 IAM policy、visibility timeout 對齊 consumer 最大處理時間。</li>
<li><strong>Phase 2：consumer 改寫 + dual-consume</strong> — application consumer 改成 SQS pull 模型（或 Lambda event source）、先讓新 consumer 跟舊 RabbitMQ consumer <em>並存</em>、producer 暫時雙寫到 RabbitMQ + SQS、驗證 SQS 端處理正確。</li>
<li><strong>Phase 3：producer cutover</strong> — 逐 queue 把 producer 從 RabbitMQ 切到 SQS / SNS、停掉該 queue 的雙寫。這步可逆：發現問題切回 RabbitMQ producer 即可。</li>
<li><strong>Phase 4：下線 RabbitMQ queue</strong> — 確認某 queue 在 SQS 穩定運行、且 RabbitMQ 端該 queue 已排空、才停掉 RabbitMQ 對應的 exchange / queue。這是不可逆步驟、不該過早。</li>
<li><strong>Phase 5：叢集退役</strong> — 所有適合遷的 queue 都切完、RabbitMQ 只剩保留的複雜 routing queue（或完全清空）、才縮編或退役叢集。</li>
</ol>
<p>漸進 cutover 的關鍵是 <em>按 queue 切、不按叢集切</em>。每條 queue 是獨立的遷移單元、各自走 Phase 2-4、互不阻塞。複雜 routing 的 queue 可以永遠留在 RabbitMQ、形成 RabbitMQ + SQS 長期共存的混合架構。</p>
<h2 id="production-故障演練">Production 故障演練</h2>
<h3 id="case-1dlx-改-redrive-policy重試語意不對等">Case 1：DLX 改 redrive policy，重試語意不對等</h3>
<p><strong>徵兆</strong>：RabbitMQ 端用 DLX 配 message TTL 做「延遲重試 + 多層 escalation」（如 <a href="/blog/backend/03-message-queue/cases/rabbitmq-indeed-delay-dlq-escalation/" data-link-title="3.C25 Indeed：Delay queue &#43; DLQ 三層 escalation" data-link-desc="Indeed 每天 35M&#43; 職缺、設計 Requeue → Delay queue → DLQ 三層 escalation 避開 head-of-line blocking。">3.C25 Indeed Delay + DLQ</a> 的三層 retry）；遷到 SQS 後發現 redrive policy 只能設「失敗 N 次直接進 DLQ」、做不出原本的延遲重試階梯。</p>
<p><strong>根因</strong>：RabbitMQ DLX 是 routing 機制、能配 TTL + 多個中繼 queue 組出任意 escalation 拓樸；SQS redrive policy 是單一規則（maxReceiveCount 到了就送 DLQ）、沒有中繼層。兩者都叫「DLQ」、但 RabbitMQ 的是可編程 routing、SQS 的是固定計數。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>指數退避用 visibility timeout 做</strong>：失敗時 application 主動 <code>ChangeMessageVisibility</code> 延長隱藏時間、實現退避、而不是依賴 DLX TTL。</li>
<li><strong>多層 escalation 用多 queue 串</strong>：若真需要 N 層、建 N 個 SQS queue、application 失敗時把訊息 send 到下一層 queue、每層設不同 redrive policy。複雜度比 DLX 高、是「複雜 routing 不該遷」的訊號之一。</li>
<li><strong>接受簡化</strong>：多數 task queue 的重試需求是「重試幾次後進 DLQ 人工檢視」、SQS redrive policy 直接對應、不需要重建 escalation 階梯。</li>
</ol>
<h3 id="case-2prefetch-改-batch--visibility併發控制行為變了">Case 2：prefetch 改 batch + visibility，併發控制行為變了</h3>
<p><strong>徵兆</strong>：RabbitMQ 端 <code>prefetch_count=1</code> 確保 worker 一次只處理一條（公平派發、慢任務不囤積）；遷 SQS 後 consumer 一次 <code>receive_message</code> 領 10 條、其中一條慢任務拖累整批、且 visibility timeout 對整批同時計時、處理到一半超時導致前面已處理的訊息重複。</p>
<p><strong>根因</strong>：RabbitMQ prefetch 是 per-message 的未 ack 上限、broker 逐條控制；SQS 的 batch 是一次領多條、visibility timeout 對 batch 內每條<em>獨立</em>計時、但 application 若同步處理整批、慢的那條會讓後面的訊息在處理前就接近超時。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>慢任務用 batch size 1</strong>：對等 RabbitMQ <code>prefetch=1</code> 就設 <code>MaxNumberOfMessages=1</code>、一次領一條、避免批內互相拖累。</li>
<li><strong>visibility timeout 設成略高於最大處理時間</strong>：Capital One 的 SQS + Lambda 實務明示這點（見 <a href="/blog/backend/03-message-queue/cases/sqs-capital-one-visibility-timeout/" data-link-title="3.C50 Capital One：Visibility timeout 設計與 Lambda event source" data-link-desc="Capital One tech blog 講 SQS &#43; Lambda：visibility timeout 應略高於最大處理時間、Lambda 初 5 個 long polling、可擴 60/min。">3.C50 Capital One</a>）— timeout 太短重複處理、太長延遲 retry。長任務處理中主動 <code>ChangeMessageVisibility</code> 續期。</li>
<li><strong>逐條 delete 不等整批</strong>：每條處理完立刻 <code>delete_message</code>、不要等整批做完才一起刪、降低整批超時導致部分重複的風險。</li>
</ol>
<h3 id="case-3fanout-改-sns-to-sqs漏訂閱導致部分-downstream-收不到">Case 3：fanout 改 SNS-to-SQS，漏訂閱導致部分 downstream 收不到</h3>
<p><strong>徵兆</strong>：RabbitMQ fanout exchange 廣播到所有 binding queue、新增 downstream 只要 bind 上去就收得到；遷成 SNS-to-SQS 後、某個新 downstream 的 SQS queue 沒訂閱到 SNS topic、或 subscription filter policy 設錯、導致該 downstream 靜默漏訊息。</p>
<p><strong>根因</strong>：RabbitMQ fanout 的廣播是 broker 內建語意、binding 一建立就生效；SNS-to-SQS 的 fan-out 是「每個 downstream 各自建 SQS queue + 訂閱 SNS topic + 設 queue policy 允許 SNS 投遞」三步、任一步漏掉或 filter policy 寫錯就靜默漏。多一層服務 = 多一層配置出錯點。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>訂閱關係 IaC 管理</strong>：SNS subscription + SQS queue policy 用 Terraform / CloudFormation 宣告、避免手動建漏。</li>
<li><strong>驗證 fan-out 完整性</strong>：cutover 前發測試訊息、確認<em>每個</em> downstream queue 都收到（對照 RabbitMQ 端 binding 清單逐一核對）。</li>
<li><strong>filter policy 預設寬鬆</strong>：除非明確要過濾、subscription 不設 filter policy（全收）、避免「以為廣播、實際被 filter 擋掉」。</li>
</ol>
<h3 id="case-4訊息超過-256kbsqs-拒收">Case 4：訊息超過 256KB，SQS 拒收</h3>
<p><strong>徵兆</strong>：RabbitMQ 對單訊息大小無硬性低上限（受 frame_max / memory 限制、實務常見 MB 級 payload）；遷 SQS 後、原本能傳的大 payload 訊息被拒、SendMessage 報 message 超過 256KB 上限。</p>
<p><strong>根因</strong>：SQS 單訊息上限 256KB（含 message attribute）。RabbitMQ 沒有這個低上限、application 可能習慣直接把大 payload（如完整文件、序列化大物件）塞進訊息體。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>Claim-check pattern</strong>：大 payload 存 S3、訊息只放 S3 物件的引用（key / presigned URL）、consumer 收到後從 S3 取。FINRA 的大檔案處理是 S3 event notification → SQS（檔案上傳 S3 後由 S3 推通知），結果同樣讓訊息只帶 S3 物件引用，但機制是 S3 觸發、不是 producer 主動 offload（見 <a href="/blog/backend/03-message-queue/cases/sqs-finra-large-file-service/" data-link-title="3.C53 FINRA：S3 → SQS notification 大檔上傳" data-link-desc="FINRA 金融監管、broker 上傳大檔、S3 → SQS notification → LFS、KMS &#43; bucket policy &#43; queue policy 三層稽核。">3.C53 FINRA Large File</a>）。</li>
<li><strong>SQS Extended Client Library</strong>：AWS 官方 library 自動把超過上限的 payload 透明存 S3、訊息存指標、consumer 端自動取回、application 程式碼幾乎不改。</li>
<li><strong>盤點 payload 大小分佈</strong>：Phase 0 audit 時量測現有訊息大小、超 256KB 的比例決定是否需要 claim-check、避免 cutover 後才發現大量訊息被拒。</li>
</ol>
<h3 id="case-5ordering-從-rabbitmq-到-sqs-fifo吞吐撞天花板">Case 5：ordering 從 RabbitMQ 到 SQS FIFO，吞吐撞天花板</h3>
<p><strong>徵兆</strong>：RabbitMQ 單 queue 提供順序消費、原本靠這個保證同一筆訂單的事件有序處理；遷 SQS standard queue 後 ordering 消失、改用 SQS FIFO queue 恢復 ordering、但吞吐從原本的數萬 msg/sec 掉到 3000 msg/sec 上限、隊列堆積。</p>
<p><strong>根因</strong>：SQS standard queue 無 ordering（為了吞吐跟可用性的設計取捨）；FIFO queue 提供 per-MessageGroupId 有序 + 去重、但整體吞吐上限 3000 msg/sec（with batching）。RabbitMQ 單 queue 的有序消費吞吐遠高於此。SQS FIFO 的吞吐上限是 300 TPS（不 batch）／ 3000 TPS（batch，後者為通用 SQS FIFO 數值）。Twilio 的 webhook buffer 文件特別點出 FIFO 300 TPS 這個限制（見 <a href="/blog/backend/03-message-queue/cases/sqs-twilio-webhook-buffer/" data-link-title="3.C58 Twilio：SQS 緩衝高流量 webhook" data-link-desc="Twilio 教用 SQS 緩衝 SMS / status callback webhook、分 queue（SMS vs callback）、long polling 減 cost、FIFO 300 TPS 上限要分片。">3.C58 Twilio webhook</a>）。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>重新審視 ordering 粒度</strong>：用 MessageGroupId 把 ordering 限縮到真正需要的範圍（如 per-訂單、per-用戶）、不同 group 平行處理、整體吞吐 = group 數 × per-group 吞吐、繞過單 queue 3000 上限。</li>
<li><strong>拆分 ordered 跟 unordered 流量</strong>：只有真需要 ordering 的訊息走 FIFO、其餘走 standard queue 拿高吞吐。多數 workload 只有一小部分需要嚴格 ordering。</li>
<li><strong>ordering 是「不該遷」的硬訊號</strong>：若 workload 整體都需要高吞吐 + 嚴格 ordering、SQS FIFO 兩者不可兼得、保留 RabbitMQ 或考慮 <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>（per-partition ordering + 高吞吐）。</li>
</ol>
<h2 id="capacity--cost-對照">Capacity / cost 對照</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>RabbitMQ（self-managed EC2）</th>
          <th>AWS SQS（managed）</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>叢集 baseline</td>
          <td>3 broker（HA）+ EBS</td>
          <td>無實例</td>
      </tr>
      <tr>
          <td>運維 FTE</td>
          <td>0.5-1 FTE</td>
          <td>~0.1 FTE（IAM / alarm 配置）</td>
      </tr>
      <tr>
          <td>計費模型</td>
          <td>EC2 instance hour + EBS + 流量</td>
          <td>per-request（每百萬 request）+ 跨 region 流量</td>
      </tr>
      <tr>
          <td>吞吐上限</td>
          <td>受 broker 規格 / 網路限制</td>
          <td>standard 近乎無限、FIFO 3000 msg/sec</td>
      </tr>
      <tr>
          <td>Ordering</td>
          <td>單 queue 有序、consistent hash per-key</td>
          <td>standard 無、FIFO per-group</td>
      </tr>
      <tr>
          <td>Routing</td>
          <td>broker 內建 exchange</td>
          <td>無（需 SNS / application）</td>
      </tr>
      <tr>
          <td>訊息大小上限</td>
          <td>受 frame_max / memory（MB 級可行）</td>
          <td>256KB（超過用 S3 claim-check）</td>
      </tr>
      <tr>
          <td>監控延遲</td>
          <td>即時（Management UI）</td>
          <td>CloudWatch approximate、分鐘級</td>
      </tr>
  </tbody>
</table>
<p><strong>判讀</strong>：低到中吞吐、簡單 routing、AWS 生態的 task queue、SQS 在運維成本上顯著划算（FTE 從 0.5-1 降到約 0.1）。高吞吐 + 嚴格 ordering、或重度 exchange routing 的 workload、SQS 的 per-request 成本跟能力限制可能讓 RabbitMQ（或 Kafka）反而合適。SQS 的 cost 是用量驅動、流量大時 per-request 費用要納入評估、對照 <a href="/blog/backend/00-service-selection/cost-risk-tradeoffs/" data-link-title="0.6 成本、風險與選型取捨" data-link-desc="用人力成本、雲端成本、操作成本與失敗代價判斷後端能力投入順序">0.6 成本取捨</a>。</p>
<h2 id="整合--下一步">整合 / 下一步</h2>
<h3 id="混合架構是常見終態">混合架構是常見終態</h3>
<p>多數遷移不會把 RabbitMQ 完全清空。簡單 task queue 遷 SQS、複雜 topic routing / broker ordering / RPC 留 RabbitMQ、形成長期共存：</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 queue / fanout]              [複雜 topic routing / RPC / ordering]
</span></span><span class="line"><span class="ln">2</span><span class="cl">        AWS SQS / SNS                              RabbitMQ
</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">   Lambda / ECS consumer                    自管叢集（縮編後）</span></span></code></pre></div><p>按 queue 漸進切的結果就是混合架構 — 不需要為了「遷乾淨」勉強把不適合的 queue 也搬過去。</p>
<h3 id="跟-rabbitmq--kafka-的對照">跟 RabbitMQ → Kafka 的對照</h3>
<p>RabbitMQ 還有另一條遷移路徑是 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">RabbitMQ → Kafka</a>（work queue → event streaming）。兩條路的差異：遷 SQS 是 <em>交出運維、能力對等簡化</em>（仍是 task queue）；遷 Kafka 是 <em>換 paradigm、要 replay / 高吞吐 streaming</em>（從任務隊列變 event log）。選哪條看的是「想擺脫運維」還是「需要 streaming 能力」、不是同一個決策。</p>
<h3 id="跟前面-migration-playbook-的結構對照">跟前面 migration playbook 的結構對照</h3>
<table>
  <thead>
      <tr>
          <th>篇</th>
          <th>主導差異維度</th>
          <th>結構</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Kafka ↔ NATS</td>
          <td>Paradigm（高）</td>
          <td>partial + 混合</td>
      </tr>
      <tr>
          <td>RabbitMQ → SQS（本篇）</td>
          <td>Operational（高）</td>
          <td>Type C operational hybrid</td>
      </tr>
  </tbody>
</table>
<p><strong>結論</strong>：兩篇都是 message queue 跨 vendor、但主導差異維度不同 — Kafka ↔ NATS 卡在 paradigm（不同抽象層）、RabbitMQ → SQS 卡在 operational（運維責任轉移）。結構由主導維度決定、不是 universal phased playbook。</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/aws-sqs/" data-link-title="AWS SQS" data-link-desc="AWS managed queue、簡單可靠、無 ordering（standard）">AWS SQS</a></li>
<li>平行 vendor：<a href="/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Google Pub/Sub</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>平行 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></li>
<li>引用案例：<a href="/blog/backend/03-message-queue/cases/sqs-airbnb-dynein-delayed-jobs/" data-link-title="3.C48 Airbnb Dynein：SQS 分散式延遲任務排程" data-link-desc="Airbnb 用 SQS at-least-once &#43; DLQ 取代 Resque 單 Redis 限制、每 scheduler 1000 QPS、SQS wrap DynamoDB 處理 &gt; 15 分鐘 delay。">3.C48 Airbnb Dynein</a> / <a href="/blog/backend/03-message-queue/cases/sqs-capital-one-visibility-timeout/" data-link-title="3.C50 Capital One：Visibility timeout 設計與 Lambda event source" data-link-desc="Capital One tech blog 講 SQS &#43; Lambda：visibility timeout 應略高於最大處理時間、Lambda 初 5 個 long polling、可擴 60/min。">3.C50 Capital One</a> / <a href="/blog/backend/03-message-queue/cases/sqs-twitch-eventsub-fanout/" data-link-title="3.C54 Twitch EventSub：SNS&#43;SQS fan-out 給第三方" data-link-desc="Twitch Event Bus ~1660 events/sec 進 SNS、EventSub 用 SQS 接收 &#43; Dispatcher fan-out 給訂閱者。">3.C54 Twitch EventSub</a> / <a href="/blog/backend/03-message-queue/cases/sqs-twilio-webhook-buffer/" data-link-title="3.C58 Twilio：SQS 緩衝高流量 webhook" data-link-desc="Twilio 教用 SQS 緩衝 SMS / status callback webhook、分 queue（SMS vs callback）、long polling 減 cost、FIFO 300 TPS 上限要分片。">3.C58 Twilio webhook</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 XCLAIM / PEL 失敗接管與 Cluster 影響</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/redis-streams/xclaim-pel-recovery/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/redis-streams/xclaim-pel-recovery/</guid><description>&lt;blockquote>
&lt;p>本文是 &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> overview 的 implementation-layer deep article。Overview 給選型與最短路徑、本文聚焦「consumer crash 之後、卡在 PEL 的訊息怎麼回到處理流程」這條 implementation flow。實機輸出來自 &lt;code>redis:7&lt;/code>（7.4.9）單節點。&lt;/p>&lt;/blockquote>
&lt;h2 id="consumer-crash-後訊息卡在哪裡">consumer crash 後、訊息卡在哪裡&lt;/h2>
&lt;p>Redis Streams 的 consumer group 設計是「先投遞、後 ack」：&lt;code>XREADGROUP&lt;/code> 把 entry 投給某個 consumer 的同時、entry 進入該 group 的 &lt;strong>PEL（Pending Entries List）&lt;/strong>、標記為「已投遞、未確認」。consumer 處理完才呼叫 &lt;code>XACK&lt;/code> 把 entry 移出 PEL。這一段「已投遞未 ack」的視窗、是 Redis Streams 提供 at-least-once 的全部依據。&lt;/p>
&lt;p>問題在於 consumer crash 時機落在這個視窗內。consumer 已經拿到訊息、PEL 已經記了它的名字、但它在 ack 之前就死了。Redis 沒有 broker 級的「重新投遞」背景程序——不像 RabbitMQ consumer 斷線後 unacked 訊息自動 requeue。Redis 把這筆訊息留在 PEL、owner 仍是那個死掉的 consumer、然後什麼都不做。要讓這筆訊息回到處理流程、只有 application 主動呼叫 &lt;code>XCLAIM&lt;/code> 或 &lt;code>XAUTOCLAIM&lt;/code> 改寫 owner。&lt;/p>
&lt;p>這就是 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/redis-streams-bitso-reliable-streams/" data-link-title="3.C42 Bitso：Reliable Redis Streams 抽象 &amp;#43; 自建 DLQ" data-link-desc="Bitso 加密交易所、千 msg/sec/stream &amp;#43; 亞毫秒延遲、自建 Reliable Streams 封裝 PEL &amp;#43; retry &amp;#43; DLQ、idempotent processing。">Bitso 自建 Reliable Streams 抽象&lt;/a> 揭露的核心事實：Redis Streams 是「資料結構」、不是「broker 系統」、可靠性責任在 application 層。本文展開的就是這個責任的具體形狀——PEL 怎麼累積、怎麼判讀、接管機制怎麼運作、以及哪些操作會讓接管失效。&lt;/p>
&lt;h2 id="pel-機制xreadgroup-進xack-出">PEL 機制：XREADGROUP 進、XACK 出&lt;/h2>
&lt;p>PEL 是 per-group 的結構、記錄每個 entry 的四個欄位：entry ID、目前 owner consumer、idle time（距上次投遞的毫秒數）、delivery count（被投遞過幾次）。先用實機輸出建立基礎。寫入 5 筆、建 group、兩個 consumer 各讀一部分：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">$ redis-cli XADD mystream &lt;span class="s1">&amp;#39;*&amp;#39;&lt;/span> event order_1 amount &lt;span class="m">100&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">1781584105202-0
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="c1"># ... order_2 ~ order_5、各得遞增 entry ID&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">$ redis-cli XGROUP CREATE mystream g1 &lt;span class="m">0&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">OK
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">$ redis-cli XREADGROUP GROUP g1 c1 COUNT &lt;span class="m">3&lt;/span> STREAMS mystream &lt;span class="s1">&amp;#39;&amp;gt;&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="c1"># c1 拿到 order_1 / order_2 / order_3&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">$ redis-cli XREADGROUP GROUP g1 c2 COUNT &lt;span class="m">10&lt;/span> STREAMS mystream &lt;span class="s1">&amp;#39;&amp;gt;&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="c1"># c2 拿到 order_4 / order_5&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>'&amp;gt;'&lt;/code> 代表「只取從未投遞給本 group 的新訊息」。投遞後這 5 筆全進 PEL。&lt;code>XPENDING&lt;/code> 的 summary 形式給總覽：&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <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> overview 的 implementation-layer deep article。Overview 給選型與最短路徑、本文聚焦「consumer crash 之後、卡在 PEL 的訊息怎麼回到處理流程」這條 implementation flow。實機輸出來自 <code>redis:7</code>（7.4.9）單節點。</p></blockquote>
<h2 id="consumer-crash-後訊息卡在哪裡">consumer crash 後、訊息卡在哪裡</h2>
<p>Redis Streams 的 consumer group 設計是「先投遞、後 ack」：<code>XREADGROUP</code> 把 entry 投給某個 consumer 的同時、entry 進入該 group 的 <strong>PEL（Pending Entries List）</strong>、標記為「已投遞、未確認」。consumer 處理完才呼叫 <code>XACK</code> 把 entry 移出 PEL。這一段「已投遞未 ack」的視窗、是 Redis Streams 提供 at-least-once 的全部依據。</p>
<p>問題在於 consumer crash 時機落在這個視窗內。consumer 已經拿到訊息、PEL 已經記了它的名字、但它在 ack 之前就死了。Redis 沒有 broker 級的「重新投遞」背景程序——不像 RabbitMQ consumer 斷線後 unacked 訊息自動 requeue。Redis 把這筆訊息留在 PEL、owner 仍是那個死掉的 consumer、然後什麼都不做。要讓這筆訊息回到處理流程、只有 application 主動呼叫 <code>XCLAIM</code> 或 <code>XAUTOCLAIM</code> 改寫 owner。</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 自建 Reliable Streams 抽象</a> 揭露的核心事實：Redis Streams 是「資料結構」、不是「broker 系統」、可靠性責任在 application 層。本文展開的就是這個責任的具體形狀——PEL 怎麼累積、怎麼判讀、接管機制怎麼運作、以及哪些操作會讓接管失效。</p>
<h2 id="pel-機制xreadgroup-進xack-出">PEL 機制：XREADGROUP 進、XACK 出</h2>
<p>PEL 是 per-group 的結構、記錄每個 entry 的四個欄位：entry ID、目前 owner consumer、idle time（距上次投遞的毫秒數）、delivery count（被投遞過幾次）。先用實機輸出建立基礎。寫入 5 筆、建 group、兩個 consumer 各讀一部分：</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">$ redis-cli XADD mystream <span class="s1">&#39;*&#39;</span> event order_1 amount <span class="m">100</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">1781584105202-0
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"># ... order_2 ~ order_5、各得遞增 entry ID</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">$ redis-cli XGROUP CREATE mystream g1 <span class="m">0</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">OK
</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">$ redis-cli XREADGROUP GROUP g1 c1 COUNT <span class="m">3</span> STREAMS mystream <span class="s1">&#39;&gt;&#39;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"># c1 拿到 order_1 / order_2 / order_3</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">$ redis-cli XREADGROUP GROUP g1 c2 COUNT <span class="m">10</span> STREAMS mystream <span class="s1">&#39;&gt;&#39;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"># c2 拿到 order_4 / order_5</span></span></span></code></pre></div><p><code>'&gt;'</code> 代表「只取從未投遞給本 group 的新訊息」。投遞後這 5 筆全進 PEL。<code>XPENDING</code> 的 summary 形式給總覽：</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">$ redis-cli XPENDING mystream g1
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="m">5</span>                  <span class="c1"># PEL 總數</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">1781584105202-0    <span class="c1"># 最小 pending ID</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">1781584105578-0    <span class="c1"># 最大 pending ID</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">c1                 <span class="c1"># 各 consumer 的 pending 數</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="m">3</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">c2
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="m">2</span></span></span></code></pre></div><p>5 筆全在 PEL、c1 扛 3 筆、c2 扛 2 筆。展開形式 <code>XPENDING &lt;key&gt; &lt;group&gt; - + &lt;count&gt;</code> 給每筆細節：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">$ redis-cli XPENDING mystream g1 - + <span class="m">10</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">1781584105202-0  c1  <span class="m">6318</span>  <span class="m">1</span>    <span class="c1"># entry ID / owner / idle ms / delivery count</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">1781584105278-0  c1  <span class="m">6318</span>  <span class="m">1</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">1781584105373-0  c1  <span class="m">6318</span>  <span class="m">1</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">1781584105466-0  c2  <span class="m">6224</span>  <span class="m">1</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">1781584105578-0  c2  <span class="m">6224</span>  <span class="m">1</span></span></span></code></pre></div><p><code>idle</code> 是 6318ms（距投遞已過 6.3 秒）、<code>delivery count</code> 都是 1（只投過一次）。這兩個數字是後面接管決策的核心輸入：idle 判斷「owner 是不是死了」、delivery count 判斷「這筆是不是 <a href="/blog/backend/knowledge-cards/poison-message-quarantine/" data-link-title="Poison-Message Quarantine" data-link-desc="說明把毒訊息從主處理路徑隔離出來的機制，讓正常訊息繼續前進">poison message</a>」。</p>
<p><code>XACK</code> 把處理完的 entry 移出 PEL：</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">$ redis-cli XACK mystream g1 1781584105202-0
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="m">1</span>                  <span class="c1"># 成功移除 1 筆</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">$ redis-cli XPENDING mystream g1
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="m">4</span>                  <span class="c1"># PEL 剩 4 筆</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">1781584105278-0
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">1781584105578-0
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">c1
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="m">2</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">c2
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="m">2</span></span></span></code></pre></div><p>PEL 從 5 降到 4。判讀原則固定：<strong>PEL 持續成長就是 consumer 健康訊號異常</strong>——不是 crash 沒 ack、就是處理速度跟不上、再不然是 ACK 程式碼漏寫。三者用 idle time 區分：crash 的 entry idle 會單調成長、處理慢的 idle 在 timeout 附近震盪、漏 ACK 的 entry delivery count 停在 1 但 idle 無上限成長。</p>
<h2 id="xclaim-與-xautoclaim改寫-owner-的兩條路">XCLAIM 與 XAUTOCLAIM：改寫 owner 的兩條路</h2>
<p>接管的本質是把 PEL entry 的 owner 從死掉的 consumer 改成活著的 consumer。<code>XCLAIM</code> 是手動指定 entry ID 接管、<code>XAUTOCLAIM</code> 是自動掃 idle 超過門檻的 entry 批次接管。兩者都接受 min-idle-time 參數當安全閥。</p>
<p><code>XCLAIM &lt;key&gt; &lt;group&gt; &lt;new-consumer&gt; &lt;min-idle-time&gt; &lt;id...&gt;</code>：把指定 entry 改判給新 consumer、條件是該 entry 的 idle 已達 min-idle-time。下面用 min-idle-time 0（無條件接管）把 c1 的一筆轉給 c3：</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">$ redis-cli XCLAIM mystream g1 c3 <span class="m">0</span> 1781584105278-0
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">1781584105278-0
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">event
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">order_2
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">amount
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="m">200</span>               <span class="c1"># 回傳被接管 entry 的完整內容</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">$ redis-cli XPENDING mystream g1 - + <span class="m">10</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">1781584105278-0  c3  <span class="m">66</span>     <span class="m">2</span>    <span class="c1"># owner 變 c3、idle 歸零(66ms)、delivery count 升到 2</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">1781584105373-0  c1  <span class="m">14590</span>  <span class="m">1</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">1781584105466-0  c2  <span class="m">14496</span>  <span class="m">1</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">1781584105578-0  c2  <span class="m">14496</span>  <span class="m">1</span></span></span></code></pre></div><p>接管後三件事同時發生：owner 改成 c3、idle 重置（剛 claim、66ms）、<strong>delivery count 從 1 升到 2</strong>。delivery count 自增是接管機制留下的審計軌跡——一筆訊息 delivery count 累積到 5、10、代表它反覆被接管又反覆沒處理完、這就是 poison message 的訊號、該路由到隔離區（見 <a href="/blog/backend/knowledge-cards/recovery-semantics/" data-link-title="Recovery Semantics" data-link-desc="說明事件處理失敗後能否透過 replay、checkpoint 與補償重建正確狀態並驗證">recovery semantics</a> 與 <a href="/blog/backend/knowledge-cards/poison-message-quarantine/" data-link-title="Poison-Message Quarantine" data-link-desc="說明把毒訊息從主處理路徑隔離出來的機制，讓正常訊息繼續前進">poison message quarantine</a>）。</p>
<p><code>XAUTOCLAIM &lt;key&gt; &lt;group&gt; &lt;new-consumer&gt; &lt;min-idle-time&gt; &lt;start-id&gt;</code>（Redis 6.2+）省掉「先 XPENDING 找 ID、再逐筆 XCLAIM」兩步、一次掃描接管：</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">$ redis-cli XAUTOCLAIM mystream g1 c3 <span class="m">0</span> <span class="m">0</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">0-0                          <span class="c1"># 下次掃描的 cursor（0-0 代表掃完一輪）</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">1781584105278-0 ...          <span class="c1"># 接管的 entry 內容（order_2）</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">1781584105373-0 ...          <span class="c1"># order_3</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">1781584105466-0 ...          <span class="c1"># order_4</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">1781584105578-0 ...          <span class="c1"># order_5</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="o">(</span>empty array<span class="o">)</span>                <span class="c1"># 第三個回傳值：已從 stream 刪除的 entry ID 清單</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">$ redis-cli XPENDING mystream g1
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="m">4</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">1781584105278-0
</span></span><span class="line"><span class="ln">12</span><span class="cl">1781584105578-0
</span></span><span class="line"><span class="ln">13</span><span class="cl">c3                           <span class="c1"># 全部 4 筆 owner 變 c3</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="m">4</span></span></span></code></pre></div><p>一次呼叫把整個 group 的 idle 訊息全歸到 c3。<code>XAUTOCLAIM</code> 是 consumer crash 後接管的主力——consumer 在啟動或處理迴圈裡固定跑一輪 <code>XAUTOCLAIM</code>、把孤兒訊息撿回來。回傳的 cursor 支援分批（一次掃不完時帶 cursor 續掃）、第三個回傳值（被刪 entry 清單）對應後面 MAXLEN 修剪的故障。</p>
<h2 id="min-idle-time防止活-consumer-被搶單">min-idle-time：防止活 consumer 被搶單</h2>
<p>min-idle-time 不是裝飾參數、是接管機制的安全閥：它要求「只有 idle 超過門檻的 entry 才能被接管」。沒有這個門檻、兩個 consumer 會互相搶對方正在處理的訊息。</p>
<p>驗證搶單防護——剛被 c3 claim 的訊息 idle 很低、用 60 秒門檻去 claim 會落空：</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">$ redis-cli XCLAIM mystream g1 c4 <span class="m">60000</span> 1781584105278-0
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="o">(</span>empty array<span class="o">)</span>               <span class="c1"># 回空：該 entry idle 未達 60000ms、c4 搶不到</span></span></span></code></pre></div><p>回空陣列代表 claim 失敗、owner 不變、訊息留在 c3 手上。這就是 min-idle-time 的作用：<strong>門檻 = 我願意相信 owner consumer 還活著的最長時間</strong>。</p>
<p>門檻設定是接管設計的核心取捨、沒有通用值、由訊息處理時間分佈決定。門檻設太短、正常處理中的訊息被當成孤兒搶走、變成多 consumer 重複處理同一筆。門檻設太長、真正 crash 的訊息要等很久才有人接管、recovery 延遲拉高。<a href="/blog/backend/03-message-queue/cases/redis-streams-harness-event-driven-state/" data-link-title="3.C44 Harness：CD 微服務 async state transfer" data-link-desc="Harness CD 平台用 Redis Streams 解 brittle HTTP、揭露監控缺口 / MAXLEN truncation / head-of-line blocking 三類問題。">Harness 的 event-driven 案例</a> 正是用 XAUTOCLAIM 重派來解 head-of-line blocking（慢訊息阻塞 consumer 進度）、並自設 redelivery 策略避免上述反覆搶單。實務基準是「門檻 &gt; p99 處理時間 + 安全係數」：若單筆處理 p99 是 2 秒、門檻設 30-60 秒、確保只有真的死掉（遠超正常處理時間）的 owner 才被接管。</p>
<p>接管後仍需 application 層去重。XCLAIM 改寫 owner、不代表原 consumer 真的沒處理完——它可能正在 ack 的瞬間被 claim、結果兩邊都處理一次。at-least-once 的去重責任永遠在 application、靠 <a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a> 兜底、這跟接管門檻設多準無關。</p>
<h2 id="memory-與-retentionmaxlen--xtrim-的取捨">Memory 與 retention：MAXLEN / XTRIM 的取捨</h2>
<p>Stream 是 append-only、不主動丟資料、佔用的 Redis 記憶體單調成長。retention 的唯一旋鈕是修剪：<code>MAXLEN</code>（保留最近 N 筆）或 <code>MINID</code>（保留 ID 大於某值的 entry）。可以在 <code>XADD</code> 寫入時順帶修剪、也可以用 <code>XTRIM</code> 獨立執行。</p>
<p>精確修剪 <code>MAXLEN =</code> 跟近似修剪 <code>MAXLEN ~</code> 的差別在性能。stream 內部是 radix tree of macro-nodes（每個 node 打包多筆 entry）。精確修剪要拆 node 才能剛好留 N 筆、近似修剪只刪「整個可以丟掉的 node」、留下的筆數會略多於 N、但省掉拆 node 的開銷。<code>~</code> 是 production 預設、<code>=</code> 只在需要嚴格上限時用：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">$ redis-cli XADD mystream MAXLEN <span class="s1">&#39;~&#39;</span> <span class="m">1000</span> <span class="s1">&#39;*&#39;</span> event order_6 amount <span class="m">600</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">1781584152570-0             <span class="c1"># 近似修剪：超過 ~1000 才整 node 刪</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">$ redis-cli XADD mystream MAXLEN <span class="s1">&#39;=&#39;</span> <span class="m">3</span> <span class="s1">&#39;*&#39;</span> event order_7 amount <span class="m">700</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">1781584152871-0
</span></span><span class="line"><span class="ln">5</span><span class="cl">$ redis-cli XLEN mystream
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="m">3</span>                           <span class="c1"># 精確修剪到剛好 3 筆</span></span></span></code></pre></div><p>stream 不受 <code>maxmemory-policy</code> eviction 管理——一般 key 在記憶體壓力下會被 evict、stream entry 不會。這代表 stream 是「只進不出、除非主動修剪」的記憶體成長源。<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 把 Redis 當長期事件儲存、最終因成本與延遲退場</a> 就是沒設修剪上限的反例（該案例涵蓋 Redis 事件儲存整體、Stream 是其中一塊）：事件量每週以 GB 成長、AOF fsync 與 EBS I/O 變成 latency 痛點、最終退回 PostgreSQL。判讀訊號是 <code>MEMORY USAGE mystream</code> 對比實例 <code>maxmemory</code>、超過預算就調低 MAXLEN。</p>
<h2 id="故障演練">故障演練</h2>
<h3 id="case-1consumer-crash-後-pel-訊息卡死沒人接">Case 1：consumer crash 後 PEL 訊息卡死沒人接</h3>
<p><strong>徵兆</strong>：<code>XPENDING</code> 總數持續成長、某個 consumer 的 pending 數停在固定值不降、那些 entry 的 idle time 單調往上爬（幾分鐘、幾小時）、業務端對應的訊息「進了 stream 但沒被處理」。</p>
<p><strong>根因</strong>：consumer 進程 crash（OOM kill / 部署滾動 / panic）、留下的 PEL entry owner 仍是死掉的 consumer。Redis 不會自動重投——沒有任何背景程序會碰這些 entry。它們會永遠卡在 PEL、直到有人主動接管。新啟動的 consumer 用 <code>XREADGROUP ... '&gt;'</code> 只會拿到「從未投遞」的新訊息、不會碰到前任留下的孤兒。</p>
<p><strong>修法</strong>：consumer 啟動時跟處理迴圈裡固定跑 <code>XAUTOCLAIM</code>、把超過 idle 門檻的孤兒撿回來：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 每個 consumer 週期性執行、min-idle-time 設 60s</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">$ redis-cli XAUTOCLAIM mystream g1 self_consumer_id <span class="m">60000</span> <span class="m">0</span></span></span></code></pre></div><ol>
<li><strong>min-idle-time 設成 &gt; p99 處理時間 + 安全係數</strong>：避免把處理中的訊息誤判成孤兒（接 Case 2）。</li>
<li><strong>用回傳 cursor 分批掃</strong>：PEL 大時一次 <code>XAUTOCLAIM</code> 不掃完、帶 cursor 續掃、避免單次 block 太久。</li>
<li><strong>接管後檢查 delivery count</strong>：超過閾值（如 5）的 entry 不再處理、路由到 DLQ（Redis Streams 沒原生 DLQ、Bitso 自建一個 stream 當 DLQ）。</li>
<li><strong>監控 PEL 最大 idle</strong>：alert 設在「最老 pending entry 的 idle 超過 N 倍接管門檻」、代表接管機制本身停了。</li>
</ol>
<h3 id="case-2min-idle-time-設太短活-consumer-被搶單">Case 2：min-idle-time 設太短、活 consumer 被搶單</h3>
<p><strong>徵兆</strong>：同一筆訊息被多個 consumer 處理、下游出現重複副作用（重複扣款、重複發信）；<code>XPENDING</code> 展開看到某些 entry 的 delivery count 異常高（5、10+）但 stream 流量正常、沒有 consumer crash。</p>
<p><strong>根因</strong>：接管門檻低於正常處理時間。consumer A 拿到一筆要處理 10 秒的訊息、門檻設了 5 秒、consumer B 跑 <code>XAUTOCLAIM</code> 時這筆 idle 已過 5 秒、B 把還在 A 手上處理的訊息搶走、兩邊都處理一次。這是接管門檻設計的通用競態——一筆慢訊息被反覆搶、delivery count 暴衝、卻沒人真正完成。（<a href="/blog/backend/03-message-queue/cases/redis-streams-harness-event-driven-state/" data-link-title="3.C44 Harness：CD 微服務 async state transfer" data-link-desc="Harness CD 平台用 Redis Streams 解 brittle HTTP、揭露監控缺口 / MAXLEN truncation / head-of-line blocking 三類問題。">Harness 案例</a> 用 XAUTOCLAIM 重派解 head-of-line blocking 時、正是靠門檻與 redelivery 策略避開這種搶單。）</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>量測真實處理時間分佈、門檻設 &gt; p99</strong>：先用 metric 抓單筆處理 p50 / p99、門檻設 p99 的數倍。</li>
<li><strong>delivery count 當搶單偵測器</strong>：同一 entry delivery count 快速成長、代表它在被搶來搶去、調高門檻或隔離該訊息。</li>
<li><strong>idempotency 兜底</strong>：門檻再準也防不了「ack 瞬間被 claim」的競態、application 層去重是最後防線、不可省（見 <a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency 卡</a>）。</li>
</ol>
<h3 id="case-3maxlen-修剪掉-pel-內還沒-ack-的訊息">Case 3：MAXLEN 修剪掉 PEL 內還沒 ack 的訊息</h3>
<p><strong>徵兆</strong>：<code>XPENDING</code> 顯示某些 entry 仍 pending、但 <code>XCLAIM</code> 接管它時拿不到內容；consumer 接手後發現訊息 body 是空的、無法處理、又無法判斷該不該 ack。</p>
<p><strong>根因</strong>：<strong>修剪只看 entry ID 的新舊、不看它在不在 PEL</strong>。<code>XTRIM MAXLEN</code> 把最舊的 entry 從 stream 物理刪除、即使這些 entry 還在某個 group 的 PEL 裡等 ack。PEL 只記 entry ID、不存 body；body 存在 stream 本體。entry 被 trim 掉、PEL 還記得這個 ID、但 body 已經不存在了。實機驗證——4 筆全在 PEL、把 stream 修剪到剩 2 筆：</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">$ redis-cli XLEN mystream
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="m">5</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">$ redis-cli XPENDING mystream g1
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="m">4</span>                           <span class="c1"># 4 筆未 ack 在 PEL</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">$ redis-cli XTRIM mystream MAXLEN <span class="m">2</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="m">3</span>                           <span class="c1"># 刪掉 3 筆（含 PEL 內的未 ack entry）</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">$ redis-cli XLEN mystream
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="m">2</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">$ redis-cli XPENDING mystream g1 - + <span class="m">10</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">1781584105278-0  c3  <span class="m">19307</span>  <span class="m">3</span>   <span class="c1"># PEL 還記得這些 ID</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">1781584105373-0  c3  <span class="m">19307</span>  <span class="m">2</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">1781584105466-0  c3  <span class="m">19307</span>  <span class="m">2</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">1781584105578-0  c3  <span class="m">19307</span>  <span class="m">2</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">
</span></span><span class="line"><span class="ln">17</span><span class="cl">$ redis-cli XCLAIM mystream g1 c5 <span class="m">0</span> 1781584105278-0
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="o">(</span>empty array<span class="o">)</span>               <span class="c1"># 接管成功改 owner、但 entry body 已被 trim、拿不到內容</span></span></span></code></pre></div><p>PEL 還有 4 筆記錄、但對應的 body 已從 stream 消失。<code>XCLAIM</code> 接管這種 entry、改得了 owner、拿不到 body——這是訊息靜默遺失。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>修剪上限要 &gt; 處理 backlog 深度</strong>：MAXLEN / 流入速率 = 訊息在被修剪前的最長存活時間、這個時間要遠大於「最慢 consumer 清空 backlog 的時間」。</li>
<li><strong>修剪前檢查 PEL 最舊 ID</strong>：自動修剪前比對 <code>XPENDING</code> 的最小 pending ID、確保不會修到還在 PEL 的 entry。</li>
<li><strong>慢 consumer 監控優先於積極修剪</strong>：先解決 consumer 處理太慢導致 PEL 積壓的根因、再談用小 MAXLEN 壓記憶體；倒過來只會修掉未 ack 訊息。</li>
<li><strong>MINID 修剪比 MAXLEN 安全</strong>：MINID 用時間/業務邊界（如「保留 24 小時內」）、比 MAXLEN 的「保留 N 筆」更容易保證涵蓋未 ack 視窗。</li>
</ol>
<h3 id="case-4redis-cluster-對單-stream-的-shard-限制">Case 4：Redis Cluster 對單 stream 的 shard 限制</h3>
<p><strong>徵兆</strong>：stream 流量成長到單 node 容量上限、想像 Kafka 那樣「加 partition 分流」、卻發現 Redis Cluster 沒有這個機制；單一 stream key 的全部讀寫永遠打在同一個 node。</p>
<p><strong>根因</strong>：Redis Cluster 用 <code>CRC16(key) % 16384</code> 把 key 映射到 slot、slot 分佈在 node 上。<strong>一個 stream 是一個 key、永遠落在單一 slot、永遠在單一 shard</strong>。Streams 沒有 Kafka partition 那種「同一 topic 切多片、分散到多 broker」的概念。單 stream 的吞吐天花板就是單 node 的天花板。</p>
<p>實機驗證 keyslot 計算（cluster-enabled 節點）：</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">$ redis-cli CLUSTER KEYSLOT stream:orders
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="m">6139</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">$ redis-cli CLUSTER KEYSLOT stream:payments
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="m">3696</span>                        <span class="c1"># 不同 key 落不同 slot、可能在不同 shard</span></span></span></code></pre></div><p><strong>修法</strong>：要分流就在 application 層切多個 stream key（<code>stream:orders:0</code>、<code>stream:orders:1</code> &hellip;）、自己做 partition 路由。若需要某幾個 stream 保證落同一 shard（為了跨 stream 的原子操作或 co-located 處理）、用 hash tag——只有 <code>{}</code> 內的部分參與 CRC16：</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">$ redis-cli CLUSTER KEYSLOT <span class="s1">&#39;{shard1}:stream:orders&#39;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="m">10271</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">$ redis-cli CLUSTER KEYSLOT <span class="s1">&#39;{shard1}:stream:payments&#39;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="m">10271</span>                       <span class="c1"># 同 hash tag、強制落同 slot</span></span></span></code></pre></div><p>兩個不同 key 因為共用 <code>{shard1}</code> hash tag、CRC16 算出同一個 slot 10271、保證在同一 shard。判讀邊界：需要真正的 partition + replication + 跨節點水平擴展、Redis Streams 不是答案、改走 <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>。Redis Streams 的定位是中等規模、單 shard 容量內、不跨節點分片。</p>
<blockquote>
<p>Cluster 多節點分片下的端到端行為（resharding 期間 stream key 隨 slot 搬移、client topology cache）需要多節點環境、本文未實機驗證；slot migration 機制與踩雷見 <a href="/blog/backend/02-cache-redis/vendors/redis/cluster-resharding/" data-link-title="Redis Cluster Re-sharding：source = target，但 topology 重劃的 5 段流程" data-link-desc="Redis cluster re-sharding 是 5 type migration 漏類實證 — source / target 同 cluster、無 schema / paradigm 差、但 16384 slot 重分配是核心；本文涵蓋 4 種 re-sharding driver、slot migration 機制、redis-cli --cluster rebalance / reshard 工具、5 個 production 踩雷（cluster busy / replica lag / client cache stale / cross-slot transaction / monitor gap）">Redis Cluster Re-sharding</a>。</p></blockquote>
<h3 id="case-5failover-後-pel-狀態不一致">Case 5：failover 後 PEL 狀態不一致</h3>
<p><strong>徵兆</strong>：Sentinel / Cluster failover 後（replica 升 primary）、原本在 PEL 的部分訊息「消失」或「重複投遞」；<code>XPENDING</code> 數字跟 failover 前對不上；consumer 接管邏輯撿到不該撿的訊息、或漏撿該撿的。</p>
<p><strong>根因</strong>：Redis 的 replication 是非同步的。primary 上的 <code>XADD</code> / <code>XACK</code> / <code>XCLAIM</code> 先在本地生效、再非同步傳給 replica。failover 那一刻、replica 的 PEL 狀態落後 primary 一個 replication lag 的視窗。新 primary 從它當下的（落後的）PEL 狀態接手：lag 視窗內已 ack 的訊息在新 primary 上仍 pending（重複投遞）、lag 視窗內剛 claim 的 owner 改寫可能丟失（接管邏輯錯亂）。AOF / RDB 持久化只保證單機重啟的恢復、不改變跨 replica 的非同步本質。</p>
<blockquote>
<p>failover 對 PEL 一致性的影響需要多節點 Sentinel / Cluster 環境跨節點觀測、本文未實機驗證；以下依官方 replication 語義與案例敘述判讀。</p></blockquote>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>接受 at-least-once、靠 idempotency 收斂</strong>：failover 造成的重複投遞跟正常的重複投遞同一性質、application 去重邏輯本來就要處理（見 <a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency 卡</a>）。</li>
<li><strong>failover 後主動全量 XAUTOCLAIM 對帳</strong>：failover 偵測到後、consumer 跑一輪低門檻 <code>XAUTOCLAIM</code> 重新接管、用 application 端的處理紀錄判斷哪些真的沒處理。</li>
<li><strong>降低 replication lag</strong>：lag 越小、failover 視窗的 PEL 偏差越小；監控 <code>master_repl_offset</code> 與 replica offset 差。</li>
<li><strong>語義誤配風險</strong>：把 Redis Streams 當「不丟訊息的 broker」用、在 failover 邊界會破功——這是 <a href="/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9 語義誤配</a> 的思路、選型時就要認清 Redis Streams 的一致性等級。</li>
</ol>
<h2 id="capacity-與判讀路由">Capacity 與判讀路由</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>判讀訊號</th>
          <th>邊界</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>PEL 深度</td>
          <td><code>XPENDING</code> 總數持續成長</td>
          <td>成長不停 = consumer 健康問題、不是調 MAXLEN 能解</td>
      </tr>
      <tr>
          <td>接管門檻</td>
          <td>delivery count 異常高（搶單）/ 最老 idle 不收斂</td>
          <td>門檻 &gt; p99 處理時間 + 安全係數</td>
      </tr>
      <tr>
          <td>Stream 記憶體</td>
          <td><code>MEMORY USAGE</code> 對比 <code>maxmemory</code></td>
          <td>stream 不被 eviction、唯一旋鈕是 MAXLEN / MINID 修剪</td>
      </tr>
      <tr>
          <td>修剪 vs 未 ack 視窗</td>
          <td>修剪上限 / 流入速率 &lt; backlog 清空時間</td>
          <td>違反就會修掉 PEL 內未 ack 訊息（Case 3）</td>
      </tr>
      <tr>
          <td>單 stream 吞吐</td>
          <td>單 node CPU / memory 打滿、無法加 partition</td>
          <td>達單 shard 天花板 = 該評估 Kafka</td>
      </tr>
  </tbody>
</table>
<p>判讀路由固定三層：先看 PEL 是「整 group 成長」（流入 &gt; 處理、擴 consumer）還是「單 consumer 卡住」（crash、要接管）；接管時先確認 min-idle-time 對得上處理時間分佈、再看 delivery count 篩 poison message；retention 調整前先確認修剪上限涵蓋 PEL 未 ack 視窗。</p>
<h2 id="整合與下一步">整合與下一步</h2>
<p>接管機制是 <a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">consumer 設計</a> 在 Redis Streams 上的具體落地——consumer 不只是讀訊息的迴圈、還要承擔「撿前任孤兒」的責任。設計 consumer 時把 <code>XAUTOCLAIM</code> 排進處理迴圈、跟 <code>XREADGROUP '&gt;'</code> 並列、不是事後補丁。</p>
<p>知識卡對位：delivery count 超閾值的訊息對應 <a href="/blog/backend/knowledge-cards/poison-message-quarantine/" data-link-title="Poison-Message Quarantine" data-link-desc="說明把毒訊息從主處理路徑隔離出來的機制，讓正常訊息繼續前進">poison message quarantine</a>（Redis Streams 沒原生 DLQ、自建一個 stream 當隔離區）；接管後的去重對應 <a href="/blog/backend/knowledge-cards/recovery-semantics/" data-link-title="Recovery Semantics" data-link-desc="說明事件處理失敗後能否透過 replay、checkpoint 與補償重建正確狀態並驗證">recovery semantics</a> 跟 <a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a>（at-least-once 的收斂責任在 application）。</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> 把本文這些機制封裝成 Reliable Streams 抽象層 + 自建 DLQ、是「application 層補可靠性」的完整實作參考；<a href="/blog/backend/03-message-queue/cases/redis-streams-klaxit-rust-log-pipeline/" data-link-title="3.C45 Klaxit：Rust &#43; Redis Streams 處理 Heroku Logplex" data-link-desc="Klaxit carpool 用 Redis Streams 處理 Heroku Logplex 匯流、自動偵測修復平台 perf 問題、6 個月 production Rust。">Klaxit Rust + Logplex</a> 是高吞吐 log ingestion 下 consumer group 分流長時間穩定運轉的範例；接管門檻搶單的反面教訓在 <a href="/blog/backend/03-message-queue/cases/redis-streams-harness-event-driven-state/" data-link-title="3.C44 Harness：CD 微服務 async state transfer" data-link-desc="Harness CD 平台用 Redis Streams 解 brittle HTTP、揭露監控缺口 / MAXLEN truncation / head-of-line blocking 三類問題。">Harness event-driven</a>。</p>
<p>選型回路：單 stream 撞到單 shard 天花板、或 failover 一致性要求超出 at-least-once、回 <a href="/blog/backend/03-message-queue/vendors/redis-streams/#%e4%bd%95%e6%99%82%e6%94%b9%e8%b5%b0%e5%85%b6%e4%bb%96%e6%9c%8d%e5%8b%99" data-link-title="Redis Streams" data-link-desc="Redis 生態內的 streams、append-only log &#43; consumer group">Redis Streams overview 的「何時改走其他服務」</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>（partition + replication）。Cluster 層的 slot / topology 行為見 <a href="/blog/backend/02-cache-redis/vendors/redis/cluster-resharding/" data-link-title="Redis Cluster Re-sharding：source = target，但 topology 重劃的 5 段流程" data-link-desc="Redis cluster re-sharding 是 5 type migration 漏類實證 — source / target 同 cluster、無 schema / paradigm 差、但 16384 slot 重分配是核心；本文涵蓋 4 種 re-sharding driver、slot migration 機制、redis-cli --cluster rebalance / reshard 工具、5 個 production 踩雷（cluster busy / replica lag / client cache stale / cross-slot transaction / monitor gap）">Redis Cluster Re-sharding</a>。</p>
]]></content:encoded></item><item><title>Self-managed Kafka → AWS MSK：把 $15K/month operational cost 拆解到 managed</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/migrate-to-msk/</link><pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/migrate-to-msk/</guid><description>&lt;blockquote>
&lt;p>本文是跨 vendor migration playbook、cross-link &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka&lt;/a> 跟 AWS MSK。跑 &lt;a href="https://tarrragon.github.io/blog/posts/migration-playbook-%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84stage-0-variant-%E8%A6%8F%E5%8A%83%E6%8A%8A-collapse-%E7%8E%87%E5%BE%9E-60-%E9%99%8D%E5%88%B0-0/" data-link-title="Migration Playbook 方法論的演化紀錄：Stage 0 variant 規劃把 collapse 率從 60% 降到 0%" data-link-desc="跨 vendor migration playbook 需要獨立寫作方法論的依據，以及這套方法論從三輪 batch dogfood 中演化出來的驗證證據。">migration-playbook-methodology 6 維 audit&lt;/a> 後對映 &lt;em>Operational = High（self-managed → AWS managed）→ Type C operational redesign hybrid&lt;/em>。&lt;/p>&lt;/blockquote>
&lt;h2 id="15kmonth-operational-cost-拆解">$15K/month operational cost 拆解&lt;/h2>
&lt;p>跟 &lt;a href="https://tarrragon.github.io/blog/backend/04-observability/vendors/datadog/migrate-to-grafana-stack/" data-link-title="Datadog → Grafana Stack：把 $50K/month bill 拆解到 self-hosted observability" data-link-desc="Datadog 五層計費（host APM / metric / log ingest / log retention / RUM）拆解、對位 Grafana Stack（Mimir / Loki / Tempo / Grafana / Alloy）的 5 層責任；OTel-based agent migration、5 個 production 踩雷（cardinality 爆 / log volume cost / dashboard 不直接轉 / alert routing 換邏輯 / SLO definition 差異）、cost reality check">Datadog → Grafana Stack&lt;/a>（H cost variant）同 framing — 用 cost 拆解開頭、不是「為什麼遷」driver list：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Self-managed Kafka cost 項&lt;/th>
 &lt;th>中型 (3 broker + 3 ZK + monitoring) / month&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>EC2 (3× r6g.xlarge broker)&lt;/td>
 &lt;td>$660&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>EBS (3× 1TB io2)&lt;/td>
 &lt;td>$1,500&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>EC2 (3× t3.medium ZK / KRaft)&lt;/td>
 &lt;td>$90&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Monitoring (Prometheus + Grafana on EC2)&lt;/td>
 &lt;td>$200&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Backup S3 (1TB)&lt;/td>
 &lt;td>$25&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Cross-AZ traffic&lt;/td>
 &lt;td>$300&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Operational FTE (0.5)&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$5,000-8,000&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Patching window cost&lt;/td>
 &lt;td>$200 (downtime opportunity)&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total infrastructure&lt;/td>
 &lt;td>$7,975-10,975&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total with FTE&lt;/td>
 &lt;td>&lt;strong>$13,000-18,975&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>最大成本塊是 operational FTE、不是 infrastructure&lt;/strong>。MSK 把 50-80% operational 工作轉嫁 AWS、留 application + cost monitoring 給 SRE。&lt;/p>
&lt;p>跑 &lt;a href="https://tarrragon.github.io/blog/posts/migration-playbook-%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84stage-0-variant-%E8%A6%8F%E5%8A%83%E6%8A%8A-collapse-%E7%8E%87%E5%BE%9E-60-%E9%99%8D%E5%88%B0-0/" data-link-title="Migration Playbook 方法論的演化紀錄：Stage 0 variant 規劃把 collapse 率從 60% 降到 0%" data-link-desc="跨 vendor migration playbook 需要獨立寫作方法論的依據，以及這套方法論從三輪 batch dogfood 中演化出來的驗證證據。">6 維 diff dimension audit&lt;/a>：&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是跨 vendor migration playbook、cross-link <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka</a> 跟 AWS MSK。跑 <a href="/blog/posts/migration-playbook-%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84stage-0-variant-%E8%A6%8F%E5%8A%83%E6%8A%8A-collapse-%E7%8E%87%E5%BE%9E-60-%E9%99%8D%E5%88%B0-0/" data-link-title="Migration Playbook 方法論的演化紀錄：Stage 0 variant 規劃把 collapse 率從 60% 降到 0%" data-link-desc="跨 vendor migration playbook 需要獨立寫作方法論的依據，以及這套方法論從三輪 batch dogfood 中演化出來的驗證證據。">migration-playbook-methodology 6 維 audit</a> 後對映 <em>Operational = High（self-managed → AWS managed）→ Type C operational redesign hybrid</em>。</p></blockquote>
<h2 id="15kmonth-operational-cost-拆解">$15K/month operational cost 拆解</h2>
<p>跟 <a href="/blog/backend/04-observability/vendors/datadog/migrate-to-grafana-stack/" data-link-title="Datadog → Grafana Stack：把 $50K/month bill 拆解到 self-hosted observability" data-link-desc="Datadog 五層計費（host APM / metric / log ingest / log retention / RUM）拆解、對位 Grafana Stack（Mimir / Loki / Tempo / Grafana / Alloy）的 5 層責任；OTel-based agent migration、5 個 production 踩雷（cardinality 爆 / log volume cost / dashboard 不直接轉 / alert routing 換邏輯 / SLO definition 差異）、cost reality check">Datadog → Grafana Stack</a>（H cost variant）同 framing — 用 cost 拆解開頭、不是「為什麼遷」driver list：</p>
<table>
  <thead>
      <tr>
          <th>Self-managed Kafka cost 項</th>
          <th>中型 (3 broker + 3 ZK + monitoring) / month</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>EC2 (3× r6g.xlarge broker)</td>
          <td>$660</td>
      </tr>
      <tr>
          <td>EBS (3× 1TB io2)</td>
          <td>$1,500</td>
      </tr>
      <tr>
          <td>EC2 (3× t3.medium ZK / KRaft)</td>
          <td>$90</td>
      </tr>
      <tr>
          <td>Monitoring (Prometheus + Grafana on EC2)</td>
          <td>$200</td>
      </tr>
      <tr>
          <td>Backup S3 (1TB)</td>
          <td>$25</td>
      </tr>
      <tr>
          <td>Cross-AZ traffic</td>
          <td>$300</td>
      </tr>
      <tr>
          <td><strong>Operational FTE (0.5)</strong></td>
          <td><strong>$5,000-8,000</strong></td>
      </tr>
      <tr>
          <td>Patching window cost</td>
          <td>$200 (downtime opportunity)</td>
      </tr>
      <tr>
          <td>Total infrastructure</td>
          <td>$7,975-10,975</td>
      </tr>
      <tr>
          <td>Total with FTE</td>
          <td><strong>$13,000-18,975</strong></td>
      </tr>
  </tbody>
</table>
<p><strong>最大成本塊是 operational FTE、不是 infrastructure</strong>。MSK 把 50-80% operational 工作轉嫁 AWS、留 application + cost monitoring 給 SRE。</p>
<p>跑 <a href="/blog/posts/migration-playbook-%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84stage-0-variant-%E8%A6%8F%E5%8A%83%E6%8A%8A-collapse-%E7%8E%87%E5%BE%9E-60-%E9%99%8D%E5%88%B0-0/" data-link-title="Migration Playbook 方法論的演化紀錄：Stage 0 variant 規劃把 collapse 率從 60% 降到 0%" data-link-desc="跨 vendor migration playbook 需要獨立寫作方法論的依據，以及這套方法論從三輪 batch dogfood 中演化出來的驗證證據。">6 維 diff dimension audit</a>：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>評估</th>
          <th>等級</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Schema / API</td>
          <td>同 Kafka protocol、client SDK 不改</td>
          <td>Low</td>
      </tr>
      <tr>
          <td>Operational model</td>
          <td>Self-managed → AWS managed、HA / patch / backup 全託管</td>
          <td><strong>High</strong></td>
      </tr>
      <tr>
          <td>Paradigm</td>
          <td>同 Kafka log-based</td>
          <td>Low</td>
      </tr>
      <tr>
          <td>Components</td>
          <td>同 1 個 Kafka cluster</td>
          <td>Low</td>
      </tr>
      <tr>
          <td>Application change</td>
          <td>Auth config 改（IAM / SASL）、其他不變</td>
          <td>Low-Medium</td>
      </tr>
      <tr>
          <td>Data topology</td>
          <td>同 broker + partition 配置</td>
          <td>Low</td>
      </tr>
  </tbody>
</table>
<p>Operational = High（其他 Low-Medium）→ <strong>Type C operational redesign hybrid</strong>。</p>
<h2 id="為什麼遷fte--availability--consistency-三條-driver">為什麼遷：FTE / availability / consistency 三條 driver</h2>
<ul>
<li><strong>Operational FTE</strong>：Kafka self-managed + ZooKeeper / KRaft + Prometheus 端到端 ops 是 0.5-1 FTE、MSK 把 patch / HA / backup 全託管</li>
<li><strong>Availability</strong>：MSK 自動 multi-AZ broker + auto-recovery、self-managed 自管 broker 故障 RTO 30 分鐘-2 小時</li>
<li><strong>Consistency with cloud stack</strong>：已 deep on AWS（RDS / S3 / Lambda）、MSK 進 same VPC + IAM auth、降低 cross-vendor 設置成本</li>
</ul>
<p>反向 driver（MSK → self-managed）：</p>
<ul>
<li>Throughput / GB 規模大時 MSK 跨 broker cost 反轉（cost &gt; self-managed）</li>
<li>需要 Kafka 客製化（custom plugin / kraft early adopter / 非 AWS region）</li>
<li>Multi-cloud / hybrid 架構不想 vendor lock</li>
</ul>
<h2 id="operational-redesign-對位">Operational redesign 對位</h2>
<p>跟 <a href="/blog/backend/01-database/vendors/postgresql/migrate-to-aurora/" data-link-title="PostgreSQL → Aurora Migration：protocol 相容、operational 重設計" data-link-desc="Aurora 號稱 PostgreSQL-compatible 但 operational model 不同（storage decouple / cluster endpoint / instance class / 自家備份）；遷移流程是混合（protocol drop-in &#43; operational phased）、5 個 production 踩雷（extension 不支援 / replication slot 不直通 / autovacuum 行為差 / IAM 認證強制 / cost model 換算）、跟 Patroni / read replica / DR 對位">PostgreSQL → Aurora</a> / <a href="/blog/backend/01-database/vendors/mongodb/migrate-to-atlas/" data-link-title="MongoDB → Atlas：Atlas 不是 MongoDB &#43; managed、是另一個 product" data-link-desc="Atlas 號稱「MongoDB managed」但 operational model 完全不同（auto-scaling / VPC peering / IAM-driven access / 內建 backup / billing 模型）；本文採用 Type C operational redesign hybrid 結構、4-phase operational migration &#43; drop-in cutover、5 個 production 踩雷（連線數限制 / IP whitelist / backup retention / IAM token 過期 / billing 暴漲）">MongoDB → Atlas</a> 同 Type C pattern：</p>
<table>
  <thead>
      <tr>
          <th>Operational concept</th>
          <th>Self-managed Kafka</th>
          <th>MSK</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Cluster bootstrap</td>
          <td>手動配置 broker + ZK + brokers.properties</td>
          <td>UI / Terraform 一鍵建</td>
      </tr>
      <tr>
          <td>HA</td>
          <td>自管 replica + ISR + broker placement</td>
          <td>自動 multi-AZ + auto-recovery</td>
      </tr>
      <tr>
          <td>Patching</td>
          <td>Rolling restart 手動 / 工具</td>
          <td>MSK 自動 monthly maintenance window</td>
      </tr>
      <tr>
          <td>Backup</td>
          <td>自管 MirrorMaker / cluster snapshot</td>
          <td>MSK 內建 backup（S3、自動）</td>
      </tr>
      <tr>
          <td>Authentication</td>
          <td>SASL/SCRAM / mTLS 自管</td>
          <td>IAM auth（推薦）/ SASL/SCRAM via Secrets Manager</td>
      </tr>
      <tr>
          <td>Monitoring</td>
          <td>Prometheus + JMX exporter 自建</td>
          <td>CloudWatch + open monitoring + Prometheus</td>
      </tr>
      <tr>
          <td>Sizing</td>
          <td>手動 broker instance class</td>
          <td>MSK broker size（kafka.m5.large+）</td>
      </tr>
      <tr>
          <td>Configuration</td>
          <td>server.properties 全控</td>
          <td>Configuration set（限制可調 parameter）</td>
      </tr>
      <tr>
          <td>Cluster topology</td>
          <td>自管 placement / rack awareness</td>
          <td>MSK 自動 multi-AZ + rack-aware</td>
      </tr>
      <tr>
          <td>Tiered storage</td>
          <td>Kafka 3.6+ 自管</td>
          <td>MSK Tiered Storage（auto-tier 到 S3）</td>
      </tr>
  </tbody>
</table>
<p>每行 operational concept 都需要 migration plan、application code 不變但 <em>運維知識體系全換</em>。</p>
<h2 id="4-phase-migrationtype-c-標準流程">4-phase migration（Type C 標準流程）</h2>
<h3 id="phase-0pre-migration-audit">Phase 0：Pre-migration audit</h3>
<ul>
<li><strong>Workload sizing → MSK broker class</strong>：當前 throughput / partition count / topic count</li>
<li><strong>Application connection pattern audit</strong>：客戶端 producer / consumer 用 SASL / mTLS / plaintext？哪個 application</li>
<li><strong>Topic config audit</strong>：retention / replication factor / cleanup policy</li>
<li><strong>Backup pattern audit</strong>：有 MirrorMaker / cross-cluster mirror 嗎</li>
</ul>
<h3 id="phase-1msk-cluster-建置2-3-週">Phase 1：MSK cluster 建置（2-3 週）</h3>





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





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





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





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





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





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





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





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





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





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





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl">kafka-topics.sh --create --topic ret-compact --partitions <span class="m">1</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="se"></span>  --config cleanup.policy<span class="o">=</span>compact <span class="se">\
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="se"></span>  --config min.cleanable.dirty.ratio<span class="o">=</span>0.01 <span class="se">\
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="se"></span>  --config segment.ms<span class="o">=</span><span class="m">5000</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="se"></span>  --config delete.retention.ms<span class="o">=</span><span class="m">100</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="se"></span>  --bootstrap-server localhost:9092
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"># 寫 k1/k2/k3 各舊值一筆、再各新值一筆（key:value 用冒號分隔）</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="nb">printf</span> <span class="s1">&#39;k1:v1-old\nk2:v1-old\nk3:v1-old\nk1:v2-new\nk2:v2-new\nk3:v2-new\n&#39;</span> <span class="p">|</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="se"></span>  kafka-console-producer.sh --topic ret-compact <span class="se">\
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="se"></span>  --property parse.key<span class="o">=</span><span class="nb">true</span> --property key.separator<span class="o">=</span>: <span class="se">\
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="se"></span>  --bootstrap-server localhost:9092
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1"># 等 segment roll + compaction，再從頭消費</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">kafka-console-consumer.sh --topic ret-compact --from-beginning <span class="se">\
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="se"></span>  --property print.key<span class="o">=</span><span class="nb">true</span> --property print.offset<span class="o">=</span><span class="nb">true</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="se"></span>  --timeout-ms <span class="m">6000</span> --bootstrap-server localhost:9092
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="c1"># Offset:3  k1  v2-new</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="c1"># Offset:4  k2  v2-new</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="c1"># Offset:5  k3  v2-new</span></span></span></code></pre></div><p>寫進 6 筆、從頭只讀到 3 筆——k1/k2/k3 的 <code>v1-old</code>（offset 0-2）被壓縮移除、只留每個 key 的 <code>v2-new</code>。關鍵細節：offset 沒有重新編號、留存記錄保留原始 offset（3、4、5）、log 的位置語意不變、其他 consumer 的 offset 進度不會錯位。</p>
<p>Compaction 的觸發不是即時的、由幾個參數共同決定。<code>min.cleanable.dirty.ratio</code> 是「髒比例」門檻、髒記錄（已被新版本取代但還沒清掉的舊版本）佔 log 比例超過這個值、cleaner 才會處理該 partition、預設 0.5（驗證時調成 0.01 加速觸發）。<code>segment.ms</code> 控制 active segment 多久 roll、只有 roll 過的 segment 能被 compact。<code>delete.retention.ms</code> 控制 tombstone（value 為 null 的刪除標記）保留多久——compaction topic 用 null value 表示「這個 key 已刪除」、tombstone 要保留夠久讓所有 consumer 都讀到刪除事件、之後才清掉。</p>
<p>Tombstone 是 compaction 表達「刪除」的方式：寫一筆 key 存在、value 為 null 的記錄、compaction 會把該 key 的所有歷史連同這筆 tombstone 在 <code>delete.retention.ms</code> 之後一起清除。這讓 compact topic 能表達「key 從存在到被刪」的完整生命週期、而不只是「永遠累積最新值」。</p>
<h2 id="tiered-storage-讓容量與保留期解耦">Tiered Storage 讓容量與保留期解耦</h2>
<blockquote>
<p>以下 tiered storage 段落依 Apache Kafka 官方文件（KIP-405）與 Pinterest / LinkedIn 公開案例敘述、未在本文的 KRaft 單節點環境實機驗證。Apache Kafka 的原生 tiered storage（<code>remote.storage.enable</code>）在當前版本屬 early-access、需要額外的 RemoteStorageManager plugin 與 broker 設定；正式採用前以官方文件版本標註為準。</p></blockquote>
<p>Tiered storage 的核心責任是把 broker 的「儲存容量」跟「保留期長度」解耦。傳統 Kafka 的保留期受限於 broker 本機磁碟：想保留 30 天、就得讓每個 broker 的 local disk 容納 30 天的全量資料、retention 拉長等於 broker 數量或單機磁碟線性增長、而 broker 的 CPU / 記憶體 / 網路其實沒用到那麼多。Tiered storage 把 log 分成兩層：熱資料（近期、頻繁讀）留在 broker local disk（local tier）、冷資料（過期門檻之外、偶爾 replay）卸載到遠端物件儲存如 S3（remote tier）。Broker 只需放得下熱資料、保留期可以拉到數月甚至更久、成本變成 S3 的物件儲存費而非 broker 機群。</p>
<p>分層的觸發由 <code>local.retention.ms</code> / <code>local.retention.bytes</code>（本機保留多久 / 多大、超過就卸到 remote）跟整體的 <code>retention.ms</code> / <code>retention.bytes</code>（含 remote 的總保留邊界、超過才真正刪除）共同界定。一筆訊息的生命週期變成：寫入 local tier、超過 local retention 卸到 remote tier、超過整體 retention 從 remote 刪除。Replay window 因此可以遠大於 broker local disk 容量。</p>
<p>讀取路徑分熱冷兩條、效能特性不同。Consumer 讀近期 offset、資料在 local tier、走的是 Kafka 一向的 page cache + 順序讀路徑、低延遲高吞吐。Consumer 讀很舊的 offset（例如出事後從幾週前重播）、資料在 remote tier、broker 要先從 S3 把對應 segment 拉回來才能 serve、第一次讀的延遲明顯高於熱路徑、吞吐受 S3 頻寬與 broker 拉取並行度限制。這個熱冷讀差異是 tiered storage 的核心取捨——也是故障演練要處理的場景。</p>
<p>業界對 tiered storage 有兩條不同的工程路線、對應不同的 broker 角色定位：</p>
<table>
  <thead>
      <tr>
          <th>路線</th>
          <th>broker 角色</th>
          <th>代表案例</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Broker-coupled（KIP-405 原生）</td>
          <td>broker 仍是 remote 讀的熱路徑、代理拉取</td>
          <td>Apache Kafka 原生 tiered storage</td>
      </tr>
      <tr>
          <td>Broker-decoupled</td>
          <td>consumer 直接從 S3 拉、broker 不在熱路徑</td>
          <td><a href="/blog/backend/03-message-queue/cases/kafka-pinterest-tiered-storage/" data-link-title="3.C11 Pinterest：Kafka tiered storage broker-decoupled" data-link-desc="Pinterest 採 broker-decoupled tiered storage、把 ~200 TB/day 熱資料卸到 S3、broker 不再是熱路徑。">3.C11 Pinterest Tiered Storage</a></td>
      </tr>
  </tbody>
</table>
<p><a href="/blog/backend/03-message-queue/cases/kafka-pinterest-tiered-storage/" data-link-title="3.C11 Pinterest：Kafka tiered storage broker-decoupled" data-link-desc="Pinterest 採 broker-decoupled tiered storage、把 ~200 TB/day 熱資料卸到 S3、broker 不再是熱路徑。">Pinterest 的 broker-decoupled 做法</a>把 ~200 TB/day 熱資料卸到 S3、讓 consumer 直接從 S3 拉冷資料、broker 不再是冷讀的熱路徑。它揭露的設計判讀是「broker 運算資源」跟「跨 AZ 網路成本」其實該分開治理、而不是綁在 broker 容量擴張上——保留期變長不該等於 broker 機群變大。</p>
<p><a href="/blog/backend/03-message-queue/cases/linkedin-kafka-tiered-clusters/" data-link-title="3.C4 LinkedIn：Kafka 分層叢集治理" data-link-desc="Kafka 從單叢集走向 tiered clusters 的轉換案例。">LinkedIn 的分層叢集策略</a>是另一個層次的「分層」：把不同業務特性與可靠性需求的 workload 拆到不同叢集（依關鍵程度分群、例如關鍵 / 一般 / 實驗性，分層名稱為示意而非案例原文用詞）、避免混在同一叢集時故障與資源競爭互相放大。這裡的「分層」指叢集隔離、不是儲存的冷熱分層。兩種「分層」常被混談、但解的是不同問題：tiered storage 解單一 topic 的儲存成本、tiered clusters 解多 workload 的隔離治理。</p>
<h2 id="故障演練">故障演練</h2>
<h3 id="retention-太短replay-window-不夠補事故">Retention 太短、replay window 不夠補事故</h3>
<p><strong>徵兆</strong>：下游 consumer 出 bug、產出錯誤的衍生資料、幾天後才被對帳發現；要從原始事件重播修復時、發現最舊的事件已經被刪、replay 從某個時間點之後才有資料、之前的修不回來。</p>
<p><strong>根因</strong>：retention.ms 設得比「事故從發生到偵測到開始修復的最長時間」短。Replay window 由 broker retention 與 consumer checkpoint 共同界定、retention 是其物理上限；偵測延遲一旦超過 retention、要補算時原始事件已過期。常見的隱性誘因是把 retention 按「正常 consumer 跟得上的進度」來設（例如 consumer 通常落後幾分鐘、就設 1 天保險）、卻沒按「最壞情況下多久才會發現問題」來設。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>把 retention.ms 對齊事故偵測到修復的最長時間、而非 consumer 正常落後量；對帳 / 審計類 pipeline 的偵測週期常以天計、retention 要跟著拉到對應天數。</li>
<li>對「偵測延遲可能很長」的關鍵 topic、在下游另留可重算的來源（資料庫快照、上游 source of truth）、不把 Kafka retention 當唯一補償依據。</li>
<li>用 <code>kafka-configs.sh --alter</code> 動態延長 retention 是即時生效的、但只對「還沒被刪」的訊息有用——已刪的救不回來；所以調整要趁事故升級前、發現偵測週期被低估的當下就改、不是等出事才改。</li>
<li>Replay 邊界對齊見 <a href="/blog/backend/03-message-queue/event-contract-replay-boundary/" data-link-title="3.7 Event Contract 與 Replay Boundary" data-link-desc="說明 event schema、idempotency key、replay window 與補償如何先於 broker 選型。">3.7 Event Contract 與 Replay Boundary</a>：replay 要能指定 time range、超出 retention 的 time range 直接無效。</li>
</ol>
<h3 id="compaction-開了磁碟卻沒回收">Compaction 開了、磁碟卻沒回收</h3>
<p><strong>徵兆</strong>：topic 設了 <code>cleanup.policy=compact</code>、預期同 key 舊版本會被清掉、磁碟用量卻持續上漲、<code>--describe</code> 看 partition log 一直變大；從頭消費仍讀到大量同 key 的歷史版本。</p>
<p><strong>根因</strong>：compaction 觸發條件沒滿足。log cleaner 只處理已 roll 的 segment、active segment 永遠不壓縮；<code>min.cleanable.dirty.ratio</code> 預設 0.5、髒比例沒到一半 cleaner 不動手；如果寫入集中在少數 key、active segment 遲遲不 roll（segment.bytes / segment.ms 都沒到）、髒記錄全積在 active segment 裡、compaction 看不到它們。另一個常見原因是 broker 的 log cleaner 執行緒數（<code>log.cleaner.threads</code>）不足以跟上高寫入量、cleaner backlog 累積。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>確認 active segment 會適時 roll：對寫入量不大但需要及時壓縮的 topic、設 <code>segment.ms</code>（例如數小時）強制 roll、讓髒記錄離開 active segment 進入可壓縮範圍。</li>
<li>視壓縮急迫度調 <code>min.cleanable.dirty.ratio</code>：要更積極壓縮就調低（驗證時用 0.01）、但調太低會讓 cleaner 頻繁重寫 segment、增加 I/O——這是壓縮及時性跟 cleaner 開銷的取捨。</li>
<li>監控 cleaner backlog：看 broker 的 <code>log-cleaner</code> 相關 metric、backlog 持續成長代表 cleaner 執行緒不夠、加 <code>log.cleaner.threads</code>。</li>
<li>確認沒有把 compact 用在「其實該 delete」的事件流上——事件流每筆 key 多半唯一、compaction 沒有舊版本可壓、磁碟自然不會降；那種情況該用 <code>delete</code> 加 retention。</li>
</ol>
<h3 id="cold-tier-讀延遲拖垮-replay">Cold tier 讀延遲拖垮 replay</h3>
<p><strong>徵兆</strong>：開了 tiered storage、平時讀近期資料正常、一旦發起從幾週前的舊 offset 大規模 replay、consumer 的吞吐驟降、p99 拉取延遲飆高、broker S3 拉取頻寬打滿、同 broker 上其他正常 consumer 也跟著受影響。</p>
<p><strong>根因</strong>：舊 offset 的資料在 remote tier、每次讀要先從 S3 把 segment 拉回 broker、第一次冷讀延遲遠高於 local tier 的順序讀。大規模 replay 等於一次要從 S3 拉大量冷 segment、S3 頻寬與 broker 拉取並行成為瓶頸；broker-coupled 架構下這些拉取流量全經過 broker、會排擠到熱路徑的正常服務。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>把大規模冷 replay 排到低流量時段、避免跟線上熱路徑爭 broker 資源與 S3 頻寬。</li>
<li>控制 replay 的並行度與範圍：依 <a href="/blog/backend/03-message-queue/event-contract-replay-boundary/" data-link-title="3.7 Event Contract 與 Replay Boundary" data-link-desc="說明 event schema、idempotency key、replay window 與補償如何先於 broker 選型。">replay boundary</a> 指定 time range / tenant / partition、分批拉冷資料、不要一次全量回放整個保留期。</li>
<li>評估 broker-decoupled 架構（如 <a href="/blog/backend/03-message-queue/cases/kafka-pinterest-tiered-storage/" data-link-title="3.C11 Pinterest：Kafka tiered storage broker-decoupled" data-link-desc="Pinterest 採 broker-decoupled tiered storage、把 ~200 TB/day 熱資料卸到 S3、broker 不再是熱路徑。">Pinterest 做法</a>）：consumer 直接從 S3 拉冷資料、把冷讀流量從 broker 熱路徑移開、保護線上服務。</li>
<li>容量規劃把「冷讀延遲」算進 RTO：replay window 拉很長能補很久以前的事故、但補的速度受 cold tier 吞吐限制、事故修復時間估算要把這段拉取時間算進去。</li>
</ol>
<h3 id="retentionbytes-在高流量時段提早刪">retention.bytes 在高流量時段提早刪</h3>
<p><strong>徵兆</strong>：retention.ms 明明設了 7 天、某次流量突增後、consumer 卻發現幾小時前的事件就已經被刪、replay 拿不到本該還在的資料；earliest offset 在沒人預期的時候大幅前移。</p>
<p><strong>根因</strong>：retention.ms 與 retention.bytes 同時設時是「誰先觸發誰生效」。流量突增讓 partition log 在遠不到 7 天時就撞到 retention.bytes 容量上限、容量軸先觸發、舊 segment 被提前刪除——時間軸的 7 天承諾在高流量下失效。常見於「按平均流量估容量上限、卻遇到尖峰流量」、或多個 topic 共享磁碟時為了保護磁碟把每 topic 容量上限壓得偏低。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>釐清這個 topic 的保留承諾是時間還是容量主導：以 replay window 為準的關鍵 topic、容量上限要按「尖峰流量 × 保留天數」估、而非平均流量、否則尖峰時容量軸會偷走時間承諾。</li>
<li>監控 earliest offset 與 log 大小的變化率：earliest offset 在非預期時間前移、就是 retention.bytes 提前觸發的訊號、加進告警。</li>
<li>要硬保證時間保留、就把 retention.bytes 設成 -1（不限容量、純時間軸）、改用獨立的磁碟告警與容量規劃來防磁碟塞爆、而不是用 retention.bytes 兼做兩件事。</li>
<li>評估 tiered storage：把保留壓力從 broker local disk 移到 remote tier、local 只留熱資料、就不必為了保護 broker 磁碟而把 retention.bytes 壓低、時間承諾不再被容量上限侵蝕。</li>
</ol>
<h2 id="容量與成本">容量與成本</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>估算與判讀</th>
          <th>警戒</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Local disk 用量</td>
          <td>partition 數 × 單 partition log 大小 × replication factor</td>
          <td>接近磁碟上限時 retention.bytes 會提前砍時間承諾</td>
      </tr>
      <tr>
          <td>保留期 vs 成本</td>
          <td>純 local 時 retention 線性推高 broker 磁碟成本</td>
          <td>數月保留 + 純 local = broker 機群為冷資料買單</td>
      </tr>
      <tr>
          <td>Tiered remote 成本</td>
          <td>S3 物件儲存費 + 冷讀時的拉取 / egress 流量費</td>
          <td>跨 AZ / 跨 region 冷讀 egress 成本易被低估</td>
      </tr>
      <tr>
          <td>Retention 檢查延遲</td>
          <td>過期到實際刪除最長一個 <code>log.retention.check.interval.ms</code>（預設 5 分）</td>
          <td>容量規劃要預留 overshoot 緩衝</td>
      </tr>
      <tr>
          <td>Compaction 開銷</td>
          <td>cleaner 重寫 segment 的 I/O、隨 dirty.ratio 調低而上升</td>
          <td>dirty.ratio 過低 = cleaner 頻繁重寫、I/O 壓力升</td>
      </tr>
      <tr>
          <td>Cold replay 吞吐</td>
          <td>受 remote tier（S3）頻寬與 broker 拉取並行度限制</td>
          <td>大規模 cold replay 排低流量時段、分批進行</td>
      </tr>
  </tbody>
</table>
<p>實務 default：</p>
<ul>
<li>事件流 topic 用 <code>delete</code>、retention.ms 對齊事故偵測到修復的最長時間、retention.bytes 設 -1 或按尖峰流量估、不讓容量軸偷走時間承諾。</li>
<li>狀態快照 / CDC 鏡像 topic 用 <code>compact</code>、確認 active segment 會適時 roll、監控 cleaner backlog。</li>
<li>需要長保留期（數月以上）且 broker 磁碟成本敏感時、評估 tiered storage、把冷資料移到 S3、broker 只放熱資料。</li>
<li>任何 retention 調整前先確認當前生效層級（<code>kafka-configs.sh --describe</code> 看 synonyms）、避免 broker 預設與 topic 動態配置混淆。</li>
</ul>
<h2 id="整合與下一步">整合與下一步</h2>
<h3 id="跟-replay-邊界對齊">跟 replay 邊界對齊</h3>
<p>Retention 是 <a href="/blog/backend/knowledge-cards/replay-window/" data-link-title="Replay Window" data-link-desc="說明事件可重播的時間或 offset 範圍邊界，由 retention 與 checkpoint 決定">replay window</a> 的物理上限、但 replay 能不能正確執行還要看 <a href="/blog/backend/03-message-queue/event-contract-replay-boundary/" data-link-title="3.7 Event Contract 與 Replay Boundary" data-link-desc="說明 event schema、idempotency key、replay window 與補償如何先於 broker 選型。">event contract</a> 是否齊備（event id / schema version / occurred time / dedup key）。保留策略設計要跟 <a href="/blog/backend/03-message-queue/event-contract-replay-boundary/" data-link-title="3.7 Event Contract 與 Replay Boundary" data-link-desc="說明 event schema、idempotency key、replay window 與補償如何先於 broker 選型。">3.7 Event Contract 與 Replay Boundary</a> 一起看：retention 決定「能不能讀到」、event contract 決定「讀到了能不能正確重播」、兩者缺一 replay 都不成立。相關概念見 <a href="/blog/backend/knowledge-cards/retention/" data-link-title="Retention" data-link-desc="說明資料或事件保留多久，以及保留期限如何影響重放與成本">retention</a> 與 <a href="/blog/backend/knowledge-cards/offset/" data-link-title="Offset" data-link-desc="說明 consumer 在事件流中的讀取位置與重放基準">offset</a> 知識卡。</p>
<h3 id="跟分層叢集治理對位">跟分層叢集治理對位</h3>
<p>本文的 tiered storage 解的是單一 topic 的儲存成本；<a href="/blog/backend/03-message-queue/cases/linkedin-kafka-tiered-clusters/" data-link-title="3.C4 LinkedIn：Kafka 分層叢集治理" data-link-desc="Kafka 從單叢集走向 tiered clusters 的轉換案例。">3.C4 LinkedIn 分層叢集</a>解的是多 workload 的隔離——把不同可靠性需求的 topic 拆到不同叢集、避免資源競爭互相放大。保留策略在分層叢集裡會按層差異化：critical 叢集拉長 retention 保 replay、experimental 叢集縮短 retention 控成本。</p>
<h3 id="跟-broker-decoupled-架構的取捨">跟 broker-decoupled 架構的取捨</h3>
<p><a href="/blog/backend/03-message-queue/cases/kafka-pinterest-tiered-storage/" data-link-title="3.C11 Pinterest：Kafka tiered storage broker-decoupled" data-link-desc="Pinterest 採 broker-decoupled tiered storage、把 ~200 TB/day 熱資料卸到 S3、broker 不再是熱路徑。">3.C11 Pinterest broker-decoupled tiered storage</a> 把冷讀流量從 broker 熱路徑移開、是「cold tier 讀延遲拖垮 replay」故障演練的架構級解法；它跟 <a href="/blog/backend/03-message-queue/cases/kafka-pinterest-shallow-mirror/" data-link-title="3.C12 Pinterest：Shallow Mirror 優化 MirrorMaker" data-link-desc="Pinterest 跨 3 region MirrorMaker、原版解壓&#43;重壓造成 CPU/memory 2-10x spike、改 RecordBatch 層淺迭代。">3.C12 Pinterest Shallow Mirror</a> 揭露的「跨區同步是 CPU + memory + 網路三維壓力」一起、構成 Pinterest 在儲存與複製兩條路徑上的成本治理。</p>
<h3 id="回上游">回上游</h3>
<ul>
<li>上游 vendor 頁：<a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Apache Kafka</a>（「Tiered storage」與「Cross-region 與分層叢集」段）</li>
<li>平行 deep article：<a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">consumer rebalance 與 lag 診斷</a> / <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">replication、ISR 與 exactly-once</a>（同 vendor 其他實作層議題）</li>
<li>下游能力：<a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計</a> / <a href="/blog/backend/06-reliability/idempotency-replay/" data-link-title="6.12 Idempotency 與 Replay 驗證" data-link-desc="把重試、重播與冪等性從口頭約定變成可驗證屬性">6.12 idempotency / replay</a></li>
</ul>
]]></content:encoded></item><item><title>RabbitMQ Queue Type 選型：Classic、Quorum、Stream 的責任邊界與容量取捨</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/rabbitmq/queue-types-classic-quorum-stream/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/rabbitmq/queue-types-classic-quorum-stream/</guid><description>&lt;blockquote>
&lt;p>本文是 &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> overview 的 implementation-layer deep article、對應 overview「Classic queue vs Quorum queue vs Stream」段。Overview 回答「RabbitMQ 該不該選、跟 Kafka / SQS 差在哪」、本文回答「選了 RabbitMQ 之後、同一個 broker 內三種 queue type 怎麼挑、各自的容量與故障形狀」。&lt;/p>&lt;/blockquote>
&lt;h2 id="同一個-broker三套儲存引擎">同一個 broker、三套儲存引擎&lt;/h2>
&lt;p>RabbitMQ 的 queue 由三種 &lt;em>儲存引擎&lt;/em> 構成、共用同一套 AMQP 協議與 management 介面。Queue type 決定訊息怎麼持久化、怎麼跨節點複製、消費後是否保留 — 這些差異在宣告 queue 的那一刻就鎖定、之後無法原地切換。選錯 queue type 的代價不是參數調整、是 &lt;em>重建 queue + 遷移 in-flight 訊息&lt;/em>。&lt;/p>
&lt;p>三種 type 各自承擔不同責任：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Classic queue&lt;/strong>：單節點的 durable / transient queue、訊息消費即刪除、是 RabbitMQ 最原始的工作隊列模型。跨節點高可用曾靠 &lt;em>mirrored queue&lt;/em>（鏡像複製）達成、但該機制在 3.x 已標記 deprecated、4.0 移除。&lt;/li>
&lt;li>&lt;strong>Quorum queue&lt;/strong>：Raft 共識協議實作的 replicated queue、跨節點維持強一致的訊息狀態、設計目標是 &lt;em>取代 mirrored queue&lt;/em> 提供可靠的工作隊列高可用。訊息仍是消費即刪除的隊列語意。&lt;/li>
&lt;li>&lt;strong>Stream&lt;/strong>：3.9 引入的 append-only log、訊息寫入後 &lt;em>不因消費而刪除&lt;/em>、多個 consumer 可從各自的 offset 重複讀取、retention 由時間 / 大小上限控制。語意接近 Kafka 的 partition log、但跑在 RabbitMQ 體系內、共用 AMQP 與專屬 stream protocol。&lt;/li>
&lt;/ul>
&lt;p>判讀起點是一個問題：訊息被消費後該不該保留。需要 replay、多 consumer 各自進度、長期事件流 → stream；訊息是一次性任務、處理完即丟、要跨節點不丟 → quorum；單節點夠用、可接受節點故障時該 queue 暫時不可用 → classic。&lt;/p>
&lt;p>本文用 RabbitMQ 3.13.7（OrbStack 單節點）實機驗證宣告語意差異。生產的跨節點行為（Raft 選舉、replica lag）單節點環境無法重現、相關段落標注來源。&lt;/p>
&lt;h2 id="三種-queue-type-的宣告語意差異實機驗證">三種 queue type 的宣告語意差異（實機驗證）&lt;/h2>
&lt;p>Queue type 由宣告時的 &lt;code>x-queue-type&lt;/code> argument 決定。三種 type 在同一 broker 宣告後、&lt;code>type&lt;/code> 欄位區分清楚：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">rabbitmqadmin &lt;span class="nb">declare&lt;/span> queue &lt;span class="nv">name&lt;/span>&lt;span class="o">=&lt;/span>q-classic &lt;span class="nv">durable&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">rabbitmqadmin &lt;span class="nb">declare&lt;/span> queue &lt;span class="nv">name&lt;/span>&lt;span class="o">=&lt;/span>q-quorum &lt;span class="nv">durable&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span> &lt;span class="nv">arguments&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s1">&amp;#39;{&amp;#34;x-queue-type&amp;#34;:&amp;#34;quorum&amp;#34;}&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">rabbitmqadmin &lt;span class="nb">declare&lt;/span> queue &lt;span class="nv">name&lt;/span>&lt;span class="o">=&lt;/span>q-stream &lt;span class="nv">durable&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span> &lt;span class="nv">arguments&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s1">&amp;#39;{&amp;#34;x-queue-type&amp;#34;:&amp;#34;stream&amp;#34;}&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">rabbitmqctl list_queues name &lt;span class="nb">type&lt;/span> durable leader members&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>實機輸出（節錄、單節點所以 leader / members 都是同一節點）：&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">name type durable leader members
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">q-classic classic true
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">q-quorum quorum true rabbit@&amp;lt;node&amp;gt; [rabbit@&amp;lt;node&amp;gt;]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">q-stream stream true rabbit@&amp;lt;node&amp;gt; [rabbit@&amp;lt;node&amp;gt;]&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>兩個關鍵差異在這裡浮現。&lt;/p>
&lt;p>第一、&lt;strong>quorum 與 stream 強制 durable&lt;/strong>。Classic queue 可宣告為 transient（&lt;code>durable=false&lt;/code>、broker 重啟後消失、適合臨時 RPC reply queue）；quorum 與 stream 不允許 transient — 嘗試宣告會直接被拒：&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">*** invalid property &amp;#39;non-durable&amp;#39; for queue &amp;#39;q-quorum-nondur&amp;#39; in vhost &amp;#39;/&amp;#39;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">*** invalid property &amp;#39;non-durable&amp;#39; for queue &amp;#39;q-stream-nondur&amp;#39; in vhost &amp;#39;/&amp;#39;&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個限制反映設計意圖：quorum 與 stream 存在的理由是 &lt;em>資料安全&lt;/em>、transient 模式與該目標矛盾、所以從宣告層就封死。Classic queue 保留 transient 選項、是因為它要同時服務「臨時隊列」與「持久隊列」兩種場景。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/03-message-queue/vendors/rabbitmq/" data-link-title="RabbitMQ" data-link-desc="Classic message broker、AMQP routing 為主">RabbitMQ</a> overview 的 implementation-layer deep article、對應 overview「Classic queue vs Quorum queue vs Stream」段。Overview 回答「RabbitMQ 該不該選、跟 Kafka / SQS 差在哪」、本文回答「選了 RabbitMQ 之後、同一個 broker 內三種 queue type 怎麼挑、各自的容量與故障形狀」。</p></blockquote>
<h2 id="同一個-broker三套儲存引擎">同一個 broker、三套儲存引擎</h2>
<p>RabbitMQ 的 queue 由三種 <em>儲存引擎</em> 構成、共用同一套 AMQP 協議與 management 介面。Queue type 決定訊息怎麼持久化、怎麼跨節點複製、消費後是否保留 — 這些差異在宣告 queue 的那一刻就鎖定、之後無法原地切換。選錯 queue type 的代價不是參數調整、是 <em>重建 queue + 遷移 in-flight 訊息</em>。</p>
<p>三種 type 各自承擔不同責任：</p>
<ul>
<li><strong>Classic queue</strong>：單節點的 durable / transient queue、訊息消費即刪除、是 RabbitMQ 最原始的工作隊列模型。跨節點高可用曾靠 <em>mirrored queue</em>（鏡像複製）達成、但該機制在 3.x 已標記 deprecated、4.0 移除。</li>
<li><strong>Quorum queue</strong>：Raft 共識協議實作的 replicated queue、跨節點維持強一致的訊息狀態、設計目標是 <em>取代 mirrored queue</em> 提供可靠的工作隊列高可用。訊息仍是消費即刪除的隊列語意。</li>
<li><strong>Stream</strong>：3.9 引入的 append-only log、訊息寫入後 <em>不因消費而刪除</em>、多個 consumer 可從各自的 offset 重複讀取、retention 由時間 / 大小上限控制。語意接近 Kafka 的 partition log、但跑在 RabbitMQ 體系內、共用 AMQP 與專屬 stream protocol。</li>
</ul>
<p>判讀起點是一個問題：訊息被消費後該不該保留。需要 replay、多 consumer 各自進度、長期事件流 → stream；訊息是一次性任務、處理完即丟、要跨節點不丟 → quorum；單節點夠用、可接受節點故障時該 queue 暫時不可用 → classic。</p>
<p>本文用 RabbitMQ 3.13.7（OrbStack 單節點）實機驗證宣告語意差異。生產的跨節點行為（Raft 選舉、replica lag）單節點環境無法重現、相關段落標注來源。</p>
<h2 id="三種-queue-type-的宣告語意差異實機驗證">三種 queue type 的宣告語意差異（實機驗證）</h2>
<p>Queue type 由宣告時的 <code>x-queue-type</code> argument 決定。三種 type 在同一 broker 宣告後、<code>type</code> 欄位區分清楚：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">rabbitmqadmin <span class="nb">declare</span> queue <span class="nv">name</span><span class="o">=</span>q-classic <span class="nv">durable</span><span class="o">=</span><span class="nb">true</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">rabbitmqadmin <span class="nb">declare</span> queue <span class="nv">name</span><span class="o">=</span>q-quorum  <span class="nv">durable</span><span class="o">=</span><span class="nb">true</span> <span class="nv">arguments</span><span class="o">=</span><span class="s1">&#39;{&#34;x-queue-type&#34;:&#34;quorum&#34;}&#39;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">rabbitmqadmin <span class="nb">declare</span> queue <span class="nv">name</span><span class="o">=</span>q-stream  <span class="nv">durable</span><span class="o">=</span><span class="nb">true</span> <span class="nv">arguments</span><span class="o">=</span><span class="s1">&#39;{&#34;x-queue-type&#34;:&#34;stream&#34;}&#39;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl">rabbitmqctl list_queues name <span class="nb">type</span> durable leader members</span></span></code></pre></div><p>實機輸出（節錄、單節點所以 leader / members 都是同一節點）：</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       type     durable  leader              members
</span></span><span class="line"><span class="ln">2</span><span class="cl">q-classic  classic  true
</span></span><span class="line"><span class="ln">3</span><span class="cl">q-quorum   quorum   true     rabbit@&lt;node&gt;       [rabbit@&lt;node&gt;]
</span></span><span class="line"><span class="ln">4</span><span class="cl">q-stream   stream   true     rabbit@&lt;node&gt;       [rabbit@&lt;node&gt;]</span></span></code></pre></div><p>兩個關鍵差異在這裡浮現。</p>
<p>第一、<strong>quorum 與 stream 強制 durable</strong>。Classic queue 可宣告為 transient（<code>durable=false</code>、broker 重啟後消失、適合臨時 RPC reply queue）；quorum 與 stream 不允許 transient — 嘗試宣告會直接被拒：</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">*** invalid property &#39;non-durable&#39; for queue &#39;q-quorum-nondur&#39; in vhost &#39;/&#39;
</span></span><span class="line"><span class="ln">2</span><span class="cl">*** invalid property &#39;non-durable&#39; for queue &#39;q-stream-nondur&#39; in vhost &#39;/&#39;</span></span></code></pre></div><p>這個限制反映設計意圖：quorum 與 stream 存在的理由是 <em>資料安全</em>、transient 模式與該目標矛盾、所以從宣告層就封死。Classic queue 保留 transient 選項、是因為它要同時服務「臨時隊列」與「持久隊列」兩種場景。</p>
<p>第二、<strong>quorum 與 stream 有 leader / members、classic 沒有</strong>。Classic queue 的訊息只存在宣告它的節點上（mirrored policy 另算）；quorum 與 stream 在設計上就是 <em>cluster-aware</em> 的 replicated 結構、leader 處理讀寫、members 列出 replica 所在節點。單節點環境下 members 只有一個、但欄位本身揭露了複製拓樸的存在。</p>
<p>Stream 的 retention 與 segment 參數在宣告時設定、宣告後可查：</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">rabbitmqadmin <span class="nb">declare</span> queue <span class="nv">name</span><span class="o">=</span>q-stream-ret <span class="nv">durable</span><span class="o">=</span><span class="nb">true</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  <span class="nv">arguments</span><span class="o">=</span><span class="s1">&#39;{&#34;x-queue-type&#34;:&#34;stream&#34;,&#34;x-max-length-bytes&#34;:20000000000,&#34;x-max-age&#34;:&#34;7D&#34;,&#34;x-stream-max-segment-size-bytes&#34;:100000000}&#39;</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">rabbitmqctl list_queues name <span class="nb">type</span> arguments</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">q-stream-ret  stream  [{&#34;x-max-age&#34;,&#34;7D&#34;},{&#34;x-max-length-bytes&#34;,20000000000},
</span></span><span class="line"><span class="ln">2</span><span class="cl">                       {&#34;x-queue-type&#34;,&#34;stream&#34;},{&#34;x-stream-max-segment-size-bytes&#34;,100000000}]</span></span></code></pre></div><p><code>x-max-age</code>（保留 7 天）與 <code>x-max-length-bytes</code>（保留 20GB）是 stream 獨有的 retention 控制 — classic 與 quorum 沒有這個概念、因為它們消費即刪除、不存在「保留多久」的問題。Quorum queue 對應的是 <code>x-delivery-limit</code>（投遞次數上限、超過進 dead-letter）這類 <em>重試治理</em> 參數、而非 retention：</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">q-quorum-dl  quorum  [{&#34;x-delivery-limit&#34;,5},{&#34;x-queue-type&#34;,&#34;quorum&#34;}]</span></span></code></pre></div><p>宣告參數的差異就是責任邊界的縮影：stream 的參數圍繞「保留多少歷史」、quorum 的參數圍繞「重試到第幾次放棄」、classic 兩者都精簡。</p>
<h2 id="三軸選型判讀">三軸選型判讀</h2>
<p>Queue type 的選擇由三個軸決定：消費後是否保留（retention / replay）、跨節點一致性需求、記憶體與 throughput 成本。</p>
<table>
  <thead>
      <tr>
          <th>判讀軸</th>
          <th>Classic</th>
          <th>Quorum</th>
          <th>Stream</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>消費語意</td>
          <td>消費即刪除</td>
          <td>消費即刪除</td>
          <td>消費不刪除、offset 各自獨立</td>
      </tr>
      <tr>
          <td>Replay</td>
          <td>不支援</td>
          <td>不支援</td>
          <td>支援、consumer 可重設 offset 重讀</td>
      </tr>
      <tr>
          <td>跨節點一致性</td>
          <td>無（mirrored deprecated）</td>
          <td>Raft 強一致、majority 寫入才 ack</td>
          <td>Leader-follower 複製、append-only</td>
      </tr>
      <tr>
          <td>高 throughput</td>
          <td>中（單節點 fsync 上限）</td>
          <td>中（Raft majority round-trip 成本）</td>
          <td>高（順序寫 log、批次讀）</td>
      </tr>
      <tr>
          <td>記憶體成本</td>
          <td>高（訊息常駐記憶體、lazy 例外）</td>
          <td>中（on-disk 為主、index 在記憶體）</td>
          <td>低（log 在磁碟、讀靠 page cache）</td>
      </tr>
      <tr>
          <td>典型場景</td>
          <td>單節點任務隊列、臨時 RPC reply</td>
          <td>跨節點不可丟的工作隊列</td>
          <td>事件流、多 consumer、需要 replay 的審計</td>
      </tr>
  </tbody>
</table>
<h3 id="消費後是否保留retention-與-replay">消費後是否保留：retention 與 replay</h3>
<p>Stream 與 classic / quorum 的根本分界是訊息生命週期。Classic 與 quorum 是 <em>隊列</em>：訊息被 ack 後從 queue 移除、後到的 consumer 看不到歷史。Stream 是 <em>log</em>：訊息寫入後常駐到 retention 上限為止、consumer 各自維護 offset、可以從 offset 0 重讀整段歷史、也可以從 timestamp 起讀。</p>
<p>實機可觀察到 stream 的訊息在 publish 後保留在 queue 內：</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">rabbitmqadmin publish <span class="nv">exchange</span><span class="o">=</span>amq.default <span class="nv">routing_key</span><span class="o">=</span>q-stream <span class="nv">payload</span><span class="o">=</span><span class="s2">&#34;msg1&#34;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">rabbitmqadmin publish <span class="nv">exchange</span><span class="o">=</span>amq.default <span class="nv">routing_key</span><span class="o">=</span>q-stream <span class="nv">payload</span><span class="o">=</span><span class="s2">&#34;msg2&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">rabbitmqadmin publish <span class="nv">exchange</span><span class="o">=</span>amq.default <span class="nv">routing_key</span><span class="o">=</span>q-stream <span class="nv">payload</span><span class="o">=</span><span class="s2">&#34;msg3&#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">rabbitmqctl list_queues name <span class="nb">type</span> messages messages_ready</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">q-stream   stream  3  3</span></span></code></pre></div><p>對 classic queue、同樣 publish 後 consumer ack 一次、訊息歸零；對 stream、即使一個 consumer 讀完、<code>messages</code> 仍維持 3、因為訊息保留供其他 consumer 與未來 replay。這個差異決定了選型：需要「新上線的 consumer 補讀歷史事件」「同一份事件流餵給多個下游」「審計與重算」→ stream 是唯一選項；只要「一個任務交給一個 worker 處理一次」→ classic 或 quorum、不要用 stream（log 保留會吃磁碟、且隊列語意更貼合任務分派）。</p>
<p>需要在 RabbitMQ 體系外做大規模事件流（跨團隊 schema 治理、tiered storage、生態工具）時、stream 不是終點、改評估 <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>。Stream 的定位是「已經在用 RabbitMQ、需要 replay 但不想引入第二套 broker」。</p>
<h3 id="跨節點一致性mirrored-的退場與-quorum-的接手">跨節點一致性：mirrored 的退場與 quorum 的接手</h3>
<p>Classic queue 在單節點上沒有複製。早期要跨節點高可用、靠 <em>mirrored queue</em> — 一個 master、多個 mirror、master 寫入同步到所有 mirror。這個機制的問題在 <a href="/blog/backend/03-message-queue/cases/rabbitmq-runtastic-mirrored-queue-bottleneck/" data-link-title="3.C30 Runtastic：Mirrored queue 網路負載瓶頸" data-link-desc="Runtastic 2020 lockdown 流量暴增、performance test 揭露 mirroring 邏輯把網路元件壓垮、調整 mirroring 配置消除瓶頸。">3.C30 Runtastic</a> 揭露：mirror 數越多、每筆訊息的網路放大越大、規模化時網路元件先被壓垮。RabbitMQ 3.x 已將 mirrored queue 標記 deprecated、4.0 移除。</p>
<p>Quorum queue 用 Raft 共識取代 mirroring。差異在「同步多少 replica 才算寫成功」：mirrored queue 要求 <em>所有</em> mirror 同步（全量放大）；Raft 只要求 <em>majority</em>（多數派）寫入即 ack，少數派慢或暫時離線不阻塞寫入。majority 機制讓 quorum queue 在「容忍少數節點故障」與「寫入延遲」之間取得 mirrored 做不到的平衡。</p>
<p>代價是 Raft 的 round-trip 成本：每筆訊息要等多數派落盤、單筆延遲高於 classic 單節點 fsync。所以 quorum queue 適合「不可丟、可接受中等延遲」的工作隊列、不適合追求極致低延遲的場景。</p>
<h3 id="記憶體與-throughput-成本">記憶體與 throughput 成本</h3>
<p>Classic queue 的歷史包袱是訊息傾向常駐記憶體、queue 堆積時記憶體壓力大（lazy queue 模式可緩解、但仍是 classic 的調校負擔）。Quorum queue 預設 on-disk 為主、記憶體只放 index 與近期訊息、堆積時記憶體曲線比 classic 平緩。Stream 是 append-only log、寫入是順序磁碟 I/O、讀取靠 OS page cache、是三者中記憶體效率最高、throughput 最高的 — 順序寫與批次讀讓它在高吞吐事件流場景接近 Kafka 的量級。</p>
<p>throughput 排序大致是 stream &gt; classic ≈ quorum（quorum 因 Raft round-trip 略低於單節點 classic、但換得一致性）。選型時 throughput 不該是唯一軸：stream throughput 高但語意是 log、用它跑任務隊列會錯配；quorum throughput 中但提供 classic 給不了的高可用。</p>
<h2 id="故障演練">故障演練</h2>
<p>三種 queue type 的故障形狀完全不同。以下四個場景對應實際遷移與運維會踩的坑。</p>
<h3 id="mirrored-queue-的網路放大成本">Mirrored queue 的網路放大成本</h3>
<p><strong>徵兆</strong>：流量暴增期間、RabbitMQ cluster 出現高延遲與間歇中斷、但 CPU 與磁碟未飽和；performance test 指向網路元件被壓垮。這正是 <a href="/blog/backend/03-message-queue/cases/rabbitmq-runtastic-mirrored-queue-bottleneck/" data-link-title="3.C30 Runtastic：Mirrored queue 網路負載瓶頸" data-link-desc="Runtastic 2020 lockdown 流量暴增、performance test 揭露 mirroring 邏輯把網路元件壓垮、調整 mirroring 配置消除瓶頸。">3.C30 Runtastic</a> 2020 lockdown 期間的情況。</p>
<p><strong>根因</strong>：mirrored queue 把每筆訊息同步到 <em>所有</em> mirror。一個 master + 2 mirror 的 queue、每筆 publish 產生 2 份額外的跨節點複製流量；mirror 數與訊息量相乘、網路頻寬隨規模線性放大。可靠性看似免費（多一個 mirror 就多一份備援）、實際成本藏在網路層、平時不顯、流量尖峰才爆。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>量化 mirror 的網路成本</strong>：mirror 數不是越多越安全、每個 mirror 都是固定的複製流量稅。生產上 mirror 數很少需要超過總節點的 majority。</li>
<li><strong>遷移到 quorum queue</strong>：Raft 的 majority 寫入取代全量同步、把網路放大從「mirror 數」降到「majority round-trip」。Runtastic case 是「為何該遷 quorum」的典型動機。</li>
<li><strong>監控網路而非只看 CPU / 磁碟</strong>：mirrored queue 的瓶頸常在網路、用 Prometheus integration 把跨節點複製流量納入告警基線。</li>
</ol>
<h3 id="quorum-queue-的-quorum-loss">Quorum queue 的 quorum loss</h3>
<p><strong>徵兆</strong>：cluster 有節點故障後、某些 quorum queue 變成不可寫、publisher confirm 卡住超時、<code>rabbitmq-diagnostics check_if_node_is_quorum_critical</code> 報警。</p>
<blockquote>
<p>以下跨節點行為依官方文件、單節點環境未實機驗證。</p></blockquote>
<p><strong>根因</strong>：quorum queue 靠 Raft majority 運作。一個 3-replica 的 queue 容忍 1 個節點故障（剩 2 個構成 majority）；故障 2 個節點時、剩 1 個無法構成多數派、queue 進入 <em>無 leader</em> 狀態、拒絕寫入以保證一致性。這是 Raft 的設計選擇：寧可不可用、不可不一致。replica 數設成偶數（如 2 或 4）更糟 — 偶數的 majority 門檻不會提升容錯、反而浪費資源。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>replica 數設奇數</strong>：3 replica 容忍 1 故障、5 replica 容忍 2 故障。奇數讓 majority 計算最有效率。</li>
<li><strong>監控 quorum critical 狀態</strong>：<code>rabbitmq-diagnostics check_if_node_is_quorum_critical</code> 在「再掛一個節點就會失去 quorum」時提前告警、在維護重啟前先確認不會打破 majority。</li>
<li><strong>跨故障域分佈 replica</strong>：把 3 個 replica 放在不同 AZ / 機架、避免單一故障域同時帶走多數派。</li>
<li><strong>理解不可用是預期行為</strong>：quorum loss 時 queue 拒寫是 <em>正確</em> 的、不是 bug。恢復路徑是把故障節點拉回 cluster 重組 majority、不是強制覆寫。</li>
</ol>
<h3 id="stream-retention-超量">Stream retention 超量</h3>
<p><strong>徵兆</strong>：stream queue 所在節點磁碟使用率持續上升、最終觸發 disk alarm、broker 暫停所有 publisher；或 consumer 嘗試讀取較舊的 offset 時拿到「offset 不存在」、發現歷史訊息已被截斷。</p>
<p><strong>根因</strong>：stream 是 append-only log、訊息 <em>不因消費而刪除</em>、只靠 retention 上限（<code>x-max-age</code> 時間 / <code>x-max-length-bytes</code> 大小）回收。retention 設太寬、或寫入速率超過預估、log 持續長大直到塞滿磁碟。反過來 retention 設太緊、consumer 還沒讀到的舊訊息就被截斷、replay 場景拿不到完整歷史。Stream 的容量管理是「設定 retention」、不是「靠消費清空」 — 這跟隊列直覺相反。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>retention 雙保險</strong>：同時設 <code>x-max-age</code>（時間上限、對齊業務 replay 窗口、如 7 天）與 <code>x-max-length-bytes</code>（大小上限、對齊磁碟容量）。先到的條件先觸發截斷、避免單一維度失控。</li>
<li><strong>segment 大小對齊回收粒度</strong>：<code>x-stream-max-segment-size-bytes</code> 決定 log 分段大小、retention 以 segment 為單位回收。segment 太大、retention 觸發後一次釋放大量空間、磁碟曲線鋸齒；太小、segment 檔案數量爆炸。</li>
<li><strong>容量公式先算再設</strong>：預估 <code>寫入速率 × 訊息平均大小 × retention 時間</code>、確認低於節點磁碟可用空間的安全水位（如 70%）、再上線。</li>
<li><strong>monitor disk_free_limit</strong>：stream 節點的磁碟告警閾值要比一般節點更早、因為 stream 是磁碟密集型、disk alarm 觸發會凍結整個 broker 的 publisher。</li>
</ol>
<h3 id="classic--quorum-遷移的-in-flight-message">Classic → Quorum 遷移的 in-flight message</h3>
<p><strong>徵兆</strong>：把工作隊列從 classic（或 deprecated mirrored）遷到 quorum 時、切換瞬間有訊息遺失、或重複處理 — queue 重建期間 publisher 已經在發、consumer 還沒接上新 queue。</p>
<p><strong>根因</strong>：queue type 無法原地變更、遷移本質是 <em>建新 queue + 切流量 + 排空舊 queue</em>。最大的坑是 in-flight 訊息：舊 classic queue 裡還有未消費的訊息、若直接刪除舊 queue、這些訊息就丟了；若 publisher 提前切到新 queue、舊 queue 的 consumer 還在處理、就出現新舊兩條路徑並存的一致性窗口。<a href="/blog/backend/03-message-queue/cases/rabbitmq-zalando-aws-master-selection/" data-link-title="3.C27 Zalando：RabbitMQ on AWS 自動化 master selection" data-link-desc="Zalando 用 sidekick 服務查 AWS API 動態識別 cluster、指定最老 instance 當 master、跨版本升級用 federation 過渡。">3.C27 Zalando</a> 跨版本升級用 federation 過渡、正是為了平滑搬移而非硬切。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>新 queue 先建、binding 並存</strong>：用新 routing key 或新 queue 名建立 quorum queue、舊 classic queue 暫不刪。</li>
<li><strong>consumer 先切、publisher 後切</strong>：先讓 consumer 同時消費新舊兩個 queue、確認新 queue 路徑正常、再把 publisher 切到只發新 queue。順序顛倒（publisher 先切）會讓舊 queue 的 in-flight 訊息沒人消費。</li>
<li><strong>排空舊 queue 再刪</strong>：publisher 切換後、等舊 classic queue <code>messages</code> 歸零（用 <code>list_queues name messages</code> 確認）、才刪除舊 queue。</li>
<li><strong>依賴 idempotency 兜底</strong>：遷移窗口內訊息可能重複投遞、consumer 端的 <a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a> 是最後一道防線（語義誤配的後果見 <a href="/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9</a>）、不要假設遷移零重複。</li>
<li><strong>用 federation / shovel 做大規模搬移</strong>：跨 cluster 或跨版本場景、用 federation upstream 把舊 cluster 訊息引流到新 cluster、避免一次性硬切（Zalando case 的做法）。</li>
</ol>
<h2 id="容量與成本規劃">容量與成本規劃</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>Classic</th>
          <th>Quorum</th>
          <th>Stream</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>單筆寫入延遲</td>
          <td>低（單節點 fsync）</td>
          <td>中（Raft majority round-trip）</td>
          <td>低（順序 append、批次 ack）</td>
      </tr>
      <tr>
          <td>記憶體 / 訊息</td>
          <td>高（常駐、lazy 緩解）</td>
          <td>中（on-disk 為主 + index）</td>
          <td>低（log 在磁碟、靠 page cache）</td>
      </tr>
      <tr>
          <td>磁碟成長</td>
          <td>隨未消費堆積</td>
          <td>隨未消費堆積</td>
          <td>隨 retention 上限、消費不回收</td>
      </tr>
      <tr>
          <td>節點故障容忍</td>
          <td>無（該 queue 不可用）</td>
          <td>容忍少數派故障（3 replica 容 1）</td>
          <td>Leader 故障可切 follower</td>
      </tr>
      <tr>
          <td>適用規模上限訊號</td>
          <td>堆積導致記憶體壓力 / 需要跨節點 HA</td>
          <td>Raft 延遲成為瓶頸 / 超高吞吐</td>
          <td>事件流規模需要跨團隊 schema 治理</td>
      </tr>
      <tr>
          <td>超出後改走</td>
          <td>Quorum（要 HA）/ Stream（要 replay）</td>
          <td>Stream（要 replay）/ Kafka（要生態）</td>
          <td><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>（跨團隊事件平台）</td>
      </tr>
  </tbody>
</table>
<p>實務 default：</p>
<ul>
<li><strong>單節點開發 / 臨時隊列</strong>：classic、最簡單、transient 模式適合 RPC reply。</li>
<li><strong>生產工作隊列、不可丟訊息</strong>：quorum、3 replica 跨 AZ、replica 數設奇數。</li>
<li><strong>事件流 / 多 consumer / 需要 replay</strong>：stream、retention 雙保險、磁碟容量先算。</li>
<li><strong>判斷該不該升級到 Kafka</strong>：當 stream 場景開始需要跨團隊 schema registry、tiered storage、或成熟的 streaming 生態工具時、stream 是過渡、Kafka 是終點。</li>
</ul>
<h2 id="整合與下一步">整合與下一步</h2>
<p>Queue type 的選擇與 RabbitMQ 其他能力交織：</p>
<ul>
<li><strong>回 vendor overview</strong>：三種 queue type 的取捨在 <a href="/blog/backend/03-message-queue/vendors/rabbitmq/" data-link-title="RabbitMQ" data-link-desc="Classic message broker、AMQP routing 為主">RabbitMQ overview</a>「Classic queue vs Quorum queue vs Stream」段有 vendor-level 定位；本文是其 implementation 展開。</li>
<li><strong>durable queue 能力層</strong>：queue type 的持久化語意建立在 <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> 的概念上 — quorum 與 stream 強制 durable、正是把「處理即承諾」的可靠性從單節點延伸到跨節點。</li>
<li><strong>durable queue 知識卡</strong>：訊息持久化的概念基礎見 <a href="/blog/backend/knowledge-cards/durable-queue/" data-link-title="Durable Queue" data-link-desc="說明可持久化的 queue 如何在重啟與失敗後保留待處理工作">durable queue 知識卡</a>。</li>
<li><strong>mirrored → quorum 的遷移動機</strong>：<a href="/blog/backend/03-message-queue/cases/rabbitmq-runtastic-mirrored-queue-bottleneck/" data-link-title="3.C30 Runtastic：Mirrored queue 網路負載瓶頸" data-link-desc="Runtastic 2020 lockdown 流量暴增、performance test 揭露 mirroring 邏輯把網路元件壓垮、調整 mirroring 配置消除瓶頸。">3.C30 Runtastic</a> 量化 mirrored 網路成本、是遷 quorum 的證據。</li>
<li><strong>跨版本 / 跨 cluster 平滑遷移</strong>：<a href="/blog/backend/03-message-queue/cases/rabbitmq-zalando-aws-master-selection/" data-link-title="3.C27 Zalando：RabbitMQ on AWS 自動化 master selection" data-link-desc="Zalando 用 sidekick 服務查 AWS API 動態識別 cluster、指定最老 instance 當 master、跨版本升級用 federation 過渡。">3.C27 Zalando</a> 用 federation 過渡、是 in-flight message 安全搬移的範本。</li>
</ul>
<p>何時 revisit queue type 選擇：classic queue 開始出現記憶體壓力或需要跨節點 HA 時、評估 quorum；任何 queue 場景開始需要「補讀歷史」「多 consumer 各自進度」「replay 重算」時、評估 stream；stream 場景開始需要跨團隊事件治理時、評估遷 <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>。</p>
]]></content:encoded></item><item><title>3.C13 Shopify：Debezium CDC over sharded MySQL</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-shopify-debezium-cdc/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-shopify-debezium-cdc/</guid><description>&lt;p>Shopify 的 CDC pipeline 揭露了 sharded monolith 上大規模 log-based CDC 的真實工程壓力。壓力集中在 snapshot 跟 oversized payload，穩態複製本身反而是最穩定的部分。&lt;/p>
&lt;h2 id="業務背景">業務背景&lt;/h2>
&lt;p>Shopify 的核心資料儲存是 100+ 個 MySQL shard，每個 shard 承載不同商家的交易資料。下游系統（搜尋索引、analytics、資料倉儲）需要近即時地取得資料變更。原本用 query-based 方案（內部系統 Longboat）輪詢資料庫，但隨 shard 數量跟資料量成長，輪詢的延遲跟資料庫負載壓力持續惡化。&lt;/p>
&lt;p>遷移到 log-based CDC（Debezium over Kafka Connect）後，pipeline 的穩態規模是 ~150 個 Debezium connector 跑在 12 個 Kubernetes pod、Black Friday peak 100K records/sec、P99 latency &amp;lt; 10s。&lt;/p>
&lt;h2 id="技術挑戰">技術挑戰&lt;/h2>
&lt;h3 id="snapshot-鎖定-read-replica">Snapshot 鎖定 read replica&lt;/h3>
&lt;p>Debezium 在初始同步（snapshot）時需要取得一致性快照。MySQL connector 的預設行為是對 read replica 取 global read lock，鎖住的時間跟表大小成正比。Shopify 的大表 snapshot 可能鎖住 read replica 數小時，影響線上查詢。&lt;/p>
&lt;p>Shopify 工程師直接向 Debezium 上游貢獻了「lock-free snapshot」機制 — 用 MySQL 的 GTID（Global Transaction ID）確保一致性，取代 global read lock。這個改動後來合併進 Debezium 主線，所有使用者都受益。&lt;/p>
&lt;h3 id="oversized-record">Oversized record&lt;/h3>
&lt;p>MySQL 的 blob / text 欄位可能產生超過 1 MB 的 CDC record。Kafka 的 message size limit（預設 1 MB）會讓這些 record 被 producer 拒絕。調大 &lt;code>max.message.bytes&lt;/code> 是一個選項，但會影響 broker 的記憶體跟 replication 效率。&lt;/p>
&lt;p>Shopify 的解法是把 oversized payload 寫到 GCS（Google Cloud Storage），CDC record 只帶 GCS pointer。Consumer 端在需要完整資料時再從 GCS 取。這個 pattern 把 Kafka 維持在「傳遞事件 metadata」的定位，大型 payload 走 object storage。&lt;/p>
&lt;h3 id="connector-故障隔離">Connector 故障隔離&lt;/h3>
&lt;p>150 個 connector 跑在 12 個 pod 上，一個 connector 的 failure（例如某個 shard 的 MySQL 做了 schema change、binlog 格式不相容）可能影響同 pod 上的其他 connector。Shopify 用 Kafka Connect 的 distributed mode + task rebalance 做故障隔離，但 rebalance 本身在 connector 數量多時有延遲。&lt;/p>
&lt;h2 id="解法與取捨">解法與取捨&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>挑戰&lt;/th>
 &lt;th>解法&lt;/th>
 &lt;th>取捨&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Snapshot 鎖定&lt;/td>
 &lt;td>Lock-free snapshot（GTID）&lt;/td>
 &lt;td>需要 MySQL 啟用 GTID、upstream contribution 維護成本&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Oversized record&lt;/td>
 &lt;td>GCS pointer 替代 inline data&lt;/td>
 &lt;td>Consumer 端要多一步 GCS 讀取、增加端到端延遲&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Connector 隔離&lt;/td>
 &lt;td>Distributed mode + rebalance&lt;/td>
 &lt;td>Rebalance storm 在大量 connector 時可能造成全域暫停&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>高峰流量&lt;/td>
 &lt;td>12 pod K8s 部署、水平擴展&lt;/td>
 &lt;td>Pod 數量增加讓 Kafka Connect worker 的 rebalance 更複雜&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="回寫教材的連結">回寫教材的連結&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/outbox-pattern/" data-link-title="3.3 outbox pattern 與發佈一致性" data-link-desc="把 transaction 與 event publish 分離">3.3 outbox pattern&lt;/a>：CDC 是 outbox pattern 的 log-based 替代方案。Shopify 的 case 揭露 CDC 的工程成本集中在 snapshot 跟 schema evolution，outbox 的成本集中在應用層 dual-write。&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka vendor 頁&lt;/a>：Kafka Connect / CDC 的進階主題。&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/broker-basics/" data-link-title="3.1 broker 基礎與投遞模型" data-link-desc="先理解 broker、queue、consumer 與 delivery semantics">3.1 broker basics&lt;/a>：message size limit 跟 broker 資源的關係。&lt;/li>
&lt;/ul>
&lt;h2 id="判讀徵兆">判讀徵兆&lt;/h2>
&lt;p>讀者在自己的系統看到以下訊號時，應該回讀本案例：&lt;/p></description><content:encoded><![CDATA[<p>Shopify 的 CDC pipeline 揭露了 sharded monolith 上大規模 log-based CDC 的真實工程壓力。壓力集中在 snapshot 跟 oversized payload，穩態複製本身反而是最穩定的部分。</p>
<h2 id="業務背景">業務背景</h2>
<p>Shopify 的核心資料儲存是 100+ 個 MySQL shard，每個 shard 承載不同商家的交易資料。下游系統（搜尋索引、analytics、資料倉儲）需要近即時地取得資料變更。原本用 query-based 方案（內部系統 Longboat）輪詢資料庫，但隨 shard 數量跟資料量成長，輪詢的延遲跟資料庫負載壓力持續惡化。</p>
<p>遷移到 log-based CDC（Debezium over Kafka Connect）後，pipeline 的穩態規模是 ~150 個 Debezium connector 跑在 12 個 Kubernetes pod、Black Friday peak 100K records/sec、P99 latency &lt; 10s。</p>
<h2 id="技術挑戰">技術挑戰</h2>
<h3 id="snapshot-鎖定-read-replica">Snapshot 鎖定 read replica</h3>
<p>Debezium 在初始同步（snapshot）時需要取得一致性快照。MySQL connector 的預設行為是對 read replica 取 global read lock，鎖住的時間跟表大小成正比。Shopify 的大表 snapshot 可能鎖住 read replica 數小時，影響線上查詢。</p>
<p>Shopify 工程師直接向 Debezium 上游貢獻了「lock-free snapshot」機制 — 用 MySQL 的 GTID（Global Transaction ID）確保一致性，取代 global read lock。這個改動後來合併進 Debezium 主線，所有使用者都受益。</p>
<h3 id="oversized-record">Oversized record</h3>
<p>MySQL 的 blob / text 欄位可能產生超過 1 MB 的 CDC record。Kafka 的 message size limit（預設 1 MB）會讓這些 record 被 producer 拒絕。調大 <code>max.message.bytes</code> 是一個選項，但會影響 broker 的記憶體跟 replication 效率。</p>
<p>Shopify 的解法是把 oversized payload 寫到 GCS（Google Cloud Storage），CDC record 只帶 GCS pointer。Consumer 端在需要完整資料時再從 GCS 取。這個 pattern 把 Kafka 維持在「傳遞事件 metadata」的定位，大型 payload 走 object storage。</p>
<h3 id="connector-故障隔離">Connector 故障隔離</h3>
<p>150 個 connector 跑在 12 個 pod 上，一個 connector 的 failure（例如某個 shard 的 MySQL 做了 schema change、binlog 格式不相容）可能影響同 pod 上的其他 connector。Shopify 用 Kafka Connect 的 distributed mode + task rebalance 做故障隔離，但 rebalance 本身在 connector 數量多時有延遲。</p>
<h2 id="解法與取捨">解法與取捨</h2>
<table>
  <thead>
      <tr>
          <th>挑戰</th>
          <th>解法</th>
          <th>取捨</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Snapshot 鎖定</td>
          <td>Lock-free snapshot（GTID）</td>
          <td>需要 MySQL 啟用 GTID、upstream contribution 維護成本</td>
      </tr>
      <tr>
          <td>Oversized record</td>
          <td>GCS pointer 替代 inline data</td>
          <td>Consumer 端要多一步 GCS 讀取、增加端到端延遲</td>
      </tr>
      <tr>
          <td>Connector 隔離</td>
          <td>Distributed mode + rebalance</td>
          <td>Rebalance storm 在大量 connector 時可能造成全域暫停</td>
      </tr>
      <tr>
          <td>高峰流量</td>
          <td>12 pod K8s 部署、水平擴展</td>
          <td>Pod 數量增加讓 Kafka Connect worker 的 rebalance 更複雜</td>
      </tr>
  </tbody>
</table>
<h2 id="回寫教材的連結">回寫教材的連結</h2>
<ul>
<li><a href="/blog/backend/03-message-queue/outbox-pattern/" data-link-title="3.3 outbox pattern 與發佈一致性" data-link-desc="把 transaction 與 event publish 分離">3.3 outbox pattern</a>：CDC 是 outbox pattern 的 log-based 替代方案。Shopify 的 case 揭露 CDC 的工程成本集中在 snapshot 跟 schema evolution，outbox 的成本集中在應用層 dual-write。</li>
<li><a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka vendor 頁</a>：Kafka Connect / CDC 的進階主題。</li>
<li><a href="/blog/backend/03-message-queue/broker-basics/" data-link-title="3.1 broker 基礎與投遞模型" data-link-desc="先理解 broker、queue、consumer 與 delivery semantics">3.1 broker basics</a>：message size limit 跟 broker 資源的關係。</li>
</ul>
<h2 id="判讀徵兆">判讀徵兆</h2>
<p>讀者在自己的系統看到以下訊號時，應該回讀本案例：</p>
<ul>
<li>CDC snapshot 過程持續數小時、鎖住 read replica 影響線上查詢</li>
<li>CDC record size 頻繁超過 Kafka 的 message size limit</li>
<li>Kafka Connect connector 數量超過 50 個、rebalance 時間開始明顯增長</li>
<li>從 query-based 同步（輪詢）切換到 log-based CDC 的評估階段</li>
</ul>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://shopify.engineering/capturing-every-change-shopify-sharded-monolith">Capturing Every Change From Shopify&rsquo;s Sharded Monolith</a></li>
</ul>
]]></content:encoded></item><item><title>Kafka Schema Registry 與 schema 演進：wire format、compatibility level 與安全演進規則</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/schema-registry-evolution/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/schema-registry-evolution/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Apache Kafka&lt;/a> overview「KRaft 與 Schema Registry」段的 implementation-layer deep article。Overview 已交代 Schema Registry 在事件總線中的定位；本文聚焦 &lt;em>怎麼設 compatibility、wire format 長什麼樣、schema 怎麼安全演進、演進設錯會打掛什麼&lt;/em>。對應 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/event-schema-compatibility/" data-link-title="Event Schema Compatibility" data-link-desc="說明 event schema 演進時，新舊 producer 與 consumer 能否互通的相容性等級">Event Schema Compatibility&lt;/a> 知識卡的 implementation 展開。&lt;/p>&lt;/blockquote>
&lt;h2 id="為什麼事件總線需要一個獨立的-schema-治理元件">為什麼事件總線需要一個獨立的 schema 治理元件&lt;/h2>
&lt;p>Schema Registry 是把「event 的結構契約」從 producer 與 consumer 的程式碼裡抽出來、集中存放並強制版本相容性的元件。它承擔的責任是讓不同 service、不同部署節奏的 producer 與 consumer 在 schema 改版時仍能互通，而不需要全體同時上線。Kafka broker 本身只存 bytes、不理解 payload 結構；一旦多個團隊往同一個 topic 寫事件、又各自獨立發版，schema 漂移就會在 consumer 端炸開。&lt;/p>
&lt;p>這個責任在單一 service 內部不存在。一個 service 自己 produce、自己 consume，schema 改版同一個 deploy 就同步了，序列化用什麼格式都行。Schema Registry 解的是 &lt;em>跨 service、跨團隊、跨部署時間&lt;/em> 的契約問題：A 團隊升級了訂單事件加一個欄位，B 團隊的對帳服務還跑舊版 consumer，C 團隊的風控服務跑更舊版——三方不同步演進，靠的就是 registry 在 producer 註冊新 schema 時先擋下破壞相容性的改動。&lt;/p>
&lt;p>Yelp 的 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-yelp-schematizer/" data-link-title="3.C14 Yelp：Schematizer 自建 Schema Registry" data-link-desc="Yelp data pipeline 強制所有 message 走 Avro、自建 Schematizer 做 schema evolution 與 topic 自動分配。">Schematizer 案例&lt;/a> 把這個責任拉到極端：一天數十億訊息、數百個 service、數千個 schema，自建 registry 強制所有 message 走 Avro、訊息只帶 schema ID。它揭露 schema 治理是 data pipeline 的核心責任、不是 add-on——當規模到了數千 schema，沒有集中強制的相容性檢查，跨服務事件契約會在某次發版後悄悄斷掉，而 broker 不會報任何錯。&lt;/p>
&lt;p>Confluent Schema Registry 是業界事實標準的實作；Apicurio 是 CNCF 生態的開源替代，額外支援 OpenAPI / AsyncAPI artifact、且提供 Confluent-compatible API endpoint，遷移成本低。兩者都把 schema 存進一個 Kafka topic（Confluent 用 &lt;code>_schemas&lt;/code>，single-partition、compacted），registry 自己是無狀態的，掛掉重啟後從該 topic rebuild。&lt;/p>
&lt;h2 id="schema-id-嵌進訊息的-wire-format">Schema ID 嵌進訊息的 wire format&lt;/h2>
&lt;p>Confluent wire format 在每筆訊息的 value（或 key）前面加 5 個 byte：1 個 magic byte（固定 &lt;code>0x00&lt;/code>）加 4 個 big-endian byte 的 schema ID，後面才接序列化後的 payload。Consumer 拿到訊息先讀這 5 個 byte，用 schema ID 去 registry 查對應 schema，再用該 schema 反序列化。這是「訊息只帶 schema ID、不帶 schema 本體」的機制——schema 本體只在 registry 存一份，訊息裡放的是指標。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Apache Kafka</a> overview「KRaft 與 Schema Registry」段的 implementation-layer deep article。Overview 已交代 Schema Registry 在事件總線中的定位；本文聚焦 <em>怎麼設 compatibility、wire format 長什麼樣、schema 怎麼安全演進、演進設錯會打掛什麼</em>。對應 <a href="/blog/backend/knowledge-cards/event-schema-compatibility/" data-link-title="Event Schema Compatibility" data-link-desc="說明 event schema 演進時，新舊 producer 與 consumer 能否互通的相容性等級">Event Schema Compatibility</a> 知識卡的 implementation 展開。</p></blockquote>
<h2 id="為什麼事件總線需要一個獨立的-schema-治理元件">為什麼事件總線需要一個獨立的 schema 治理元件</h2>
<p>Schema Registry 是把「event 的結構契約」從 producer 與 consumer 的程式碼裡抽出來、集中存放並強制版本相容性的元件。它承擔的責任是讓不同 service、不同部署節奏的 producer 與 consumer 在 schema 改版時仍能互通，而不需要全體同時上線。Kafka broker 本身只存 bytes、不理解 payload 結構；一旦多個團隊往同一個 topic 寫事件、又各自獨立發版，schema 漂移就會在 consumer 端炸開。</p>
<p>這個責任在單一 service 內部不存在。一個 service 自己 produce、自己 consume，schema 改版同一個 deploy 就同步了，序列化用什麼格式都行。Schema Registry 解的是 <em>跨 service、跨團隊、跨部署時間</em> 的契約問題：A 團隊升級了訂單事件加一個欄位，B 團隊的對帳服務還跑舊版 consumer，C 團隊的風控服務跑更舊版——三方不同步演進，靠的就是 registry 在 producer 註冊新 schema 時先擋下破壞相容性的改動。</p>
<p>Yelp 的 <a href="/blog/backend/03-message-queue/cases/kafka-yelp-schematizer/" data-link-title="3.C14 Yelp：Schematizer 自建 Schema Registry" data-link-desc="Yelp data pipeline 強制所有 message 走 Avro、自建 Schematizer 做 schema evolution 與 topic 自動分配。">Schematizer 案例</a> 把這個責任拉到極端：一天數十億訊息、數百個 service、數千個 schema，自建 registry 強制所有 message 走 Avro、訊息只帶 schema ID。它揭露 schema 治理是 data pipeline 的核心責任、不是 add-on——當規模到了數千 schema，沒有集中強制的相容性檢查，跨服務事件契約會在某次發版後悄悄斷掉，而 broker 不會報任何錯。</p>
<p>Confluent Schema Registry 是業界事實標準的實作；Apicurio 是 CNCF 生態的開源替代，額外支援 OpenAPI / AsyncAPI artifact、且提供 Confluent-compatible API endpoint，遷移成本低。兩者都把 schema 存進一個 Kafka topic（Confluent 用 <code>_schemas</code>，single-partition、compacted），registry 自己是無狀態的，掛掉重啟後從該 topic rebuild。</p>
<h2 id="schema-id-嵌進訊息的-wire-format">Schema ID 嵌進訊息的 wire format</h2>
<p>Confluent wire format 在每筆訊息的 value（或 key）前面加 5 個 byte：1 個 magic byte（固定 <code>0x00</code>）加 4 個 big-endian byte 的 schema ID，後面才接序列化後的 payload。Consumer 拿到訊息先讀這 5 個 byte，用 schema ID 去 registry 查對應 schema，再用該 schema 反序列化。這是「訊息只帶 schema ID、不帶 schema 本體」的機制——schema 本體只在 registry 存一份，訊息裡放的是指標。</p>
<p>本文用 OrbStack 起 <code>confluentinc/cp-kafka</code> + <code>confluentinc/cp-schema-registry</code>，用 Avro console producer 寫一筆 <code>{&quot;id&quot;:1,&quot;name&quot;:&quot;alice&quot;}</code>，再 dump 出 raw bytes 驗證 wire format：</p>





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





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





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





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





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





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





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





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">{</span><span class="nt">&#34;is_compatible&#34;</span><span class="p">:</span><span class="kc">false</span><span class="p">}</span></span></span></code></pre></div><p>由此導出兩條安全演進的操作規則。<strong>加欄位</strong>：一律帶 default（BACKWARD / FULL 都要），舊資料才能用新 schema 讀出。沒有合理 default 的「必填新欄位」不能直接加——要嘛在 producer 端先全部開始寫該欄位、確認資料齊全後再 promote，要嘛走新 topic / 新 record 而非原地演進。<strong>刪欄位</strong>：分步做。先讓所有 consumer 停止依賴該欄位（部署一輪），確認沒人讀之後，下一輪才從 schema 拿掉。一步到位刪掉還在被讀的欄位，會在 FORWARD / FULL 下被擋、在 BACKWARD 下放行但打掛還沒升級的 consumer。</p>
<h2 id="production-故障演練">Production 故障演練</h2>
<h3 id="case-1producer-加必填欄位無-default打掛舊-consumer">Case 1：producer 加必填欄位無 default，打掛舊 consumer</h3>
<p><strong>徵兆</strong>：某團隊 producer 發版後，另一團隊的舊 consumer 開始大量反序列化失敗、<code>SerializationException</code> 或 <code>AvroTypeException: Found X, expecting Y</code>，consumer lag 暴衝、訊息卡在 poll 階段。producer 端與 broker 端完全沒報錯——訊息照寫成功。</p>
<p><strong>根因</strong>：subject 的 compatibility level 被設成 NONE（或該欄位走了 FORWARD 不檢查 reader 缺欄位的路徑）。producer 加了一個沒有 default 的必填欄位、registry 沒擋，新訊息帶新 schema ID 寫進 topic。舊 consumer 用自己的舊 reader schema 去反序列化新 writer schema 的資料，遇到自己不認識又無從補值的結構就炸。問題不在 producer 也不在 broker，在 <em>registry 沒在註冊時擋下這次演進</em>。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>把 compatibility level 改回至少 BACKWARD</strong>：實機驗證過 NONE 會直接放行破壞性 schema——把 <code>compatibility</code> 設成 NONE 後，前面被 409 拒絕的破壞性 schema 立刻被接受成 v3。NONE 等於把演進安全完全交給人，多團隊場景幾乎一定出事。</li>
<li><strong>回退 producer</strong>：先讓 producer 退回舊 schema 止血，恢復舊 consumer 可讀。</li>
<li><strong>重新演進</strong>：欄位帶 default 重發，或若該欄位語意上必填、走「先讓 producer 寫、consumer 升級、再 promote」的分步路徑。</li>
<li><strong>CI 防線</strong>：把 compatibility check API（<code>/compatibility/subjects/&lt;s&gt;/versions/latest</code>）接進 producer repo 的 CI，PR 階段就用 <code>is_compatible:false</code> 擋掉，不等到 production 註冊時才發現。</li>
</ol>
<h3 id="case-2compatibility-level-設錯放行破壞性變更">Case 2：compatibility level 設錯，放行破壞性變更</h3>
<p><strong>徵兆</strong>：team 以為有 registry 把關所以放心演進，某次刪掉一個還在被下游讀的欄位、registry 接受了，下游服務隔天開始拿到 null / 缺欄位、business logic 走錯分支，但沒有任何 exception——資料「看起來正常」只是少了東西。</p>
<p><strong>根因</strong>：compatibility level 設成了 FORWARD 而需求其實是 BACKWARD，或設成 NONE。實機驗證可以看到 per-subject 覆寫的行為——對 <code>user-value</code> 單獨 PUT <code>FORWARD</code> 後查 config 回 <code>{&quot;compatibilityLevel&quot;:&quot;FORWARD&quot;}</code>，這個 subject 的檢查方向就跟全域預設不同了。FORWARD 允許刪帶 default 的欄位（保護 producer 先升級的順序），但團隊實際的演進順序是 consumer 後升級——方向錯配，registry 放行的正是會打掛 consumer 的那類改動。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>依演進順序選 level，不是隨手設</strong>：consumer 先升級選 BACKWARD；producer 先升級選 FORWARD；順序無法協調選 FULL。把這個決策寫進 topic ownership 文件、不是留給註冊當下的人臨時判斷。</li>
<li><strong>可能 replay 歷史就用 transitive</strong>：Kafka 長期保留 + replay 是常態，非 transitive 只擋最近一版、replay 舊資料時舊 schema 仍可能讀不出。長期保留的 topic 預設用 <code>*_TRANSITIVE</code>。</li>
<li><strong>per-subject 覆寫要留審計</strong>：全域預設外的每一個 per-subject 覆寫都是一個風險點，要能查出「誰、何時、為什麼把這個 subject 改成跟預設不同」。</li>
</ol>
<h3 id="case-3schema-id-對不上consumer-反序列化失敗">Case 3：schema ID 對不上，consumer 反序列化失敗</h3>
<p><strong>徵兆</strong>：consumer 報 <code>Schema not found; error code: 40403</code> 或反序列化拿到亂碼、欄位錯位。某些訊息正常、某些失敗，跟特定 producer 或特定時間段相關。</p>
<p><strong>根因</strong>有幾種，靠讀訊息前 5 byte 的 schema ID 定位：</p>
<ul>
<li><strong>registry 換過、ID 不一致</strong>：跨環境（dev / staging / prod）各自一套 registry，schema ID 全域遞增的順序不同，同一份 schema 在不同環境是不同 ID。如果有人把 prod 的訊息 mirror 到 staging 而沒搬 schema，staging consumer 拿 prod 的 schema ID 去 staging registry 查就 404。</li>
<li><strong>訊息根本不是 Confluent wire format</strong>：有 producer 沒走 schema-aware serializer、直接寫 raw bytes，前 5 byte 不是 magic + ID。consumer 把第一個 byte 當 magic、後 4 byte 當 ID 去查，撈到不存在或錯誤的 schema。</li>
<li><strong>registry 不可達或 cache 失效</strong>：consumer 端 schema cache 沒命中、又連不上 registry。</li>
</ul>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>讀 wire format 確認</strong>：dump 訊息 raw bytes，確認第一個 byte 是 <code>00</code>、接下來 4 byte 解出來的 ID 在目標 registry 查得到。本文驗證過 <code>00 00 00 00 01</code> 對應 schema id 1，這是除錯的第一手證據。</li>
<li><strong>跨環境 schema 搬遷</strong>：mirror 訊息時用 registry 的 import / export，或 MirrorMaker 搭配 schema 同步，不要只搬資料不搬 schema。</li>
<li><strong>隔離非 schema-aware producer</strong>：用 ACL 或 topic 命名規範強制所有 producer 走 schema serializer，避免 raw bytes 混進 schema-managed topic。</li>
</ol>
<h3 id="case-4subject-naming-strategy-衝突">Case 4：subject naming strategy 衝突</h3>
<p><strong>徵兆</strong>：把第二種事件型別寫進既有 topic 時，producer 直接註冊失敗報 incompatible，或多 producer 寫同 topic 互相把對方的 schema 判成不相容、彼此發版互相擋。</p>
<p><strong>根因</strong>：用 TopicNameStrategy（預設）卻往同一個 topic 放多種 record。subject 是 <code>&lt;topic&gt;-value</code>、整個 topic 共用一條演進線，registry 拿 <code>OrderCancelled</code> 去跟既有的 <code>OrderCreated</code> 做相容性檢查——兩種結構不同的事件當然不相容。strategy 的隱含假設（一 topic 一事件型別）跟實際用法（一 topic 多事件保 ordering）衝突。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>改 strategy 配合用法</strong>：一 topic 多事件 → TopicRecordNameStrategy，subject 變成 <code>&lt;topic&gt;-&lt;record 全名&gt;</code>，每種 record 各自一條演進線、不互相檢查。</li>
<li><strong>producer 與 consumer 設同一個 strategy</strong>：strategy 不一致時 consumer 會用錯 subject 查 schema，拿到 null 或錯 schema。這是部署層的硬約束，要在共用 config 統一。</li>
<li><strong>若只是不小心寫錯 topic</strong>：那不是 strategy 問題、是路由問題，修 producer 的 topic 選擇邏輯，別為了繞過檢查改成 RecordNameStrategy。</li>
</ol>
<h2 id="容量與運維邊界">容量與運維邊界</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>估算 / 邊界</th>
          <th>警戒</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Schema 數量</td>
          <td>數千 schema registry 仍可運作（Yelp 等級）</td>
          <td><code>_schemas</code> topic 是 single-partition</td>
      </tr>
      <tr>
          <td>Wire format overhead</td>
          <td>每筆訊息固定 +5 byte</td>
          <td>高頻小訊息時相對 overhead 不可忽略</td>
      </tr>
      <tr>
          <td>Registry 可用性</td>
          <td>consumer cache 命中時可短暫容忍 registry 不可達</td>
          <td>冷 consumer / 新 schema ID 時硬依賴</td>
      </tr>
      <tr>
          <td>Compatibility 檢查</td>
          <td>註冊時做、非 hot path</td>
          <td>transitive 對長歷史 subject 檢查較慢</td>
      </tr>
      <tr>
          <td>環境隔離</td>
          <td>每環境一套 registry、schema ID 不跨環境一致</td>
          <td>跨環境 mirror 要同步搬 schema</td>
      </tr>
  </tbody>
</table>
<p>實務 default：data pipeline 場景選 Avro + 至少 BACKWARD；長期保留 + replay 的 topic 用 transitive；compatibility check 接進 CI 在 PR 階段擋破壞性改動，不依賴註冊當下把關；一 topic 一事件型別當預設、要多型別才動 naming strategy。Schema Registry 自己也是個要 HA 的元件——production 跑多副本、<code>_schemas</code> topic 的 replication factor 拉高，registry 是事件總線的單點時要當關鍵基礎設施對待。</p>
<h2 id="整合與下一步">整合與下一步</h2>
<h3 id="跟-cdc-pipeline-的銜接">跟 CDC pipeline 的銜接</h3>
<p><a href="/blog/backend/03-message-queue/cases/kafka-shopify-debezium-cdc/" data-link-title="3.C13 Shopify：Debezium CDC over sharded MySQL" data-link-desc="Shopify 100&#43; MySQL shard、150 Debezium connector、Black Friday 100K records/sec P99 &lt; 10s。">Shopify Debezium CDC 案例</a> 跑在 100+ MySQL shard、150 個 Debezium connector 的規模（該案例記載的重點是 lock-free snapshot 與 oversized record 處理）。CDC pipeline 有一個一般性的 schema 演進壓力，以下依 CDC 機制推導、非該案例的結論：上游 DDL 一改，Debezium 產生的 Kafka record schema 跟著變，下游 consumer 受影響。Schema Registry 的 compatibility 檢查就是把這道衝擊在進 Kafka 時攔下的關卡——選錯 compatibility level，一次 ALTER TABLE 就可能透過 CDC 打穿整條 pipeline。Debezium 與 Kafka Connect 原生整合 Schema Registry，connector 設定裡指定 registry URL 與 naming strategy。</p>
<h3 id="跟-replay-邊界與事件契約">跟 replay 邊界與事件契約</h3>
<p><a href="/blog/backend/03-message-queue/event-contract-replay-boundary/" data-link-title="3.7 Event Contract 與 Replay Boundary" data-link-desc="說明 event schema、idempotency key、replay window 與補償如何先於 broker 選型。">3.7 event contract / replay boundary</a> 講的是事件契約能 replay 多遠；schema 層的對應就是本文的 transitive compatibility。Replay 跨越多個 schema 版本時，只有 transitive 能保證任何歷史版本都讀得出來。兩者一起界定「這條事件流的契約能安全回放到多久以前」。</p>
<h3 id="下游能力">下游能力</h3>
<ul>
<li>概念索引：<a href="/blog/backend/knowledge-cards/event-schema-compatibility/" data-link-title="Event Schema Compatibility" data-link-desc="說明 event schema 演進時，新舊 producer 與 consumer 能否互通的相容性等級">Event Schema Compatibility</a> 知識卡（本文的 implementation 來源）</li>
<li>上游 vendor 頁：<a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Apache Kafka</a>（KRaft 與 Schema Registry 段）</li>
<li>對應案例：<a href="/blog/backend/03-message-queue/cases/kafka-yelp-schematizer/" data-link-title="3.C14 Yelp：Schematizer 自建 Schema Registry" data-link-desc="Yelp data pipeline 強制所有 message 走 Avro、自建 Schematizer 做 schema evolution 與 topic 自動分配。">3.C14 Yelp Schematizer</a>（schema 治理拉到平台層）、<a href="/blog/backend/03-message-queue/cases/kafka-shopify-debezium-cdc/" data-link-title="3.C13 Shopify：Debezium CDC over sharded MySQL" data-link-desc="Shopify 100&#43; MySQL shard、150 Debezium connector、Black Friday 100K records/sec P99 &lt; 10s。">3.C13 Shopify Debezium CDC</a>（CDC 場景的 schema evolution）</li>
<li>方法論：<a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章的寫作方法論</a></li>
</ul>
]]></content:encoded></item><item><title>RabbitMQ Network Partition 與 Cluster 一致性：腦裂下要保誰</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/rabbitmq/network-partition-clustering/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/rabbitmq/network-partition-clustering/</guid><description>&lt;blockquote>
&lt;p>本文是 &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> overview「Erlang clustering 與 network partition」段的 implementation-layer deep article。Overview 回答「RabbitMQ cluster 是什麼、跟同類 broker 差在哪」；本文回答「partition 發生時 broker 怎麼決策、各策略保住什麼、丟掉什麼」。&lt;/p>&lt;/blockquote>
&lt;p>Network partition 是 cluster 節點之間的網路連線中斷、雙方各自仍存活但互相不可達的狀態。RabbitMQ cluster 建立在 Erlang distribution 之上、節點靠固定心跳（net_tick）互相確認存活；心跳連續數次收不到、Erlang 就判定對方失聯、把單一 cluster 切成兩個互不知道對方狀態的子群。此時的核心問題不是「怎麼避免 partition」——跨機房、跨 AZ、雲端 VPC 路由抖動都會造成短暫不可達、partition 在分散式系統是必然會遇到的物理事件——而是「分裂的瞬間、broker 要犧牲可用性保一致性、還是犧牲一致性保可用性」。&lt;code>cluster_partition_handling&lt;/code> 設定就是這個取捨的開關。&lt;/p>
&lt;h2 id="問題情境兩邊都覺得自己是對的">問題情境：兩邊都覺得自己是對的&lt;/h2>
&lt;p>腦裂（split-brain）的破壞性在於分裂的兩個子群各自繼續服務、各自接受寫入、各自認為對方已死。等到網路恢復、兩邊的狀態已經分歧：同一個 queue 在 A 子群被消費掉的訊息、在 B 子群還在；同一個 exchange 的 binding 在兩邊被改成不同樣子；同一筆業務在兩邊各被處理一次。&lt;/p>
&lt;p>RabbitMQ 的 classic queue 沒有內建的衝突解決機制。當兩個子群在 partition 期間各自修改了 cluster metadata（queue / exchange / binding 的定義）、恢復連線後 RabbitMQ 無法自動合併這些分歧、預設行為是 &lt;em>拒絕自動重新加入&lt;/em>、把節點停在 partition 狀態等人工處置。這就是為什麼 partition handling 策略的選擇、本質是「願意在分裂瞬間付出什麼代價、來換取恢復時的可預測性」。&lt;/p>
&lt;p>這個取捨跟 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/processing-recovery-semantics/" data-link-title="3.6 Processing Semantics 與 Recovery Semantics" data-link-desc="說明投遞成功、處理成功與恢復成功為何是三個不同判斷。">3.6 processing semantics 與 recovery semantics&lt;/a> 的判斷同源：投遞成功、處理成功、恢復成功是三件事。Partition 期間「broker 還在收訊息」（投遞層可用）不代表「訊息會被正確處理一次」（處理層一致）、更不代表「partition 結束後狀態能無損合併」（恢復層一致）。&lt;/p>
&lt;h2 id="核心概念一disc-node-與-ram-node">核心概念一：disc node 與 ram node&lt;/h2>
&lt;p>RabbitMQ cluster 的每個節點承擔一種角色、決定它存哪些資料。Cluster metadata（vhost、user、exchange、queue 定義、binding）在所有節點間複製、但 &lt;em>持久化到磁碟&lt;/em> 與否分兩種：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>節點類型&lt;/th>
 &lt;th>metadata 存放&lt;/th>
 &lt;th>適用場景&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Disc node&lt;/td>
 &lt;td>記憶體 + 磁碟&lt;/td>
 &lt;td>預設、cluster 必須至少有一個&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Ram node&lt;/td>
 &lt;td>僅記憶體&lt;/td>
 &lt;td>metadata 變更極頻繁的特殊場景、現代極少使用&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Disc node 把 cluster metadata 寫到磁碟、整個 cluster 重啟後能從磁碟恢復拓樸定義。Ram node 只把 metadata 放記憶體、metadata 操作（宣告大量 queue / binding）較快、但 cluster 若全部節點同時掛掉就會遺失定義。&lt;/p>
&lt;p>Ram node 是早期為了加速高頻 metadata 變更而設計的角色。實務上現代 RabbitMQ 部署幾乎都用全 disc node：metadata 操作的效能瓶頸在現代硬體上不再顯著、而全 disc 換來的「任意節點重啟都能恢復拓樸」的可預測性、價值遠高於那點 metadata 寫入速度。官方文件也建議 cluster 內 disc node 至少兩個、避免唯一的 disc node 掛掉時整個 cluster 的 metadata 無法持久化。&lt;/p>
&lt;p>本文實機演練的 3-node cluster 全部是 disc node、這也是 &lt;code>rabbitmqctl cluster_status&lt;/code> 在 OrbStack 上的實際輸出：&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">Disk Nodes
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">rabbit@rmq1
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">rabbit@rmq2
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">rabbit@rmq3&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>要特別區分的是：disc / ram 講的是 &lt;em>cluster metadata&lt;/em> 的持久化、跟 &lt;em>訊息本身&lt;/em> 是否持久化（durable queue + persistent message）是兩個獨立軸。Disc node 不會讓 transient queue 的訊息變持久、ram node 也不會讓 durable queue 的訊息變揮發。訊息持久化的判讀見 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/durable-queue/" data-link-title="3.2 durable queue 與重試策略" data-link-desc="整理持久化佇列、DLQ 與重試流程">3.2 durable queue&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/03-message-queue/vendors/rabbitmq/" data-link-title="RabbitMQ" data-link-desc="Classic message broker、AMQP routing 為主">RabbitMQ</a> overview「Erlang clustering 與 network partition」段的 implementation-layer deep article。Overview 回答「RabbitMQ cluster 是什麼、跟同類 broker 差在哪」；本文回答「partition 發生時 broker 怎麼決策、各策略保住什麼、丟掉什麼」。</p></blockquote>
<p>Network partition 是 cluster 節點之間的網路連線中斷、雙方各自仍存活但互相不可達的狀態。RabbitMQ cluster 建立在 Erlang distribution 之上、節點靠固定心跳（net_tick）互相確認存活；心跳連續數次收不到、Erlang 就判定對方失聯、把單一 cluster 切成兩個互不知道對方狀態的子群。此時的核心問題不是「怎麼避免 partition」——跨機房、跨 AZ、雲端 VPC 路由抖動都會造成短暫不可達、partition 在分散式系統是必然會遇到的物理事件——而是「分裂的瞬間、broker 要犧牲可用性保一致性、還是犧牲一致性保可用性」。<code>cluster_partition_handling</code> 設定就是這個取捨的開關。</p>
<h2 id="問題情境兩邊都覺得自己是對的">問題情境：兩邊都覺得自己是對的</h2>
<p>腦裂（split-brain）的破壞性在於分裂的兩個子群各自繼續服務、各自接受寫入、各自認為對方已死。等到網路恢復、兩邊的狀態已經分歧：同一個 queue 在 A 子群被消費掉的訊息、在 B 子群還在；同一個 exchange 的 binding 在兩邊被改成不同樣子；同一筆業務在兩邊各被處理一次。</p>
<p>RabbitMQ 的 classic queue 沒有內建的衝突解決機制。當兩個子群在 partition 期間各自修改了 cluster metadata（queue / exchange / binding 的定義）、恢復連線後 RabbitMQ 無法自動合併這些分歧、預設行為是 <em>拒絕自動重新加入</em>、把節點停在 partition 狀態等人工處置。這就是為什麼 partition handling 策略的選擇、本質是「願意在分裂瞬間付出什麼代價、來換取恢復時的可預測性」。</p>
<p>這個取捨跟 <a href="/blog/backend/03-message-queue/processing-recovery-semantics/" data-link-title="3.6 Processing Semantics 與 Recovery Semantics" data-link-desc="說明投遞成功、處理成功與恢復成功為何是三個不同判斷。">3.6 processing semantics 與 recovery semantics</a> 的判斷同源：投遞成功、處理成功、恢復成功是三件事。Partition 期間「broker 還在收訊息」（投遞層可用）不代表「訊息會被正確處理一次」（處理層一致）、更不代表「partition 結束後狀態能無損合併」（恢復層一致）。</p>
<h2 id="核心概念一disc-node-與-ram-node">核心概念一：disc node 與 ram node</h2>
<p>RabbitMQ cluster 的每個節點承擔一種角色、決定它存哪些資料。Cluster metadata（vhost、user、exchange、queue 定義、binding）在所有節點間複製、但 <em>持久化到磁碟</em> 與否分兩種：</p>
<table>
  <thead>
      <tr>
          <th>節點類型</th>
          <th>metadata 存放</th>
          <th>適用場景</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Disc node</td>
          <td>記憶體 + 磁碟</td>
          <td>預設、cluster 必須至少有一個</td>
      </tr>
      <tr>
          <td>Ram node</td>
          <td>僅記憶體</td>
          <td>metadata 變更極頻繁的特殊場景、現代極少使用</td>
      </tr>
  </tbody>
</table>
<p>Disc node 把 cluster metadata 寫到磁碟、整個 cluster 重啟後能從磁碟恢復拓樸定義。Ram node 只把 metadata 放記憶體、metadata 操作（宣告大量 queue / binding）較快、但 cluster 若全部節點同時掛掉就會遺失定義。</p>
<p>Ram node 是早期為了加速高頻 metadata 變更而設計的角色。實務上現代 RabbitMQ 部署幾乎都用全 disc node：metadata 操作的效能瓶頸在現代硬體上不再顯著、而全 disc 換來的「任意節點重啟都能恢復拓樸」的可預測性、價值遠高於那點 metadata 寫入速度。官方文件也建議 cluster 內 disc node 至少兩個、避免唯一的 disc node 掛掉時整個 cluster 的 metadata 無法持久化。</p>
<p>本文實機演練的 3-node cluster 全部是 disc node、這也是 <code>rabbitmqctl cluster_status</code> 在 OrbStack 上的實際輸出：</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">Disk Nodes
</span></span><span class="line"><span class="ln">2</span><span class="cl">rabbit@rmq1
</span></span><span class="line"><span class="ln">3</span><span class="cl">rabbit@rmq2
</span></span><span class="line"><span class="ln">4</span><span class="cl">rabbit@rmq3</span></span></code></pre></div><p>要特別區分的是：disc / ram 講的是 <em>cluster metadata</em> 的持久化、跟 <em>訊息本身</em> 是否持久化（durable queue + persistent message）是兩個獨立軸。Disc node 不會讓 transient queue 的訊息變持久、ram node 也不會讓 durable queue 的訊息變揮發。訊息持久化的判讀見 <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>。</p>
<h2 id="核心概念二partition-偵測機制">核心概念二：partition 偵測機制</h2>
<p>RabbitMQ 不自己實作節點存活偵測、而是直接用 Erlang distribution 的 net_tick 機制。每個節點對 cluster 內其他節點定期送 tick、<code>net_ticktime</code> 預設 60 秒；連續數個 tick interval（預設約 4 個、即 net_ticktime 區間內）收不到對方回應、Erlang 就判定該節點 <code>nodedown</code>、向上層的 RabbitMQ partition handler 報告。</p>
<p>這個機制有兩個實務後果。第一、partition 偵測有 <em>延遲</em>：短於 net_ticktime 的網路抖動（幾秒的 GC pause、瞬間封包遺失）不會觸發 partition、避免把暫時性抖動誤判成永久分裂。第二、偵測延遲是雙刃：net_ticktime 設太長、真的 partition 了也要等很久才反應、期間腦裂持續擴大；設太短、雲端環境正常的網路抖動會頻繁誤觸發 partition handler、造成不必要的節點暫停。</p>
<p>本文實機演練用 <code>docker network disconnect</code> 切斷一個節點的網路、實測偵測延遲：disconnect 後約 60 秒（吻合 net_ticktime 預設值）、多數派側的 <code>cluster_status</code> 的 Running Nodes 才從三個掉到兩個：</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">disconnect 後立即查 → Running Nodes 仍顯示 3 個（尚未偵測）
</span></span><span class="line"><span class="ln">2</span><span class="cl">等待約 60 秒 → Running Nodes 掉到 2 個（partition 已偵測）</span></span></code></pre></div><p>偵測到 partition 之後、broker 怎麼處置、完全取決於 <code>cluster_partition_handling</code> 設定。</p>
<h2 id="核心概念三cluster_partition_handling-三策略">核心概念三：cluster_partition_handling 三策略</h2>
<p>這個設定決定 broker 在偵測到 partition 後的行為、是整個 cluster 一致性與可用性取捨的單一開關。三種策略對應三種不同的 CAP 立場。</p>
<table>
  <thead>
      <tr>
          <th>策略</th>
          <th>partition 時行為</th>
          <th>保住</th>
          <th>犧牲</th>
          <th>適用</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>ignore</code></td>
          <td>兩邊都繼續服務、不做任何處置</td>
          <td>可用性</td>
          <td>一致性（會腦裂）</td>
          <td>單機 / 不在乎一致性的場景</td>
      </tr>
      <tr>
          <td><code>pause_minority</code></td>
          <td>少數派節點暫停 broker、多數派繼續</td>
          <td>一致性</td>
          <td>少數派可用性</td>
          <td>奇數節點 cluster（推薦）</td>
      </tr>
      <tr>
          <td><code>autoheal</code></td>
          <td>partition 結束後自動選贏家、輸家重啟丟狀態</td>
          <td>自動恢復</td>
          <td>輸家側的訊息</td>
          <td>可容忍少量訊息遺失的場景</td>
      </tr>
  </tbody>
</table>
<p>設定方式在 <code>rabbitmq.conf</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="ln">1</span><span class="cl"><span class="na">cluster_partition_handling</span> <span class="o">=</span> <span class="s">pause_minority</span></span></span></code></pre></div><p>或在舊版 advanced config（Erlang term 格式）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-erlang" data-lang="erlang"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">[</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="p">{</span><span class="n">rabbit</span><span class="p">,</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="p">{</span><span class="n">cluster_partition_handling</span><span class="p">,</span> <span class="n">pause_minority</span><span class="p">}</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="p">]}</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">].</span></span></span></code></pre></div><p>三個策略的差異不在「哪個比較好」、而在「分裂瞬間願意讓誰停下來」。下面三段把每個策略在真實服務裡長什麼樣展開。</p>
<h3 id="ignore兩邊都活恢復時等人來">ignore：兩邊都活、恢復時等人來</h3>
<p><code>ignore</code> 是預設值（OrbStack 起的 cluster <code>rabbitmqctl environment</code> 實測輸出 <code>{cluster_partition_handling, ignore}</code>）。它的行為是 partition 偵測到了、但 broker 什麼都不做、兩個子群繼續各自服務。</p>
<p>這在單節點部署完全沒問題——沒有 cluster 就沒有 partition。問題出在多節點 cluster：兩個子群會各自接受 publish、各自讓 consumer 消費、各自修改 metadata。網路恢復後、RabbitMQ 偵測到兩邊狀態分歧、會把節點停在 partition 狀態、不自動重新加入、在 log 留下 partition 警告等人工介入。此時 metadata 已經分歧、需要人工決定保留哪一邊、reset 另一邊重新 join。</p>
<p><code>ignore</code> 適合的場景很窄：單機部署、或刻意接受腦裂並在應用層做衝突解決的特殊架構。多數需要 cluster 的場景不該用 <code>ignore</code>——它把一致性的責任完全推給人工處置、而人工處置在凌晨三點的 incident 現場是最不可靠的環節。</p>
<h3 id="pause_minority少數派主動停下">pause_minority：少數派主動停下</h3>
<p><code>pause_minority</code> 是奇數節點 cluster 的推薦策略、它的設計直接對應 quorum 的數學：partition 把 cluster 切成兩半時、節點數較少的那一側（少數派）主動 <em>暫停自己的 broker</em>、停止接受任何 client 連線；節點數較多的那一側（多數派）繼續服務。</p>
<p>這保證了任何時刻最多只有一個子群在服務、從根本上杜絕腦裂。代價是少數派側的所有 client 在 partition 期間完全失去服務。</p>
<p>3-node cluster 是這個策略的最小有效配置。實機演練：把 rmq3 從 network disconnect、製造「rmq1 + rmq2 多數派 vs rmq3 少數派」的分裂、約 60 秒後查少數派 rmq3 的狀態：</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">$ rabbitmqctl cluster_status   # 在被孤立的 rmq3 上執行
</span></span><span class="line"><span class="ln">2</span><span class="cl">Error: this command requires the &#39;rabbit&#39; app to be running on the target node.
</span></span><span class="line"><span class="ln">3</span><span class="cl">       Start it with &#39;rabbitmqctl start_app&#39;.</span></span></code></pre></div><p>少數派 rmq3 的 rabbit 應用被 partition handler 主動停止——這正是 pause_minority 的預期行為。同時多數派側 rmq1 的 cluster_status 顯示 Running Nodes 只剩 rmq1 + rmq2、繼續正常服務。</p>
<p>恢復也是自動的。把 rmq3 重新 network connect、約 15 秒後它自動重啟 rabbit 應用、重新加入 cluster、Running Nodes 回到三個、Network Partitions 顯示 <code>(none)</code>、無殘留 partition 需要人工處置。這是 pause_minority 相對 ignore 的關鍵優勢：恢復路徑自動化、不依賴凌晨的人工判斷。</p>
<p>pause_minority 有一個硬性前提：cluster 必須是奇數節點、且要能形成明確的多數。2-node cluster 用 pause_minority 是反模式——partition 時兩邊各 1 個、都不是多數、結果兩邊都暫停、整個 cluster 完全不可用。4-node cluster 切成 2:2 也同樣兩邊都停。要用 pause_minority、節點數必須是 3、5、7 這種能在最常見的 1-node 失聯情境下仍形成多數的奇數。</p>
<h3 id="autoheal分裂時都活恢復時選贏家丟輸家">autoheal：分裂時都活、恢復時選贏家丟輸家</h3>
<p><code>autoheal</code> 走另一條路：partition 期間 <em>兩個子群都繼續服務</em>（跟 ignore 一樣）、但在 partition <em>結束</em> 的瞬間、broker 自動裁決——選出一個「贏家」子群、強制「輸家」子群的節點重啟、丟棄輸家在 partition 期間累積的狀態、然後重新加入贏家。</p>
<p>贏家的選擇規則是：先比 client 連線數（連線多的贏）、連線數相同比節點數、再相同比節點名稱。</p>
<p>autoheal 的取捨點跟 pause_minority 相反。pause_minority 在分裂瞬間就讓少數派停止、犧牲的是少數派 partition 期間的 <em>可用性</em>；autoheal 讓兩邊都活、犧牲的是輸家 partition 期間累積的 <em>訊息與狀態</em>。輸家側在 partition 期間被消費掉的訊息、被接受的新 publish、被修改的 binding、在 autoheal 重啟輸家後全部丟失。</p>
<p>這讓 autoheal 適合一種特定場景：可用性比訊息完整性重要、且訊息本身是冪等或可重送的。例如純粹的快取失效通知、可重算的衍生事件——丟幾條重新觸發即可。對「丟一條訊息等於丟一筆訂單」的場景、autoheal 的自動丟棄是不可接受的。</p>
<h2 id="quorum-queue-在-partition-下的行為">quorum queue 在 partition 下的行為</h2>
<p>前面三個 <code>cluster_partition_handling</code> 策略管的是 <em>classic queue 與 cluster metadata</em> 的 partition 行為。Quorum queue 是另一套機制——它不依賴 <code>cluster_partition_handling</code>、而是用 Raft 共識協議自己決定 partition 下的行為。這是 RabbitMQ 對腦裂問題的根本性改寫。</p>
<p>Quorum queue 把每個 queue 實作成一個獨立的 Raft 複製群組：一個 leader 加數個 follower、預設複製到奇數個節點（3-node cluster 通常 3 副本）。每筆 publish 必須被 <em>多數副本</em> 確認寫入、leader 才回 publisher confirm。實機驗證 3-node cluster 上 quorum queue 的 Raft 拓樸：</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">$ rabbitmq-queues quorum_status qq.test
</span></span><span class="line"><span class="ln">2</span><span class="cl">Node Name      Raft State   Membership
</span></span><span class="line"><span class="ln">3</span><span class="cl">rabbit@rmq1    leader       voter
</span></span><span class="line"><span class="ln">4</span><span class="cl">rabbit@rmq2    follower     voter
</span></span><span class="line"><span class="ln">5</span><span class="cl">rabbit@rmq3    follower     voter</span></span></code></pre></div><p>Partition 切斷 Raft 群組時、行為完全由 Raft 的 majority 規則決定、不需要 <code>cluster_partition_handling</code> 介入：</p>
<p>含 majority 副本的那一側選出（或維持）leader、繼續接受讀寫；不含 majority 的那一側無法 commit 任何寫入、自動進入唯讀或拒絕狀態。因為 commit 需要 majority 確認、少數派永遠湊不到 majority、所以少數派 <em>物理上不可能</em> 接受新寫入並確認——腦裂在協議層被排除、不靠運維設定。</p>
<p>實機演練最關鍵的一段：把 rmq2 與 rmq3 <em>同時</em> disconnect、讓 quorum queue 的 leader（在 rmq1）只剩自己一個副本、3 副本只剩 1 副本、失去 majority（1/3 &lt; 2/3）。此時 <code>quorum_status</code> 顯示其他兩個節點變 <code>timeout</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">Node Name      Raft State   Membership
</span></span><span class="line"><span class="ln">2</span><span class="cl">rabbit@rmq1    leader       voter
</span></span><span class="line"><span class="ln">3</span><span class="cl">rabbit@rmq2    timeout
</span></span><span class="line"><span class="ln">4</span><span class="cl">rabbit@rmq3    timeout</span></span></code></pre></div><p>然後對這個失去 quorum 的 queue 嘗試 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">$ rabbitmqadmin publish routing_key=qq.test payload=&#34;during-quorum-loss&#34;
</span></span><span class="line"><span class="ln">2</span><span class="cl">[實測：publish 阻塞、12 秒後仍未返回——Raft 無 majority 可 commit]</span></span></code></pre></div><p>Publish 被阻塞、不返回 publisher confirm。因為 leader 拿不到任何 follower 的確認、無法達成 majority、寫入永遠 commit 不了。這是 quorum queue 用 <em>阻塞</em> 換 <em>一致性</em>：寧可不接受寫入、也不接受一筆無法被多數副本保證的寫入。</p>
<p>同一個 partition 情境下、對 classic queue 做同樣的 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">$ rabbitmqadmin publish routing_key=cq.test payload=&#34;classic-during-partition&#34;
</span></span><span class="line"><span class="ln">2</span><span class="cl">Message published   # 立即成功</span></span></code></pre></div><p>Classic queue 立即接受寫入。它沒有 Raft、leader 節點獨自決定、可用性優先——但這也正是它在腦裂下會分歧的根源：rmq1 接受的這筆、partition 結束後可能跟另一側的狀態衝突。</p>
<p>把兩邊 disconnect 的節點重新 connect、quorum 恢復、<code>quorum_status</code> 三個節點回到 leader + 2 follower、原本被阻塞的 publish 路徑恢復、新 publish 立即成功。Quorum queue 的恢復是協議自動完成的、不需要人工 reset 任何節點。</p>
<p>這就是 classic queue 加 <code>cluster_partition_handling</code> 與 quorum queue 的根本差異：前者是 <em>用運維策略事後補救</em> 一個本身會腦裂的資料結構、後者是 <em>用共識協議從設計上排除</em> 腦裂。現代 RabbitMQ 對需要跨節點一致性的 queue、官方建議直接用 quorum queue、把 partition 一致性交給 Raft、而不是依賴 <code>cluster_partition_handling</code> 的 classic queue 補救。Classic / quorum / stream 的完整選型判讀見 <a href="/blog/backend/03-message-queue/vendors/rabbitmq/queue-types-classic-quorum-stream/" data-link-title="RabbitMQ Queue Type 選型：Classic、Quorum、Stream 的責任邊界與容量取捨" data-link-desc="RabbitMQ 3.x 三種 queue type 的選型 deep article — classic queue（mirrored 已 deprecated）、quorum queue（Raft 一致性、取代 mirrored）、stream（3.9&#43; append-only log、可重複消費）。涵蓋三種模型在 throughput / retention / replay / 記憶體成本的判讀、宣告語意差異（實機驗證）、4 個 production 故障演練（mirrored 網路放大 / quorum loss / stream retention 超量 / classic→quorum in-flight message），與容量規劃。">Queue Type 選型</a>。</p>
<h2 id="真實-cluster-治理以-zalando-為例">真實 cluster 治理：以 Zalando 為例</h2>
<p><a href="/blog/backend/03-message-queue/cases/rabbitmq-zalando-aws-master-selection/" data-link-title="3.C27 Zalando：RabbitMQ on AWS 自動化 master selection" data-link-desc="Zalando 用 sidekick 服務查 AWS API 動態識別 cluster、指定最老 instance 當 master、跨版本升級用 federation 過渡。">3.C27 Zalando RabbitMQ on AWS</a> 案例揭露了 K8s 普及之前、雲端 RabbitMQ cluster 治理的工程模式（master selection 與成員協調），跟 cluster 拓樸治理相關。</p>
<p>Zalando 的 communication platform 把 RabbitMQ cluster 跑在 EC2 上、自建 sidekick 服務查 AWS API 動態識別 cluster 成員、指定「最老的 instance」當 master、master 死後晉升下一個最老的節點。這套機制本質是在 RabbitMQ 內建的 partition handling 之外、額外加一層 <em>外部協調者</em> 來決定 cluster 拓樸（case 記載的直接動機是用 AWS API 動態識別成員、配合每 region 5 個 Elastic IP 的限制處理 master 角色）。把它讀作「早期雲端 RabbitMQ 在節點角色確定性上需要外部補強」是本文的判讀、非 case 明述的結論。</p>
<p>這個案例對映到本文的判讀是：早期 RabbitMQ cluster 的 partition 一致性需要大量外部工程（sidekick + AWS API + 自訂 master selection）來補足。Quorum queue 用 Raft 把這套外部協調內化進 broker——Raft 的 leader election 與 majority commit 取代了 Zalando 手寫的「最老 instance 當 master」邏輯。現代部署若用 quorum queue + pause_minority、不再需要外部 sidekick 來決定誰是 master。</p>
<p>語義誤配的風險在 partition 場景同樣存在。<a href="/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9 Queue 語義切換誤配</a> 指出 broker 行為改變時、「表面上訊息仍被送達、但業務資料開始出現重複或遺漏」。Partition 恢復正是這種高風險時刻：autoheal 丟棄輸家狀態、或人工從 ignore 的腦裂中合併、都可能讓同一批事件被處理零次或兩次。Partition 恢復後的 reconciliation、要對照 <a href="/blog/backend/03-message-queue/processing-recovery-semantics/" data-link-title="3.6 Processing Semantics 與 Recovery Semantics" data-link-desc="說明投遞成功、處理成功與恢復成功為何是三個不同判斷。">3.6 recovery semantics</a> 確認哪一段資料已被哪一側處理過、而不是假設「broker 恢復了 = 狀態正確了」。</p>
<h2 id="容量與規模判讀">容量與規模判讀</h2>
<p>Partition 處理策略的選擇隨 cluster 規模與一致性需求變化、不存在單一最佳解。</p>
<table>
  <thead>
      <tr>
          <th>規模 / 場景</th>
          <th>建議策略</th>
          <th>判讀</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>單節點</td>
          <td><code>ignore</code>（無 partition 可言）</td>
          <td>沒有 cluster、不需要 partition 處理</td>
      </tr>
      <tr>
          <td>3 / 5 / 7 奇數節點、需一致性</td>
          <td><code>pause_minority</code> + quorum queue</td>
          <td>少數派暫停、quorum queue 用 Raft 保一致</td>
      </tr>
      <tr>
          <td>偶數節點</td>
          <td>加一個節點變奇數、再用 pause_minority</td>
          <td>偶數節點對 pause_minority 是反模式</td>
      </tr>
      <tr>
          <td>可容忍訊息遺失、可用性優先</td>
          <td><code>autoheal</code> + classic queue</td>
          <td>接受輸家丟狀態、換 partition 期間雙邊可用</td>
      </tr>
      <tr>
          <td>跨 AZ / 跨 region</td>
          <td>重新評估是否該用單一 cluster</td>
          <td>partition 機率高、考慮 federation 拆成獨立 cluster</td>
      </tr>
  </tbody>
</table>
<p>幾個容量相關的硬性邊界：</p>
<p>跨 region 拉一個 RabbitMQ cluster 是高風險配置。跨 region 網路延遲與抖動讓 partition 從「偶發事件」變成「常態」——net_tick 頻繁逾時、pause_minority 頻繁暫停節點、cluster 實質不穩定。跨 region 的正確做法是每個 region 一個獨立 cluster、用 federation 或 shovel 做 region 間的訊息搬運、partition 限制在單一 region 內。</p>
<p>quorum queue 的副本數要對齊 cluster 規模。3-node cluster 配 3 副本能容忍 1 節點失聯（仍有 2/3 majority）；5-node 配 5 副本能容忍 2 節點失聯。副本數越多、容錯越高、但每筆寫入要等的確認也越多、寫入延遲上升。多數場景 3 副本是延遲與容錯的平衡點。</p>
<p>net_ticktime 的調整要保守。把它調短以加速 partition 偵測、會讓雲端正常抖動頻繁誤觸發 partition handler——pause_minority 下就是節點被頻繁暫停、可用性反而下降。除非有明確證據顯示偵測延遲是問題、否則保留 60 秒預設值。</p>
<h2 id="整合與下一步">整合與下一步</h2>
<p>Partition 處理是 RabbitMQ cluster 可靠性的一環、跟以下能力環環相扣：</p>
<p>queue 類型的選擇直接決定 partition 行為。Classic queue 靠 <code>cluster_partition_handling</code> 事後補救、quorum queue 靠 Raft 從設計排除腦裂、stream 又是另一套複製模型。三者在 partition、throughput、retention 上的完整取捨、見 <a href="/blog/backend/03-message-queue/vendors/rabbitmq/queue-types-classic-quorum-stream/" data-link-title="RabbitMQ Queue Type 選型：Classic、Quorum、Stream 的責任邊界與容量取捨" data-link-desc="RabbitMQ 3.x 三種 queue type 的選型 deep article — classic queue（mirrored 已 deprecated）、quorum queue（Raft 一致性、取代 mirrored）、stream（3.9&#43; append-only log、可重複消費）。涵蓋三種模型在 throughput / retention / replay / 記憶體成本的判讀、宣告語意差異（實機驗證）、4 個 production 故障演練（mirrored 網路放大 / quorum loss / stream retention 超量 / classic→quorum in-flight message），與容量規劃。">Queue Type 選型</a>。</p>
<p>partition 恢復的核心是恢復語義、不是連線恢復。Broker 重新連上不等於狀態一致——這正是 <a href="/blog/backend/03-message-queue/processing-recovery-semantics/" data-link-title="3.6 Processing Semantics 與 Recovery Semantics" data-link-desc="說明投遞成功、處理成功與恢復成功為何是三個不同判斷。">3.6 processing semantics 與 recovery semantics</a> 區分投遞、處理、恢復三層的價值。Partition 後的 reconciliation 要對照這三層判斷。</p>
<p>雲端 cluster 治理的歷史脈絡見 <a href="/blog/backend/03-message-queue/cases/rabbitmq-zalando-aws-master-selection/" data-link-title="3.C27 Zalando：RabbitMQ on AWS 自動化 master selection" data-link-desc="Zalando 用 sidekick 服務查 AWS API 動態識別 cluster、指定最老 instance 當 master、跨版本升級用 federation 過渡。">3.C27 Zalando AWS master selection</a>——理解外部協調者怎麼被 Raft 內化、有助於判斷現代部署該把多少責任交給 broker、多少留給運維。</p>
<p>語義誤配在 partition 恢復時的具體告警條件見 <a href="/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9 Queue 語義切換誤配</a>——下游同時出現重複與遺漏、是 partition 恢復處置出錯的典型訊號。</p>
<p>回到上游：<a href="/blog/backend/03-message-queue/vendors/rabbitmq/" data-link-title="RabbitMQ" data-link-desc="Classic message broker、AMQP routing 為主">RabbitMQ overview</a> 的進階主題段列了 Erlang clustering 之外的 federation / shovel / Cluster Operator 議題；<a href="/blog/backend/03-message-queue/broker-basics/" data-link-title="3.1 broker 基礎與投遞模型" data-link-desc="先理解 broker、queue、consumer 與 delivery semantics">3.1 broker basics</a> 是 broker 通用概念的起點。</p>
]]></content:encoded></item><item><title>3.C14 Yelp：Schematizer 自建 Schema Registry</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-yelp-schematizer/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-yelp-schematizer/</guid><description>&lt;p>這個案例的核心責任是說明 schema 治理是 data pipeline 的核心責任、不是 add-on。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>Yelp data pipeline 一天數十億訊息、跨數百個 service、數千 schema、用自建 Schematizer 強制所有 message 走 Avro schema、訊息只帶 schema ID。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>Schematizer 不只是 schema store、還做 schema evolution compatibility 與 topic 自動分配（不相容 schema 強制新 topic）。揭露 producer / consumer schema 治理要拉到平台層、靠工具強制、不靠人約定。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>Kafka 進階主題：Schema Registry / Schema evolution。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/event-contract-replay-boundary/" data-link-title="3.7 Event Contract 與 Replay Boundary" data-link-desc="說明 event schema、idempotency key、replay window 與補償如何先於 broker 選型。">3.7 event contract / replay boundary&lt;/a>。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://engineeringblog.yelp.com/2016/08/more-than-just-a-schema-store.html">Yelp Schematizer: More than just a schema store&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 schema 治理是 data pipeline 的核心責任、不是 add-on。</p>
<h2 id="觀察">觀察</h2>
<p>Yelp data pipeline 一天數十億訊息、跨數百個 service、數千 schema、用自建 Schematizer 強制所有 message 走 Avro schema、訊息只帶 schema ID。</p>
<h2 id="判讀">判讀</h2>
<p>Schematizer 不只是 schema store、還做 schema evolution compatibility 與 topic 自動分配（不相容 schema 強制新 topic）。揭露 producer / consumer schema 治理要拉到平台層、靠工具強制、不靠人約定。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>Kafka 進階主題：Schema Registry / Schema evolution。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/event-contract-replay-boundary/" data-link-title="3.7 Event Contract 與 Replay Boundary" data-link-desc="說明 event schema、idempotency key、replay window 與補償如何先於 broker 選型。">3.7 event contract / replay boundary</a>。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://engineeringblog.yelp.com/2016/08/more-than-just-a-schema-store.html">Yelp Schematizer: More than just a schema store</a></li>
</ul>
]]></content:encoded></item><item><title>Kafka Multi-tenant 治理：quota 限流、ACL 授權與 topic 生命週期</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/multi-tenant-quota-acl/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/multi-tenant-quota-acl/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Apache Kafka&lt;/a> overview「Multi-tenant 與配額治理」「Topic 生命週期治理」兩段的 implementation-layer deep article。Overview 說明這些議題對應哪些案例跟子議題、本文展開具體的 quota / ACL 配置、授權模型推導、故障徵兆與修法。&lt;/p>&lt;/blockquote>
&lt;h2 id="共享叢集的治理問題一個叢集多個互不信任的租戶">共享叢集的治理問題：一個叢集、多個互不信任的租戶&lt;/h2>
&lt;p>Multi-tenant Kafka 的核心問題是把一個物理叢集切成多個彼此隔離的邏輯空間、讓每個團隊用同一組 broker 卻不互相干擾。當 Kafka 從單一團隊的工具長成全公司的事件總線、叢集承載的不再是一條 pipeline、而是數十到數百個團隊的 producer 跟 consumer。這時叢集的瓶頸從「broker 夠不夠快」轉成「怎麼防止某個團隊的流量、權限、或 topic 失控波及其他所有人」。&lt;/p>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/uber-kafka-infrastructure-evolution/" data-link-title="3.C6 Uber：Kafka 事件平台演進" data-link-desc="事件平台從團隊自管走向多租戶共享基礎設施。">Uber 的 Kafka 平台演進&lt;/a>把這個轉換描述為「從單隊列問題提升到平台治理問題」。當事件平台服務眾多團隊、重點是配額、隔離、觀測與運維標準化、而非只擴 broker。擴 broker 解決的是總容量、解決不了「單一租戶吃光共享資源」這類隔離問題。&lt;/p>
&lt;p>共享叢集的治理分三個獨立的軸、各自處理不同的失控來源：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>治理軸&lt;/th>
 &lt;th>防的是什麼&lt;/th>
 &lt;th>工具&lt;/th>
 &lt;th>失控後果&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Quota（資源配額）&lt;/td>
 &lt;td>單租戶吃滿頻寬 / request 容量、餓死其他租戶&lt;/td>
 &lt;td>&lt;code>kafka-configs.sh&lt;/code> 設 byte rate&lt;/td>
 &lt;td>鄰居 producer 寫入卡死、consumer lag&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>ACL（存取授權）&lt;/td>
 &lt;td>租戶讀寫不屬於自己的 topic、或被未授權方寫入&lt;/td>
 &lt;td>&lt;code>kafka-acls.sh&lt;/code> + broker authorizer&lt;/td>
 &lt;td>資料外洩、跨租戶污染、誤刪 topic&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>生命週期（治理）&lt;/td>
 &lt;td>死 topic 累積、partition 數爆炸壓垮 metadata 面&lt;/td>
 &lt;td>命名規範 + 活躍判準 + 自動回收&lt;/td>
 &lt;td>controller 變慢、rebalance 風暴&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>三軸正交：quota 設好不代表權限對、ACL 鎖好不代表 topic 不會爆炸。下面逐軸展開、每軸都對應 production 踩過的失控場景。本文 quota 與 ACL 操作以 Kafka 4.2.0（KRaft 模式、&lt;code>apache/kafka:latest&lt;/code>）實機驗證。&lt;/p>
&lt;h2 id="quota把頻寬與-request-容量切給租戶">Quota：把頻寬與 request 容量切給租戶&lt;/h2>
&lt;p>Quota 是 broker 端對 client 的流量上限、由 broker 在超限時主動 throttle（延遲回應）而非拒絕、讓單一租戶無法把共享頻寬吃光。Kafka 的 quota 是 broker-side 強制、不依賴 client 自律 —— 即使 client 不配合、broker 也會在回應裡插入 throttle 延遲、把該 client 的有效吞吐壓回配額內。&lt;/p>
&lt;h3 id="三類-quota-度量">三類 quota 度量&lt;/h3>
&lt;p>Kafka quota 度量三種資源、對應三類飽和：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Quota 鍵&lt;/th>
 &lt;th>單位&lt;/th>
 &lt;th>限制對象&lt;/th>
 &lt;th>飽和訊號&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>producer_byte_rate&lt;/code>&lt;/td>
 &lt;td>bytes/sec&lt;/td>
 &lt;td>單一 client 每秒寫入 broker 的 bytes&lt;/td>
 &lt;td>寫入端 network / disk I/O 飽和&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>consumer_byte_rate&lt;/code>&lt;/td>
 &lt;td>bytes/sec&lt;/td>
 &lt;td>單一 client 每秒從 broker 讀取的 bytes&lt;/td>
 &lt;td>讀取端 network 飽和、fan-out 過大&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>request_percentage&lt;/code>&lt;/td>
 &lt;td>百分比&lt;/td>
 &lt;td>單一 client 佔用 broker request handler 的 CPU 時間&lt;/td>
 &lt;td>broker CPU 飽和、小訊息高頻請求&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>前兩個 byte rate 防的是頻寬類飽和、適合「大訊息、穩定流量」的租戶。&lt;code>request_percentage&lt;/code> 防的是另一種失控 —— 某租戶送大量極小的 request（例如每筆一個 byte、每秒幾萬筆）、byte rate 看起來很低、卻把 broker 的 request handler thread 佔滿。這種「請求數爆炸但流量不大」的攻擊型 pattern 只有 &lt;code>request_percentage&lt;/code> 抓得到。一個 broker 預設有 N 個 request handler thread、&lt;code>request_percentage=200&lt;/code> 代表允許該 client 用掉 2 條 thread 的時間（100% = 1 條）。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Apache Kafka</a> overview「Multi-tenant 與配額治理」「Topic 生命週期治理」兩段的 implementation-layer deep article。Overview 說明這些議題對應哪些案例跟子議題、本文展開具體的 quota / ACL 配置、授權模型推導、故障徵兆與修法。</p></blockquote>
<h2 id="共享叢集的治理問題一個叢集多個互不信任的租戶">共享叢集的治理問題：一個叢集、多個互不信任的租戶</h2>
<p>Multi-tenant Kafka 的核心問題是把一個物理叢集切成多個彼此隔離的邏輯空間、讓每個團隊用同一組 broker 卻不互相干擾。當 Kafka 從單一團隊的工具長成全公司的事件總線、叢集承載的不再是一條 pipeline、而是數十到數百個團隊的 producer 跟 consumer。這時叢集的瓶頸從「broker 夠不夠快」轉成「怎麼防止某個團隊的流量、權限、或 topic 失控波及其他所有人」。</p>
<p><a href="/blog/backend/03-message-queue/cases/uber-kafka-infrastructure-evolution/" data-link-title="3.C6 Uber：Kafka 事件平台演進" data-link-desc="事件平台從團隊自管走向多租戶共享基礎設施。">Uber 的 Kafka 平台演進</a>把這個轉換描述為「從單隊列問題提升到平台治理問題」。當事件平台服務眾多團隊、重點是配額、隔離、觀測與運維標準化、而非只擴 broker。擴 broker 解決的是總容量、解決不了「單一租戶吃光共享資源」這類隔離問題。</p>
<p>共享叢集的治理分三個獨立的軸、各自處理不同的失控來源：</p>
<table>
  <thead>
      <tr>
          <th>治理軸</th>
          <th>防的是什麼</th>
          <th>工具</th>
          <th>失控後果</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Quota（資源配額）</td>
          <td>單租戶吃滿頻寬 / request 容量、餓死其他租戶</td>
          <td><code>kafka-configs.sh</code> 設 byte rate</td>
          <td>鄰居 producer 寫入卡死、consumer lag</td>
      </tr>
      <tr>
          <td>ACL（存取授權）</td>
          <td>租戶讀寫不屬於自己的 topic、或被未授權方寫入</td>
          <td><code>kafka-acls.sh</code> + broker authorizer</td>
          <td>資料外洩、跨租戶污染、誤刪 topic</td>
      </tr>
      <tr>
          <td>生命週期（治理）</td>
          <td>死 topic 累積、partition 數爆炸壓垮 metadata 面</td>
          <td>命名規範 + 活躍判準 + 自動回收</td>
          <td>controller 變慢、rebalance 風暴</td>
      </tr>
  </tbody>
</table>
<p>三軸正交：quota 設好不代表權限對、ACL 鎖好不代表 topic 不會爆炸。下面逐軸展開、每軸都對應 production 踩過的失控場景。本文 quota 與 ACL 操作以 Kafka 4.2.0（KRaft 模式、<code>apache/kafka:latest</code>）實機驗證。</p>
<h2 id="quota把頻寬與-request-容量切給租戶">Quota：把頻寬與 request 容量切給租戶</h2>
<p>Quota 是 broker 端對 client 的流量上限、由 broker 在超限時主動 throttle（延遲回應）而非拒絕、讓單一租戶無法把共享頻寬吃光。Kafka 的 quota 是 broker-side 強制、不依賴 client 自律 —— 即使 client 不配合、broker 也會在回應裡插入 throttle 延遲、把該 client 的有效吞吐壓回配額內。</p>
<h3 id="三類-quota-度量">三類 quota 度量</h3>
<p>Kafka quota 度量三種資源、對應三類飽和：</p>
<table>
  <thead>
      <tr>
          <th>Quota 鍵</th>
          <th>單位</th>
          <th>限制對象</th>
          <th>飽和訊號</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>producer_byte_rate</code></td>
          <td>bytes/sec</td>
          <td>單一 client 每秒寫入 broker 的 bytes</td>
          <td>寫入端 network / disk I/O 飽和</td>
      </tr>
      <tr>
          <td><code>consumer_byte_rate</code></td>
          <td>bytes/sec</td>
          <td>單一 client 每秒從 broker 讀取的 bytes</td>
          <td>讀取端 network 飽和、fan-out 過大</td>
      </tr>
      <tr>
          <td><code>request_percentage</code></td>
          <td>百分比</td>
          <td>單一 client 佔用 broker request handler 的 CPU 時間</td>
          <td>broker CPU 飽和、小訊息高頻請求</td>
      </tr>
  </tbody>
</table>
<p>前兩個 byte rate 防的是頻寬類飽和、適合「大訊息、穩定流量」的租戶。<code>request_percentage</code> 防的是另一種失控 —— 某租戶送大量極小的 request（例如每筆一個 byte、每秒幾萬筆）、byte rate 看起來很低、卻把 broker 的 request handler thread 佔滿。這種「請求數爆炸但流量不大」的攻擊型 pattern 只有 <code>request_percentage</code> 抓得到。一個 broker 預設有 N 個 request handler thread、<code>request_percentage=200</code> 代表允許該 client 用掉 2 條 thread 的時間（100% = 1 條）。</p>
<h3 id="三種套用層級">三種套用層級</h3>
<p>Quota 可以套在三種 entity 上、精度遞增：</p>
<table>
  <thead>
      <tr>
          <th>套用層級</th>
          <th>entity 指定</th>
          <th>適用情境</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>client-id</td>
          <td><code>--entity-type clients --entity-name &lt;id&gt;</code></td>
          <td>沒有認證、用 client.id 區分服務</td>
      </tr>
      <tr>
          <td>user</td>
          <td><code>--entity-type users --entity-name &lt;user&gt;</code></td>
          <td>有 SASL 認證、整個租戶共用一個 quota</td>
      </tr>
      <tr>
          <td>user + client-id</td>
          <td>兩個 entity 同時指定</td>
          <td>同租戶內不同服務分別配額（最細）</td>
      </tr>
  </tbody>
</table>
<p>層級的選擇取決於認證模型。沒開認證的叢集只能用 client-id —— 但 client.id 由 client 自行宣告、可偽造、只適合內部信任環境的粗略區分。開了 SASL 認證後、user 才是可信的租戶邊界、quota 綁 user 才有隔離意義。最細的 user + client-id 組合用在「同一個租戶內、batch 匯入服務跟即時 API 服務要分開限流」這種情境：整個 billing 租戶有一個總配額、但裡面的 <code>batch-importer</code> 再單獨壓低、避免夜間批次把租戶配額吃光、害同租戶的即時服務沒頻寬。</p>
<h3 id="設定與查詢實機驗證">設定與查詢（實機驗證）</h3>
<p>設 client-id 層級、同時給 producer 跟 consumer byte rate：</p>





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





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





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





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





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





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





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





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





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





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





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





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





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">WARNING: Due to limitations in metric names, topics with a period (&#39;.&#39;)
</span></span><span class="line"><span class="ln">2</span><span class="cl">or underscore (&#39;_&#39;) could collide. To avoid issues it is best to use
</span></span><span class="line"><span class="ln">3</span><span class="cl">either, but not both.</span></span></code></pre></div><p>成因是 metric 名把 topic 名裡的 <code>.</code> 跟 <code>_</code> 都正規化掉、<code>billing.invoices</code> 跟 <code>billing_invoices</code> 可能對映到同一條 metric。命名規範應在 <code>.</code> 跟 <code>_</code> 之間選一個當分隔符、全叢集一致、避免監控數據互相污染。</p>
<h3 id="活躍判準與自動回收">活躍判準與自動回收</h3>
<p>死 topic 的回收靠可量化的活躍判準。<a href="/blog/backend/03-message-queue/cases/linkedin-topicgc-kafka-governance/" data-link-title="3.C3 LinkedIn：TopicGC 與 Kafka 治理轉換" data-link-desc="Kafka topic 從手動治理轉自動治理對叢集的影響。">LinkedIn 的 TopicGC</a>以自動治理取代手動清理未使用 topic、降低 metadata 壓力並改善 produce / consume 效能。它的判讀是：當 queue 規模擴大、僅靠容量擴充不夠、topic 生命週期與治理自動化會成為可靠性關鍵。</p>
<p>TopicGC 是 LinkedIn 的內部系統、不是 Kafka 內建指令；它揭示的是一套可借鏡的回收流程結構：</p>
<ol>
<li>定義活躍判準：以 last produce / last consume timestamp 判斷 topic 是否仍在使用、設一段觀察窗（例如 N 天無寫入且無讀取）。</li>
<li>分級回收：先標記（soft）、進入待回收狀態並通知 owner、保留一段 grace period、無人認領才真正刪除（hard）。兩段式避免誤刪仍有低頻流量的 topic。</li>
<li>保留稽核：每次標記與刪除留紀錄、回收前後比對 controller log、partition 數量、produce / consume 效能指標、確認治理有效且無誤傷。</li>
</ol>
<p>回收條件的設定要對齊業務節奏。純看 produce timestamp 會誤判「低頻但關鍵」的 topic（如月結批次）；活躍判準要同時看 produce 跟 consume、且觀察窗要長於最長的合法閒置週期。</p>
<h2 id="production-故障演練">Production 故障演練</h2>
<h3 id="case-1單一租戶暴衝吃滿頻寬quota-缺位">Case 1：單一租戶暴衝吃滿頻寬（quota 缺位）</h3>
<p><strong>徵兆</strong>：某團隊上線一支新 backfill job、開始全速寫入；同叢集其他租戶的 producer 端 <code>request-latency</code> p99 從個位數 ms 跳到數百 ms、consumer lag 全面上升；broker network out 打到網卡上限、但 CPU 不高。受害的不是暴衝者自己、是所有共用 broker 的鄰居。</p>
<p><strong>根因</strong>：叢集沒設任何 producer quota、或只對部分租戶設了 quota。沒有 broker-side throttle 時、單一 client 能用滿 broker 的 network / disk I/O、把共享頻寬擠光。byte rate 飽和的特徵是 network 打滿但 CPU 不高 —— 區別於 <code>request_percentage</code> 缺位導致的 CPU 飽和。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>立即對暴衝 client 設 <code>producer_byte_rate</code>、broker 即時 throttle、無需重啟。</li>
<li>建立 quota 預設值：對所有 client-id（或 user）設一個保守的 default byte rate、新租戶上線自動受限、避免「漏設就無限」。</li>
<li>區分 byte rate 與 request_percentage 飽和：network 打滿設 byte rate、CPU 打滿（高頻小訊息）補 <code>request_percentage</code>。</li>
<li>容量規劃：把各租戶 quota 總和對齊 broker 的 network / disk 容量、留 headroom、避免「每個 quota 都合理但加總超過物理上限」。</li>
</ol>
<h3 id="case-2acl-設太鬆或太緊">Case 2：ACL 設太鬆或太緊</h3>
<p><strong>徵兆（太鬆）</strong>：稽核發現某 consumer 服務能讀到不屬於它的租戶 topic；或某 topic 被預期外的 principal 寫入、資料被污染。最壞情況是 <code>allow.everyone.if.no.acl.found=true</code> 下漏設 ACL 的 topic 對全叢集可讀寫。</p>
<p><strong>徵兆（太緊）</strong>：consumer 能讀訊息卻不斷重複消費、log 顯示 commit offset 被拒；或 producer 報 <code>TOPIC_AUTHORIZATION_FAILED</code>、明明該有權限。</p>
<p><strong>根因</strong>：太鬆來自 deny-list 心態 —— <code>allow.everyone.if.no.acl.found=true</code> 把「沒設 ACL」當成「開放」、漏設就外洩。太緊通常是漏掉 operation 或 resource：consumer 只給了 topic 的 <code>Read</code>、漏給 consumer group 的 <code>Read</code> <code>Describe</code>、於是讀得到但 commit 不了、表現成重複消費；producer 漏給 <code>Describe</code>、拿不到 partition metadata。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>走 allow-list：<code>allow.everyone.if.no.acl.found=false</code>、預設拒絕、明確授權才放行。</li>
<li>ACL 對齊角色模板：producer = topic Write + Describe；consumer = topic Read + Describe 加 group Read + Describe；漏 group ACL 是重複消費的常見根因。</li>
<li>用 prefixed ACL 而非逐 topic 設、把授權邊界對齊命名規範前綴、減少漏設。</li>
<li>稽核流程：定期 <code>kafka-acls.sh --list</code> 比對預期授權矩陣、把 ACL 納入版本控制與 review、而非手動逐條加。</li>
</ol>
<h3 id="case-3topic-數量爆炸壓垮-metadata-面">Case 3：Topic 數量爆炸壓垮 metadata 面</h3>
<p><strong>徵兆</strong>：叢集 topic / partition 總數隨團隊增長爬到數萬以上；controller failover 時間從秒級拉長到分鐘級；broker 啟動載入 metadata 變慢；client 的 metadata fetch 變大變慢、rebalance 期間出現連鎖延遲。容量沒滿、但整個叢集的 control plane 變鈍。</p>
<p><strong>根因</strong>：partition 是 controller 要追蹤的 metadata 單位、數量只增不減。每個團隊隨手建 topic、每個 topic 又開高 partition 數、總 partition 數線性甚至超線性成長、壓垮 metadata 處理。KRaft 相比 ZooKeeper 提高了 metadata 上限、但上限仍存在、不是無限。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>Partition 數規劃納入 topic 建立流程：partition 數對應並行度上限、不是越多越好；多餘 partition 是純 metadata 成本。詳見 <a href="/blog/backend/knowledge-cards/partition/" data-link-title="Partition" data-link-desc="說明事件流如何切分成多個可並行處理的有序片段">Partition</a> 卡。</li>
<li>回收死 topic 釋放 partition slot：見 Case 4 與生命週期治理段。</li>
<li>監控 metadata 壓力訊號：controller log、partition 總數、controller failover 時間設告警、在壓垮前介入。</li>
<li>規模化路徑：單叢集 metadata 逼近上限時、評估分群（依關鍵程度分多叢集）、見 overview 的 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Cross-region 與分層叢集</a>段與 <a href="/blog/backend/03-message-queue/cases/linkedin-kafka-tiered-clusters/" data-link-title="3.C4 LinkedIn：Kafka 分層叢集治理" data-link-desc="Kafka 從單叢集走向 tiered clusters 的轉換案例。">LinkedIn Tiered Clusters</a>案例。</li>
</ol>
<h3 id="case-4unused-topic-未回收">Case 4：Unused topic 未回收</h3>
<p><strong>徵兆</strong>：叢集裡大量 topic 數月無 produce 也無 consume、卻持續佔 partition slot 跟 metadata；沒人記得某些 topic 屬於哪個團隊、不敢刪；新 topic 想建時撞到 partition 上限、被迫先擴叢集而非先回收。</p>
<p><strong>根因</strong>：沒有活躍判準與回收流程、topic 只建不刪。歸屬資訊沒編碼進命名、回收時找不到 owner、於是「不敢刪」成為預設、死 topic 無限累積。這是 Case 3（metadata 爆炸）的慢性來源。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>建立活躍判準：以 last produce / last consume timestamp 加觀察窗判定死 topic、觀察窗長於最長合法閒置週期（避免誤刪月結類低頻 topic）。</li>
<li>兩段式回收：先 soft 標記並通知 owner、grace period 內無人認領才 hard 刪除、避免誤刪。</li>
<li>命名規範補 ownership：前綴對齊團隊、回收時能直接找到 owner、消除「不敢刪」。</li>
<li>自動化加稽核：參考 <a href="/blog/backend/03-message-queue/cases/linkedin-topicgc-kafka-governance/" data-link-title="3.C3 LinkedIn：TopicGC 與 Kafka 治理轉換" data-link-desc="Kafka topic 從手動治理轉自動治理對叢集的影響。">TopicGC</a>的流程結構、回收前後比對 metadata 與效能指標、留稽核紀錄。</li>
</ol>
<h2 id="容量與規模邊界">容量與規模邊界</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>估算 / 訊號</th>
          <th>警戒與下一步</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Quota 總和 vs 物理容量</td>
          <td>各租戶 byte rate 加總對 broker network / disk 容量</td>
          <td>加總逼近物理上限要重新切分、留 headroom</td>
      </tr>
      <tr>
          <td>ACL 條目數</td>
          <td>逐 topic 設會隨 topic 數線性成長</td>
          <td>改 prefixed ACL 對齊命名規範、降條目數與漏設風險</td>
      </tr>
      <tr>
          <td>Partition 總數</td>
          <td>controller failover 時間、metadata fetch 延遲</td>
          <td>逼近上限先回收死 topic、再評估分群</td>
      </tr>
      <tr>
          <td>Topic 活躍率</td>
          <td>有 produce / consume 的 topic 佔比</td>
          <td>死 topic 比例高代表缺回收流程、補活躍判準</td>
      </tr>
  </tbody>
</table>
<p>Quota 與 ACL 是 broker-side 即時生效、不需重啟、可隨租戶調整、運維成本低。生命週期治理是持續流程、不是一次性操作 —— 死 topic 會持續產生、回收要常態化。三軸的共同前提是命名規範：沒有可治理的命名、quota 找不到歸屬、ACL 邊界對不齊、回收找不到 owner。多租戶治理的第一步是先把命名規範立起來、再談 quota 與 ACL。</p>
<h2 id="整合與下一步">整合與下一步</h2>
<h3 id="跟-overview-與案例的對位">跟 overview 與案例的對位</h3>
<ul>
<li>上游 vendor 頁：<a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Apache Kafka</a> —— 本文展開其「Multi-tenant 與配額治理」「Topic 生命週期治理」兩段</li>
<li>平台治理案例：<a href="/blog/backend/03-message-queue/cases/uber-kafka-infrastructure-evolution/" data-link-title="3.C6 Uber：Kafka 事件平台演進" data-link-desc="事件平台從團隊自管走向多租戶共享基礎設施。">3.C6 Uber Kafka 事件平台</a> —— 單隊列問題提升到平台治理</li>
<li>生命週期案例：<a href="/blog/backend/03-message-queue/cases/linkedin-topicgc-kafka-governance/" data-link-title="3.C3 LinkedIn：TopicGC 與 Kafka 治理轉換" data-link-desc="Kafka topic 從手動治理轉自動治理對叢集的影響。">3.C3 LinkedIn TopicGC</a> —— 自動回收與 metadata 壓力</li>
<li>規模化分群：<a href="/blog/backend/03-message-queue/cases/linkedin-kafka-tiered-clusters/" data-link-title="3.C4 LinkedIn：Kafka 分層叢集治理" data-link-desc="Kafka 從單叢集走向 tiered clusters 的轉換案例。">3.C4 LinkedIn Tiered Clusters</a> —— metadata 逼近上限時的多叢集路徑</li>
<li>自管轉 managed 的 ACL cutover：<a href="/blog/backend/03-message-queue/cases/vmware-kafka-to-msk/" data-link-title="3.C2 VMware Tanzu CloudHealth：Kafka 轉 Amazon MSK" data-link-desc="自管 Kafka 遷移到託管平台時的治理重點。">3.C2 VMware → MSK</a></li>
</ul>
<h3 id="跟安全模組對位">跟安全模組對位</h3>
<p>ACL 是 Kafka 內建的授權層、處理 broker 級的 principal × resource 授權。完整的 secret 管理（SASL 認證憑證怎麼發、輪替、撤銷）屬於 <a href="/blog/backend/07-security-data-protection/" data-link-title="模組七：資安與資料保護" data-link-desc="以問題驅動方式擴充資安知識網：先定義服務環節問題，再以案例作為觸發式參考">07 資料保護與安全模組</a>的範疇 —— ACL 綁的 principal 從哪來、由認證層決定、ACL 只負責「這個 principal 能做什麼」。多租戶的完整信任鏈是「認證確認身分（07）→ ACL 授權操作（本文）→ quota 限制用量（本文）」三層。</p>
<h3 id="下一步議題">下一步議題</h3>
<ul>
<li>Schema 治理：跨租戶共用 topic 時、schema compatibility 是另一層契約治理、見 overview 的 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">KRaft 與 Schema Registry</a>段</li>
<li>Consumer group ACL 細節：跟 <a href="/blog/backend/knowledge-cards/consumer-group/" data-link-title="Consumer Group" data-link-desc="說明一組 consumer 如何共同分攤 stream 或 topic 的處理責任">Consumer group</a> rebalance 的互動</li>
<li>Quota 與 <a href="/blog/backend/knowledge-cards/delivery-semantics/" data-link-title="Delivery Semantics" data-link-desc="說明事件投遞語意如何定義遺失、重複、順序與補償策略">delivery semantics</a>：throttle 延遲對 producer timeout / retry 的影響</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li>上游 vendor 頁：<a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Apache Kafka</a></li>
<li>對位 deep article（同模組）：本模組其他 Kafka deep article 見 vendor 頁進階主題段</li>
<li>跨模組授權鏈：<a href="/blog/backend/07-security-data-protection/" data-link-title="模組七：資安與資料保護" data-link-desc="以問題驅動方式擴充資安知識網：先定義服務環節問題，再以案例作為觸發式參考">07 資料保護與安全模組</a></li>
<li>方法論：<a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章的寫作方法論</a></li>
<li>知識卡：<a href="/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">Topic</a>、<a href="/blog/backend/knowledge-cards/partition/" data-link-title="Partition" data-link-desc="說明事件流如何切分成多個可並行處理的有序片段">Partition</a>、<a href="/blog/backend/knowledge-cards/consumer-group/" data-link-title="Consumer Group" data-link-desc="說明一組 consumer 如何共同分攤 stream 或 topic 的處理責任">Consumer group</a></li>
</ul>
]]></content:encoded></item><item><title>3.C15 Airbnb：Spark Streaming Kafka reader rebalance</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-airbnb-spark-streaming-rebalance/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-airbnb-spark-streaming-rebalance/</guid><description>&lt;p>這個案例的核心責任是說明 stream processor 與 Kafka partition 數的緊耦合是 production scaling 瓶頸。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>Airbnb logging pipeline 跨多個 topic、event size 從幾百 bytes 到幾百 KB、QPS 跨數個量級差異、Spark 一個 partition 對一個 task 造成 data skew、catch-up 一個 4 小時 lag 要再花 4 小時。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>自建 balanced Spark Kafka reader、把 parallelism 從 partition 數解耦、按 event volume × size 重新分派 work。揭露 partition 數不該等同 consumer parallelism、要看 event 形狀。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>Kafka 進階主題：Consumer 設計 / consumer lag / rebalance / partition + consumer group。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計&lt;/a>。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://medium.com/airbnb-engineering/scaling-spark-streaming-for-logging-event-ingestion-4a03141d135d">Scaling Spark Streaming for Logging Event Ingestion&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 stream processor 與 Kafka partition 數的緊耦合是 production scaling 瓶頸。</p>
<h2 id="觀察">觀察</h2>
<p>Airbnb logging pipeline 跨多個 topic、event size 從幾百 bytes 到幾百 KB、QPS 跨數個量級差異、Spark 一個 partition 對一個 task 造成 data skew、catch-up 一個 4 小時 lag 要再花 4 小時。</p>
<h2 id="判讀">判讀</h2>
<p>自建 balanced Spark Kafka reader、把 parallelism 從 partition 數解耦、按 event volume × size 重新分派 work。揭露 partition 數不該等同 consumer parallelism、要看 event 形狀。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>Kafka 進階主題：Consumer 設計 / consumer lag / rebalance / partition + consumer group。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計</a>。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://medium.com/airbnb-engineering/scaling-spark-streaming-for-logging-event-ingestion-4a03141d135d">Scaling Spark Streaming for Logging Event Ingestion</a></li>
</ul>
]]></content:encoded></item><item><title>3.C16 Robinhood：Faust Python stream processing</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-robinhood-faust-python-streaming/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-robinhood-faust-python-streaming/</guid><description>&lt;p>這個案例的核心責任是說明語言生態與 stream framework 的選型張力。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>Robinhood 每天處理 billions of events / TB 資料、用於 risk signal、order quality、market data、fraud detection；team 多為 Python、不想用 JVM 生態。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>把 Kafka Streams 的 stateful streaming 模式（topology、tables、windowing）移植到 Python library 形式、不需要 Yarn / Mesos resource manager。揭露 stream processing framework 選型常被語言生態主導、不是技術 feature。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>Kafka 進階主題：跨語言 client / Streams framework / stream processing on Kafka。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計&lt;/a>。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://medium.com/robinhood-engineering/faust-stream-processing-for-python-a66d3a51212d">Faust: Stream Processing for Python&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明語言生態與 stream framework 的選型張力。</p>
<h2 id="觀察">觀察</h2>
<p>Robinhood 每天處理 billions of events / TB 資料、用於 risk signal、order quality、market data、fraud detection；team 多為 Python、不想用 JVM 生態。</p>
<h2 id="判讀">判讀</h2>
<p>把 Kafka Streams 的 stateful streaming 模式（topology、tables、windowing）移植到 Python library 形式、不需要 Yarn / Mesos resource manager。揭露 stream processing framework 選型常被語言生態主導、不是技術 feature。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>Kafka 進階主題：跨語言 client / Streams framework / stream processing on Kafka。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計</a>。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://medium.com/robinhood-engineering/faust-stream-processing-for-python-a66d3a51212d">Faust: Stream Processing for Python</a></li>
</ul>
]]></content:encoded></item><item><title>3.C17 Walmart：Messaging Proxy Service 解 rebalance storm</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-walmart-mps-rebalance/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-walmart-mps-rebalance/</guid><description>&lt;p>這個案例的核心責任是說明 partition-consumer 1:1 模型在大規模 K8s 環境的擴張極限。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>Walmart 每天 trillions of message、25K+ Kafka consumer 跑在 WCNP Kubernetes 多雲環境；最大痛點是 pod scaling / deploy / heartbeat fail 觸發 consumer rebalance、lag spike。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>自建 Messaging Proxy Service（MPS、Kafka Connect sink connector）、把 consumer 從 partition-bound 解耦成 stateless REST service、可獨立 auto-scale、不用增 partition；內建 DLQ 處理 poison pill。揭露「consumer 該跟 partition 數綁定」這個假設在 K8s 規模化下不再成立。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>Kafka 進階主題：rebalance storm / consumer lag / multi-tenant 配額。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計&lt;/a>。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://medium.com/walmartglobaltech/reliably-processing-trillions-of-kafka-messages-per-day-23494f553ef9">Reliably Processing Trillions of Kafka Messages Per Day&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 partition-consumer 1:1 模型在大規模 K8s 環境的擴張極限。</p>
<h2 id="觀察">觀察</h2>
<p>Walmart 每天 trillions of message、25K+ Kafka consumer 跑在 WCNP Kubernetes 多雲環境；最大痛點是 pod scaling / deploy / heartbeat fail 觸發 consumer rebalance、lag spike。</p>
<h2 id="判讀">判讀</h2>
<p>自建 Messaging Proxy Service（MPS、Kafka Connect sink connector）、把 consumer 從 partition-bound 解耦成 stateless REST service、可獨立 auto-scale、不用增 partition；內建 DLQ 處理 poison pill。揭露「consumer 該跟 partition 數綁定」這個假設在 K8s 規模化下不再成立。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>Kafka 進階主題：rebalance storm / consumer lag / multi-tenant 配額。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計</a>。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://medium.com/walmartglobaltech/reliably-processing-trillions-of-kafka-messages-per-day-23494f553ef9">Reliably Processing Trillions of Kafka Messages Per Day</a></li>
</ul>
]]></content:encoded></item><item><title>3.C18 Wix：Greyhound TLLSR 解 consumer 卡住</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-wix-greyhound-troubleshooting/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-wix-greyhound-troubleshooting/</guid><description>&lt;p>這個案例的核心責任是說明大規模 multi-tenant Kafka 的營運可視性需求遠超原生 metric。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>Wix 2000+ microservice、每天 66 billion Kafka 訊息、用自建 Greyhound（JVM library + polyglot sidecar）抽象 Kafka；troubleshooting 痛點是「卡住的 consumer 看不到原因、只能寫 DB 修復腳本」。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>TLLSR 框架（Trace / Lookup / Longest-running / Skip-replay / Redistribute）解 single-partition lag、單筆 poison pill、handler 卡住等情境；consumer lag alert &amp;gt; 30 分鐘觸發。揭露原生 lag metric 無法定位「卡在哪」、需要 message-level trace + 操作介面。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>Kafka 進階主題：consumer lag / observability / multi-tenant / poison message。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/red-team-delivery-layer/" data-link-title="3.5 攻擊者視角（紅隊）：傳遞層弱點判讀" data-link-desc="從重複投遞、重放濫用、毒訊息與容量壓力，盤點 message delivery 的主要弱點">3.5 紅隊章&lt;/a>。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://medium.com/wix-engineering/troubleshooting-kafka-for-2000-microservices-at-wix-986ee382fd1e">Troubleshooting Kafka for 2000 Microservices at Wix&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明大規模 multi-tenant Kafka 的營運可視性需求遠超原生 metric。</p>
<h2 id="觀察">觀察</h2>
<p>Wix 2000+ microservice、每天 66 billion Kafka 訊息、用自建 Greyhound（JVM library + polyglot sidecar）抽象 Kafka；troubleshooting 痛點是「卡住的 consumer 看不到原因、只能寫 DB 修復腳本」。</p>
<h2 id="判讀">判讀</h2>
<p>TLLSR 框架（Trace / Lookup / Longest-running / Skip-replay / Redistribute）解 single-partition lag、單筆 poison pill、handler 卡住等情境；consumer lag alert &gt; 30 分鐘觸發。揭露原生 lag metric 無法定位「卡在哪」、需要 message-level trace + 操作介面。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>Kafka 進階主題：consumer lag / observability / multi-tenant / poison message。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/red-team-delivery-layer/" data-link-title="3.5 攻擊者視角（紅隊）：傳遞層弱點判讀" data-link-desc="從重複投遞、重放濫用、毒訊息與容量壓力，盤點 message delivery 的主要弱點">3.5 紅隊章</a>。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://medium.com/wix-engineering/troubleshooting-kafka-for-2000-microservices-at-wix-986ee382fd1e">Troubleshooting Kafka for 2000 Microservices at Wix</a></li>
</ul>
]]></content:encoded></item><item><title>3.C19 Wix：Multi-cluster Kafka zero-downtime 遷移</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-wix-multi-cluster-migration/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-wix-multi-cluster-migration/</guid><description>&lt;p>這個案例的核心責任是說明 single mega-cluster 的 metadata scaling ceiling 與分群策略。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>Wix cluster metadata 從 2019 年 5K topic / 45K partition 漲到 20K topic / 200K partition、每日 record 從 450M 漲到 2.5B、controller startup 與 broker stability 受 metadata 量壓垮。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>不用 MirrorMaker、自建 Replicator service + Migration Orchestrator、用 Kafka topic 當控制平面協調 consumer 切換 + offset mapping；按 SLA 切多 cluster。揭露「topic / partition 數量」是 broker 級別的物理上限、不能無限擴張。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>Kafka 進階主題：cross-region MirrorMaker / topic 生命週期 / 分層叢集策略。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/linkedin-topicgc-kafka-governance/" data-link-title="3.C3 LinkedIn：TopicGC 與 Kafka 治理轉換" data-link-desc="Kafka topic 從手動治理轉自動治理對叢集的影響。">3.C3 LinkedIn TopicGC&lt;/a>。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://medium.com/wix-engineering/migrating-to-a-multi-cluster-managed-kafka-with-0-downtime-b936655f888e">Migrating to a Multi-Cluster Managed Kafka with 0 Downtime&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 single mega-cluster 的 metadata scaling ceiling 與分群策略。</p>
<h2 id="觀察">觀察</h2>
<p>Wix cluster metadata 從 2019 年 5K topic / 45K partition 漲到 20K topic / 200K partition、每日 record 從 450M 漲到 2.5B、controller startup 與 broker stability 受 metadata 量壓垮。</p>
<h2 id="判讀">判讀</h2>
<p>不用 MirrorMaker、自建 Replicator service + Migration Orchestrator、用 Kafka topic 當控制平面協調 consumer 切換 + offset mapping；按 SLA 切多 cluster。揭露「topic / partition 數量」是 broker 級別的物理上限、不能無限擴張。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>Kafka 進階主題：cross-region MirrorMaker / topic 生命週期 / 分層叢集策略。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/cases/linkedin-topicgc-kafka-governance/" data-link-title="3.C3 LinkedIn：TopicGC 與 Kafka 治理轉換" data-link-desc="Kafka topic 從手動治理轉自動治理對叢集的影響。">3.C3 LinkedIn TopicGC</a>。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://medium.com/wix-engineering/migrating-to-a-multi-cluster-managed-kafka-with-0-downtime-b936655f888e">Migrating to a Multi-Cluster Managed Kafka with 0 Downtime</a></li>
</ul>
]]></content:encoded></item><item><title>3.C20 Spotify：Event Delivery 從 Kafka 遷出（反例）</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-spotify-event-delivery-exodus/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-spotify-event-delivery-exodus/</guid><description>&lt;p>Spotify 從 Kafka 遷出到 GCP Pub/Sub 的決策揭露了兩件事：broker 的可靠性保證是版本特性而非 Kafka 的不變量；以及「升級到新版」跟「換到另一個系統」之間的決策判準。&lt;/p>
&lt;h2 id="業務背景">業務背景&lt;/h2>
&lt;p>Spotify 的事件傳遞系統（Event Delivery）負責把使用者行為事件（播放、搜尋、推薦互動）從客戶端送到資料管線。系統跨 5 個 datacenter 運行 Kafka 0.7，production peak 700K events/sec、pressure test 達到 2M events/sec。事件資料是推薦系統、analytics 跟廣告計費的輸入，遺失事件直接影響商業決策的準確性。&lt;/p>
&lt;p>2016 年，Spotify 決定把 Event Delivery 從 Kafka 遷移到 GCP Pub/Sub，而非升級到當時已發布的 Kafka 0.8+。&lt;/p>
&lt;h2 id="技術挑戰">技術挑戰&lt;/h2>
&lt;h3 id="mirrormaker-的-best-effort-語意">MirrorMaker 的 best-effort 語意&lt;/h3>
&lt;p>Kafka 0.7 的跨 datacenter replication 工具 MirrorMaker 在 best-effort mode 下會丟失資料但向 producer 回報成功。對 Spotify 的場景，producer 端認為事件已送達，但跨 datacenter 的 mirror 實際上丟了一部分。丟失比例在正常情況下很低，但在 broker restart 或網路抖動時可以升高到影響 analytics 準確性的程度。&lt;/p>
&lt;p>這個問題的根源是 Kafka 0.7 的 producer 沒有 idempotent 保證，MirrorMaker 的 consumer offset commit 跟 producer ack 之間有 gap。&lt;/p>
&lt;h3 id="broker-restart-後-producer-無法自動恢復">Broker restart 後 producer 無法自動恢復&lt;/h3>
&lt;p>Kafka 0.7 的 producer 在 broker restart 後可能進入無法自動恢復的狀態 — 需要人工重啟 producer process。在 5 個 datacenter、數百個 producer instance 的規模下，每次 broker 維護操作都需要人工介入恢復 producer，運維成本跟 broker 數量成正比。&lt;/p>
&lt;h3 id="為什麼不升級到-kafka-08">為什麼不升級到 Kafka 0.8+&lt;/h3>
&lt;p>Kafka 0.8 引入了 replication、新的 consumer API 跟更可靠的 producer。但 Spotify 評估後認為升級的成本接近重新部署：&lt;/p>
&lt;ul>
&lt;li>Kafka 0.7 到 0.8 的 wire protocol 不相容，需要全量遷移而非滾動升級&lt;/li>
&lt;li>所有 producer / consumer 的 client library 都要更換&lt;/li>
&lt;li>Spotify 同時在向 GCP 遷移基礎設施，Kafka 的自管運維模式跟 GCP 的託管方向不一致&lt;/li>
&lt;/ul>
&lt;p>相比之下，GCP Pub/Sub 提供了託管的 exactly-once 語意、跨 region replication、零運維。遷移成本跟升級 Kafka 版本的成本相當，但遷移後的長期運維成本低得多。&lt;/p>
&lt;h2 id="解法與取捨">解法與取捨&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>面向&lt;/th>
 &lt;th>留在 Kafka（升級 0.8+）&lt;/th>
 &lt;th>遷到 GCP Pub/Sub&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>一次性遷移成本&lt;/td>
 &lt;td>中（全量遷移、不可滾動升級）&lt;/td>
 &lt;td>中（同樣需要改所有 client）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>長期運維成本&lt;/td>
 &lt;td>高（自管 broker × 5 DC）&lt;/td>
 &lt;td>低（託管、零 broker 維護）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>可靠性保證&lt;/td>
 &lt;td>0.8+ 有 replication、改善大&lt;/td>
 &lt;td>Pub/Sub 原生 exactly-once&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>跨 region replication&lt;/td>
 &lt;td>需要自建 MirrorMaker 2.0&lt;/td>
 &lt;td>原生支援&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>生態鎖定&lt;/td>
 &lt;td>Kafka 生態成熟&lt;/td>
 &lt;td>GCP 鎖定、跨雲成本高&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Spotify 的判斷是：在同時進行 GCP 遷移的背景下，維護自管 Kafka 的投資回報比不上切換到託管方案。這個判斷跟 Kafka 本身的能力無關 — Kafka 0.8+ 的可靠性已經解決了 0.7 的問題。決策的關鍵變數是「組織正在往哪走」，不只是「技術上哪個更好」。&lt;/p></description><content:encoded><![CDATA[<p>Spotify 從 Kafka 遷出到 GCP Pub/Sub 的決策揭露了兩件事：broker 的可靠性保證是版本特性而非 Kafka 的不變量；以及「升級到新版」跟「換到另一個系統」之間的決策判準。</p>
<h2 id="業務背景">業務背景</h2>
<p>Spotify 的事件傳遞系統（Event Delivery）負責把使用者行為事件（播放、搜尋、推薦互動）從客戶端送到資料管線。系統跨 5 個 datacenter 運行 Kafka 0.7，production peak 700K events/sec、pressure test 達到 2M events/sec。事件資料是推薦系統、analytics 跟廣告計費的輸入，遺失事件直接影響商業決策的準確性。</p>
<p>2016 年，Spotify 決定把 Event Delivery 從 Kafka 遷移到 GCP Pub/Sub，而非升級到當時已發布的 Kafka 0.8+。</p>
<h2 id="技術挑戰">技術挑戰</h2>
<h3 id="mirrormaker-的-best-effort-語意">MirrorMaker 的 best-effort 語意</h3>
<p>Kafka 0.7 的跨 datacenter replication 工具 MirrorMaker 在 best-effort mode 下會丟失資料但向 producer 回報成功。對 Spotify 的場景，producer 端認為事件已送達，但跨 datacenter 的 mirror 實際上丟了一部分。丟失比例在正常情況下很低，但在 broker restart 或網路抖動時可以升高到影響 analytics 準確性的程度。</p>
<p>這個問題的根源是 Kafka 0.7 的 producer 沒有 idempotent 保證，MirrorMaker 的 consumer offset commit 跟 producer ack 之間有 gap。</p>
<h3 id="broker-restart-後-producer-無法自動恢復">Broker restart 後 producer 無法自動恢復</h3>
<p>Kafka 0.7 的 producer 在 broker restart 後可能進入無法自動恢復的狀態 — 需要人工重啟 producer process。在 5 個 datacenter、數百個 producer instance 的規模下，每次 broker 維護操作都需要人工介入恢復 producer，運維成本跟 broker 數量成正比。</p>
<h3 id="為什麼不升級到-kafka-08">為什麼不升級到 Kafka 0.8+</h3>
<p>Kafka 0.8 引入了 replication、新的 consumer API 跟更可靠的 producer。但 Spotify 評估後認為升級的成本接近重新部署：</p>
<ul>
<li>Kafka 0.7 到 0.8 的 wire protocol 不相容，需要全量遷移而非滾動升級</li>
<li>所有 producer / consumer 的 client library 都要更換</li>
<li>Spotify 同時在向 GCP 遷移基礎設施，Kafka 的自管運維模式跟 GCP 的託管方向不一致</li>
</ul>
<p>相比之下，GCP Pub/Sub 提供了託管的 exactly-once 語意、跨 region replication、零運維。遷移成本跟升級 Kafka 版本的成本相當，但遷移後的長期運維成本低得多。</p>
<h2 id="解法與取捨">解法與取捨</h2>
<table>
  <thead>
      <tr>
          <th>面向</th>
          <th>留在 Kafka（升級 0.8+）</th>
          <th>遷到 GCP Pub/Sub</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>一次性遷移成本</td>
          <td>中（全量遷移、不可滾動升級）</td>
          <td>中（同樣需要改所有 client）</td>
      </tr>
      <tr>
          <td>長期運維成本</td>
          <td>高（自管 broker × 5 DC）</td>
          <td>低（託管、零 broker 維護）</td>
      </tr>
      <tr>
          <td>可靠性保證</td>
          <td>0.8+ 有 replication、改善大</td>
          <td>Pub/Sub 原生 exactly-once</td>
      </tr>
      <tr>
          <td>跨 region replication</td>
          <td>需要自建 MirrorMaker 2.0</td>
          <td>原生支援</td>
      </tr>
      <tr>
          <td>生態鎖定</td>
          <td>Kafka 生態成熟</td>
          <td>GCP 鎖定、跨雲成本高</td>
      </tr>
  </tbody>
</table>
<p>Spotify 的判斷是：在同時進行 GCP 遷移的背景下，維護自管 Kafka 的投資回報比不上切換到託管方案。這個判斷跟 Kafka 本身的能力無關 — Kafka 0.8+ 的可靠性已經解決了 0.7 的問題。決策的關鍵變數是「組織正在往哪走」，不只是「技術上哪個更好」。</p>
<h2 id="回寫教材的連結">回寫教材的連結</h2>
<ul>
<li><a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka vendor 頁</a>：cross-region replication 跟 MirrorMaker 的進階主題。Spotify 的案例是「早期版本限制」的歷史教訓，Kafka 3.x 的 KRaft + idempotent producer 已解決這些問題。</li>
<li><a href="/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Pub/Sub vendor 頁</a>：託管 MQ 的定位跟適用場景。</li>
<li><a href="/blog/backend/03-message-queue/processing-recovery-semantics/" data-link-title="3.6 Processing Semantics 與 Recovery Semantics" data-link-desc="說明投遞成功、處理成功與恢復成功為何是三個不同判斷。">3.6 processing recovery semantics</a>：exactly-once 語意的工程實踐。Spotify 案例揭露 exactly-once 在早期 Kafka 版本不成立。</li>
<li><a href="/blog/backend/03-message-queue/broker-basics/" data-link-title="3.1 broker 基礎與投遞模型" data-link-desc="先理解 broker、queue、consumer 與 delivery semantics">3.1 broker basics</a>：broker 版本跟可靠性保證的關係。</li>
</ul>
<h2 id="判讀徵兆">判讀徵兆</h2>
<p>讀者在自己的系統看到以下訊號時，應該回讀本案例：</p>
<ul>
<li>使用舊版 Kafka（&lt; 2.0）且跨 region replication 的資料完整性無法驗證</li>
<li>Broker restart 後需要人工重啟 producer、運維成本跟 broker 數量成正比</li>
<li>組織正在做基礎設施遷移（on-prem → cloud），考慮是否同步切換 MQ</li>
<li>評估「升級現有系統 vs 遷移到新系統」的決策框架</li>
</ul>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://engineering.atspotify.com/2017/03/spotifys-event-delivery-the-road-to-the-cloud-part-ii">Spotify&rsquo;s Event Delivery — The Road to the Cloud (Part II)</a></li>
</ul>
]]></content:encoded></item><item><title>3.C21 Goldman Sachs：MSK 遷移 with MirrorMaker 2</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-goldman-sachs-msk-migration/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-goldman-sachs-msk-migration/</guid><description>&lt;p>這個案例的核心責任是說明 MM2 在 production cutover 的真實 tuning 與 LB 整合 pitfall。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>Global Investment Research 把 ~12 microservice / 30 instance 從 on-prem Kafka 遷到 MSK；用 MM2 同步 topic / ACL / consumer group / offset、選擇 atomic cutover、整體耗時 ~7 小時。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>把 MM2 預設的 prefixed topic 改成 identical name；遇到 flush timeout（5s → 30s）、request size、NLB idle timeout 350s vs client 540s 衝突。揭露 managed 服務遷移的細節風險集中在「LB / timeout / topic naming」這些 client 端配置、不在 broker 本身。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>Kafka 進階主題：cross-region MirrorMaker / managed broker 遷移 / ACL 設計。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/vmware-kafka-to-msk/" data-link-title="3.C2 VMware Tanzu CloudHealth：Kafka 轉 Amazon MSK" data-link-desc="自管 Kafka 遷移到託管平台時的治理重點。">3.C2 VMware → MSK&lt;/a>。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://aws.amazon.com/blogs/big-data/how-goldman-sachs-migrated-from-their-on-premises-apache-kafka-cluster-to-amazon-msk/">How Goldman Sachs Migrated from On-Premises Apache Kafka to Amazon MSK&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 MM2 在 production cutover 的真實 tuning 與 LB 整合 pitfall。</p>
<h2 id="觀察">觀察</h2>
<p>Global Investment Research 把 ~12 microservice / 30 instance 從 on-prem Kafka 遷到 MSK；用 MM2 同步 topic / ACL / consumer group / offset、選擇 atomic cutover、整體耗時 ~7 小時。</p>
<h2 id="判讀">判讀</h2>
<p>把 MM2 預設的 prefixed topic 改成 identical name；遇到 flush timeout（5s → 30s）、request size、NLB idle timeout 350s vs client 540s 衝突。揭露 managed 服務遷移的細節風險集中在「LB / timeout / topic naming」這些 client 端配置、不在 broker 本身。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>Kafka 進階主題：cross-region MirrorMaker / managed broker 遷移 / ACL 設計。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/cases/vmware-kafka-to-msk/" data-link-title="3.C2 VMware Tanzu CloudHealth：Kafka 轉 Amazon MSK" data-link-desc="自管 Kafka 遷移到託管平台時的治理重點。">3.C2 VMware → MSK</a>。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://aws.amazon.com/blogs/big-data/how-goldman-sachs-migrated-from-their-on-premises-apache-kafka-cluster-to-amazon-msk/">How Goldman Sachs Migrated from On-Premises Apache Kafka to Amazon MSK</a></li>
</ul>
]]></content:encoded></item><item><title>3.C22 Trivago：KEDA scale-to-zero by Kafka lag</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-trivago-keda-scale-to-zero/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-trivago-keda-scale-to-zero/</guid><description>&lt;p>這個案例的核心責任是說明 event-driven workload 該按 backlog 而非 resource usage scale 的設計判準。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>Trivago 跨 3 個 region 跑 50+ Kafka sink service、每個 always-on 用 1 CPU + 1 GB；CPU/mem-based autoscaling 無效（sink 多為 I/O bottleneck、CPU 平坦）。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>KEDA 以 consumer lag 為 scaling signal、minReplicaCount=0 達到 scale-to-zero、daily replica-hour 從 50 降到 1-2。揭露「resource usage 不等於工作量」、event-driven 場景該看 backlog signal。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>Kafka 進階主題：consumer lag / autoscaling / multi-tenant 配額。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計&lt;/a>。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tech.trivago.com/post/2026-02-18-from-always-on-to-on-demand-scaling-kafka-sinks-with-keda">From Always-On to On-Demand: Scaling Kafka Sinks with KEDA&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 event-driven workload 該按 backlog 而非 resource usage scale 的設計判準。</p>
<h2 id="觀察">觀察</h2>
<p>Trivago 跨 3 個 region 跑 50+ Kafka sink service、每個 always-on 用 1 CPU + 1 GB；CPU/mem-based autoscaling 無效（sink 多為 I/O bottleneck、CPU 平坦）。</p>
<h2 id="判讀">判讀</h2>
<p>KEDA 以 consumer lag 為 scaling signal、minReplicaCount=0 達到 scale-to-zero、daily replica-hour 從 50 降到 1-2。揭露「resource usage 不等於工作量」、event-driven 場景該看 backlog signal。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>Kafka 進階主題：consumer lag / autoscaling / multi-tenant 配額。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計</a>。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://tech.trivago.com/post/2026-02-18-from-always-on-to-on-demand-scaling-kafka-sinks-with-keda">From Always-On to On-Demand: Scaling Kafka Sinks with KEDA</a></li>
</ul>
]]></content:encoded></item><item><title>3.C23 Bloomberg：多租戶 vhost + 自助平台化</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/rabbitmq-bloomberg-multi-tenant-vhost/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/rabbitmq-bloomberg-multi-tenant-vhost/</guid><description>&lt;p>Bloomberg 的 RabbitMQ 平台化案例揭露了 broker 從幾個團隊的工具演變成上百個團隊的共享基礎設施時，治理責任邊界應該前置設計，而非在規模化之後補救。&lt;/p>
&lt;h2 id="業務背景">業務背景&lt;/h2>
&lt;p>Bloomberg 有 5000+ 工程師，內部系統涵蓋金融資料處理、交易系統、新聞分發與分析平台。RabbitMQ 的使用從最初幾個團隊的 microservice 解耦開始，逐步擴展到上百個團隊。到 2019 年，Bloomberg 的 RabbitMQ 基礎設施每週處理超過 2 億條訊息，尖峰每秒數萬條。&lt;/p>
&lt;p>這個規模下，原本由平台團隊手動配置的 queue / exchange / binding 模式無法持續 — 上百個團隊各自有不同的 queue 需求，平台團隊成為所有變更的人工瓶頸。&lt;/p>
&lt;h2 id="技術挑戰">技術挑戰&lt;/h2>
&lt;h3 id="多租戶隔離">多租戶隔離&lt;/h3>
&lt;p>多個團隊共用同一個 RabbitMQ cluster 時，一個團隊的 queue 爆量或 consumer 故障可能影響其他團隊的訊息處理。RabbitMQ 的 Erlang scheduler 是共用的 — 一個 queue 的 message accumulation 會消耗 broker 的記憶體跟 CPU，影響同 cluster 上所有 queue 的效能。&lt;/p>
&lt;p>隔離需要在 broker 層實作，client 端的 best practice（限制 message size、設定 TTL）只能降低風險但無法保證隔離。&lt;/p>
&lt;h3 id="自助配置的安全邊界">自助配置的安全邊界&lt;/h3>
&lt;p>讓上百個團隊自助建立 queue / exchange / binding 需要明確的安全邊界 — 團隊 A 能在自己的 namespace 建立資源，但不能存取團隊 B 的 queue。RabbitMQ 的 vhost 機制提供了這個隔離單位，但 vhost 的建立跟權限配置本身需要自動化。&lt;/p>
&lt;h3 id="容量規劃與配額">容量規劃與配額&lt;/h3>
&lt;p>共享 cluster 的容量被所有租戶分攤。沒有配額機制時，一個團隊的 queue 可以無限增長直到 broker 記憶體告警、觸發 flow control、影響全部租戶。配額需要在 queue 層面設定上限（max-length、max-length-bytes），同時提供超出配額時的降級策略而非直接拒絕。&lt;/p>
&lt;h2 id="解法vhost-分層--自助平台">解法：vhost 分層 + 自助平台&lt;/h2>
&lt;h3 id="vhost-作為租戶邊界">Vhost 作為租戶邊界&lt;/h3>
&lt;p>Bloomberg 把 vhost 作為多租戶隔離的基本單位。每個團隊（或每個應用）分配一個 vhost，vhost 內的 queue / exchange / binding 只對該團隊可見。跨 vhost 的訊息傳遞透過 shovel 或 federation plugin，需要顯式配置，預設不互通。&lt;/p>
&lt;p>Vhost 的隔離粒度是「資源可見性 + 權限」而非「硬體資源」。同 cluster 上的 vhost 仍然共用 Erlang runtime 跟記憶體。完全的硬體隔離需要獨立 cluster — Bloomberg 對高敏感度的工作負載（交易相關）使用專用 cluster，一般業務共用大 cluster + vhost 隔離。&lt;/p>
&lt;h3 id="自助-vhost-註冊">自助 vhost 註冊&lt;/h3>
&lt;p>Bloomberg 建立了內部自助平台，團隊透過 API 或內部 portal 申請 vhost。申請時需要提供：應用名稱、預期的 message rate、保留期限、是否需要 HA（mirrored / quorum queue）。平台自動建立 vhost、設定權限、分配連線端點。&lt;/p>
&lt;p>自助流程的價值是去除平台團隊的人工瓶頸。新團隊從申請到拿到可用的 RabbitMQ 端點，時間從「提 ticket 等平台團隊排程」縮短到「填表 → 自動配置 → 立即可用」。&lt;/p>
&lt;h3 id="配額與監控">配額與監控&lt;/h3>
&lt;p>每個 vhost 有預設配額（max-length、max-connections）。超出配額時 broker 行為可配 — drop-head（丟最舊的訊息）或 reject-publish（拒絕新訊息）。配額不是懲罰機制，是保護共享 cluster 的防線。&lt;/p>
&lt;p>監控用 RabbitMQ 的 management plugin + Prometheus exporter，按 vhost 維度匯出 queue depth、message rate、connection count。每個 vhost 的 dashboard 對應到 owner 團隊，讓團隊自行判讀自己的使用狀況。&lt;/p></description><content:encoded><![CDATA[<p>Bloomberg 的 RabbitMQ 平台化案例揭露了 broker 從幾個團隊的工具演變成上百個團隊的共享基礎設施時，治理責任邊界應該前置設計，而非在規模化之後補救。</p>
<h2 id="業務背景">業務背景</h2>
<p>Bloomberg 有 5000+ 工程師，內部系統涵蓋金融資料處理、交易系統、新聞分發與分析平台。RabbitMQ 的使用從最初幾個團隊的 microservice 解耦開始，逐步擴展到上百個團隊。到 2019 年，Bloomberg 的 RabbitMQ 基礎設施每週處理超過 2 億條訊息，尖峰每秒數萬條。</p>
<p>這個規模下，原本由平台團隊手動配置的 queue / exchange / binding 模式無法持續 — 上百個團隊各自有不同的 queue 需求，平台團隊成為所有變更的人工瓶頸。</p>
<h2 id="技術挑戰">技術挑戰</h2>
<h3 id="多租戶隔離">多租戶隔離</h3>
<p>多個團隊共用同一個 RabbitMQ cluster 時，一個團隊的 queue 爆量或 consumer 故障可能影響其他團隊的訊息處理。RabbitMQ 的 Erlang scheduler 是共用的 — 一個 queue 的 message accumulation 會消耗 broker 的記憶體跟 CPU，影響同 cluster 上所有 queue 的效能。</p>
<p>隔離需要在 broker 層實作，client 端的 best practice（限制 message size、設定 TTL）只能降低風險但無法保證隔離。</p>
<h3 id="自助配置的安全邊界">自助配置的安全邊界</h3>
<p>讓上百個團隊自助建立 queue / exchange / binding 需要明確的安全邊界 — 團隊 A 能在自己的 namespace 建立資源，但不能存取團隊 B 的 queue。RabbitMQ 的 vhost 機制提供了這個隔離單位，但 vhost 的建立跟權限配置本身需要自動化。</p>
<h3 id="容量規劃與配額">容量規劃與配額</h3>
<p>共享 cluster 的容量被所有租戶分攤。沒有配額機制時，一個團隊的 queue 可以無限增長直到 broker 記憶體告警、觸發 flow control、影響全部租戶。配額需要在 queue 層面設定上限（max-length、max-length-bytes），同時提供超出配額時的降級策略而非直接拒絕。</p>
<h2 id="解法vhost-分層--自助平台">解法：vhost 分層 + 自助平台</h2>
<h3 id="vhost-作為租戶邊界">Vhost 作為租戶邊界</h3>
<p>Bloomberg 把 vhost 作為多租戶隔離的基本單位。每個團隊（或每個應用）分配一個 vhost，vhost 內的 queue / exchange / binding 只對該團隊可見。跨 vhost 的訊息傳遞透過 shovel 或 federation plugin，需要顯式配置，預設不互通。</p>
<p>Vhost 的隔離粒度是「資源可見性 + 權限」而非「硬體資源」。同 cluster 上的 vhost 仍然共用 Erlang runtime 跟記憶體。完全的硬體隔離需要獨立 cluster — Bloomberg 對高敏感度的工作負載（交易相關）使用專用 cluster，一般業務共用大 cluster + vhost 隔離。</p>
<h3 id="自助-vhost-註冊">自助 vhost 註冊</h3>
<p>Bloomberg 建立了內部自助平台，團隊透過 API 或內部 portal 申請 vhost。申請時需要提供：應用名稱、預期的 message rate、保留期限、是否需要 HA（mirrored / quorum queue）。平台自動建立 vhost、設定權限、分配連線端點。</p>
<p>自助流程的價值是去除平台團隊的人工瓶頸。新團隊從申請到拿到可用的 RabbitMQ 端點，時間從「提 ticket 等平台團隊排程」縮短到「填表 → 自動配置 → 立即可用」。</p>
<h3 id="配額與監控">配額與監控</h3>
<p>每個 vhost 有預設配額（max-length、max-connections）。超出配額時 broker 行為可配 — drop-head（丟最舊的訊息）或 reject-publish（拒絕新訊息）。配額不是懲罰機制，是保護共享 cluster 的防線。</p>
<p>監控用 RabbitMQ 的 management plugin + Prometheus exporter，按 vhost 維度匯出 queue depth、message rate、connection count。每個 vhost 的 dashboard 對應到 owner 團隊，讓團隊自行判讀自己的使用狀況。</p>
<h2 id="取捨">取捨</h2>
<p><strong>Vhost 隔離 vs 硬體隔離</strong>：vhost 隔離成本低（不需要額外 cluster），但隔離程度有限 — Erlang scheduler 跟記憶體仍然共用。Bloomberg 的做法是多數團隊用 vhost 隔離、高敏感度工作負載用專用 cluster，兩者共存。</p>
<p><strong>自助配置 vs 中央管控</strong>：自助配置加速團隊迭代，但也增加了 configuration drift 的風險。Bloomberg 透過配額跟自動化審計（定期掃描 vhost 的 queue 狀態、alert 異常 pattern）平衡自助跟管控。</p>
<h2 id="回寫教材的連結">回寫教材的連結</h2>
<ul>
<li><a href="/blog/backend/03-message-queue/broker-basics/" data-link-title="3.1 broker 基礎與投遞模型" data-link-desc="先理解 broker、queue、consumer 與 delivery semantics">3.1 broker basics</a>：broker 的多租戶治理責任</li>
<li><a href="/blog/backend/03-message-queue/cases/uber-kafka-infrastructure-evolution/" data-link-title="3.C6 Uber：Kafka 事件平台演進" data-link-desc="事件平台從團隊自管走向多租戶共享基礎設施。">3.C6 Uber Kafka 平台</a>：Kafka 生態的多租戶治理比較 — Kafka 用 topic-level ACL + quota，RabbitMQ 用 vhost</li>
<li><a href="/blog/backend/04-observability/observability-operating-model/" data-link-title="4.18 Observability Operating Model" data-link-desc="定義 platform / service team / on-call 對訊號、dashboard、alert 與成本的 ownership">4.18 operating model</a>：平台團隊跟服務團隊的 ownership 邊界</li>
</ul>
<h2 id="判讀徵兆">判讀徵兆</h2>
<p>以下訊號出現時，應該回讀本案例：</p>
<ul>
<li>RabbitMQ 的使用團隊數從個位數增長到雙位數、平台團隊成為配置瓶頸</li>
<li>單一 cluster 上的 queue 數量超過數百個、owner 不明</li>
<li>某個團隊的 queue 爆量影響了其他團隊的 consumer 效能</li>
<li>新團隊要用 RabbitMQ 但平台團隊的 ticket 要排隊數天</li>
<li>沒有 per-team 的 message rate 或 queue depth 監控</li>
</ul>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://www.cloudamqp.com/blog/growing-a-farm-of-rabbits.html">Growing a Farm of Rabbits at Bloomberg</a></li>
</ul>
]]></content:encoded></item><item><title>Retry Policy</title><link>https://tarrragon.github.io/blog/backend/knowledge-cards/retry-policy/</link><pubDate>Mon, 22 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/knowledge-cards/retry-policy/</guid><description>&lt;p>Retry policy 的核心概念是「定義失敗後何時再試、試幾次、用什麼間隔、何時停止」。重試可以吸收暫時性故障（網路抖動、下游短暫不可用），但也可能放大下游壓力或重複造成副作用，因此跟 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/dead-letter-queue/" data-link-title="Dead-Letter Queue" data-link-desc="說明 dead-letter queue 如何隔離多次處理失敗的訊息">dead-letter queue&lt;/a> 是成對設計。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>Retry policy 跟 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/dead-letter-queue/" data-link-title="Dead-Letter Queue" data-link-desc="說明 dead-letter queue 如何隔離多次處理失敗的訊息">dead-letter queue&lt;/a> 構成錯誤處理的兩層機制 — retry 處理暫時性失敗，DLQ 承接 retry 耗盡後仍無法處理的訊息。Retry 跟 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency&lt;/a> 是成對的設計 — 有 retry 就要有 idempotent consumer，否則重試可能造成重複扣款、重複發通知。&lt;/p>
&lt;p>Retry 跟 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/retry-storm/" data-link-title="Retry Storm" data-link-desc="說明大量重試如何把局部故障放大成系統壓力">retry storm&lt;/a> 的關係是：大量 consumer 同時 retry 失敗的訊息會形成下游的流量尖峰，把暫時性故障放大成全系統問題。Exponential backoff + jitter 是緩解 retry storm 的標準做法。&lt;/p>
&lt;h2 id="使用情境">使用情境&lt;/h2>
&lt;p>系統需要 retry policy 的訊號是下游偶發失敗影響成功率。付款查詢 API 短暫 timeout 可以重試；已送出的扣款請求則需要先查詢結果或用 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency&lt;/a> key，避免重試造成重複扣款。&lt;/p>
&lt;p>Retry 的判斷分類：暫時性錯誤（5xx、timeout、connection refused）適合 retry；永久性錯誤（4xx、schema validation failure、business rule violation）不應該 retry，直接送 DLQ 或 reject。分類錯誤是 retry policy 最常見的 bug — 對永久性錯誤 retry 只會消耗 quota、延遲問題被發現的時間。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>Retry policy 要包含最大重試次數、backoff 策略（fixed / exponential / exponential + jitter）、每次 retry 的 timeout、錯誤分類規則（哪些 error code 算暫時性）、觀測欄位（retry count、最終結果）與停止條件（超過 N 次進 DLQ）。高流量系統還要設定 retry budget — 限制 retry 流量佔總流量的比例，避免 retry 自身成為負載來源。&lt;/p></description><content:encoded><![CDATA[<p>Retry policy 的核心概念是「定義失敗後何時再試、試幾次、用什麼間隔、何時停止」。重試可以吸收暫時性故障（網路抖動、下游短暫不可用），但也可能放大下游壓力或重複造成副作用，因此跟 <a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</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> 是成對設計。</p>
<h2 id="概念位置">概念位置</h2>
<p>Retry policy 跟 <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> 構成錯誤處理的兩層機制 — retry 處理暫時性失敗，DLQ 承接 retry 耗盡後仍無法處理的訊息。Retry 跟 <a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a> 是成對的設計 — 有 retry 就要有 idempotent consumer，否則重試可能造成重複扣款、重複發通知。</p>
<p>Retry 跟 <a href="/blog/backend/knowledge-cards/retry-storm/" data-link-title="Retry Storm" data-link-desc="說明大量重試如何把局部故障放大成系統壓力">retry storm</a> 的關係是：大量 consumer 同時 retry 失敗的訊息會形成下游的流量尖峰，把暫時性故障放大成全系統問題。Exponential backoff + jitter 是緩解 retry storm 的標準做法。</p>
<h2 id="使用情境">使用情境</h2>
<p>系統需要 retry policy 的訊號是下游偶發失敗影響成功率。付款查詢 API 短暫 timeout 可以重試；已送出的扣款請求則需要先查詢結果或用 <a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a> key，避免重試造成重複扣款。</p>
<p>Retry 的判斷分類：暫時性錯誤（5xx、timeout、connection refused）適合 retry；永久性錯誤（4xx、schema validation failure、business rule violation）不應該 retry，直接送 DLQ 或 reject。分類錯誤是 retry policy 最常見的 bug — 對永久性錯誤 retry 只會消耗 quota、延遲問題被發現的時間。</p>
<h2 id="設計責任">設計責任</h2>
<p>Retry policy 要包含最大重試次數、backoff 策略（fixed / exponential / exponential + jitter）、每次 retry 的 timeout、錯誤分類規則（哪些 error code 算暫時性）、觀測欄位（retry count、最終結果）與停止條件（超過 N 次進 DLQ）。高流量系統還要設定 retry budget — 限制 retry 流量佔總流量的比例，避免 retry 自身成為負載來源。</p>
]]></content:encoded></item><item><title>3.C24 SoundCloud：AMQP fan-out 音訊處理 pipeline</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/rabbitmq-soundcloud-fanout-audio/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/rabbitmq-soundcloud-fanout-audio/</guid><description>&lt;p>這個案例的核心責任是說明 fan-out 處理 pipeline 該按處理類型拆隊列、不該共用 queue。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>上傳音訊後用 RabbitMQ 觸發 transcode + 波形圖 + follower 通知。當 Skrillex 等大號上傳時、要避免同步寫 Cassandra 千萬次。每秒 20-30,000 條 persistent message。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>不同處理類型分開隊列、各自獨立 scale。揭露 fan-out 不是「broadcast 同一份工作」、而是「同事件觸發多種獨立 pipeline」、每種 pipeline 的 throughput / latency 要求不同。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>RabbitMQ 進階主題：Prefetch + consumer 併發 / classic queue vs Streams（log fan-out 場景）。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &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 vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計&lt;/a>。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blogs.vmware.com/tanzu/scaling-with-rabbitmq-soundcloud">Scaling with RabbitMQ at SoundCloud (VMware Tanzu)&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.infoq.com/presentations/amqp-soundcloud/">AMQP at SoundCloud (InfoQ)&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 fan-out 處理 pipeline 該按處理類型拆隊列、不該共用 queue。</p>
<h2 id="觀察">觀察</h2>
<p>上傳音訊後用 RabbitMQ 觸發 transcode + 波形圖 + follower 通知。當 Skrillex 等大號上傳時、要避免同步寫 Cassandra 千萬次。每秒 20-30,000 條 persistent message。</p>
<h2 id="判讀">判讀</h2>
<p>不同處理類型分開隊列、各自獨立 scale。揭露 fan-out 不是「broadcast 同一份工作」、而是「同事件觸發多種獨立 pipeline」、每種 pipeline 的 throughput / latency 要求不同。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>RabbitMQ 進階主題：Prefetch + consumer 併發 / classic queue vs Streams（log fan-out 場景）。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/rabbitmq/" data-link-title="RabbitMQ" data-link-desc="Classic message broker、AMQP routing 為主">RabbitMQ vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計</a>。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://blogs.vmware.com/tanzu/scaling-with-rabbitmq-soundcloud">Scaling with RabbitMQ at SoundCloud (VMware Tanzu)</a></li>
<li><a href="https://www.infoq.com/presentations/amqp-soundcloud/">AMQP at SoundCloud (InfoQ)</a></li>
</ul>
]]></content:encoded></item><item><title>3.C25 Indeed：Delay queue + DLQ 三層 escalation</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/rabbitmq-indeed-delay-dlq-escalation/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/rabbitmq-indeed-delay-dlq-escalation/</guid><description>&lt;p>這個案例的核心責任是說明 retry 策略要跟 queue 拓樸結合設計，分層延遲 + &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/dead-letter-queue/" data-link-title="Dead-Letter Queue" data-link-desc="說明 dead-letter queue 如何隔離多次處理失敗的訊息">DLQ&lt;/a> 的三層 escalation 能避免 head-of-line blocking。&lt;/p>
&lt;h2 id="業務背景">業務背景&lt;/h2>
&lt;p>Indeed 是全球最大的求職搜尋引擎之一，每天處理 35M+ 筆職缺資料的索引、更新與推送。職缺資料從雇主端進入系統後，需要經過解析、標準化、索引、推送到搜尋引擎等多個處理步驟，每個步驟由 RabbitMQ 串接的 consumer 處理。&lt;/p>
&lt;p>這個規模下，任何一個處理步驟的暫時失敗（downstream service timeout、資料格式異常、外部 API rate limit）都會產生需要 retry 的訊息。每天有數十萬筆訊息需要至少一次 retry。&lt;/p>
&lt;h2 id="技術挑戰head-of-line-blocking">技術挑戰：Head-of-line blocking&lt;/h2>
&lt;p>Indeed 原本的 retry 策略是 consumer 處理失敗時把訊息 requeue（&lt;code>basic.nack&lt;/code> with &lt;code>requeue=true&lt;/code>）。RabbitMQ 的 requeue 行為是把訊息放回 queue 的 head — 下一次 consumer 拿到的還是這條失敗的訊息。&lt;/p>
&lt;p>當一條訊息因為 downstream timeout 反覆失敗時，它會持續佔住 queue head，阻塞後面所有等待處理的訊息。單一 consumer 的時間被一條失敗訊息反覆消耗，其他正常的訊息延遲累積。在 35M+ 筆/天的吞吐量下，一條 head-of-line blocking 訊息就能讓整個 pipeline 的 processing lag 從秒級升到分鐘級。&lt;/p>
&lt;p>這個問題的根源是 retry 策略跟 queue 拓樸耦合在一起 — requeue 把 retry 決策留在同一個 queue 裡，讓失敗訊息跟正常訊息搶同一條通道。&lt;/p>
&lt;h2 id="解法三層-escalation">解法：三層 escalation&lt;/h2>
&lt;p>Indeed 設計了一個三層 escalation 模型，把失敗訊息依嚴重程度逐層隔離：&lt;/p>
&lt;h3 id="第一層immediate-retry同-queue">第一層：Immediate retry（同 queue）&lt;/h3>
&lt;p>Consumer 處理失敗時，先在 client 端做短暫 backoff（數百毫秒到數秒），然後 ack 原訊息、重新 publish 到同一個 queue 的 tail（而非 requeue 到 head）。&lt;/p>
&lt;p>這層處理的是暫態錯誤 — downstream 偶發的 500、短暫的 network hiccup。多數訊息在第一層就能恢復。重新 publish 到 tail 確保失敗訊息排在正常訊息後面，不阻塞其他訊息。&lt;/p>
&lt;h3 id="第二層delay-queue">第二層：Delay queue&lt;/h3>
&lt;p>第一層 retry N 次仍然失敗的訊息，透過 RabbitMQ 的 Dead Letter Exchange（DLX）路由到 delay queue。Delay queue 用 &lt;code>x-message-ttl&lt;/code> 設定延遲時間（例如 30 秒、1 分鐘、5 分鐘），TTL 到期後訊息透過另一個 DLX 路由回原始 queue 的 tail。&lt;/p>
&lt;p>Indeed 用多個不同 TTL 的 delay queue 實作 exponential backoff — 第一次進 delay 等 30 秒、第二次等 1 分鐘、第三次等 5 分鐘。這個做法利用 RabbitMQ 原生的 DLX + TTL 機制，不需要額外的 scheduler 或 cron job。&lt;/p>
&lt;p>這層處理的是持續性錯誤 — downstream 在做 deployment、外部 API 在做 maintenance。延遲重試讓 downstream 有時間恢復，同時失敗訊息完全離開主 queue、不影響正常處理。&lt;/p>
&lt;h3 id="第三層dead-letter-queue">第三層：Dead Letter Queue&lt;/h3>
&lt;p>Delay queue retry M 次後仍然失敗的訊息進入 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/dead-letter-queue/" data-link-title="Dead-Letter Queue" data-link-desc="說明 dead-letter queue 如何隔離多次處理失敗的訊息">DLQ&lt;/a>。DLQ 中的訊息不再自動重試，需要人工審視或批次 replay。&lt;/p></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 retry 策略要跟 queue 拓樸結合設計，分層延遲 + <a href="/blog/backend/knowledge-cards/dead-letter-queue/" data-link-title="Dead-Letter Queue" data-link-desc="說明 dead-letter queue 如何隔離多次處理失敗的訊息">DLQ</a> 的三層 escalation 能避免 head-of-line blocking。</p>
<h2 id="業務背景">業務背景</h2>
<p>Indeed 是全球最大的求職搜尋引擎之一，每天處理 35M+ 筆職缺資料的索引、更新與推送。職缺資料從雇主端進入系統後，需要經過解析、標準化、索引、推送到搜尋引擎等多個處理步驟，每個步驟由 RabbitMQ 串接的 consumer 處理。</p>
<p>這個規模下，任何一個處理步驟的暫時失敗（downstream service timeout、資料格式異常、外部 API rate limit）都會產生需要 retry 的訊息。每天有數十萬筆訊息需要至少一次 retry。</p>
<h2 id="技術挑戰head-of-line-blocking">技術挑戰：Head-of-line blocking</h2>
<p>Indeed 原本的 retry 策略是 consumer 處理失敗時把訊息 requeue（<code>basic.nack</code> with <code>requeue=true</code>）。RabbitMQ 的 requeue 行為是把訊息放回 queue 的 head — 下一次 consumer 拿到的還是這條失敗的訊息。</p>
<p>當一條訊息因為 downstream timeout 反覆失敗時，它會持續佔住 queue head，阻塞後面所有等待處理的訊息。單一 consumer 的時間被一條失敗訊息反覆消耗，其他正常的訊息延遲累積。在 35M+ 筆/天的吞吐量下，一條 head-of-line blocking 訊息就能讓整個 pipeline 的 processing lag 從秒級升到分鐘級。</p>
<p>這個問題的根源是 retry 策略跟 queue 拓樸耦合在一起 — requeue 把 retry 決策留在同一個 queue 裡，讓失敗訊息跟正常訊息搶同一條通道。</p>
<h2 id="解法三層-escalation">解法：三層 escalation</h2>
<p>Indeed 設計了一個三層 escalation 模型，把失敗訊息依嚴重程度逐層隔離：</p>
<h3 id="第一層immediate-retry同-queue">第一層：Immediate retry（同 queue）</h3>
<p>Consumer 處理失敗時，先在 client 端做短暫 backoff（數百毫秒到數秒），然後 ack 原訊息、重新 publish 到同一個 queue 的 tail（而非 requeue 到 head）。</p>
<p>這層處理的是暫態錯誤 — downstream 偶發的 500、短暫的 network hiccup。多數訊息在第一層就能恢復。重新 publish 到 tail 確保失敗訊息排在正常訊息後面，不阻塞其他訊息。</p>
<h3 id="第二層delay-queue">第二層：Delay queue</h3>
<p>第一層 retry N 次仍然失敗的訊息，透過 RabbitMQ 的 Dead Letter Exchange（DLX）路由到 delay queue。Delay queue 用 <code>x-message-ttl</code> 設定延遲時間（例如 30 秒、1 分鐘、5 分鐘），TTL 到期後訊息透過另一個 DLX 路由回原始 queue 的 tail。</p>
<p>Indeed 用多個不同 TTL 的 delay queue 實作 exponential backoff — 第一次進 delay 等 30 秒、第二次等 1 分鐘、第三次等 5 分鐘。這個做法利用 RabbitMQ 原生的 DLX + TTL 機制，不需要額外的 scheduler 或 cron job。</p>
<p>這層處理的是持續性錯誤 — downstream 在做 deployment、外部 API 在做 maintenance。延遲重試讓 downstream 有時間恢復，同時失敗訊息完全離開主 queue、不影響正常處理。</p>
<h3 id="第三層dead-letter-queue">第三層：Dead Letter Queue</h3>
<p>Delay queue retry M 次後仍然失敗的訊息進入 <a href="/blog/backend/knowledge-cards/dead-letter-queue/" data-link-title="Dead-Letter Queue" data-link-desc="說明 dead-letter queue 如何隔離多次處理失敗的訊息">DLQ</a>。DLQ 中的訊息不再自動重試，需要人工審視或批次 replay。</p>
<p>DLQ 的價值是把「目前無法處理」的訊息安全保存，不讓它們無限消耗 retry 資源。Indeed 的維運團隊定期檢查 DLQ 中的訊息 — 按 error type 分群、判斷是 bug（需要修 code 再 replay）還是資料問題（需要修正資料再 replay）。</p>
<h2 id="取捨">取捨</h2>
<p><strong>犧牲的是 delivery order</strong>。訊息從 delay queue 回到主 queue tail 時，已經不在原始的位置。對 Indeed 的職缺處理來說，order 不影響正確性 — 職缺更新是 idempotent 的，最終狀態正確即可。對 order-sensitive 的場景，這個模型需要額外的 ordering 機制。</p>
<p><strong>增加的是拓樸複雜度</strong>。三層 escalation 涉及主 queue + 多個 delay queue + DLQ + 多個 DLX 的 binding。RabbitMQ 的 exchange / queue / binding 組合需要明確規劃跟文件化，否則維運時搞不清楚訊息的路由路徑。</p>
<h2 id="回寫教材的連結">回寫教材的連結</h2>
<ul>
<li><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>：DLX + TTL 是 RabbitMQ 原生的 durable 機制</li>
<li><a href="/blog/backend/03-message-queue/processing-recovery-semantics/" data-link-title="3.6 Processing Semantics 與 Recovery Semantics" data-link-desc="說明投遞成功、處理成功與恢復成功為何是三個不同判斷。">3.6 processing recovery semantics</a>：retry 策略跟 consumer 的 ack/nack 行為</li>
<li><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 escalation</a>：DLX 配置的實作細節</li>
<li><a href="/blog/backend/03-message-queue/cases/uber-kafka-infrastructure-evolution/" data-link-title="3.C6 Uber：Kafka 事件平台演進" data-link-desc="事件平台從團隊自管走向多租戶共享基礎設施。">3.C6 Uber Kafka 平台</a>：Kafka 生態的 retry topic 跟 DLQ 設計比較</li>
</ul>
<h2 id="判讀徵兆">判讀徵兆</h2>
<p>以下訊號出現時，應該回讀本案例：</p>
<ul>
<li>Consumer 的 processing lag 在特定時段突然升高、但訊息產生速率沒變</li>
<li>同一條訊息的 retry 佔據 consumer 的大部分處理時間</li>
<li>Requeue 後訊息立刻又被同一個 consumer 取到、進入 retry 迴圈</li>
<li>DLQ 中的訊息堆積、沒有定期審視跟 replay 的機制</li>
<li>Retry 策略只有 client 端 backoff、沒有 queue 拓樸層面的隔離</li>
</ul>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://engineering.indeedblog.com/blog/2017/06/delaying-messages/">Delaying Messages with RabbitMQ at Indeed</a></li>
<li><a href="https://engineering.indeedblog.com/talks/get-job-35-million-times-day-using-rabbitmq/">Get a Job 35 Million Times a Day Using RabbitMQ (talk)</a></li>
</ul>
]]></content:encoded></item><item><title>3.C26 GoCardless：Hutch + 單一 topic exchange service mesh</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/rabbitmq-gocardless-hutch-service-mesh/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/rabbitmq-gocardless-hutch-service-mesh/</guid><description>&lt;p>這個案例的核心責任是說明小規模時單 vhost + 統一 routing key 規範可作為 service mesh 基礎。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>單一 RabbitMQ cluster 作為所有服務之間的通訊中樞、自家 Hutch（Ruby lib）2013 從 production 抽出開源。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>routing key 格式 &lt;code>service.subject.action&lt;/code>（如 &lt;code>paysvc.payment.chargedback&lt;/code>）、單一 topic exchange、JSON 序列化（多語言可讀）。揭露小規模單 cluster 可以用「routing key 命名規範」取代複雜 exchange 拓樸。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>RabbitMQ 進階主題：Exchange types 與 routing 設計 / 多 vhost（單 vhost 服務 mesh 的反向案例）。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &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 vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/rabbitmq-bloomberg-multi-tenant-vhost/" data-link-title="3.C23 Bloomberg：多租戶 vhost &amp;#43; 自助平台化" data-link-desc="Bloomberg 從幾個團隊推到上百個團隊、靠自助 vhost 註冊跟專用叢集分離應用與 broker。">3.C23 Bloomberg&lt;/a>（規模化後的對照）。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://gocardless.com/blog/hutch-inter-service-communication-with-rabbitmq/">Hutch: Inter-Service Communication with RabbitMQ&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明小規模時單 vhost + 統一 routing key 規範可作為 service mesh 基礎。</p>
<h2 id="觀察">觀察</h2>
<p>單一 RabbitMQ cluster 作為所有服務之間的通訊中樞、自家 Hutch（Ruby lib）2013 從 production 抽出開源。</p>
<h2 id="判讀">判讀</h2>
<p>routing key 格式 <code>service.subject.action</code>（如 <code>paysvc.payment.chargedback</code>）、單一 topic exchange、JSON 序列化（多語言可讀）。揭露小規模單 cluster 可以用「routing key 命名規範」取代複雜 exchange 拓樸。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>RabbitMQ 進階主題：Exchange types 與 routing 設計 / 多 vhost（單 vhost 服務 mesh 的反向案例）。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/rabbitmq/" data-link-title="RabbitMQ" data-link-desc="Classic message broker、AMQP routing 為主">RabbitMQ vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/cases/rabbitmq-bloomberg-multi-tenant-vhost/" data-link-title="3.C23 Bloomberg：多租戶 vhost &#43; 自助平台化" data-link-desc="Bloomberg 從幾個團隊推到上百個團隊、靠自助 vhost 註冊跟專用叢集分離應用與 broker。">3.C23 Bloomberg</a>（規模化後的對照）。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://gocardless.com/blog/hutch-inter-service-communication-with-rabbitmq/">Hutch: Inter-Service Communication with RabbitMQ</a></li>
</ul>
]]></content:encoded></item><item><title>3.C27 Zalando：RabbitMQ on AWS 自動化 master selection</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/rabbitmq-zalando-aws-master-selection/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/rabbitmq-zalando-aws-master-selection/</guid><description>&lt;p>這個案例的核心責任是說明雲端 cluster 治理在 K8s 之前的工程模式。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>Communication platform 用 RabbitMQ cluster、跑在 EC2 / Docker container 上、用 supervisord 並行 sidekick + RabbitMQ。AWS 帳號限制每 region 5 個 Elastic IP。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>自建 sidekick 服務查 AWS API 動態識別 cluster、指定最老 instance 當 master、master 死後晉升下一個最老 node。跨版本升級用 federation 上游接到新 cluster 過渡。揭露「cluster master selection」跟「IP 限制」是雲端部署的早期關鍵限制。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>RabbitMQ 進階主題：Erlang clustering + network partition / Federation + Shovel / RabbitMQ Cluster Operator（K8s 之前的雲端 cluster 治理）。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &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 vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/broker-basics/" data-link-title="3.1 broker 基礎與投遞模型" data-link-desc="先理解 broker、queue、consumer 與 delivery semantics">3.1 broker basics&lt;/a>。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://engineering.zalando.com/posts/2018/02/rabbit-in-the-cloud.html">Rabbit in the Cloud (Zalando Engineering)&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明雲端 cluster 治理在 K8s 之前的工程模式。</p>
<h2 id="觀察">觀察</h2>
<p>Communication platform 用 RabbitMQ cluster、跑在 EC2 / Docker container 上、用 supervisord 並行 sidekick + RabbitMQ。AWS 帳號限制每 region 5 個 Elastic IP。</p>
<h2 id="判讀">判讀</h2>
<p>自建 sidekick 服務查 AWS API 動態識別 cluster、指定最老 instance 當 master、master 死後晉升下一個最老 node。跨版本升級用 federation 上游接到新 cluster 過渡。揭露「cluster master selection」跟「IP 限制」是雲端部署的早期關鍵限制。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>RabbitMQ 進階主題：Erlang clustering + network partition / Federation + Shovel / RabbitMQ Cluster Operator（K8s 之前的雲端 cluster 治理）。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/rabbitmq/" data-link-title="RabbitMQ" data-link-desc="Classic message broker、AMQP routing 為主">RabbitMQ vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/broker-basics/" data-link-title="3.1 broker 基礎與投遞模型" data-link-desc="先理解 broker、queue、consumer 與 delivery semantics">3.1 broker basics</a>。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://engineering.zalando.com/posts/2018/02/rabbit-in-the-cloud.html">Rabbit in the Cloud (Zalando Engineering)</a></li>
</ul>
]]></content:encoded></item><item><title>3.C28 WeWork：Consistent hash exchange 保證帳戶順序</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/rabbitmq-wework-consistent-hash-ordering/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/rabbitmq-wework-consistent-hash-ordering/</guid><description>&lt;p>這個案例的核心責任是說明 RabbitMQ 也能做「per-key ordering」、用 consistent hash exchange 模擬 partition。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>訊息順序對某些業務流程關鍵、但全局排序代價高。WeWork 採固定數量 queue + 用 account ID hash 路由到特定 queue。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>每個 queue 一個 SideKiq worker + exclusive consumer 保證單帳戶順序。文後發現 RabbitMQ Consistent Hashing plugin 已內建類似機制（類似 Kafka 分區）。揭露 partition-level ordering 不是 Kafka 專屬、在 broker model 可用 hash exchange 達成。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>RabbitMQ 進階主題：Exchange types / Prefetch + consumer 併發（partition-level ordering 模式）。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &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 vendor 頁&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 vendor 頁&lt;/a>（partition + key 對照）。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://www.cloudamqp.com/blog/weworks-good-enough-order%20guarantee.html">WeWork&amp;rsquo;s &amp;ldquo;Good Enough&amp;rdquo; Order Guarantee&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 RabbitMQ 也能做「per-key ordering」、用 consistent hash exchange 模擬 partition。</p>
<h2 id="觀察">觀察</h2>
<p>訊息順序對某些業務流程關鍵、但全局排序代價高。WeWork 採固定數量 queue + 用 account ID hash 路由到特定 queue。</p>
<h2 id="判讀">判讀</h2>
<p>每個 queue 一個 SideKiq worker + exclusive consumer 保證單帳戶順序。文後發現 RabbitMQ Consistent Hashing plugin 已內建類似機制（類似 Kafka 分區）。揭露 partition-level ordering 不是 Kafka 專屬、在 broker model 可用 hash exchange 達成。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>RabbitMQ 進階主題：Exchange types / Prefetch + consumer 併發（partition-level ordering 模式）。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/rabbitmq/" data-link-title="RabbitMQ" data-link-desc="Classic message broker、AMQP routing 為主">RabbitMQ vendor 頁</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 vendor 頁</a>（partition + key 對照）。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://www.cloudamqp.com/blog/weworks-good-enough-order%20guarantee.html">WeWork&rsquo;s &ldquo;Good Enough&rdquo; Order Guarantee</a></li>
</ul>
]]></content:encoded></item><item><title>3.C29 WeWork：Bunny + Puma 多執行緒 channel pool</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/rabbitmq-wework-bunny-channel-pool/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/rabbitmq-wework-bunny-channel-pool/</guid><description>&lt;p>這個案例的核心責任是說明 AMQP client 的 connection / channel 邊界跟執行緒模型緊密耦合。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>從 Unicorn 切到 Puma 後遇到 &lt;code>ConnectionClosedError&lt;/code>、根因是快取 Bunny channel 在多執行緒間共享。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>AMQP channel 不應跨執行緒共用、改用 &lt;code>connection_pool&lt;/code> gem 管理 channel pool。揭露 AMQP 不是 stateless HTTP-style client、channel 是 statefull 物件、多 thread 模型要特別處理。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>RabbitMQ 進階主題：Prefetch + consumer 併發（client library 層的 connection / channel 邊界）。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &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 vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計&lt;/a>。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://wework.github.io/ruby/rails/bunny/rabbitmq/threads/concurrency/puma/errors/2015/11/12/bunny-threads/">Bunny Threads in Puma at WeWork&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 AMQP client 的 connection / channel 邊界跟執行緒模型緊密耦合。</p>
<h2 id="觀察">觀察</h2>
<p>從 Unicorn 切到 Puma 後遇到 <code>ConnectionClosedError</code>、根因是快取 Bunny channel 在多執行緒間共享。</p>
<h2 id="判讀">判讀</h2>
<p>AMQP channel 不應跨執行緒共用、改用 <code>connection_pool</code> gem 管理 channel pool。揭露 AMQP 不是 stateless HTTP-style client、channel 是 statefull 物件、多 thread 模型要特別處理。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>RabbitMQ 進階主題：Prefetch + consumer 併發（client library 層的 connection / channel 邊界）。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/rabbitmq/" data-link-title="RabbitMQ" data-link-desc="Classic message broker、AMQP routing 為主">RabbitMQ vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計</a>。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://wework.github.io/ruby/rails/bunny/rabbitmq/threads/concurrency/puma/errors/2015/11/12/bunny-threads/">Bunny Threads in Puma at WeWork</a></li>
</ul>
]]></content:encoded></item><item><title>3.C30 Runtastic：Mirrored queue 網路負載瓶頸</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/rabbitmq-runtastic-mirrored-queue-bottleneck/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/rabbitmq-runtastic-mirrored-queue-bottleneck/</guid><description>&lt;p>Runtastic 的案例暴露了 RabbitMQ mirrored queue 的網路成本被嚴重低估。Mirrored queue 的可靠性提升代價是 message 在 cluster 內的網路複製量跟 mirror 數成正比，而這個成本在日常流量下可能不可見、只在壓力測試或突發流量時才暴露。&lt;/p>
&lt;h2 id="業務背景">業務背景&lt;/h2>
&lt;p>Runtastic 是 Adidas 旗下的健身追蹤平台，使用者透過 app 記錄跑步、騎車、重訓等運動資料。2020 年 COVID-19 lockdown 期間，居家運動需求爆增，平台的 concurrent user 數量在數週內翻倍。&lt;/p>
&lt;p>Runtastic 的後端架構是 microservice 架構，RabbitMQ 是服務間訊息傳遞的核心。運動資料記錄、通知推送、社交功能（好友排行、挑戰）、analytics 事件都透過 RabbitMQ 的 queue 串接。&lt;/p>
&lt;h2 id="技術挑戰mirroring-的隱藏網路成本">技術挑戰：Mirroring 的隱藏網路成本&lt;/h2>
&lt;p>Runtastic 的 RabbitMQ cluster 使用 mirrored queue（&lt;code>ha-mode: all&lt;/code>）確保訊息在 broker 故障時不遺失。Mirrored queue 把每條訊息同步複製到 cluster 中所有 node — 3 node cluster 代表每條訊息的網路傳輸量是原始大小的 3 倍。&lt;/p>
&lt;p>日常流量下，mirroring 的額外網路負載在 cluster 的頻寬容量之內，效能影響不明顯。但 lockdown 後流量翻倍時，mirroring 的網路負載跟著翻倍 — 更準確地說是翻 2×N 倍（流量 2 倍 × mirror 數 N）。&lt;/p>
&lt;p>Runtastic 的 cluster 使用了共享的網路元件（network switch / load balancer），mirroring 的流量把共享網路元件的頻寬壓到極限。表現是 broker 間的 mirroring 延遲上升 → publisher confirm 延遲上升 → producer 端的 publish latency 從毫秒跳到秒級 → 上游服務開始 timeout。&lt;/p>
&lt;p>問題的隱蔽性在於：日常監控只看 broker 的 CPU、memory、disk，沒有把 inter-node network throughput 作為關鍵指標。網路瓶頸在 broker-level metric 上的表現是「publish confirm 變慢」，容易被誤判為 broker 過載而非網路飽和。&lt;/p>
&lt;h2 id="解法">解法&lt;/h2>
&lt;h3 id="performance-test-定位瓶頸">Performance test 定位瓶頸&lt;/h3>
&lt;p>Runtastic 在事件發生後用 performance test 重現問題。測試揭露了 mirroring 流量跟 broker 間網路頻寬的關係 — 把 message rate 從日常的 X 推到 2X 時，inter-node traffic 超過 switch 容量，publish confirm latency 開始非線性增長。&lt;/p>
&lt;p>Performance test 的關鍵是把 inter-node network throughput 加入監控維度。RabbitMQ 3.8 的 Prometheus integration 提供了 &lt;code>rabbitmq_raft_term_total&lt;/code>、&lt;code>rabbitmq_channel_messages_published_total&lt;/code> 等指標，但 inter-node bandwidth 需要從 OS 層（&lt;code>node_exporter&lt;/code> 的 network bytes）或 switch 層取得。&lt;/p>
&lt;h3 id="調整-mirroring-配置">調整 mirroring 配置&lt;/h3>
&lt;p>Runtastic 從 &lt;code>ha-mode: all&lt;/code>（所有 node 都 mirror）調整為 &lt;code>ha-mode: exactly, ha-params: 2&lt;/code>（只 mirror 到 2 個 node）。這把每條訊息的網路複製量從 N 倍降到 2 倍，在可靠性（2 個 copy 可以容忍 1 node failure）跟網路成本之間取得平衡。&lt;/p></description><content:encoded><![CDATA[<p>Runtastic 的案例暴露了 RabbitMQ mirrored queue 的網路成本被嚴重低估。Mirrored queue 的可靠性提升代價是 message 在 cluster 內的網路複製量跟 mirror 數成正比，而這個成本在日常流量下可能不可見、只在壓力測試或突發流量時才暴露。</p>
<h2 id="業務背景">業務背景</h2>
<p>Runtastic 是 Adidas 旗下的健身追蹤平台，使用者透過 app 記錄跑步、騎車、重訓等運動資料。2020 年 COVID-19 lockdown 期間，居家運動需求爆增，平台的 concurrent user 數量在數週內翻倍。</p>
<p>Runtastic 的後端架構是 microservice 架構，RabbitMQ 是服務間訊息傳遞的核心。運動資料記錄、通知推送、社交功能（好友排行、挑戰）、analytics 事件都透過 RabbitMQ 的 queue 串接。</p>
<h2 id="技術挑戰mirroring-的隱藏網路成本">技術挑戰：Mirroring 的隱藏網路成本</h2>
<p>Runtastic 的 RabbitMQ cluster 使用 mirrored queue（<code>ha-mode: all</code>）確保訊息在 broker 故障時不遺失。Mirrored queue 把每條訊息同步複製到 cluster 中所有 node — 3 node cluster 代表每條訊息的網路傳輸量是原始大小的 3 倍。</p>
<p>日常流量下，mirroring 的額外網路負載在 cluster 的頻寬容量之內，效能影響不明顯。但 lockdown 後流量翻倍時，mirroring 的網路負載跟著翻倍 — 更準確地說是翻 2×N 倍（流量 2 倍 × mirror 數 N）。</p>
<p>Runtastic 的 cluster 使用了共享的網路元件（network switch / load balancer），mirroring 的流量把共享網路元件的頻寬壓到極限。表現是 broker 間的 mirroring 延遲上升 → publisher confirm 延遲上升 → producer 端的 publish latency 從毫秒跳到秒級 → 上游服務開始 timeout。</p>
<p>問題的隱蔽性在於：日常監控只看 broker 的 CPU、memory、disk，沒有把 inter-node network throughput 作為關鍵指標。網路瓶頸在 broker-level metric 上的表現是「publish confirm 變慢」，容易被誤判為 broker 過載而非網路飽和。</p>
<h2 id="解法">解法</h2>
<h3 id="performance-test-定位瓶頸">Performance test 定位瓶頸</h3>
<p>Runtastic 在事件發生後用 performance test 重現問題。測試揭露了 mirroring 流量跟 broker 間網路頻寬的關係 — 把 message rate 從日常的 X 推到 2X 時，inter-node traffic 超過 switch 容量，publish confirm latency 開始非線性增長。</p>
<p>Performance test 的關鍵是把 inter-node network throughput 加入監控維度。RabbitMQ 3.8 的 Prometheus integration 提供了 <code>rabbitmq_raft_term_total</code>、<code>rabbitmq_channel_messages_published_total</code> 等指標，但 inter-node bandwidth 需要從 OS 層（<code>node_exporter</code> 的 network bytes）或 switch 層取得。</p>
<h3 id="調整-mirroring-配置">調整 mirroring 配置</h3>
<p>Runtastic 從 <code>ha-mode: all</code>（所有 node 都 mirror）調整為 <code>ha-mode: exactly, ha-params: 2</code>（只 mirror 到 2 個 node）。這把每條訊息的網路複製量從 N 倍降到 2 倍，在可靠性（2 個 copy 可以容忍 1 node failure）跟網路成本之間取得平衡。</p>
<p>對可靠性要求最高的 queue（交易相關），維持 <code>ha-mode: all</code> 但把這些 queue 移到頻寬更高的專屬 network segment。</p>
<h3 id="遷移到-quorum-queue-的動機">遷移到 Quorum queue 的動機</h3>
<p>Mirrored queue 的另一個問題是同步機制 — 新 mirror 加入時需要全量同步（sync），sync 期間 queue 可能暫停接受新訊息。RabbitMQ 3.8 引入的 Quorum queue 用 Raft consensus 取代 mirrored queue 的 GM（Guaranteed Multicast），在網路效率跟故障恢復上都有改進。</p>
<p>Runtastic 的案例是「為什麼應該評估從 mirrored queue 遷到 quorum queue」的典型動機 — mirrored queue 的網路成本跟同步行為在規模化時成為瓶頸。</p>
<h2 id="取捨">取捨</h2>
<table>
  <thead>
      <tr>
          <th>面向</th>
          <th>ha-mode: all</th>
          <th>ha-mode: exactly 2</th>
          <th>Quorum queue</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>網路成本</td>
          <td>每條訊息 × N node</td>
          <td>每條訊息 × 2 node</td>
          <td>每條訊息 × majority</td>
      </tr>
      <tr>
          <td>可容忍的故障</td>
          <td>N-1 node failure</td>
          <td>1 node failure</td>
          <td>minority node failure</td>
      </tr>
      <tr>
          <td>新 node 加入</td>
          <td>全量同步（可能暫停 queue）</td>
          <td>全量同步（影響面小）</td>
          <td>Raft log replay（漸進）</td>
      </tr>
      <tr>
          <td>適合場景</td>
          <td>小 cluster、低流量</td>
          <td>中 cluster、中流量</td>
          <td>中大 cluster、推薦路徑</td>
      </tr>
  </tbody>
</table>
<h2 id="回寫教材的連結">回寫教材的連結</h2>
<ul>
<li><a href="/blog/backend/03-message-queue/broker-basics/" data-link-title="3.1 broker 基礎與投遞模型" data-link-desc="先理解 broker、queue、consumer 與 delivery semantics">3.1 broker basics</a>：broker 的 replication 跟 network 成本的關係</li>
<li><a href="/blog/backend/03-message-queue/vendors/rabbitmq/" data-link-title="RabbitMQ" data-link-desc="Classic message broker、AMQP routing 為主">RabbitMQ vendor 頁</a>：mirrored queue vs quorum queue 的詳細比較</li>
<li><a href="/blog/backend/03-message-queue/vendors/rabbitmq/queue-types-classic-quorum-stream/" data-link-title="RabbitMQ Queue Type 選型：Classic、Quorum、Stream 的責任邊界與容量取捨" data-link-desc="RabbitMQ 3.x 三種 queue type 的選型 deep article — classic queue（mirrored 已 deprecated）、quorum queue（Raft 一致性、取代 mirrored）、stream（3.9&#43; append-only log、可重複消費）。涵蓋三種模型在 throughput / retention / replay / 記憶體成本的判讀、宣告語意差異（實機驗證）、4 個 production 故障演練（mirrored 網路放大 / quorum loss / stream retention 超量 / classic→quorum in-flight message），與容量規劃。">RabbitMQ queue types</a>：Classic / Mirrored / Quorum / Stream 四種 queue type 的取捨</li>
<li><a href="/blog/backend/04-observability/telemetry-pipeline/" data-link-title="4.11 Telemetry Pipeline 架構" data-link-desc="把 log / metric / trace 的 agent → collector → ingest → storage → query 分層治理">4.11 telemetry pipeline</a>：broker 的 inter-node 網路作為 pipeline 健康指標</li>
</ul>
<h2 id="判讀徵兆">判讀徵兆</h2>
<p>以下訊號出現時，應該回讀本案例：</p>
<ul>
<li>RabbitMQ cluster 使用 <code>ha-mode: all</code> 且 node 數量 &gt; 3</li>
<li>Publish confirm latency 在流量上升時非線性增長</li>
<li>Broker 的 CPU / memory / disk 指標正常但 publish 變慢</li>
<li>Broker 間的 network traffic 佔比超過 cluster 總頻寬的 50%</li>
<li>新 mirror 加入時 queue 出現暫停或大量延遲</li>
</ul>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://seventhstate.io/portfolio/portfolio-runtastic/">Runtastic RabbitMQ Performance Case Study</a></li>
</ul>
]]></content:encoded></item><item><title>3.C31 Mozilla Pulse：命名前綴 + ACL 取代 vhost 多租戶</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/rabbitmq-mozilla-pulse-naming-isolation/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/rabbitmq-mozilla-pulse-naming-isolation/</guid><description>&lt;p>這個案例的核心責任是說明多租戶隔離可用「ACL + naming convention」取代 vhost、適合社群協作場景。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>Pulse 是 Mozilla 自動化 / 基礎設施工具間的 managed RabbitMQ cluster、用 AMQP 0-9-1 + RabbitMQ 擴充、由 CloudAMQP 託管於 pulse.mozilla.org:5671（AMQP over TLS）。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>技術上不需 vhost、改用權限限制 + 命名前綴（&lt;code>exchange/&amp;lt;username&amp;gt;/*&lt;/code>、&lt;code>queue/&amp;lt;username&amp;gt;/*&lt;/code>）做隔離。PulseGuardian 跑在 Heroku 管理使用者 / queue / exchange。揭露多租戶隔離不一定要 vhost、權限粒度可以拉到 resource naming 層。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>RabbitMQ 進階主題：多 vhost + 多租戶（反向案例：用 ACL + naming 取代 vhost）。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &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 vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/rabbitmq-bloomberg-multi-tenant-vhost/" data-link-title="3.C23 Bloomberg：多租戶 vhost &amp;#43; 自助平台化" data-link-desc="Bloomberg 從幾個團隊推到上百個團隊、靠自助 vhost 註冊跟專用叢集分離應用與 broker。">3.C23 Bloomberg vhost 多租戶&lt;/a>（對照）。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://wiki.mozilla.org/Auto-tools/Projects/Pulse">Mozilla Pulse Wiki&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://pulse.mozilla.org/api/">Pulse API&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明多租戶隔離可用「ACL + naming convention」取代 vhost、適合社群協作場景。</p>
<h2 id="觀察">觀察</h2>
<p>Pulse 是 Mozilla 自動化 / 基礎設施工具間的 managed RabbitMQ cluster、用 AMQP 0-9-1 + RabbitMQ 擴充、由 CloudAMQP 託管於 pulse.mozilla.org:5671（AMQP over TLS）。</p>
<h2 id="判讀">判讀</h2>
<p>技術上不需 vhost、改用權限限制 + 命名前綴（<code>exchange/&lt;username&gt;/*</code>、<code>queue/&lt;username&gt;/*</code>）做隔離。PulseGuardian 跑在 Heroku 管理使用者 / queue / exchange。揭露多租戶隔離不一定要 vhost、權限粒度可以拉到 resource naming 層。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>RabbitMQ 進階主題：多 vhost + 多租戶（反向案例：用 ACL + naming 取代 vhost）。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/rabbitmq/" data-link-title="RabbitMQ" data-link-desc="Classic message broker、AMQP routing 為主">RabbitMQ vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/cases/rabbitmq-bloomberg-multi-tenant-vhost/" data-link-title="3.C23 Bloomberg：多租戶 vhost &#43; 自助平台化" data-link-desc="Bloomberg 從幾個團隊推到上百個團隊、靠自助 vhost 註冊跟專用叢集分離應用與 broker。">3.C23 Bloomberg vhost 多租戶</a>（對照）。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://wiki.mozilla.org/Auto-tools/Projects/Pulse">Mozilla Pulse Wiki</a></li>
<li><a href="https://pulse.mozilla.org/api/">Pulse API</a></li>
</ul>
]]></content:encoded></item><item><title>3.C32 LoyaltyLion：監控數千 RabbitMQ queue</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/rabbitmq-loyaltylion-monitoring-thousands/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/rabbitmq-loyaltylion-monitoring-thousands/</guid><description>&lt;p>這個案例的核心責任是說明大規模 queue topology 的監控議題超出 Management plugin 能力範圍。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>LoyaltyLion 跑數千個 RabbitMQ queue、用 rabbitmqctl 跑 recurring script 抓 queue 資訊、透過 statsd 送到 Datadog。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>大規模 queue 拓撲下管理 plugin API 不夠用、需自寫採集腳本。揭露 queue 數量上萬時、原生 monitoring 介面（HTTP API、Management UI）會變成瓶頸、需要 metrics agent 模式。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>RabbitMQ 進階主題：Prefetch + consumer 併發（大規模 queue topology 的監控議題）/ RabbitMQ Cluster Operator（運維邊界）。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &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 vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/04-observability/" data-link-title="模組四：可觀測性平台" data-link-desc="整理 log、metric、trace、dashboard 與 alert 的後端操作實務">4 觀測模組&lt;/a>。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://engineering.loyaltylion.com/monitoring-thousands-of-rabbitmq-queues-with-datadog-d3168c088ea6">Monitoring Thousands of RabbitMQ Queues with Datadog&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明大規模 queue topology 的監控議題超出 Management plugin 能力範圍。</p>
<h2 id="觀察">觀察</h2>
<p>LoyaltyLion 跑數千個 RabbitMQ queue、用 rabbitmqctl 跑 recurring script 抓 queue 資訊、透過 statsd 送到 Datadog。</p>
<h2 id="判讀">判讀</h2>
<p>大規模 queue 拓撲下管理 plugin API 不夠用、需自寫採集腳本。揭露 queue 數量上萬時、原生 monitoring 介面（HTTP API、Management UI）會變成瓶頸、需要 metrics agent 模式。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>RabbitMQ 進階主題：Prefetch + consumer 併發（大規模 queue topology 的監控議題）/ RabbitMQ Cluster Operator（運維邊界）。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/rabbitmq/" data-link-title="RabbitMQ" data-link-desc="Classic message broker、AMQP routing 為主">RabbitMQ vendor 頁</a> 與 <a href="/blog/backend/04-observability/" data-link-title="模組四：可觀測性平台" data-link-desc="整理 log、metric、trace、dashboard 與 alert 的後端操作實務">4 觀測模組</a>。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://engineering.loyaltylion.com/monitoring-thousands-of-rabbitmq-queues-with-datadog-d3168c088ea6">Monitoring Thousands of RabbitMQ Queues with Datadog</a></li>
</ul>
]]></content:encoded></item><item><title>3.C33 Wargaming：World of Tanks 戰後 dossier 解耦</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/rabbitmq-wargaming-game-portal-decoupling/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/rabbitmq-wargaming-game-portal-decoupling/</guid><description>&lt;p>這個案例的核心責任是說明 game server / web portal 異步解耦、queue 吸收戰後事件 burst。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>World of Tanks server 全 Linux、用 RabbitMQ 作為 web service stack 核心。每場戰鬥結束後玩家 tank dossier 寫入 message queue、讓 game portal 顯示最新統計而不增加 game server load。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>Queue 是 game server 與 portal 的解耦邊界、subscription 也走 RabbitMQ。揭露遊戲場景的「戰後事件 burst」適合用 queue 吸收、不該打到 game server 內部狀態。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>RabbitMQ 進階主題：Federation + Shovel（多 region game server 同步）/ 多 vhost + 多租戶（多遊戲共用 broker）。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &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 vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計&lt;/a>。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://www.linuxfoundation.org/blog/blog/wargaming-mobilizes-with-linux-and-open-source">Wargaming Mobilizes with Linux and Open Source (Linux Foundation)&lt;/a>&lt;/li>
&lt;li>&lt;a href="http://ftr.wot-news.com/2014/07/17/wargaming-public-api-part-2/">Wargaming Public API&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 game server / web portal 異步解耦、queue 吸收戰後事件 burst。</p>
<h2 id="觀察">觀察</h2>
<p>World of Tanks server 全 Linux、用 RabbitMQ 作為 web service stack 核心。每場戰鬥結束後玩家 tank dossier 寫入 message queue、讓 game portal 顯示最新統計而不增加 game server load。</p>
<h2 id="判讀">判讀</h2>
<p>Queue 是 game server 與 portal 的解耦邊界、subscription 也走 RabbitMQ。揭露遊戲場景的「戰後事件 burst」適合用 queue 吸收、不該打到 game server 內部狀態。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>RabbitMQ 進階主題：Federation + Shovel（多 region game server 同步）/ 多 vhost + 多租戶（多遊戲共用 broker）。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/rabbitmq/" data-link-title="RabbitMQ" data-link-desc="Classic message broker、AMQP routing 為主">RabbitMQ vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計</a>。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://www.linuxfoundation.org/blog/blog/wargaming-mobilizes-with-linux-and-open-source">Wargaming Mobilizes with Linux and Open Source (Linux Foundation)</a></li>
<li><a href="http://ftr.wot-news.com/2014/07/17/wargaming-public-api-part-2/">Wargaming Public API</a></li>
</ul>
]]></content:encoded></item><item><title>3.C34 Netlify：NATS 當全球 metrics/logs 統一資料平面</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/nats-netlify-data-plane-fanout/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/nats-netlify-data-plane-fanout/</guid><description>&lt;p>Netlify 的 NATS 選型示範了 subject-based fan-out 在跨雲觀測資料平面的優勢 — 協議極簡帶來的是部署簡單跟 client 整合成本低，代價是放棄持久化保證。&lt;/p>
&lt;h2 id="業務背景">業務背景&lt;/h2>
&lt;p>Netlify 是靜態網站跟 serverless function 的部署平台，服務 70,000+ 網站、近月 10 億 page view。基礎設施橫跨 Rackspace、AWS、GCP、Digital Ocean 四個雲端供應商。每個服務節點都會產生 metrics 跟 logs，需要一條統一的資料路徑把這些訊號從各地收集到中央觀測系統。&lt;/p>
&lt;h2 id="技術挑戰">技術挑戰&lt;/h2>
&lt;h3 id="跨雲統一資料平面">跨雲統一資料平面&lt;/h3>
&lt;p>四個雲的服務各自有不同的網路拓樸跟存取方式。觀測資料需要跨雲收集到同一個目的地（Elasticsearch），但直接讓每個服務 HTTP POST 到 Elasticsearch 會有連線管理、背壓、格式轉換的問題分散在每個服務裡。&lt;/p>
&lt;p>Netlify 需要一個中介層 — 各服務把 metrics / logs 推到中介層，中介層負責 fan-out 到下游消費者（Elasticsearch、即時 dashboard、告警系統）。&lt;/p>
&lt;h3 id="選型nats-vs-rabbitmq">選型：NATS vs RabbitMQ&lt;/h3>
&lt;p>Netlify 評估了 RabbitMQ 跟 NATS。RabbitMQ 在功能上更完整（持久化 queue、DLQ、ack 機制），但 Netlify 的觀測資料場景有三個特性讓 NATS 更合適：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>資料可丟&lt;/strong>：metrics 跟 logs 是 best-effort 的觀測資料，遺失幾秒的資料不影響業務 — 持久化保證帶來的運維成本大於收益&lt;/li>
&lt;li>&lt;strong>Fan-out 是主要模式&lt;/strong>：同一份資料要被多個消費者訂閱（Elasticsearch、即時 tail、告警），NATS 的 subject-based pub/sub 天然支援，RabbitMQ 需要設 exchange + 多個 binding&lt;/li>
&lt;li>&lt;strong>部署極簡&lt;/strong>：NATS server 是單一 binary、零依賴、幾秒鐘啟動，跨四個雲部署時每個雲跑一個 NATS node 的運維成本遠低於 RabbitMQ cluster&lt;/li>
&lt;/ul>
&lt;h2 id="解法與取捨">解法與取捨&lt;/h2>
&lt;h3 id="架構">架構&lt;/h3>
&lt;p>Netlify 用 Core NATS（非 JetStream）搭建觀測資料平面：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Producer 端&lt;/strong>：用 logrus 的 NATS hook 讓所有 Go 服務的 structured log 自動推到 NATS subject；另用 log-tail 工具從 file-based log 讀取推送&lt;/li>
&lt;li>&lt;strong>Consumer 端&lt;/strong>：一個 elastinats 消費者訂閱 NATS subject、批次寫入 Elasticsearch；其他消費者可以各自訂閱同一個 subject 做即時處理&lt;/li>
&lt;/ul>
&lt;p>Subject 的命名用階層式結構（例如 &lt;code>logs.production.api&lt;/code>），讓消費者可以用 wildcard 訂閱整個子樹（&lt;code>logs.production.*&lt;/code>）或特定服務。&lt;/p>
&lt;h3 id="取捨">取捨&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>面向&lt;/th>
 &lt;th>選擇&lt;/th>
 &lt;th>代價&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>持久化&lt;/td>
 &lt;td>放棄（Core NATS）&lt;/td>
 &lt;td>NATS server 重啟時 in-flight 的訊息遺失&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Ack 機制&lt;/td>
 &lt;td>放棄（fire-and-forget）&lt;/td>
 &lt;td>Consumer 處理失敗的訊息不會被重送&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>跨雲連接&lt;/td>
 &lt;td>NATS cluster&lt;/td>
 &lt;td>需要跨雲的網路連線、延遲影響 cluster 一致性&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Consumer 擴展&lt;/td>
 &lt;td>多個訂閱者各自訂閱&lt;/td>
 &lt;td>每個消費者收到全量資料、沒有 consumer group 的分攤機制&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Core NATS 的 fire-and-forget 語意在觀測資料場景是有意的選擇 — 觀測資料的價值隨時間快速衰減，遺失一秒鐘的 metrics 不影響趨勢判讀。如果場景需要持久化（例：audit log、交易事件），Core NATS 就不適合，需要 JetStream 或其他有持久化保證的 broker。&lt;/p>
&lt;h2 id="回寫教材的連結">回寫教材的連結&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/broker-basics/" data-link-title="3.1 broker 基礎與投遞模型" data-link-desc="先理解 broker、queue、consumer 與 delivery semantics">3.1 broker basics&lt;/a>：Core NATS 的 fire-and-forget 是 broker 可靠性光譜的一端（at-most-once），Kafka 跟 RabbitMQ 在另一端（at-least-once / durable）&lt;/li>
&lt;li>&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 vendor 頁&lt;/a>：Core NATS vs JetStream 的選型判準 — 本案例是純 Core NATS 的代表場景&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/04-observability/telemetry-pipeline/" data-link-title="4.11 Telemetry Pipeline 架構" data-link-desc="把 log / metric / trace 的 agent → collector → ingest → storage → query 分層治理">4.11 telemetry pipeline&lt;/a>：Netlify 的 NATS 資料平面在觀測 pipeline 架構中扮演 collector 跟 storage 之間的 transport 層&lt;/li>
&lt;/ul>
&lt;h2 id="判讀徵兆">判讀徵兆&lt;/h2>
&lt;p>讀者在自己的系統看到以下訊號時，應該回讀本案例：&lt;/p></description><content:encoded><![CDATA[<p>Netlify 的 NATS 選型示範了 subject-based fan-out 在跨雲觀測資料平面的優勢 — 協議極簡帶來的是部署簡單跟 client 整合成本低，代價是放棄持久化保證。</p>
<h2 id="業務背景">業務背景</h2>
<p>Netlify 是靜態網站跟 serverless function 的部署平台，服務 70,000+ 網站、近月 10 億 page view。基礎設施橫跨 Rackspace、AWS、GCP、Digital Ocean 四個雲端供應商。每個服務節點都會產生 metrics 跟 logs，需要一條統一的資料路徑把這些訊號從各地收集到中央觀測系統。</p>
<h2 id="技術挑戰">技術挑戰</h2>
<h3 id="跨雲統一資料平面">跨雲統一資料平面</h3>
<p>四個雲的服務各自有不同的網路拓樸跟存取方式。觀測資料需要跨雲收集到同一個目的地（Elasticsearch），但直接讓每個服務 HTTP POST 到 Elasticsearch 會有連線管理、背壓、格式轉換的問題分散在每個服務裡。</p>
<p>Netlify 需要一個中介層 — 各服務把 metrics / logs 推到中介層，中介層負責 fan-out 到下游消費者（Elasticsearch、即時 dashboard、告警系統）。</p>
<h3 id="選型nats-vs-rabbitmq">選型：NATS vs RabbitMQ</h3>
<p>Netlify 評估了 RabbitMQ 跟 NATS。RabbitMQ 在功能上更完整（持久化 queue、DLQ、ack 機制），但 Netlify 的觀測資料場景有三個特性讓 NATS 更合適：</p>
<ul>
<li><strong>資料可丟</strong>：metrics 跟 logs 是 best-effort 的觀測資料，遺失幾秒的資料不影響業務 — 持久化保證帶來的運維成本大於收益</li>
<li><strong>Fan-out 是主要模式</strong>：同一份資料要被多個消費者訂閱（Elasticsearch、即時 tail、告警），NATS 的 subject-based pub/sub 天然支援，RabbitMQ 需要設 exchange + 多個 binding</li>
<li><strong>部署極簡</strong>：NATS server 是單一 binary、零依賴、幾秒鐘啟動，跨四個雲部署時每個雲跑一個 NATS node 的運維成本遠低於 RabbitMQ cluster</li>
</ul>
<h2 id="解法與取捨">解法與取捨</h2>
<h3 id="架構">架構</h3>
<p>Netlify 用 Core NATS（非 JetStream）搭建觀測資料平面：</p>
<ul>
<li><strong>Producer 端</strong>：用 logrus 的 NATS hook 讓所有 Go 服務的 structured log 自動推到 NATS subject；另用 log-tail 工具從 file-based log 讀取推送</li>
<li><strong>Consumer 端</strong>：一個 elastinats 消費者訂閱 NATS subject、批次寫入 Elasticsearch；其他消費者可以各自訂閱同一個 subject 做即時處理</li>
</ul>
<p>Subject 的命名用階層式結構（例如 <code>logs.production.api</code>），讓消費者可以用 wildcard 訂閱整個子樹（<code>logs.production.*</code>）或特定服務。</p>
<h3 id="取捨">取捨</h3>
<table>
  <thead>
      <tr>
          <th>面向</th>
          <th>選擇</th>
          <th>代價</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>持久化</td>
          <td>放棄（Core NATS）</td>
          <td>NATS server 重啟時 in-flight 的訊息遺失</td>
      </tr>
      <tr>
          <td>Ack 機制</td>
          <td>放棄（fire-and-forget）</td>
          <td>Consumer 處理失敗的訊息不會被重送</td>
      </tr>
      <tr>
          <td>跨雲連接</td>
          <td>NATS cluster</td>
          <td>需要跨雲的網路連線、延遲影響 cluster 一致性</td>
      </tr>
      <tr>
          <td>Consumer 擴展</td>
          <td>多個訂閱者各自訂閱</td>
          <td>每個消費者收到全量資料、沒有 consumer group 的分攤機制</td>
      </tr>
  </tbody>
</table>
<p>Core NATS 的 fire-and-forget 語意在觀測資料場景是有意的選擇 — 觀測資料的價值隨時間快速衰減，遺失一秒鐘的 metrics 不影響趨勢判讀。如果場景需要持久化（例：audit log、交易事件），Core NATS 就不適合，需要 JetStream 或其他有持久化保證的 broker。</p>
<h2 id="回寫教材的連結">回寫教材的連結</h2>
<ul>
<li><a href="/blog/backend/03-message-queue/broker-basics/" data-link-title="3.1 broker 基礎與投遞模型" data-link-desc="先理解 broker、queue、consumer 與 delivery semantics">3.1 broker basics</a>：Core NATS 的 fire-and-forget 是 broker 可靠性光譜的一端（at-most-once），Kafka 跟 RabbitMQ 在另一端（at-least-once / durable）</li>
<li><a href="/blog/backend/03-message-queue/vendors/nats/" data-link-title="NATS" data-link-desc="Lightweight messaging、JetStream 加持久化與 streams">NATS vendor 頁</a>：Core NATS vs JetStream 的選型判準 — 本案例是純 Core NATS 的代表場景</li>
<li><a href="/blog/backend/04-observability/telemetry-pipeline/" data-link-title="4.11 Telemetry Pipeline 架構" data-link-desc="把 log / metric / trace 的 agent → collector → ingest → storage → query 分層治理">4.11 telemetry pipeline</a>：Netlify 的 NATS 資料平面在觀測 pipeline 架構中扮演 collector 跟 storage 之間的 transport 層</li>
</ul>
<h2 id="判讀徵兆">判讀徵兆</h2>
<p>讀者在自己的系統看到以下訊號時，應該回讀本案例：</p>
<ul>
<li>觀測資料（metrics / logs）需要跨多個雲或多個 datacenter 收集到中央系統</li>
<li>現有的 broker（RabbitMQ / Kafka）在觀測資料場景的運維成本跟資料價值不成比例</li>
<li>Fan-out 是主要消費模式 — 同一份資料需要被多個下游系統訂閱</li>
<li>對 message delivery 的可靠性要求是 best-effort 而非 at-least-once</li>
</ul>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://nats.io/blog/netlify-nats-blog/">Why Netlify chose NATS</a></li>
</ul>
]]></content:encoded></item><item><title>3.C35 Form3：NATS JetStream 多雲低延遲支付</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/nats-form3-multi-cloud-payments/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/nats-form3-multi-cloud-payments/</guid><description>&lt;p>這個案例的核心責任是說明 JetStream Leaf Node 在跨地理 / 跨雲 durability 拓樸的關鍵角色。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>Form3 服務 Tier-1 銀行（含 Mastercard、Square 等）、要求 500ms 端到端 SLA、AWS SNS/SQS 約 300ms 延遲吃掉預算。在 Faster Payments 機房資源受限下、用 NATS + JetStream 替換 legacy pub/sub bus、達到約 6× 延遲改善並做到「AWS 整個 region 掛掉時不喪失處理能力」。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>用 JetStream 的 Leaf Node 做跨雲橋接、把 on-prem Faster Payments 機房跟雲端 cluster 連起來。揭露金融支付對端到端 latency 預算的硬要求逼出特定 broker 選型、不是「Kafka / SQS 通用化」。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>NATS 進階主題：Cluster + Supercluster + Leaf node / JetStream stream 設計。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&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 vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/meta-foqs-global-migration/" data-link-title="3.C1 Meta：FOQS 從區域到全域佇列遷移" data-link-desc="佇列架構如何在不中斷下升級成 disaster-ready 模式。">3.C1 Meta FOQS&lt;/a>（跨區對照）。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://www.synadia.com/blog/how-form3-built-a-multi-cloud-low-latency-payments-service-with-nats-io-jetstream">How Form3 Built a Multi-Cloud Low-Latency Payments Service with NATS JetStream (Synadia blog)&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 JetStream Leaf Node 在跨地理 / 跨雲 durability 拓樸的關鍵角色。</p>
<h2 id="觀察">觀察</h2>
<p>Form3 服務 Tier-1 銀行（含 Mastercard、Square 等）、要求 500ms 端到端 SLA、AWS SNS/SQS 約 300ms 延遲吃掉預算。在 Faster Payments 機房資源受限下、用 NATS + JetStream 替換 legacy pub/sub bus、達到約 6× 延遲改善並做到「AWS 整個 region 掛掉時不喪失處理能力」。</p>
<h2 id="判讀">判讀</h2>
<p>用 JetStream 的 Leaf Node 做跨雲橋接、把 on-prem Faster Payments 機房跟雲端 cluster 連起來。揭露金融支付對端到端 latency 預算的硬要求逼出特定 broker 選型、不是「Kafka / SQS 通用化」。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>NATS 進階主題：Cluster + Supercluster + Leaf node / JetStream stream 設計。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/nats/" data-link-title="NATS" data-link-desc="Lightweight messaging、JetStream 加持久化與 streams">NATS vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/cases/meta-foqs-global-migration/" data-link-title="3.C1 Meta：FOQS 從區域到全域佇列遷移" data-link-desc="佇列架構如何在不中斷下升級成 disaster-ready 模式。">3.C1 Meta FOQS</a>（跨區對照）。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://www.synadia.com/blog/how-form3-built-a-multi-cloud-low-latency-payments-service-with-nats-io-jetstream">How Form3 Built a Multi-Cloud Low-Latency Payments Service with NATS JetStream (Synadia blog)</a></li>
</ul>
]]></content:encoded></item><item><title>3.C36 Intelecy：工業 IoT 即時感測 + 多租戶</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/nats-intelecy-industrial-iot/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/nats-intelecy-industrial-iot/</guid><description>&lt;p>這個案例的核心責任是說明 edge gateway 從本地 KV 演進到 JetStream 的決策訊號。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>Intelecy 在工廠端 gateway 接「數萬個 sensor」、要求 &amp;lt; 2 秒往返延遲做即時 ML 推論、需要多租戶安全隔離與雲端無鎖定方案。Gateway 把 process data 寫進 Synadia Cloud topic。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>從 BoltDB 本地快取 → JetStream 持久化的演進、揭露「無 durable layer 時 edge gateway 自己要做存儲、加 JetStream 後可放掉本地 BoltDB」的決策訊號。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>NATS 進階主題：JetStream stream 設計 / Subject-based ACL + 多租戶（sensor 隔離）。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&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 vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/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 &amp;#43; JetStream &amp;#43; KV &amp;#43; Object Store。">3.C37 MachineMetrics&lt;/a>（同類對照）。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://www.synadia.com/blog/how-intelecy-optimizes-factory-processes-with-nats-ngs-and-jetstream">How Intelecy Optimizes Factory Processes with NATS, NGS and JetStream&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 edge gateway 從本地 KV 演進到 JetStream 的決策訊號。</p>
<h2 id="觀察">觀察</h2>
<p>Intelecy 在工廠端 gateway 接「數萬個 sensor」、要求 &lt; 2 秒往返延遲做即時 ML 推論、需要多租戶安全隔離與雲端無鎖定方案。Gateway 把 process data 寫進 Synadia Cloud topic。</p>
<h2 id="判讀">判讀</h2>
<p>從 BoltDB 本地快取 → JetStream 持久化的演進、揭露「無 durable layer 時 edge gateway 自己要做存儲、加 JetStream 後可放掉本地 BoltDB」的決策訊號。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>NATS 進階主題：JetStream stream 設計 / Subject-based ACL + 多租戶（sensor 隔離）。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/nats/" data-link-title="NATS" data-link-desc="Lightweight messaging、JetStream 加持久化與 streams">NATS vendor 頁</a> 與 <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>（同類對照）。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://www.synadia.com/blog/how-intelecy-optimizes-factory-processes-with-nats-ngs-and-jetstream">How Intelecy Optimizes Factory Processes with NATS, NGS and JetStream</a></li>
</ul>
]]></content:encoded></item><item><title>3.C37 MachineMetrics：邊緣到雲端工廠資料管線</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/nats-machinemetrics-edge-to-cloud/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/nats-machinemetrics-edge-to-cloud/</guid><description>&lt;p>這個案例的核心責任是說明工業 IoT 完整的 edge-to-cloud NATS 整合（Leaf Node + JetStream + KV + Object Store + Auth）。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>跨「數百個客戶廠區、數千台機台」的 Industrial IoT、單機產出最高 1000 Hz 採樣、工廠網路斷斷續續、Kinesis 等 cloud-only 工具無法跑在資源受限 edge 上。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>用 Leaf Node 做 hub-and-spoke 把邊緣設備串到雲端、Edge 端用 JetStream 做本地持久化（取代 SQLite）抵抗網路斷線、用 KV store 做 config / 短期 cache、Object Store 派發 WASM 模組、Decentralized Auth 隔離客戶。揭露「broker 的功能集合」決定它能不能取代多套 edge 工具。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>NATS 進階主題：Cluster + Supercluster + Leaf node / JetStream KV + Object Store / Subject-based ACL + 多租戶。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&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 vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/nats-intelecy-industrial-iot/" data-link-title="3.C36 Intelecy：工業 IoT 即時感測 &amp;#43; 多租戶" data-link-desc="Intelecy 工廠 gateway 接數萬感測器、&amp;lt; 2 秒往返延遲做即時 ML、從 BoltDB 本地快取演進到 JetStream 持久化。">3.C36 Intelecy&lt;/a>（同類對照）。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://www.synadia.com/customer-stories/machinemetrics">MachineMetrics Customer Story (Synadia)&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明工業 IoT 完整的 edge-to-cloud NATS 整合（Leaf Node + JetStream + KV + Object Store + Auth）。</p>
<h2 id="觀察">觀察</h2>
<p>跨「數百個客戶廠區、數千台機台」的 Industrial IoT、單機產出最高 1000 Hz 採樣、工廠網路斷斷續續、Kinesis 等 cloud-only 工具無法跑在資源受限 edge 上。</p>
<h2 id="判讀">判讀</h2>
<p>用 Leaf Node 做 hub-and-spoke 把邊緣設備串到雲端、Edge 端用 JetStream 做本地持久化（取代 SQLite）抵抗網路斷線、用 KV store 做 config / 短期 cache、Object Store 派發 WASM 模組、Decentralized Auth 隔離客戶。揭露「broker 的功能集合」決定它能不能取代多套 edge 工具。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>NATS 進階主題：Cluster + Supercluster + Leaf node / JetStream KV + Object Store / Subject-based ACL + 多租戶。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/nats/" data-link-title="NATS" data-link-desc="Lightweight messaging、JetStream 加持久化與 streams">NATS vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/cases/nats-intelecy-industrial-iot/" data-link-title="3.C36 Intelecy：工業 IoT 即時感測 &#43; 多租戶" data-link-desc="Intelecy 工廠 gateway 接數萬感測器、&lt; 2 秒往返延遲做即時 ML、從 BoltDB 本地快取演進到 JetStream 持久化。">3.C36 Intelecy</a>（同類對照）。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://www.synadia.com/customer-stories/machinemetrics">MachineMetrics Customer Story (Synadia)</a></li>
</ul>
]]></content:encoded></item><item><title>3.C38 Clarifai：NATS Streaming ML 平台非同步任務</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/nats-clarifai-async-task-queue/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/nats-clarifai-async-task-queue/</guid><description>&lt;p>這個案例的核心責任是說明 NATS Streaming（JetStream 前身）的 queue group + at-least-once 在 ML worker pool 的角色。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>Clarifai 做 custom model 訓練、任務從幾秒到幾分鐘、原本同步呼叫遇到 rolling deployment 會掉訊息。三週內把一個服務遷到 NATS、5 個月內擴展到 5 個服務、每日 100k+ 訊息、100% uptime。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>用 NATS Streaming 的 at-least-once delivery + queue subscription group 做 worker pool、每個微服務連到三個獨立 NATS Streaming 實例做 fanout 隔離。揭露 ML 任務的長尾處理時間特別需要 at-least-once + redelivery、不能容忍 rolling deploy 掉訊息。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>NATS 進階主題：JetStream consumer 設計（NATS Streaming 是前身）/ Queue groups。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&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 vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計&lt;/a>。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://nats.io/blog/how-clarifai-uses-nats-and-kubernetes-for-machine-learning/">How Clarifai Uses NATS and Kubernetes for Machine Learning&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 NATS Streaming（JetStream 前身）的 queue group + at-least-once 在 ML worker pool 的角色。</p>
<h2 id="觀察">觀察</h2>
<p>Clarifai 做 custom model 訓練、任務從幾秒到幾分鐘、原本同步呼叫遇到 rolling deployment 會掉訊息。三週內把一個服務遷到 NATS、5 個月內擴展到 5 個服務、每日 100k+ 訊息、100% uptime。</p>
<h2 id="判讀">判讀</h2>
<p>用 NATS Streaming 的 at-least-once delivery + queue subscription group 做 worker pool、每個微服務連到三個獨立 NATS Streaming 實例做 fanout 隔離。揭露 ML 任務的長尾處理時間特別需要 at-least-once + redelivery、不能容忍 rolling deploy 掉訊息。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>NATS 進階主題：JetStream consumer 設計（NATS Streaming 是前身）/ Queue groups。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/nats/" data-link-title="NATS" data-link-desc="Lightweight messaging、JetStream 加持久化與 streams">NATS vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計</a>。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://nats.io/blog/how-clarifai-uses-nats-and-kubernetes-for-machine-learning/">How Clarifai Uses NATS and Kubernetes for Machine Learning</a></li>
</ul>
]]></content:encoded></item><item><title>3.C39 Choria：NATS 管 50 萬 server fleet</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/nats-choria-orchestration-fleet/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/nats-choria-orchestration-fleet/</guid><description>&lt;p>這個案例的核心責任是說明 fire-and-forget RPC + scatter-gather pattern 是 NATS Core 的典型場景。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>Choria 是 Puppet MCollective 的現代化替代品、目標管理數萬到數十萬節點的 fleet 同時下指令。評估過多個 broker、選 NATS 因為「單 binary、無 Zookeeper 依賴、Ruby client 品質好」、實測「單 server 300MB RAM 管 2000+ 機器」、4GB 節點可達 50 萬 server。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>MCollective 的 fire-and-forget RPC 語意正好對應 NATS Core 的 stateless best-effort + request-reply pattern、用 wildcard subject + queue group 做 parallel scatter-gather RPC。揭露 server orchestration 場景不需要 persistence、Core NATS 已足夠。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>NATS 進階主題：Request/Reply pattern / Queue groups / Cluster + Supercluster + Leaf node（Choria Federation Broker = 跨地理 federation）。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&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 vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/broker-basics/" data-link-title="3.1 broker 基礎與投遞模型" data-link-desc="先理解 broker、queue、consumer 與 delivery semantics">3.1 broker basics&lt;/a>。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://nats.io/blog/nats-for-the-marionette-collective/">NATS for the Marionette Collective (Choria)&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://choria.io/docs/concepts/">Choria Architecture Docs&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 fire-and-forget RPC + scatter-gather pattern 是 NATS Core 的典型場景。</p>
<h2 id="觀察">觀察</h2>
<p>Choria 是 Puppet MCollective 的現代化替代品、目標管理數萬到數十萬節點的 fleet 同時下指令。評估過多個 broker、選 NATS 因為「單 binary、無 Zookeeper 依賴、Ruby client 品質好」、實測「單 server 300MB RAM 管 2000+ 機器」、4GB 節點可達 50 萬 server。</p>
<h2 id="判讀">判讀</h2>
<p>MCollective 的 fire-and-forget RPC 語意正好對應 NATS Core 的 stateless best-effort + request-reply pattern、用 wildcard subject + queue group 做 parallel scatter-gather RPC。揭露 server orchestration 場景不需要 persistence、Core NATS 已足夠。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>NATS 進階主題：Request/Reply pattern / Queue groups / Cluster + Supercluster + Leaf node（Choria Federation Broker = 跨地理 federation）。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/nats/" data-link-title="NATS" data-link-desc="Lightweight messaging、JetStream 加持久化與 streams">NATS vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/broker-basics/" data-link-title="3.1 broker 基礎與投遞模型" data-link-desc="先理解 broker、queue、consumer 與 delivery semantics">3.1 broker basics</a>。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://nats.io/blog/nats-for-the-marionette-collective/">NATS for the Marionette Collective (Choria)</a></li>
<li><a href="https://choria.io/docs/concepts/">Choria Architecture Docs</a></li>
</ul>
]]></content:encoded></item><item><title>3.C40 Resgate：WebSocket-to-NATS realtime API gateway</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/nats-resgate-realtime-api-gateway/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/nats-resgate-realtime-api-gateway/</guid><description>&lt;p>這個案例的核心責任是說明「subject hierarchy 即 access control 邊界」的設計範例。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>Resgate 把 NATS subject 暴露成 REST + WebSocket、客戶端跨多 Resgate 實例自動同步狀態、事件延遲 &amp;lt; 1ms。需要同時支援 pub-sub 跟 request-reply、選 NATS 因為「performance、simplicity、兩種模式都原生支援」。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>subject 設計遵循 &lt;code>get.{service}.{resource}&lt;/code> / &lt;code>event.{service}.{resource}.{event-type}&lt;/code> 的命名規約、是「subject 階層當 schema」的典型範例。揭露 subject 命名是 NATS 的 API contract 起點、不是隨意命名。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>NATS 進階主題：Request/Reply pattern / Subject-based ACL + 多租戶（subject hierarchy 即 access control 邊界）/ Core NATS vs JetStream（純 Core）。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&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 vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/rabbitmq-gocardless-hutch-service-mesh/" data-link-title="3.C26 GoCardless：Hutch &amp;#43; 單一 topic exchange service mesh" data-link-desc="GoCardless 單一 RabbitMQ cluster 作所有 service 通訊中樞、routing key 用 service.subject.action 格式、JSON 多語言可讀。">3.C26 GoCardless Hutch routing key&lt;/a>（命名規約對照）。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://resgate.io/blog/introducing-resgate/">Introducing Resgate&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明「subject hierarchy 即 access control 邊界」的設計範例。</p>
<h2 id="觀察">觀察</h2>
<p>Resgate 把 NATS subject 暴露成 REST + WebSocket、客戶端跨多 Resgate 實例自動同步狀態、事件延遲 &lt; 1ms。需要同時支援 pub-sub 跟 request-reply、選 NATS 因為「performance、simplicity、兩種模式都原生支援」。</p>
<h2 id="判讀">判讀</h2>
<p>subject 設計遵循 <code>get.{service}.{resource}</code> / <code>event.{service}.{resource}.{event-type}</code> 的命名規約、是「subject 階層當 schema」的典型範例。揭露 subject 命名是 NATS 的 API contract 起點、不是隨意命名。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>NATS 進階主題：Request/Reply pattern / Subject-based ACL + 多租戶（subject hierarchy 即 access control 邊界）/ Core NATS vs JetStream（純 Core）。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/nats/" data-link-title="NATS" data-link-desc="Lightweight messaging、JetStream 加持久化與 streams">NATS vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/cases/rabbitmq-gocardless-hutch-service-mesh/" data-link-title="3.C26 GoCardless：Hutch &#43; 單一 topic exchange service mesh" data-link-desc="GoCardless 單一 RabbitMQ cluster 作所有 service 通訊中樞、routing key 用 service.subject.action 格式、JSON 多語言可讀。">3.C26 GoCardless Hutch routing key</a>（命名規約對照）。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://resgate.io/blog/introducing-resgate/">Introducing Resgate</a></li>
</ul>
]]></content:encoded></item><item><title>3.C41 i-flow：NATS 做 OT/IT 跨層整合 bus</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/nats-iflow-ot-it-integration/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/nats-iflow-ot-it-integration/</guid><description>&lt;p>這個案例的核心責任是說明 OT/IT 整合場景的多工廠 leaf node 拓樸。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>i-flow 是工業數據整合平台、每日 4 億筆 data operation、提供 200+ OT/IT 系統 connector、客戶含 Fortune 500 工廠（Bosch、Sto、Lenze）。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>用 NATS 當 OT/IT 跨層整合 bus、邊緣端負責 connect / harmonize / publish。揭露多工廠場景該用 leaf node hub-and-spoke、不該每工廠自管 cluster。&lt;strong>注意&lt;/strong>：此案例技術細節較淺、引用時要補其他案例的具體 stream / consumer 設計。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>NATS 進階主題：Cluster + Supercluster + Leaf node（多工廠 leaf node 連 central）。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&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 vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/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 &amp;#43; JetStream &amp;#43; KV &amp;#43; Object Store。">3.C37 MachineMetrics&lt;/a>（技術細節更深的對照）。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://nats.io/blog/i-flow-case-study/">i-flow Case Study&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 OT/IT 整合場景的多工廠 leaf node 拓樸。</p>
<h2 id="觀察">觀察</h2>
<p>i-flow 是工業數據整合平台、每日 4 億筆 data operation、提供 200+ OT/IT 系統 connector、客戶含 Fortune 500 工廠（Bosch、Sto、Lenze）。</p>
<h2 id="判讀">判讀</h2>
<p>用 NATS 當 OT/IT 跨層整合 bus、邊緣端負責 connect / harmonize / publish。揭露多工廠場景該用 leaf node hub-and-spoke、不該每工廠自管 cluster。<strong>注意</strong>：此案例技術細節較淺、引用時要補其他案例的具體 stream / consumer 設計。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>NATS 進階主題：Cluster + Supercluster + Leaf node（多工廠 leaf node 連 central）。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/nats/" data-link-title="NATS" data-link-desc="Lightweight messaging、JetStream 加持久化與 streams">NATS vendor 頁</a> 與 <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>（技術細節更深的對照）。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://nats.io/blog/i-flow-case-study/">i-flow Case Study</a></li>
</ul>
]]></content:encoded></item><item><title>3.C42 Bitso：Reliable Redis Streams 抽象 + 自建 DLQ</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/redis-streams-bitso-reliable-streams/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/redis-streams-bitso-reliable-streams/</guid><description>&lt;p>這個案例的核心責任是說明 Redis Streams 沒有原生 DLQ、要在 application 層自建抽象。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>Bitso 的 Order Engine 微服務需要 thousands of messages/sec/stream + 亞毫秒延遲、撐住 BTC 價格暴動的流量尖峰；先後評估 Kafka（latency）跟 SQS（vendor lock-in + latency）後選 Redis Streams、團隊本來就熟 Redis、已在 mission-critical service 跑超過半年。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>自建 &amp;ldquo;Reliable Redis Streams&amp;rdquo; 抽象層（StreamRedisOperations adapter / ReliableStream interface / MessageReadingLoop）封裝 readMessages + readPendingMessages、加上 Redis Streams 沒有原生支援的 DLQ（N 次 retry 後路由）、走 idempotent processing 接受重複勝過遺失。揭露 Redis Streams 是「資料結構」、不是「broker 系統」、可靠性責任在 application 層。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>Redis Streams 進階主題：Consumer group + PEL / XCLAIM + 失敗接管 / Sentinel + Cluster 可靠性。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &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 vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計&lt;/a>。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://medium.com/bitso-engineering/the-redis-streams-we-have-known-and-loved-e9e596d49a22">The Redis Streams We Have Known and Loved&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 Redis Streams 沒有原生 DLQ、要在 application 層自建抽象。</p>
<h2 id="觀察">觀察</h2>
<p>Bitso 的 Order Engine 微服務需要 thousands of messages/sec/stream + 亞毫秒延遲、撐住 BTC 價格暴動的流量尖峰；先後評估 Kafka（latency）跟 SQS（vendor lock-in + latency）後選 Redis Streams、團隊本來就熟 Redis、已在 mission-critical service 跑超過半年。</p>
<h2 id="判讀">判讀</h2>
<p>自建 &ldquo;Reliable Redis Streams&rdquo; 抽象層（StreamRedisOperations adapter / ReliableStream interface / MessageReadingLoop）封裝 readMessages + readPendingMessages、加上 Redis Streams 沒有原生支援的 DLQ（N 次 retry 後路由）、走 idempotent processing 接受重複勝過遺失。揭露 Redis Streams 是「資料結構」、不是「broker 系統」、可靠性責任在 application 層。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>Redis Streams 進階主題：Consumer group + PEL / XCLAIM + 失敗接管 / Sentinel + Cluster 可靠性。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <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 vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計</a>。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://medium.com/bitso-engineering/the-redis-streams-we-have-known-and-loved-e9e596d49a22">The Redis Streams We Have Known and Loved</a></li>
</ul>
]]></content:encoded></item><item><title>3.C43 Arcjet：Redis Streams 取代 Kafka 省 6 位數 $</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/redis-streams-arcjet-replace-kafka/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/redis-streams-arcjet-replace-kafka/</guid><description>&lt;p>Arcjet 用 Redis Streams 取代 Kafka 的案例揭露了中小規模場景下「Kafka 的 managed 成本 vs Redis Streams 的運維成本」的具體取捨 — 省下六位數年費的代價是自寫 retention 治理跟監控工具。&lt;/p>
&lt;h2 id="業務背景">業務背景&lt;/h2>
&lt;p>Arcjet 是 security / bot detection 平台，處理每個 HTTP request 的安全判斷。核心需求是 low-latency 的請求處理 — 安全判斷要在幾毫秒內完成，不能拖慢使用者的 request。&lt;/p>
&lt;p>系統架構中有一段 event-driven pipeline 負責把安全事件從 detection layer 傳遞到 analytics 跟 alerting。原本評估用 Kafka 做這段 pipeline，但 managed Kafka 的年費落在六位數美金 — 對 Arcjet 的流量規模跟業務階段，這個成本不合理。&lt;/p>
&lt;p>Arcjet 的基礎設施已經有 Redis 做 cache。把 Redis 從純 cache 升級到 cache + Streams，利用既有的 Redis infrastructure 承擔 event pipeline，總成本約 $1k/year。&lt;/p>
&lt;h2 id="技術挑戰">技術挑戰&lt;/h2>
&lt;h3 id="redis-streams-沒有自動-retention">Redis Streams 沒有自動 retention&lt;/h3>
&lt;p>Kafka 的 retention 是內建功能 — 設定 &lt;code>log.retention.hours&lt;/code> 後 broker 自動刪除到期資料。Redis Streams 沒有內建的自動 retention — stream 資料會持續累積，直到手動 &lt;code>XTRIM&lt;/code> 或 &lt;code>XDEL&lt;/code>。&lt;/p>
&lt;p>在生產環境下，不處理 retention 意味著 Redis 的記憶體持續成長，最終觸發 eviction policy 或 OOM。對 Arcjet 來說 Redis 同時做 cache 跟 Streams，Streams 的記憶體成長會擠壓 cache 的可用空間。&lt;/p>
&lt;h3 id="consumer-group-進度追蹤">Consumer group 進度追蹤&lt;/h3>
&lt;p>Redis Streams 的 consumer group 會追蹤每個 consumer 的讀取進度（last delivered ID）。做 &lt;code>XTRIM&lt;/code> 時需要確保不刪除尚未被所有 consumer group 確認的訊息 — 否則 consumer 會丟失未處理的事件。&lt;/p>
&lt;p>Kafka 的 log compaction 跟 retention 自動處理這個問題（consumer offset 以前的 segment 才會被清理）。Redis Streams 需要 application 自己確認所有 consumer group 的進度，再決定 trim 的位置。&lt;/p>
&lt;h3 id="單機-redis-的可靠性邊界">單機 Redis 的可靠性邊界&lt;/h3>
&lt;p>Redis 的持久化機制（RDB snapshot + AOF）提供的是 best-effort 的持久性，跟 Kafka 的 replication-based 持久化保證不同。Redis crash + restart 時，AOF 的最後幾筆寫入可能遺失（取決於 &lt;code>appendfsync&lt;/code> 設定）。&lt;/p>
&lt;p>對 Arcjet 的安全事件場景，偶爾丟失幾筆事件可以接受（security detection 的結果是即時判斷，事後的 analytics 容忍小量遺失）。如果場景是金融交易或 audit log，這個可靠性邊界就不夠。&lt;/p>
&lt;h2 id="解法與取捨">解法與取捨&lt;/h2>
&lt;h3 id="自建-janitor-process">自建 Janitor process&lt;/h3>
&lt;p>Arcjet 自寫了一個 Janitor process 處理 Redis Streams 的 retention：&lt;/p></description><content:encoded><![CDATA[<p>Arcjet 用 Redis Streams 取代 Kafka 的案例揭露了中小規模場景下「Kafka 的 managed 成本 vs Redis Streams 的運維成本」的具體取捨 — 省下六位數年費的代價是自寫 retention 治理跟監控工具。</p>
<h2 id="業務背景">業務背景</h2>
<p>Arcjet 是 security / bot detection 平台，處理每個 HTTP request 的安全判斷。核心需求是 low-latency 的請求處理 — 安全判斷要在幾毫秒內完成，不能拖慢使用者的 request。</p>
<p>系統架構中有一段 event-driven pipeline 負責把安全事件從 detection layer 傳遞到 analytics 跟 alerting。原本評估用 Kafka 做這段 pipeline，但 managed Kafka 的年費落在六位數美金 — 對 Arcjet 的流量規模跟業務階段，這個成本不合理。</p>
<p>Arcjet 的基礎設施已經有 Redis 做 cache。把 Redis 從純 cache 升級到 cache + Streams，利用既有的 Redis infrastructure 承擔 event pipeline，總成本約 $1k/year。</p>
<h2 id="技術挑戰">技術挑戰</h2>
<h3 id="redis-streams-沒有自動-retention">Redis Streams 沒有自動 retention</h3>
<p>Kafka 的 retention 是內建功能 — 設定 <code>log.retention.hours</code> 後 broker 自動刪除到期資料。Redis Streams 沒有內建的自動 retention — stream 資料會持續累積，直到手動 <code>XTRIM</code> 或 <code>XDEL</code>。</p>
<p>在生產環境下，不處理 retention 意味著 Redis 的記憶體持續成長，最終觸發 eviction policy 或 OOM。對 Arcjet 來說 Redis 同時做 cache 跟 Streams，Streams 的記憶體成長會擠壓 cache 的可用空間。</p>
<h3 id="consumer-group-進度追蹤">Consumer group 進度追蹤</h3>
<p>Redis Streams 的 consumer group 會追蹤每個 consumer 的讀取進度（last delivered ID）。做 <code>XTRIM</code> 時需要確保不刪除尚未被所有 consumer group 確認的訊息 — 否則 consumer 會丟失未處理的事件。</p>
<p>Kafka 的 log compaction 跟 retention 自動處理這個問題（consumer offset 以前的 segment 才會被清理）。Redis Streams 需要 application 自己確認所有 consumer group 的進度，再決定 trim 的位置。</p>
<h3 id="單機-redis-的可靠性邊界">單機 Redis 的可靠性邊界</h3>
<p>Redis 的持久化機制（RDB snapshot + AOF）提供的是 best-effort 的持久性，跟 Kafka 的 replication-based 持久化保證不同。Redis crash + restart 時，AOF 的最後幾筆寫入可能遺失（取決於 <code>appendfsync</code> 設定）。</p>
<p>對 Arcjet 的安全事件場景，偶爾丟失幾筆事件可以接受（security detection 的結果是即時判斷，事後的 analytics 容忍小量遺失）。如果場景是金融交易或 audit log，這個可靠性邊界就不夠。</p>
<h2 id="解法與取捨">解法與取捨</h2>
<h3 id="自建-janitor-process">自建 Janitor process</h3>
<p>Arcjet 自寫了一個 Janitor process 處理 Redis Streams 的 retention：</p>
<ol>
<li>定期檢查每個 stream 的長度（<code>XLEN</code>）</li>
<li>查詢所有 consumer group 的 pending entry list（PEL）跟最後確認位置</li>
<li>計算安全的 trim 位置（所有 consumer group 都已確認的最舊 ID）</li>
<li>執行 <code>XTRIM stream MINID &lt;safe-id&gt;</code> 刪除已確認的舊資料</li>
</ol>
<p>Janitor 的執行頻率根據實際處理速度（~100 msgs/min）設定 — 不需要非常頻繁，但不能完全不跑。</p>
<h3 id="取捨">取捨</h3>
<table>
  <thead>
      <tr>
          <th>面向</th>
          <th>Managed Kafka</th>
          <th>Redis Streams + Janitor</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>年成本</td>
          <td>六位數 USD</td>
          <td>~$1k USD</td>
      </tr>
      <tr>
          <td>Retention 管理</td>
          <td>內建自動</td>
          <td>自寫 Janitor</td>
      </tr>
      <tr>
          <td>持久化保證</td>
          <td>Replication-based（強）</td>
          <td>AOF/RDB（best-effort）</td>
      </tr>
      <tr>
          <td>Consumer group</td>
          <td>原生支援、offset commit 自動</td>
          <td>原生支援、但 trim 要手動協調</td>
      </tr>
      <tr>
          <td>生態工具</td>
          <td>Kafka Connect、Schema Registry</td>
          <td>無（自建）</td>
      </tr>
      <tr>
          <td>擴展性</td>
          <td>Partition 水平擴展</td>
          <td>單 Redis 受限、Cluster 模式複雜</td>
      </tr>
      <tr>
          <td>運維知識</td>
          <td>Kafka 運維（或交給 managed）</td>
          <td>Redis 運維 + 自建 Janitor 維護</td>
      </tr>
  </tbody>
</table>
<h3 id="適用邊界">適用邊界</h3>
<p>Redis Streams 取代 Kafka 的適用邊界：</p>
<ul>
<li><strong>流量規模</strong>：每分鐘數百到數千筆（超過每秒數萬筆需要 Redis Cluster 或多 stream）</li>
<li><strong>持久化要求</strong>：容忍偶爾丟失少量訊息（best-effort）</li>
<li><strong>已有 Redis</strong>：不需要額外部署 Redis、利用既有 infrastructure</li>
<li><strong>Kafka 功能不需要</strong>：不需要 Kafka Connect、Schema Registry、long-term retention、跨 region replication</li>
</ul>
<p>超過這些邊界時，Redis Streams 的自建成本（Janitor + 監控 + retention 治理 + 可靠性補償）會逐漸接近 managed Kafka 的費用，成本優勢消失。</p>
<h2 id="回寫教材的連結">回寫教材的連結</h2>
<ul>
<li><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 vendor 頁</a>：XCLAIM / PEL recovery 的進階主題</li>
<li><a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka vendor 頁</a>：成本對照 — Kafka 的固定成本高但功能完整</li>
<li><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>：Redis Streams 的持久化機制跟 Kafka 的 replication 在 durability 光譜上的位置</li>
<li><a href="/blog/backend/03-message-queue/broker-basics/" data-link-title="3.1 broker 基礎與投遞模型" data-link-desc="先理解 broker、queue、consumer 與 delivery semantics">3.1 broker basics</a>：broker 選型時成本是一級決策維度</li>
</ul>
<h2 id="判讀徵兆">判讀徵兆</h2>
<p>讀者在自己的系統看到以下訊號時，應該回讀本案例：</p>
<ul>
<li>Managed Kafka 的月帳單跟實際流量量級不成比例（低流量但高成本）</li>
<li>已有 Redis infrastructure、考慮把 event pipeline 合併到 Redis</li>
<li>Event pipeline 的流量在每秒數百筆以下、持久化要求是 best-effort</li>
<li>Redis 記憶體持續成長但不確定 Streams 的 retention 有沒有正確執行</li>
</ul>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://blog.arcjet.com/replacing-kafka-with-redis-streams/">Replacing Kafka with Redis Streams</a></li>
</ul>
]]></content:encoded></item><item><title>3.C44 Harness：CD 微服務 async state transfer</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/redis-streams-harness-event-driven-state/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/redis-streams-harness-event-driven-state/</guid><description>&lt;p>這個案例的核心責任是說明 Redis Streams 在 production 落地的三類經常性議題。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>Harness 為 CD 微服務之間的 async state transfer 採用 Redis Streams、避開「每個 service 都要知道怎麼跟其他 service 講話」的 brittle HTTP 模式；初始規模 a few thousand msgs/min、Kafka 在此規模 overkill、又能複用已存在的 Redis 基建。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>落地後揭露三類問題：監控缺口（自寫 app 追 consumer lag）、需要主動 MAXLEN truncation、head-of-line blocking 要用 XAUTOCLAIM 重派並設計 redelivery 策略。揭露「Redis Streams 適合中小規模」這個聲明、實際包含三件 production work。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>Redis Streams 進階主題：Consumer group + PEL / XCLAIM + 失敗接管 / Memory + retention 取捨。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &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 vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/red-team-delivery-layer/" data-link-title="3.5 攻擊者視角（紅隊）：傳遞層弱點判讀" data-link-desc="從重複投遞、重放濫用、毒訊息與容量壓力，盤點 message delivery 的主要弱點">3.5 紅隊章&lt;/a>。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://www.harness.io/blog/event-driven-architecture-redis-streams">Event-Driven Architecture with Redis Streams&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 Redis Streams 在 production 落地的三類經常性議題。</p>
<h2 id="觀察">觀察</h2>
<p>Harness 為 CD 微服務之間的 async state transfer 採用 Redis Streams、避開「每個 service 都要知道怎麼跟其他 service 講話」的 brittle HTTP 模式；初始規模 a few thousand msgs/min、Kafka 在此規模 overkill、又能複用已存在的 Redis 基建。</p>
<h2 id="判讀">判讀</h2>
<p>落地後揭露三類問題：監控缺口（自寫 app 追 consumer lag）、需要主動 MAXLEN truncation、head-of-line blocking 要用 XAUTOCLAIM 重派並設計 redelivery 策略。揭露「Redis Streams 適合中小規模」這個聲明、實際包含三件 production work。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>Redis Streams 進階主題：Consumer group + PEL / XCLAIM + 失敗接管 / Memory + retention 取捨。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <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 vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/red-team-delivery-layer/" data-link-title="3.5 攻擊者視角（紅隊）：傳遞層弱點判讀" data-link-desc="從重複投遞、重放濫用、毒訊息與容量壓力，盤點 message delivery 的主要弱點">3.5 紅隊章</a>。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://www.harness.io/blog/event-driven-architecture-redis-streams">Event-Driven Architecture with Redis Streams</a></li>
</ul>
]]></content:encoded></item><item><title>3.C45 Klaxit：Rust + Redis Streams 處理 Heroku Logplex</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/redis-streams-klaxit-rust-log-pipeline/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/redis-streams-klaxit-rust-log-pipeline/</guid><description>&lt;p>這個案例的核心責任是說明 Redis Streams 在高吞吐 log ingestion 的 consumer group 分流。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>Klaxit 用 Redis Streams 處理 Heroku Logplex 匯流的 log、自動偵測並修復 Heroku 平台層 perf 問題（在使用者察覺前）；正式 production 跑超過 6 個月、是團隊第一個 Rust project。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>揭露 high-throughput log ingestion 對 Redis Streams 的壓力：用 consumer group 分流到多個 Rust worker、需要長時間穩定運轉。揭露 client library 品質決定 Redis Streams 在小眾語言（Rust）的可行性。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>Redis Streams 進階主題：XADD / XREAD / XREADGROUP 操作 / Consumer group + PEL。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &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 vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計&lt;/a>。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://dev.to/goodtouch/consuming-high-throughput-redis-streams-with-rust-580c">Consuming High-Throughput Redis Streams with Rust&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 Redis Streams 在高吞吐 log ingestion 的 consumer group 分流。</p>
<h2 id="觀察">觀察</h2>
<p>Klaxit 用 Redis Streams 處理 Heroku Logplex 匯流的 log、自動偵測並修復 Heroku 平台層 perf 問題（在使用者察覺前）；正式 production 跑超過 6 個月、是團隊第一個 Rust project。</p>
<h2 id="判讀">判讀</h2>
<p>揭露 high-throughput log ingestion 對 Redis Streams 的壓力：用 consumer group 分流到多個 Rust worker、需要長時間穩定運轉。揭露 client library 品質決定 Redis Streams 在小眾語言（Rust）的可行性。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>Redis Streams 進階主題：XADD / XREAD / XREADGROUP 操作 / Consumer group + PEL。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <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 vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計</a>。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://dev.to/goodtouch/consuming-high-throughput-redis-streams-with-rust-580c">Consuming High-Throughput Redis Streams with Rust</a></li>
</ul>
]]></content:encoded></item><item><title>3.C46 Learning.com：Redis 事件源退場（反例）</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/redis-streams-learning-com-event-source-retreat/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/redis-streams-learning-com-event-source-retreat/</guid><description>&lt;p>這個反例的核心責任是說明 Redis 不適合長期事件儲存、揭露「Redis-as-event-store」的退場路徑。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>Learning.com 把 microservice 之間的 event store 放 Redis 上、一年內累積到 GB/週的 memory 成長、AOF fsync + EBS 磁碟 I/O 變成 latency 痛點。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>揭露「Redis 不適合長期事件儲存」的退場路徑：event 移到 PostgreSQL、Redis 留做訊息佇列 + snapshot；中途靠 syncTimeout 調整、提升 IOPS、調整 AOF fsync 緩解。揭露 broker 選型要看「長期存儲是 source-of-truth 還是 transient」。&lt;strong>注意&lt;/strong>：此文討論的是 Redis-as-event-store 整體、Streams 是其中一塊、引用時要小心區分。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>Redis Streams 進階主題：Memory + retention 取捨 / Sentinel + Cluster 可靠性（持久化選型）。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &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 vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/outbox-pattern/" data-link-title="3.3 outbox pattern 與發佈一致性" data-link-desc="把 transaction 與 event publish 分離">3.3 outbox pattern&lt;/a>。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://medium.com/lcom-techblog/a-year-with-redis-event-sourcing-lessons-learned-6736068e17cc">A Year with Redis Event Sourcing - Lessons Learned&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個反例的核心責任是說明 Redis 不適合長期事件儲存、揭露「Redis-as-event-store」的退場路徑。</p>
<h2 id="觀察">觀察</h2>
<p>Learning.com 把 microservice 之間的 event store 放 Redis 上、一年內累積到 GB/週的 memory 成長、AOF fsync + EBS 磁碟 I/O 變成 latency 痛點。</p>
<h2 id="判讀">判讀</h2>
<p>揭露「Redis 不適合長期事件儲存」的退場路徑：event 移到 PostgreSQL、Redis 留做訊息佇列 + snapshot；中途靠 syncTimeout 調整、提升 IOPS、調整 AOF fsync 緩解。揭露 broker 選型要看「長期存儲是 source-of-truth 還是 transient」。<strong>注意</strong>：此文討論的是 Redis-as-event-store 整體、Streams 是其中一塊、引用時要小心區分。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>Redis Streams 進階主題：Memory + retention 取捨 / Sentinel + Cluster 可靠性（持久化選型）。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <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 vendor 頁</a> 與 <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>。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://medium.com/lcom-techblog/a-year-with-redis-event-sourcing-lessons-learned-6736068e17cc">A Year with Redis Event Sourcing - Lessons Learned</a></li>
</ul>
]]></content:encoded></item><item><title>3.C47 PHP 微服務：Redis Streams + S3 hybrid storage</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/redis-streams-mateusz-php-microservices/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/redis-streams-mateusz-php-microservices/</guid><description>&lt;p>這個案例的核心責任是說明 in-memory 訊息的 payload 限制要靠 hybrid storage 解決。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>PHP 雙微服務之間的可靠通訊、Kafka 在 PHP 生態工具薄弱、團隊無 Kafka 經驗、production 跑數月後寫此文；明確覆蓋 XADD / XREADGROUP / consumer group / MAXLEN / MINID / XDEL / XACK / XACKDEL（Redis 8.2+）/ XTRIM。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>揭露 in-memory 訊息的 payload 限制：用 payload compression + S3 hybrid storage（大 payload 存 S3、stream 只放 reference）；用 MAXLEN/MINID 控制 stream 成長。揭露 broker 選型常被「語言生態 client 品質」主導、不是純技術 feature。&lt;strong>注意&lt;/strong>：作者是個人工程師、production 經驗但非知名公司。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>Redis Streams 進階主題：XADD/XREAD/XREADGROUP 操作 / Retention (MAXLEN/MINID) / Memory + retention 取捨。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &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 vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-robinhood-faust-python-streaming/" data-link-title="3.C16 Robinhood：Faust Python stream processing" data-link-desc="Robinhood 每天 billions of events、Python 團隊不想用 JVM 生態、把 Kafka Streams 移植到 Python。">3.C16 Robinhood Faust&lt;/a>（語言生態對照）。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://dev.to/mtk3d/beyond-the-hype-why-we-chose-redis-streams-over-kafka-for-our-microservices-dmc">Beyond the Hype: Why We Chose Redis Streams Over Kafka for Our Microservices&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 in-memory 訊息的 payload 限制要靠 hybrid storage 解決。</p>
<h2 id="觀察">觀察</h2>
<p>PHP 雙微服務之間的可靠通訊、Kafka 在 PHP 生態工具薄弱、團隊無 Kafka 經驗、production 跑數月後寫此文；明確覆蓋 XADD / XREADGROUP / consumer group / MAXLEN / MINID / XDEL / XACK / XACKDEL（Redis 8.2+）/ XTRIM。</p>
<h2 id="判讀">判讀</h2>
<p>揭露 in-memory 訊息的 payload 限制：用 payload compression + S3 hybrid storage（大 payload 存 S3、stream 只放 reference）；用 MAXLEN/MINID 控制 stream 成長。揭露 broker 選型常被「語言生態 client 品質」主導、不是純技術 feature。<strong>注意</strong>：作者是個人工程師、production 經驗但非知名公司。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>Redis Streams 進階主題：XADD/XREAD/XREADGROUP 操作 / Retention (MAXLEN/MINID) / Memory + retention 取捨。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <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 vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/cases/kafka-robinhood-faust-python-streaming/" data-link-title="3.C16 Robinhood：Faust Python stream processing" data-link-desc="Robinhood 每天 billions of events、Python 團隊不想用 JVM 生態、把 Kafka Streams 移植到 Python。">3.C16 Robinhood Faust</a>（語言生態對照）。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://dev.to/mtk3d/beyond-the-hype-why-we-chose-redis-streams-over-kafka-for-our-microservices-dmc">Beyond the Hype: Why We Chose Redis Streams Over Kafka for Our Microservices</a></li>
</ul>
]]></content:encoded></item><item><title>3.C48 Airbnb Dynein：SQS 分散式延遲任務排程</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/sqs-airbnb-dynein-delayed-jobs/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/sqs-airbnb-dynein-delayed-jobs/</guid><description>&lt;p>這個案例的核心責任是說明 SQS at-least-once + DLQ 模型在工作排程的對齊邏輯。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>Airbnb 構建 Dynein 分散式延遲任務排程系統取代 Resque（受限於單 Redis 實例）。明確選 SQS、利用 at-least-once delivery、dead letter queue、individual message acknowledgment、access control 與 encryption-at-rest。每個 scheduler instance 達 ~1000 QPS、可水平擴展。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>at-least-once 對工作排程「不丟資料」假設足夠、SQS wrap DynamoDB 處理 &amp;gt; 15 分鐘 delay、DLQ 分離「短暫失敗」與「永久毒訊息」。揭露 managed queue 在工作排程的取捨：trade ordering 換 scaling。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>SQS 進階主題：Standard vs FIFO / DLQ 設計。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/aws-sqs/" data-link-title="AWS SQS" data-link-desc="AWS managed queue、簡單可靠、無 ordering（standard）">SQS vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/durable-queue/" data-link-title="3.2 durable queue 與重試策略" data-link-desc="整理持久化佇列、DLQ 與重試流程">3.2 durable queue&lt;/a>。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://medium.com/airbnb-engineering/dynein-building-a-distributed-delayed-job-queueing-system-93ab10f05f99">Dynein: Building a Distributed Delayed Job Queueing System&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 SQS at-least-once + DLQ 模型在工作排程的對齊邏輯。</p>
<h2 id="觀察">觀察</h2>
<p>Airbnb 構建 Dynein 分散式延遲任務排程系統取代 Resque（受限於單 Redis 實例）。明確選 SQS、利用 at-least-once delivery、dead letter queue、individual message acknowledgment、access control 與 encryption-at-rest。每個 scheduler instance 達 ~1000 QPS、可水平擴展。</p>
<h2 id="判讀">判讀</h2>
<p>at-least-once 對工作排程「不丟資料」假設足夠、SQS wrap DynamoDB 處理 &gt; 15 分鐘 delay、DLQ 分離「短暫失敗」與「永久毒訊息」。揭露 managed queue 在工作排程的取捨：trade ordering 換 scaling。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>SQS 進階主題：Standard vs FIFO / DLQ 設計。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/aws-sqs/" data-link-title="AWS SQS" data-link-desc="AWS managed queue、簡單可靠、無 ordering（standard）">SQS vendor 頁</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>。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://medium.com/airbnb-engineering/dynein-building-a-distributed-delayed-job-queueing-system-93ab10f05f99">Dynein: Building a Distributed Delayed Job Queueing System</a></li>
</ul>
]]></content:encoded></item><item><title>3.C49 Airbnb Inspekt：Visibility timeout 當 retry budget</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/sqs-airbnb-inspekt-data-protection/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/sqs-airbnb-inspekt-data-protection/</guid><description>&lt;p>這個案例的核心責任是說明 visibility timeout 不只是「處理時間」、可當隱式的 retry 機制。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>Airbnb 的 Inspekt 隱私資料掃描系統用 SQS task queue 派發 scan task（每 table/object/app 一個 message）、Scanner nodes 水平 pull。&amp;ldquo;each message reappears N times back into the queue until a scanner node deletes it&amp;rdquo; 是 visibility timeout 在實戰的應用。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>用 message 重現次數做 retry budget、scanner 失敗時不用自管 retry table。揭露 SQS 的「不刪除即重現」是設計、不是 bug、可以當隱式 retry 機制用。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>SQS 進階主題：Visibility timeout + in-flight messages。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/aws-sqs/" data-link-title="AWS SQS" data-link-desc="AWS managed queue、簡單可靠、無 ordering（standard）">SQS vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計&lt;/a>。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://medium.com/airbnb-engineering/automating-data-protection-at-scale-part-2-c2b8d2068216">Automating Data Protection at Scale Part 2&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 visibility timeout 不只是「處理時間」、可當隱式的 retry 機制。</p>
<h2 id="觀察">觀察</h2>
<p>Airbnb 的 Inspekt 隱私資料掃描系統用 SQS task queue 派發 scan task（每 table/object/app 一個 message）、Scanner nodes 水平 pull。&ldquo;each message reappears N times back into the queue until a scanner node deletes it&rdquo; 是 visibility timeout 在實戰的應用。</p>
<h2 id="判讀">判讀</h2>
<p>用 message 重現次數做 retry budget、scanner 失敗時不用自管 retry table。揭露 SQS 的「不刪除即重現」是設計、不是 bug、可以當隱式 retry 機制用。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>SQS 進階主題：Visibility timeout + in-flight messages。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/aws-sqs/" data-link-title="AWS SQS" data-link-desc="AWS managed queue、簡單可靠、無 ordering（standard）">SQS vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計</a>。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://medium.com/airbnb-engineering/automating-data-protection-at-scale-part-2-c2b8d2068216">Automating Data Protection at Scale Part 2</a></li>
</ul>
]]></content:encoded></item><item><title>3.C50 Capital One：Visibility timeout 設計與 Lambda event source</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/sqs-capital-one-visibility-timeout/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/sqs-capital-one-visibility-timeout/</guid><description>&lt;p>Capital One 的 SQS + Lambda 實務揭露了 visibility timeout 的雙邊風險 — 太短導致重複處理、太長延遲 retry — 以及 Lambda event source mapping 的 scaling 行為跟直覺不同的地方。&lt;/p>
&lt;h2 id="業務背景">業務背景&lt;/h2>
&lt;p>Capital One 是美國大型金融機構，tech blog 公開分享了 SQS + Lambda 的 event-driven 架構實踐。金融場景的 message 處理對正確性要求極高 — 重複處理一筆交易跟遺失一筆交易的代價都是具體的金錢損失。&lt;/p>
&lt;p>SQS 是 AWS 原生的 managed queue，Lambda 是 serverless compute。兩者搭配的 event source mapping 是 AWS 上最常見的 event-driven 入門架構 — 看起來簡單（SQS → Lambda 自動觸發），但 visibility timeout 跟 Lambda scaling 的互動有不少實務細節。&lt;/p>
&lt;h2 id="技術挑戰">技術挑戰&lt;/h2>
&lt;h3 id="visibility-timeout-的雙邊風險">Visibility timeout 的雙邊風險&lt;/h3>
&lt;p>SQS 的 visibility timeout 定義了「consumer 取走訊息後，其他 consumer 多久之後才能再看到這筆訊息」。它是 SQS 的核心容錯機制 — consumer 處理失敗（crash、timeout）時，visibility timeout 到期後訊息重新出現在 queue 裡，讓其他 consumer 接手。&lt;/p>
&lt;p>&lt;strong>Timeout 太短&lt;/strong>：consumer 還在處理中、visibility timeout 已到期、另一個 consumer 取走同一筆訊息開始處理 — 重複處理。金融場景的重複處理可能導致重複扣款或重複退款。&lt;/p>
&lt;p>&lt;strong>Timeout 太長&lt;/strong>：consumer 處理失敗、需要等很久 visibility timeout 才到期、訊息才重新出現 — retry 延遲。原本幾秒就能被其他 consumer 接手的訊息，要等 15 分鐘才 retry。&lt;/p>
&lt;p>Capital One 的實務建議是 visibility timeout 設為「最大預期處理時間 + 少量緩衝」。例如：最大處理時間 30 秒 → visibility timeout 設 45 秒。&lt;/p>
&lt;h3 id="lambda-event-source-mapping-的-scaling-行為">Lambda event source mapping 的 scaling 行為&lt;/h3>
&lt;p>Lambda 跟 SQS 的整合透過 event source mapping — Lambda 服務自動從 SQS long polling 取訊息、觸發 Lambda function。使用者不需要自己寫 polling 邏輯。&lt;/p>
&lt;p>Capital One 揭露的 scaling 行為跟「Lambda 自動擴展」的直覺不同：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>初始狀態&lt;/strong>：Lambda 啟動 5 個 long polling connection（poller）&lt;/li>
&lt;li>&lt;strong>Scale up&lt;/strong>：每分鐘最多新增 60 個 poller instance（每個 instance 處理一批 message）&lt;/li>
&lt;li>&lt;strong>上限&lt;/strong>：最多 1000 個並行 batch&lt;/li>
&lt;/ul>
&lt;p>這意味著突發流量（queue 瞬間湧入大量訊息）的消化速度不是即時的 — Lambda 需要數分鐘才能 scale 到足夠的並行度。在這段 ramp-up 期間，queue depth 會持續增長。&lt;/p>
&lt;h3 id="batch-size-跟-visibility-timeout-的互動">Batch size 跟 visibility timeout 的互動&lt;/h3>
&lt;p>Lambda event source mapping 預設 batch size = 10 — 一次取 10 筆訊息、用一個 Lambda invocation 處理。如果 batch 中的某一筆處理特別慢，整個 batch 的處理時間會被拉長。&lt;/p></description><content:encoded><![CDATA[<p>Capital One 的 SQS + Lambda 實務揭露了 visibility timeout 的雙邊風險 — 太短導致重複處理、太長延遲 retry — 以及 Lambda event source mapping 的 scaling 行為跟直覺不同的地方。</p>
<h2 id="業務背景">業務背景</h2>
<p>Capital One 是美國大型金融機構，tech blog 公開分享了 SQS + Lambda 的 event-driven 架構實踐。金融場景的 message 處理對正確性要求極高 — 重複處理一筆交易跟遺失一筆交易的代價都是具體的金錢損失。</p>
<p>SQS 是 AWS 原生的 managed queue，Lambda 是 serverless compute。兩者搭配的 event source mapping 是 AWS 上最常見的 event-driven 入門架構 — 看起來簡單（SQS → Lambda 自動觸發），但 visibility timeout 跟 Lambda scaling 的互動有不少實務細節。</p>
<h2 id="技術挑戰">技術挑戰</h2>
<h3 id="visibility-timeout-的雙邊風險">Visibility timeout 的雙邊風險</h3>
<p>SQS 的 visibility timeout 定義了「consumer 取走訊息後，其他 consumer 多久之後才能再看到這筆訊息」。它是 SQS 的核心容錯機制 — consumer 處理失敗（crash、timeout）時，visibility timeout 到期後訊息重新出現在 queue 裡，讓其他 consumer 接手。</p>
<p><strong>Timeout 太短</strong>：consumer 還在處理中、visibility timeout 已到期、另一個 consumer 取走同一筆訊息開始處理 — 重複處理。金融場景的重複處理可能導致重複扣款或重複退款。</p>
<p><strong>Timeout 太長</strong>：consumer 處理失敗、需要等很久 visibility timeout 才到期、訊息才重新出現 — retry 延遲。原本幾秒就能被其他 consumer 接手的訊息，要等 15 分鐘才 retry。</p>
<p>Capital One 的實務建議是 visibility timeout 設為「最大預期處理時間 + 少量緩衝」。例如：最大處理時間 30 秒 → visibility timeout 設 45 秒。</p>
<h3 id="lambda-event-source-mapping-的-scaling-行為">Lambda event source mapping 的 scaling 行為</h3>
<p>Lambda 跟 SQS 的整合透過 event source mapping — Lambda 服務自動從 SQS long polling 取訊息、觸發 Lambda function。使用者不需要自己寫 polling 邏輯。</p>
<p>Capital One 揭露的 scaling 行為跟「Lambda 自動擴展」的直覺不同：</p>
<ul>
<li><strong>初始狀態</strong>：Lambda 啟動 5 個 long polling connection（poller）</li>
<li><strong>Scale up</strong>：每分鐘最多新增 60 個 poller instance（每個 instance 處理一批 message）</li>
<li><strong>上限</strong>：最多 1000 個並行 batch</li>
</ul>
<p>這意味著突發流量（queue 瞬間湧入大量訊息）的消化速度不是即時的 — Lambda 需要數分鐘才能 scale 到足夠的並行度。在這段 ramp-up 期間，queue depth 會持續增長。</p>
<h3 id="batch-size-跟-visibility-timeout-的互動">Batch size 跟 visibility timeout 的互動</h3>
<p>Lambda event source mapping 預設 batch size = 10 — 一次取 10 筆訊息、用一個 Lambda invocation 處理。如果 batch 中的某一筆處理特別慢，整個 batch 的處理時間會被拉長。</p>
<p>Visibility timeout 要覆蓋整個 batch 的處理時間（包含最慢的那一筆），否則 batch 還在處理中、早期取走的訊息 visibility timeout 到期、被其他 poller 重新取走 — 導致重複處理。</p>
<h2 id="解法與取捨">解法與取捨</h2>
<table>
  <thead>
      <tr>
          <th>設計參數</th>
          <th>建議值</th>
          <th>取捨</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Visibility timeout</td>
          <td>最大處理時間 + 緩衝（例 45 秒）</td>
          <td>太短重複、太長延遲 retry</td>
      </tr>
      <tr>
          <td>Batch size</td>
          <td>依處理時間變異度調整</td>
          <td>Batch 大省 invocation 費用、但延長 visibility 需求</td>
      </tr>
      <tr>
          <td>DLQ</td>
          <td>設定 maxReceiveCount（例 3 次）</td>
          <td>避免 poison message 無限 retry</td>
      </tr>
      <tr>
          <td>Concurrency limit</td>
          <td>依下游承受能力設定</td>
          <td>避免 Lambda 爆量壓垮下游 DB</td>
      </tr>
  </tbody>
</table>
<h3 id="idempotency-作為安全網">Idempotency 作為安全網</h3>
<p>Visibility timeout 無法完全避免重複處理（網路分區、Lambda timeout 等邊界條件）。Capital One 的做法是在 Lambda function 內實作 <a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a> — 用 message ID 做去重，確保同一筆訊息被多次處理時結果相同。</p>
<p>Idempotency 把 visibility timeout 的精確度要求降低 — 即使偶爾重複處理，業務結果仍然正確。Visibility timeout 仍然需要合理設定（降低不必要的重複 invocation 成本），但 idempotency 是「即使設錯也不會造成業務錯誤」的安全網。</p>
<h2 id="回寫教材的連結">回寫教材的連結</h2>
<ul>
<li><a href="/blog/backend/03-message-queue/vendors/aws-sqs/" data-link-title="AWS SQS" data-link-desc="AWS managed queue、簡單可靠、無 ordering（standard）">SQS vendor 頁</a>：visibility timeout、in-flight limit、Lambda event source 的進階主題</li>
<li><a href="/blog/backend/03-message-queue/processing-recovery-semantics/" data-link-title="3.6 Processing Semantics 與 Recovery Semantics" data-link-desc="說明投遞成功、處理成功與恢復成功為何是三個不同判斷。">3.6 processing recovery semantics</a>：at-least-once 語意下的 consumer 端 idempotency</li>
<li><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>：visibility timeout 是 SQS 的 delivery guarantee 機制</li>
<li><a href="/blog/backend/03-message-queue/queue-consumer-retry-replay-handoff/" data-link-title="3.8 Queue Consumer Retry 與 Replay Handoff（實作示範）" data-link-desc="以 order_created consumer 示範 queue 路徑如何交付 idempotency evidence、DLQ handling、replay runbook 與 incident decision log。">3.8 queue consumer retry replay handoff</a>：DLQ + maxReceiveCount 的 retry 升級策略</li>
</ul>
<h2 id="判讀徵兆">判讀徵兆</h2>
<p>讀者在自己的系統看到以下訊號時，應該回讀本案例：</p>
<ul>
<li>SQS + Lambda 架構中出現訊息重複處理（CloudWatch 的 <code>ApproximateNumberOfMessagesNotVisible</code> 跟 <code>NumberOfMessagesReceived</code> 比例異常）</li>
<li>Lambda function 的 timeout 跟 SQS visibility timeout 的關係沒有明確設計</li>
<li>突發流量時 queue depth 持續增長、Lambda 的 concurrent execution 沒有立刻跟上</li>
<li>Batch processing 中的慢訊息拖慢整個 batch、造成 visibility timeout 到期</li>
</ul>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://www.capitalone.com/tech/cloud/using-aws-solutions-for-event-driven-serverless-architectures/">Using AWS Solutions for Event-Driven Serverless Architectures</a></li>
</ul>
]]></content:encoded></item><item><title>3.C51 Atlassian JiRT：Kinesis + SQS subscription</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/sqs-atlassian-jirt-kinesis-sqs/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/sqs-atlassian-jirt-kinesis-sqs/</guid><description>&lt;p>這個案例的核心責任是說明 SQS 作為 streaming source 的 per-consumer subscription 模式。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>Atlassian 內部 event bus StreamHub 底層用 Kinesis、但「每個 consumer 自己準備 SQS queue 接收 event」。JiRT 即時服務透過此模式把輪詢式（~1 min）改成 event-driven（秒級）。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>在 Kinesis 上面疊 SQS 讓 consumer 各自設定 retention、各自獨立 visibility timeout。揭露「stream + per-consumer queue」是 fan-out 場景的常見複合 pattern、不是 streaming vs queue 二選一。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>SQS 進階主題：Standard vs FIFO / SQS 作為 fan-out subscriber。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/aws-sqs/" data-link-title="AWS SQS" data-link-desc="AWS managed queue、簡單可靠、無 ordering（standard）">SQS vendor 頁&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 vendor 頁&lt;/a>（streaming + queue 對照）。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://www.atlassian.com/blog/atlassian-engineering/using-an-event-driven-architecture-to-improve-jira-software-responsiveness">Using an Event-Driven Architecture to Improve Jira Software Responsiveness&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 SQS 作為 streaming source 的 per-consumer subscription 模式。</p>
<h2 id="觀察">觀察</h2>
<p>Atlassian 內部 event bus StreamHub 底層用 Kinesis、但「每個 consumer 自己準備 SQS queue 接收 event」。JiRT 即時服務透過此模式把輪詢式（~1 min）改成 event-driven（秒級）。</p>
<h2 id="判讀">判讀</h2>
<p>在 Kinesis 上面疊 SQS 讓 consumer 各自設定 retention、各自獨立 visibility timeout。揭露「stream + per-consumer queue」是 fan-out 場景的常見複合 pattern、不是 streaming vs queue 二選一。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>SQS 進階主題：Standard vs FIFO / SQS 作為 fan-out subscriber。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/aws-sqs/" data-link-title="AWS SQS" data-link-desc="AWS managed queue、簡單可靠、無 ordering（standard）">SQS vendor 頁</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 vendor 頁</a>（streaming + queue 對照）。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://www.atlassian.com/blog/atlassian-engineering/using-an-event-driven-architecture-to-improve-jira-software-responsiveness">Using an Event-Driven Architecture to Improve Jira Software Responsiveness</a></li>
</ul>
]]></content:encoded></item><item><title>3.C52 Nielsen：Spark on EKS 雙 SQS 工作流</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/sqs-nielsen-spark-eks-dual-queue/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/sqs-nielsen-spark-eks-dual-queue/</guid><description>&lt;p>這個案例的核心責任是說明 SQS queue depth 作為 autoscale 訊號的真實案例。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>Nielsen 每日處理 25 TB / 30 billion event。架構用兩個 SQS queue：work queue（待處理工作項）+ completion queue（回報完成）。Lambda 從 DB 拉檔案、組成 work item 推進 work queue、EKS pod 拉取處理、處理完寫 completion queue。基於 queue depth 自動擴 pod。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>不用直接 Lambda invoke（pod 上跑長時間 Spark workload）、queue depth 當 backlog signal driving autoscale。揭露長 workload 場景該用 pod + queue depth、不是 Lambda function。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>SQS 進階主題：CloudWatch metric + alarm / Standard queue / 長 workload autoscaling。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/aws-sqs/" data-link-title="AWS SQS" data-link-desc="AWS managed queue、簡單可靠、無 ordering（standard）">SQS vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-trivago-keda-scale-to-zero/" data-link-title="3.C22 Trivago：KEDA scale-to-zero by Kafka lag" data-link-desc="Trivago 50&amp;#43; Kafka sink、CPU/mem autoscaling 無效（I/O bottleneck）、KEDA 以 consumer lag 為訊號達到 scale-to-zero。">3.C22 Trivago KEDA&lt;/a>（lag-based autoscale 對照）。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://aws.amazon.com/blogs/architecture/how-nielsen-uses-serverless-concepts-on-amazon-eks-for-big-data-processing-with-spark-workloads/">How Nielsen Uses Serverless Concepts on Amazon EKS for Big Data Spark Workloads&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 SQS queue depth 作為 autoscale 訊號的真實案例。</p>
<h2 id="觀察">觀察</h2>
<p>Nielsen 每日處理 25 TB / 30 billion event。架構用兩個 SQS queue：work queue（待處理工作項）+ completion queue（回報完成）。Lambda 從 DB 拉檔案、組成 work item 推進 work queue、EKS pod 拉取處理、處理完寫 completion queue。基於 queue depth 自動擴 pod。</p>
<h2 id="判讀">判讀</h2>
<p>不用直接 Lambda invoke（pod 上跑長時間 Spark workload）、queue depth 當 backlog signal driving autoscale。揭露長 workload 場景該用 pod + queue depth、不是 Lambda function。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>SQS 進階主題：CloudWatch metric + alarm / Standard queue / 長 workload autoscaling。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/aws-sqs/" data-link-title="AWS SQS" data-link-desc="AWS managed queue、簡單可靠、無 ordering（standard）">SQS vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/cases/kafka-trivago-keda-scale-to-zero/" data-link-title="3.C22 Trivago：KEDA scale-to-zero by Kafka lag" data-link-desc="Trivago 50&#43; Kafka sink、CPU/mem autoscaling 無效（I/O bottleneck）、KEDA 以 consumer lag 為訊號達到 scale-to-zero。">3.C22 Trivago KEDA</a>（lag-based autoscale 對照）。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://aws.amazon.com/blogs/architecture/how-nielsen-uses-serverless-concepts-on-amazon-eks-for-big-data-processing-with-spark-workloads/">How Nielsen Uses Serverless Concepts on Amazon EKS for Big Data Spark Workloads</a></li>
</ul>
]]></content:encoded></item><item><title>3.C53 FINRA：S3 → SQS notification 大檔上傳</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/sqs-finra-large-file-service/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/sqs-finra-large-file-service/</guid><description>&lt;p>這個案例的核心責任是說明 S3 event notification 是 SQS 最經典 trigger、合規場景的 IAM 多層設定。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>FINRA 金融監管機構、處理 broker-dealer 上傳大檔。Large File Service 用 S3 → SQS 通知模式：使用者上傳完 loading dock bucket、S3 推 SQS message 給 LFS、移檔後再推 &amp;ldquo;file available&amp;rdquo; SQS message 給下游。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>S3 通知是 SQS 最經典 trigger、KMS + bucket policy + queue 權限的合規場景（金融業要保留稽核軌跡）。揭露金融場景的 IAM 設計不是一道權限、是多層稽核軌跡。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>SQS 進階主題：SQS + Lambda event source / IAM + Cross-account。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/aws-sqs/" data-link-title="AWS SQS" data-link-desc="AWS managed queue、簡單可靠、無 ordering（standard）">SQS vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/07-security-data-protection/" data-link-title="模組七：資安與資料保護" data-link-desc="以問題驅動方式擴充資安知識網：先定義服務環節問題，再以案例作為觸發式參考">7 security 模組&lt;/a>。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://www.finra.org/about/how-we-operate/technology/blog/large-file-service-securely-uploading-large-files-to-s3">FINRA Large File Service&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 S3 event notification 是 SQS 最經典 trigger、合規場景的 IAM 多層設定。</p>
<h2 id="觀察">觀察</h2>
<p>FINRA 金融監管機構、處理 broker-dealer 上傳大檔。Large File Service 用 S3 → SQS 通知模式：使用者上傳完 loading dock bucket、S3 推 SQS message 給 LFS、移檔後再推 &ldquo;file available&rdquo; SQS message 給下游。</p>
<h2 id="判讀">判讀</h2>
<p>S3 通知是 SQS 最經典 trigger、KMS + bucket policy + queue 權限的合規場景（金融業要保留稽核軌跡）。揭露金融場景的 IAM 設計不是一道權限、是多層稽核軌跡。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>SQS 進階主題：SQS + Lambda event source / IAM + Cross-account。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/aws-sqs/" data-link-title="AWS SQS" data-link-desc="AWS managed queue、簡單可靠、無 ordering（standard）">SQS vendor 頁</a> 與 <a href="/blog/backend/07-security-data-protection/" data-link-title="模組七：資安與資料保護" data-link-desc="以問題驅動方式擴充資安知識網：先定義服務環節問題，再以案例作為觸發式參考">7 security 模組</a>。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://www.finra.org/about/how-we-operate/technology/blog/large-file-service-securely-uploading-large-files-to-s3">FINRA Large File Service</a></li>
</ul>
]]></content:encoded></item><item><title>3.C54 Twitch EventSub：SNS+SQS fan-out 給第三方</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/sqs-twitch-eventsub-fanout/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/sqs-twitch-eventsub-fanout/</guid><description>&lt;p>這個案例的核心責任是說明 SNS-SQS fan-out + dispatcher pattern 的實戰。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>Twitch 內部 Event Bus 發佈 ~1660 events/sec 到 SNS。EventSub（給第三方應用訂閱 Twitch 事件）用 SQS 接收 async notification、再由 Dispatcher fan-out 給各訂閱者。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>fan-out 後每個 consumer 要自己一個 queue。揭露 SNS → SQS 是 AWS 生態的 fan-out 標配、SQS 是第三方訂閱的 buffer 層、Dispatcher 是 application 級別的分發責任。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>SQS 進階主題：Standard queue + SQS + Lambda / SNS-SQS fan-out。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/aws-sqs/" data-link-title="AWS SQS" data-link-desc="AWS managed queue、簡單可靠、無 ordering（standard）">SQS vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/sqs-atlassian-jirt-kinesis-sqs/" data-link-title="3.C51 Atlassian JiRT：Kinesis &amp;#43; SQS subscription" data-link-desc="Atlassian StreamHub Kinesis 底層、每 consumer 自己一個 SQS queue、JiRT 把輪詢 1 min 改成秒級 event-driven。">3.C51 Atlassian JiRT&lt;/a>（subscription 對照）。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.twitch.tv/en/2023/09/28/twitch-state-of-engineering-2023/">Twitch State of Engineering 2023&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 SNS-SQS fan-out + dispatcher pattern 的實戰。</p>
<h2 id="觀察">觀察</h2>
<p>Twitch 內部 Event Bus 發佈 ~1660 events/sec 到 SNS。EventSub（給第三方應用訂閱 Twitch 事件）用 SQS 接收 async notification、再由 Dispatcher fan-out 給各訂閱者。</p>
<h2 id="判讀">判讀</h2>
<p>fan-out 後每個 consumer 要自己一個 queue。揭露 SNS → SQS 是 AWS 生態的 fan-out 標配、SQS 是第三方訂閱的 buffer 層、Dispatcher 是 application 級別的分發責任。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>SQS 進階主題：Standard queue + SQS + Lambda / SNS-SQS fan-out。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/aws-sqs/" data-link-title="AWS SQS" data-link-desc="AWS managed queue、簡單可靠、無 ordering（standard）">SQS vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/cases/sqs-atlassian-jirt-kinesis-sqs/" data-link-title="3.C51 Atlassian JiRT：Kinesis &#43; SQS subscription" data-link-desc="Atlassian StreamHub Kinesis 底層、每 consumer 自己一個 SQS queue、JiRT 把輪詢 1 min 改成秒級 event-driven。">3.C51 Atlassian JiRT</a>（subscription 對照）。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://blog.twitch.tv/en/2023/09/28/twitch-state-of-engineering-2023/">Twitch State of Engineering 2023</a></li>
</ul>
]]></content:encoded></item><item><title>3.C55 SmugMug：SQS 驅動可重放搜尋管線</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/sqs-smugmug-search-pipeline/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/sqs-smugmug-search-pipeline/</guid><description>&lt;p>這個案例的核心責任是說明 SQS 作為「workload generator」的分散式平行化角色。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>SmugMug 用 SQS 兩種模式：(1) backfill — script 推 DynamoDB scan-segment 指令進 SQS、Lambda 拉取做平行掃描寫 OpenSearch、(2) 鏡像查詢 — production query 推副本 SQS、Lambda replay 到 replica domain。每小時可 index &amp;gt; 1 billion document、不影響 production。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>SQS 作為「workload generator」分散式平行化、不需協調 worker 數量。揭露 SQS 不只是「事件 queue」、也是「並行任務分散」的協調基礎。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>SQS 進階主題：Standard queue / Long polling / SQS + Lambda event source。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/aws-sqs/" data-link-title="AWS SQS" data-link-desc="AWS managed queue、簡單可靠、無 ordering（standard）">SQS vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計&lt;/a>。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://aws.amazon.com/blogs/big-data/smugmugs-durable-search-pipelines-for-amazon-opensearch-service/">SmugMug&amp;rsquo;s Durable Search Pipelines for Amazon OpenSearch Service&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 SQS 作為「workload generator」的分散式平行化角色。</p>
<h2 id="觀察">觀察</h2>
<p>SmugMug 用 SQS 兩種模式：(1) backfill — script 推 DynamoDB scan-segment 指令進 SQS、Lambda 拉取做平行掃描寫 OpenSearch、(2) 鏡像查詢 — production query 推副本 SQS、Lambda replay 到 replica domain。每小時可 index &gt; 1 billion document、不影響 production。</p>
<h2 id="判讀">判讀</h2>
<p>SQS 作為「workload generator」分散式平行化、不需協調 worker 數量。揭露 SQS 不只是「事件 queue」、也是「並行任務分散」的協調基礎。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>SQS 進階主題：Standard queue / Long polling / SQS + Lambda event source。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/aws-sqs/" data-link-title="AWS SQS" data-link-desc="AWS managed queue、簡單可靠、無 ordering（standard）">SQS vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計</a>。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://aws.amazon.com/blogs/big-data/smugmugs-durable-search-pipelines-for-amazon-opensearch-service/">SmugMug&rsquo;s Durable Search Pipelines for Amazon OpenSearch Service</a></li>
</ul>
]]></content:encoded></item><item><title>3.C56 PostNL EBE：完整 DLQ + retention + redrive 設計</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/sqs-postnl-mission-critical-ebe/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/sqs-postnl-mission-critical-ebe/</guid><description>&lt;p>這個案例的核心責任是業內真正完整的 DLQ + redrive + retention 設計案例、不是 demo 規模。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>PostNL（荷蘭最大物流商、每天 6.9M 信件 + 1.1M 包裹）的 Event Broker E-commerce 系統每天處理 ~10M message。完整列出 SQS 配置：每 producer/consumer 隔離 stack（最小爆炸半徑）、3 天 replay via EventBridge、exponential backoff with jitter、24 小時內最多 retry 100 次、final DLQ 允許 consumer 自己 redrive。max receive count 設 1 觸發 DLQ 告警。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>「每 producer/consumer 隔離 stack」是 mission-critical 系統的 blast radius 設計、不只是 queue 配置。揭露 production-grade SQS 設計含三件事：隔離 + retry 政策 + redrive 流程。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>SQS 進階主題：DLQ 設計 / CloudWatch alarm / Cost 模型。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/aws-sqs/" data-link-title="AWS SQS" data-link-desc="AWS managed queue、簡單可靠、無 ordering（standard）">SQS vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9 反例：語義誤配&lt;/a>。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://medium.com/postnl-engineering/design-a-mission-critical-serverless-application-for-high-resilience-2858bf11360a">Designing a Mission-Critical Serverless Application for High Resilience&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是業內真正完整的 DLQ + redrive + retention 設計案例、不是 demo 規模。</p>
<h2 id="觀察">觀察</h2>
<p>PostNL（荷蘭最大物流商、每天 6.9M 信件 + 1.1M 包裹）的 Event Broker E-commerce 系統每天處理 ~10M message。完整列出 SQS 配置：每 producer/consumer 隔離 stack（最小爆炸半徑）、3 天 replay via EventBridge、exponential backoff with jitter、24 小時內最多 retry 100 次、final DLQ 允許 consumer 自己 redrive。max receive count 設 1 觸發 DLQ 告警。</p>
<h2 id="判讀">判讀</h2>
<p>「每 producer/consumer 隔離 stack」是 mission-critical 系統的 blast radius 設計、不只是 queue 配置。揭露 production-grade SQS 設計含三件事：隔離 + retry 政策 + redrive 流程。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>SQS 進階主題：DLQ 設計 / CloudWatch alarm / Cost 模型。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/aws-sqs/" data-link-title="AWS SQS" data-link-desc="AWS managed queue、簡單可靠、無 ordering（standard）">SQS vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9 反例：語義誤配</a>。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://medium.com/postnl-engineering/design-a-mission-critical-serverless-application-for-high-resilience-2858bf11360a">Designing a Mission-Critical Serverless Application for High Resilience</a></li>
</ul>
]]></content:encoded></item><item><title>3.C57 Lob：自家 fork @lob/sqs-consumer 修 FIFO bug</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/sqs-lob-sqs-consumer-library/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/sqs-lob-sqs-consumer-library/</guid><description>&lt;p>這個案例的核心責任是說明真實 production library 維護成本、FIFO consumer 的隱性 bug。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>Lob（programmatic mail API）原本用 bbc/sqs-consumer 但被鎖在 AWS SDK v2。他們 fork 出 @lob/sqs-consumer：支援 SDK v3（模組化 import 縮 bundle、TypeScript 一級支援、async/await）、修正原 library 對 FIFO queue 的 bug。SQS 用在 Lob API 跟其他內部 service。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>不能只靠 SDK 原生 API、SDK 升級會逼出 library 維護議題。揭露「FIFO queue 跟 standard queue 的 client 行為差異」是 library 層的隱性 bug 來源。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>SQS 進階主題：Standard vs FIFO / Long polling / Client library 維護。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/aws-sqs/" data-link-title="AWS SQS" data-link-desc="AWS managed queue、簡單可靠、無 ordering（standard）">SQS vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計&lt;/a>。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://www.lob.com/blog/lob-sqs-consumer">@lob/sqs-consumer&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明真實 production library 維護成本、FIFO consumer 的隱性 bug。</p>
<h2 id="觀察">觀察</h2>
<p>Lob（programmatic mail API）原本用 bbc/sqs-consumer 但被鎖在 AWS SDK v2。他們 fork 出 @lob/sqs-consumer：支援 SDK v3（模組化 import 縮 bundle、TypeScript 一級支援、async/await）、修正原 library 對 FIFO queue 的 bug。SQS 用在 Lob API 跟其他內部 service。</p>
<h2 id="判讀">判讀</h2>
<p>不能只靠 SDK 原生 API、SDK 升級會逼出 library 維護議題。揭露「FIFO queue 跟 standard queue 的 client 行為差異」是 library 層的隱性 bug 來源。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>SQS 進階主題：Standard vs FIFO / Long polling / Client library 維護。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/aws-sqs/" data-link-title="AWS SQS" data-link-desc="AWS managed queue、簡單可靠、無 ordering（standard）">SQS vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計</a>。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://www.lob.com/blog/lob-sqs-consumer">@lob/sqs-consumer</a></li>
</ul>
]]></content:encoded></item><item><title>3.C58 Twilio：SQS 緩衝高流量 webhook</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/sqs-twilio-webhook-buffer/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/sqs-twilio-webhook-buffer/</guid><description>&lt;p>這個案例的核心責任是說明 webhook → SQS buffer 是 Twilio 推薦的 pattern、FIFO TPS 上限的分片實務。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>Twilio 自己 engineering blog 教使用者用 SQS 緩衝來自 Twilio 的高流量 SMS / status callback webhook（避免下游 app 來不及處理）。用 separate queue 區分 SMS vs status callback、long polling 減少空 API call、特別點出 FIFO 300 TPS 上限要分 queue。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>Webhook 是 push、下游可能來不及、SQS 當 buffer 是常見 pattern。揭露 FIFO 的 300 TPS 上限是 hard limit、要設計分片才能擴張。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>SQS 進階主題：Long polling / Standard vs FIFO。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/aws-sqs/" data-link-title="AWS SQS" data-link-desc="AWS managed queue、簡單可靠、無 ordering（standard）">SQS vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/durable-queue/" data-link-title="3.2 durable queue 與重試策略" data-link-desc="整理持久化佇列、DLQ 與重試流程">3.2 durable queue&lt;/a>。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://www.twilio.com/en-us/blog/handling-high-volume-inbound-sms-and-webhooks-with-twilio-functions-and-amazon-sqs-html">Handling High Volume Inbound SMS and Webhooks with Twilio Functions and Amazon SQS&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 webhook → SQS buffer 是 Twilio 推薦的 pattern、FIFO TPS 上限的分片實務。</p>
<h2 id="觀察">觀察</h2>
<p>Twilio 自己 engineering blog 教使用者用 SQS 緩衝來自 Twilio 的高流量 SMS / status callback webhook（避免下游 app 來不及處理）。用 separate queue 區分 SMS vs status callback、long polling 減少空 API call、特別點出 FIFO 300 TPS 上限要分 queue。</p>
<h2 id="判讀">判讀</h2>
<p>Webhook 是 push、下游可能來不及、SQS 當 buffer 是常見 pattern。揭露 FIFO 的 300 TPS 上限是 hard limit、要設計分片才能擴張。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>SQS 進階主題：Long polling / Standard vs FIFO。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/aws-sqs/" data-link-title="AWS SQS" data-link-desc="AWS managed queue、簡單可靠、無 ordering（standard）">SQS vendor 頁</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>。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://www.twilio.com/en-us/blog/handling-high-volume-inbound-sms-and-webhooks-with-twilio-functions-and-amazon-sqs-html">Handling High Volume Inbound SMS and Webhooks with Twilio Functions and Amazon SQS</a></li>
</ul>
]]></content:encoded></item><item><title>3.C59 Rapid7：SQS 100 億 message/day 規模</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/sqs-rapid7-scale-billion-messages/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/sqs-rapid7-scale-billion-messages/</guid><description>&lt;p>這個案例的核心責任是建立 SQS 在 10 billion+/day 規模下的成本結構與量級參考點。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>Rapid7 Platform Software Architect 公開引述：「SQS 是我們架構的關鍵元件、讓我們 scale 到處理 10s of billions of messages per day。」是 AWS 官方文中具名客戶 quote、非 marketing 概括。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>SQS 在百億訊息/日規模下仍可用、是 scale 的具體量級參考點。揭露 SQS request-based 計費在這個規模下、cost 模型該被認真評估。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>SQS 進階主題：Cost 模型 / Standard queue。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/aws-sqs/" data-link-title="AWS SQS" data-link-desc="AWS managed queue、簡單可靠、無 ordering（standard）">SQS vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/00-service-selection/cost-risk-tradeoffs/" data-link-title="0.6 成本、風險與選型取捨" data-link-desc="用人力成本、雲端成本、操作成本與失敗代價判斷後端能力投入順序">0.6 成本取捨&lt;/a>。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://aws.amazon.com/blogs/aws/amazon-sqs-15-years-and-still-queueing/">Amazon SQS — 15 Years and Still Queueing&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是建立 SQS 在 10 billion+/day 規模下的成本結構與量級參考點。</p>
<h2 id="觀察">觀察</h2>
<p>Rapid7 Platform Software Architect 公開引述：「SQS 是我們架構的關鍵元件、讓我們 scale 到處理 10s of billions of messages per day。」是 AWS 官方文中具名客戶 quote、非 marketing 概括。</p>
<h2 id="判讀">判讀</h2>
<p>SQS 在百億訊息/日規模下仍可用、是 scale 的具體量級參考點。揭露 SQS request-based 計費在這個規模下、cost 模型該被認真評估。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>SQS 進階主題：Cost 模型 / Standard queue。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/aws-sqs/" data-link-title="AWS SQS" data-link-desc="AWS managed queue、簡單可靠、無 ordering（standard）">SQS vendor 頁</a> 與 <a href="/blog/backend/00-service-selection/cost-risk-tradeoffs/" data-link-title="0.6 成本、風險與選型取捨" data-link-desc="用人力成本、雲端成本、操作成本與失敗代價判斷後端能力投入順序">0.6 成本取捨</a>。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://aws.amazon.com/blogs/aws/amazon-sqs-15-years-and-still-queueing/">Amazon SQS — 15 Years and Still Queueing</a></li>
</ul>
]]></content:encoded></item><item><title>3.C60 Spotify：Event Delivery 從 Kafka 遷到 Pub/Sub</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-spotify-event-delivery-platform/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-spotify-event-delivery-platform/</guid><description>&lt;p>Spotify 把全球 event delivery 從 Kafka 遷到 Cloud Pub/Sub 的案例揭露了大規模 pull subscription 的工程現實 — at-least-once 語意意味著應用層去重不可省。&lt;/p>
&lt;h2 id="業務背景">業務背景&lt;/h2>
&lt;p>Spotify 的 Event Delivery 系統負責把所有使用者行為事件（播放、搜尋、推薦互動、廣告曝光）從客戶端經由資料管線送到下游消費者。事件是推薦引擎、A/B test、廣告計費跟 analytics 的核心輸入。&lt;/p>
&lt;p>遷移到 GCP Pub/Sub 後的系統規模：每個 event type 一個 topic、~15 個 microservice 跑在 ~2500 VM 上、Q1 2019 高峰 8M events/sec、每日 350 TB raw event 流量。遷出 Kafka 的動機跟技術評估見 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-spotify-event-delivery-exodus/" data-link-title="3.C20 Spotify：Event Delivery 從 Kafka 遷出（反例）" data-link-desc="Spotify Kafka 0.7 MirrorMaker best-effort 會掉資料但回報成功、broker restart 後 producer 無法恢復、決定遷到 GCP Pub/Sub。">3.C20 Spotify 遷出 Kafka（反例）&lt;/a>。&lt;/p>
&lt;h2 id="技術挑戰">技術挑戰&lt;/h2>
&lt;h3 id="at-least-once-語意下的重複">At-least-once 語意下的重複&lt;/h3>
&lt;p>Cloud Pub/Sub（早期版本）提供 at-least-once delivery — 同一筆訊息可能被 deliver 多次。在每日 350 TB 的流量下，「偶爾重複」的頻率足以影響 analytics 數據跟廣告計費的準確性。&lt;/p>
&lt;p>Pub/Sub 的重複來源有兩個：ack deadline 到期前 consumer 還沒處理完、訊息被重新 deliver 給其他 consumer；以及 Pub/Sub backend 的內部 redelivery（罕見但非零）。&lt;/p>
&lt;h3 id="pull-subscription-的流控">Pull subscription 的流控&lt;/h3>
&lt;p>Pull subscription 讓 consumer 主動從 Pub/Sub 拉取訊息（vs push subscription 由 Pub/Sub 推送到 HTTP endpoint）。Pull 的好處是 consumer 可以控制自己的消費速度，避免被推送壓垮。&lt;/p>
&lt;p>大規模 pull subscription 的挑戰在於流控的精細度 — 每個 consumer VM 要設定合理的 maxOutstandingMessages 跟 maxOutstandingBytes，太大會讓 consumer 記憶體不足、太小會浪費 Pub/Sub 的吞吐能力。Spotify 的 2500 VM 各自獨立做 pull，需要在 fleet 級別保持流控的一致性。&lt;/p>
&lt;h3 id="每個-event-type-一個-topic-的治理">每個 event type 一個 topic 的治理&lt;/h3>
&lt;p>Spotify 按 event type 建立 topic（例如 &lt;code>play-event&lt;/code>、&lt;code>search-event&lt;/code>、&lt;code>ad-impression&lt;/code>）。Event type 數量成長後，topic 數量跟著增長。每個 topic 需要獨立的 subscription、monitoring、ack deadline 設定跟 retention policy。&lt;/p>
&lt;p>Topic 治理的工程問題是「誰 own 這個 topic、schema 變更怎麼協調、retention 該設多久」。Spotify 自建了 event delivery 平台層（Event Delivery Platform）來管理 topic lifecycle — 包括 topic 建立 / 刪除的 self-service API、schema registry、consumer group 管理。&lt;/p>
&lt;h2 id="解法與取捨">解法與取捨&lt;/h2>
&lt;h3 id="自建-deduplication-層">自建 deduplication 層&lt;/h3>
&lt;p>Spotify 在 consumer 端自建去重機制。每筆 event 帶 unique event ID，consumer 在處理前查 dedup store（記憶體 + 外部 cache）確認是否已處理過。已處理的 event 直接 ack、跳過處理邏輯。&lt;/p></description><content:encoded><![CDATA[<p>Spotify 把全球 event delivery 從 Kafka 遷到 Cloud Pub/Sub 的案例揭露了大規模 pull subscription 的工程現實 — at-least-once 語意意味著應用層去重不可省。</p>
<h2 id="業務背景">業務背景</h2>
<p>Spotify 的 Event Delivery 系統負責把所有使用者行為事件（播放、搜尋、推薦互動、廣告曝光）從客戶端經由資料管線送到下游消費者。事件是推薦引擎、A/B test、廣告計費跟 analytics 的核心輸入。</p>
<p>遷移到 GCP Pub/Sub 後的系統規模：每個 event type 一個 topic、~15 個 microservice 跑在 ~2500 VM 上、Q1 2019 高峰 8M events/sec、每日 350 TB raw event 流量。遷出 Kafka 的動機跟技術評估見 <a href="/blog/backend/03-message-queue/cases/kafka-spotify-event-delivery-exodus/" data-link-title="3.C20 Spotify：Event Delivery 從 Kafka 遷出（反例）" data-link-desc="Spotify Kafka 0.7 MirrorMaker best-effort 會掉資料但回報成功、broker restart 後 producer 無法恢復、決定遷到 GCP Pub/Sub。">3.C20 Spotify 遷出 Kafka（反例）</a>。</p>
<h2 id="技術挑戰">技術挑戰</h2>
<h3 id="at-least-once-語意下的重複">At-least-once 語意下的重複</h3>
<p>Cloud Pub/Sub（早期版本）提供 at-least-once delivery — 同一筆訊息可能被 deliver 多次。在每日 350 TB 的流量下，「偶爾重複」的頻率足以影響 analytics 數據跟廣告計費的準確性。</p>
<p>Pub/Sub 的重複來源有兩個：ack deadline 到期前 consumer 還沒處理完、訊息被重新 deliver 給其他 consumer；以及 Pub/Sub backend 的內部 redelivery（罕見但非零）。</p>
<h3 id="pull-subscription-的流控">Pull subscription 的流控</h3>
<p>Pull subscription 讓 consumer 主動從 Pub/Sub 拉取訊息（vs push subscription 由 Pub/Sub 推送到 HTTP endpoint）。Pull 的好處是 consumer 可以控制自己的消費速度，避免被推送壓垮。</p>
<p>大規模 pull subscription 的挑戰在於流控的精細度 — 每個 consumer VM 要設定合理的 maxOutstandingMessages 跟 maxOutstandingBytes，太大會讓 consumer 記憶體不足、太小會浪費 Pub/Sub 的吞吐能力。Spotify 的 2500 VM 各自獨立做 pull，需要在 fleet 級別保持流控的一致性。</p>
<h3 id="每個-event-type-一個-topic-的治理">每個 event type 一個 topic 的治理</h3>
<p>Spotify 按 event type 建立 topic（例如 <code>play-event</code>、<code>search-event</code>、<code>ad-impression</code>）。Event type 數量成長後，topic 數量跟著增長。每個 topic 需要獨立的 subscription、monitoring、ack deadline 設定跟 retention policy。</p>
<p>Topic 治理的工程問題是「誰 own 這個 topic、schema 變更怎麼協調、retention 該設多久」。Spotify 自建了 event delivery 平台層（Event Delivery Platform）來管理 topic lifecycle — 包括 topic 建立 / 刪除的 self-service API、schema registry、consumer group 管理。</p>
<h2 id="解法與取捨">解法與取捨</h2>
<h3 id="自建-deduplication-層">自建 deduplication 層</h3>
<p>Spotify 在 consumer 端自建去重機制。每筆 event 帶 unique event ID，consumer 在處理前查 dedup store（記憶體 + 外部 cache）確認是否已處理過。已處理的 event 直接 ack、跳過處理邏輯。</p>
<p>Dedup store 的挑戰是大小跟 TTL — 要記住多久以前的 event ID 才夠。TTL 太短會漏掉 late redelivery（Pub/Sub 在 ack deadline 之後才重新 deliver）、TTL 太長 dedup store 太大。Spotify 用滑動視窗（retention 跟 ack deadline 的倍數）設定 TTL。</p>
<h3 id="取捨">取捨</h3>
<table>
  <thead>
      <tr>
          <th>面向</th>
          <th>Pub/Sub + 自建 dedup</th>
          <th>自管 Kafka 0.8+</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>運維成本</td>
          <td>低（Pub/Sub 全託管）</td>
          <td>高（自管 broker × 多 region）</td>
      </tr>
      <tr>
          <td>語意保證</td>
          <td>At-least-once + 應用層 dedup</td>
          <td>At-least-once（idempotent 0.11+）</td>
      </tr>
      <tr>
          <td>跨 region replication</td>
          <td>原生支援</td>
          <td>需要 MirrorMaker 或自建</td>
      </tr>
      <tr>
          <td>流控精細度</td>
          <td>Pull subscription 可控</td>
          <td>Consumer group 自動分配</td>
      </tr>
      <tr>
          <td>Topic 治理</td>
          <td>需要自建平台層</td>
          <td>Kafka 生態工具（Confluent 等）</td>
      </tr>
      <tr>
          <td>Dedup 成本</td>
          <td>額外的 cache / store 成本</td>
          <td>Idempotent producer 減少需求</td>
      </tr>
  </tbody>
</table>
<p>自建 dedup 的成本是 Spotify 選 Pub/Sub 的額外付出。這個代價在託管方案的運維節省面前被接受 — 維護一個 dedup cache 的成本遠低於維護跨 5 個 datacenter 的 Kafka broker fleet。</p>
<h2 id="回寫教材的連結">回寫教材的連結</h2>
<ul>
<li><a href="/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Pub/Sub vendor 頁</a>：push vs pull subscription、ack deadline、ordering 跟 DLT 的進階主題</li>
<li><a href="/blog/backend/03-message-queue/cases/kafka-spotify-event-delivery-exodus/" data-link-title="3.C20 Spotify：Event Delivery 從 Kafka 遷出（反例）" data-link-desc="Spotify Kafka 0.7 MirrorMaker best-effort 會掉資料但回報成功、broker restart 後 producer 無法恢復、決定遷到 GCP Pub/Sub。">3.C20 Spotify 遷出 Kafka</a>：遷出 Kafka 的動機跟決策判準</li>
<li><a href="/blog/backend/03-message-queue/processing-recovery-semantics/" data-link-title="3.6 Processing Semantics 與 Recovery Semantics" data-link-desc="說明投遞成功、處理成功與恢復成功為何是三個不同判斷。">3.6 processing recovery semantics</a>：at-least-once 語意下的 dedup 策略</li>
<li><a href="/blog/backend/03-message-queue/event-contract-replay-boundary/" data-link-title="3.7 Event Contract 與 Replay Boundary" data-link-desc="說明 event schema、idempotency key、replay window 與補償如何先於 broker 選型。">3.7 event contract replay boundary</a>：event schema 跟 topic lifecycle 的治理</li>
</ul>
<h2 id="判讀徵兆">判讀徵兆</h2>
<p>讀者在自己的系統看到以下訊號時，應該回讀本案例：</p>
<ul>
<li>使用 GCP Pub/Sub 且下游消費者偶爾處理到重複事件</li>
<li>Pull subscription 的 consumer 記憶體使用不穩定、maxOutstandingMessages 設定不合理</li>
<li>Topic 數量持續增長但缺少統一的 lifecycle 管理</li>
<li>從自管 Kafka 遷移到 GCP Pub/Sub 的評估階段</li>
</ul>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://engineering.atspotify.com/2019/11/spotifys-event-delivery-life-in-the-cloud">Spotify&rsquo;s Event Delivery — Life in the Cloud</a></li>
</ul>
]]></content:encoded></item><item><title>3.C61 Spotify：Autoscaling Pub/Sub consumer 反效果</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-spotify-autoscaling-consumers/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-spotify-autoscaling-consumers/</guid><description>&lt;p>這個案例的核心責任是說明「subscription backlog 不等於 consumer healthy」、autoscaling 跟 ack deadline 的耦合風險。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>下游 Cloud Storage export 失敗時、consumer 不 ack 仍持續消耗 CPU 處理同批訊息、造成 autoscaling 把 CPU 越拉越高的反效果；解法是 exponential backoff 抑制 CPU 消耗。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>「Subscription backlog 不等於 consumer healthy」— 訊息未 ack 累積跟 autoscaling 的耦合風險。揭露 autoscale signal 該看「處理成功率」而非「CPU + backlog」。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>Pub/Sub 進階主題：Ack deadline / autoscaling signal 設計。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Pub/Sub vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/red-team-delivery-layer/" data-link-title="3.5 攻擊者視角（紅隊）：傳遞層弱點判讀" data-link-desc="從重複投遞、重放濫用、毒訊息與容量壓力，盤點 message delivery 的主要弱點">3.5 紅隊章&lt;/a>。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://engineering.atspotify.com/2017/11/autoscaling-pub-sub-consumers">Autoscaling Pub/Sub Consumers&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明「subscription backlog 不等於 consumer healthy」、autoscaling 跟 ack deadline 的耦合風險。</p>
<h2 id="觀察">觀察</h2>
<p>下游 Cloud Storage export 失敗時、consumer 不 ack 仍持續消耗 CPU 處理同批訊息、造成 autoscaling 把 CPU 越拉越高的反效果；解法是 exponential backoff 抑制 CPU 消耗。</p>
<h2 id="判讀">判讀</h2>
<p>「Subscription backlog 不等於 consumer healthy」— 訊息未 ack 累積跟 autoscaling 的耦合風險。揭露 autoscale signal 該看「處理成功率」而非「CPU + backlog」。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>Pub/Sub 進階主題：Ack deadline / autoscaling signal 設計。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Pub/Sub vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/red-team-delivery-layer/" data-link-title="3.5 攻擊者視角（紅隊）：傳遞層弱點判讀" data-link-desc="從重複投遞、重放濫用、毒訊息與容量壓力，盤點 message delivery 的主要弱點">3.5 紅隊章</a>。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://engineering.atspotify.com/2017/11/autoscaling-pub-sub-consumers">Autoscaling Pub/Sub Consumers</a></li>
</ul>
]]></content:encoded></item><item><title>3.C62 Spotify：Pub/Sub → GCS reliable export</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-spotify-cloud-storage-export/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-spotify-cloud-storage-export/</guid><description>&lt;p>這個案例的核心責任是說明 ack 是 end-to-end commit 信號、不是 buffer-flush 信號。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>Consumer 只在下游 Completionist 回 200 OK 才 ack 回 Pub/Sub、並用「Oldest Unacknowledged Message」metric 判斷 hourly bucket 何時可安全關閉；ack semantics 直接綁定下游 commit。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>ack 是 end-to-end commit 信號、不是 buffer-flush 信號。揭露為什麼後來原生 GCS subscription 有價值（Spotify 早期沒有原生、自建管線）。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>Pub/Sub 進階主題：Ack deadline / Cloud Storage subscription（早期無原生、自建對照）。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Pub/Sub vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/event-contract-replay-boundary/" data-link-title="3.7 Event Contract 與 Replay Boundary" data-link-desc="說明 event schema、idempotency key、replay window 與補償如何先於 broker 選型。">3.7 event contract / replay boundary&lt;/a>。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://engineering.atspotify.com/2017/04/reliable-export-of-cloud-pubsub-streams-to-cloud-storage">Reliable Export of Cloud Pub/Sub Streams to Cloud Storage&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 ack 是 end-to-end commit 信號、不是 buffer-flush 信號。</p>
<h2 id="觀察">觀察</h2>
<p>Consumer 只在下游 Completionist 回 200 OK 才 ack 回 Pub/Sub、並用「Oldest Unacknowledged Message」metric 判斷 hourly bucket 何時可安全關閉；ack semantics 直接綁定下游 commit。</p>
<h2 id="判讀">判讀</h2>
<p>ack 是 end-to-end commit 信號、不是 buffer-flush 信號。揭露為什麼後來原生 GCS subscription 有價值（Spotify 早期沒有原生、自建管線）。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>Pub/Sub 進階主題：Ack deadline / Cloud Storage subscription（早期無原生、自建對照）。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Pub/Sub vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/event-contract-replay-boundary/" data-link-title="3.7 Event Contract 與 Replay Boundary" data-link-desc="說明 event schema、idempotency key、replay window 與補償如何先於 broker 選型。">3.7 event contract / replay boundary</a>。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://engineering.atspotify.com/2017/04/reliable-export-of-cloud-pubsub-streams-to-cloud-storage">Reliable Export of Cloud Pub/Sub Streams to Cloud Storage</a></li>
</ul>
]]></content:encoded></item><item><title>3.C63 Mercari Actionable History：ack deadline 是 batch-level</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-mercari-actionable-history/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-mercari-actionable-history/</guid><description>&lt;p>這個案例的核心責任是揭露 Pub/Sub client lib 「ack deadline 是 batch-level」這個真實的工程陷阱。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>Merpay 支付流水帳服務用 Pub/Sub 做 async messaging、靠 nack 控制處理順序；踩到「ack deadline 是整批 batch 而非單訊息」、acked 訊息會跟同 batch 其他 expired/nacked 訊息一起 redeliver 的設計細節。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>「ack deadline 是 batch-level」是 Pub/Sub client lib 真實的工程陷阱；idempotency 是處理 duplicate 的必要設計、新出的 exactly-once delivery 才有機會降低重複量。揭露 client lib 的批次語意會「污染」單訊息 ack。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>Pub/Sub 進階主題：Ack deadline / Push vs Pull / Ordering key（exactly-once / ordering 章節）。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Pub/Sub vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9 反例：語義誤配&lt;/a>。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://engineering.mercari.com/en/blog/entry/20221212-merpay-actionable-history-displaying-millions-of-payments-with-lightning-speed/">Merpay Actionable History: Displaying Millions of Payments with Lightning Speed&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是揭露 Pub/Sub client lib 「ack deadline 是 batch-level」這個真實的工程陷阱。</p>
<h2 id="觀察">觀察</h2>
<p>Merpay 支付流水帳服務用 Pub/Sub 做 async messaging、靠 nack 控制處理順序；踩到「ack deadline 是整批 batch 而非單訊息」、acked 訊息會跟同 batch 其他 expired/nacked 訊息一起 redeliver 的設計細節。</p>
<h2 id="判讀">判讀</h2>
<p>「ack deadline 是 batch-level」是 Pub/Sub client lib 真實的工程陷阱；idempotency 是處理 duplicate 的必要設計、新出的 exactly-once delivery 才有機會降低重複量。揭露 client lib 的批次語意會「污染」單訊息 ack。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>Pub/Sub 進階主題：Ack deadline / Push vs Pull / Ordering key（exactly-once / ordering 章節）。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Pub/Sub vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9 反例：語義誤配</a>。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://engineering.mercari.com/en/blog/entry/20221212-merpay-actionable-history-displaying-millions-of-payments-with-lightning-speed/">Merpay Actionable History: Displaying Millions of Payments with Lightning Speed</a></li>
</ul>
]]></content:encoded></item><item><title>3.C64 Mercari Item Feed：DLT 防 poison message 阻塞</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-mercari-item-feed-dlt/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-mercari-item-feed-dlt/</guid><description>&lt;p>這個案例的核心責任是說明 DLT 在防止 poison message 阻塞 pipeline 的角色。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>商品 feed 同步用 pull subscription + 自家 batch requester、成功時 ack 整批、失敗時 nack 讓 Pub/Sub 重送；重試多次仍失敗則送 Dead-letter topic、後續訊息優先處理；topic 同時當突發流量的緩衝。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>直接示範 DLT 在防止 poison message 阻塞 pipeline 的角色、以及把 topic 當 load-leveling queue 的設計。揭露「topic = buffer + dispatch」雙重角色。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>Pub/Sub 進階主題：Dead-letter topic / Push vs Pull subscription。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Pub/Sub vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/sqs-postnl-mission-critical-ebe/" data-link-title="3.C56 PostNL EBE：完整 DLQ &amp;#43; retention &amp;#43; redrive 設計" data-link-desc="PostNL 物流每天 1000 萬訊息、每 producer/consumer 隔離 stack、24h 內 100 次 retry、final DLQ 可 consumer redrive。">3.C56 PostNL EBE&lt;/a>（DLQ 設計對照）。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://engineering.mercari.com/en/blog/entry/20241212-mercaris-seamless-item-feed-integration-bridging-the-gap-between-systems/">Mercari&amp;rsquo;s Seamless Item Feed Integration&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 DLT 在防止 poison message 阻塞 pipeline 的角色。</p>
<h2 id="觀察">觀察</h2>
<p>商品 feed 同步用 pull subscription + 自家 batch requester、成功時 ack 整批、失敗時 nack 讓 Pub/Sub 重送；重試多次仍失敗則送 Dead-letter topic、後續訊息優先處理；topic 同時當突發流量的緩衝。</p>
<h2 id="判讀">判讀</h2>
<p>直接示範 DLT 在防止 poison message 阻塞 pipeline 的角色、以及把 topic 當 load-leveling queue 的設計。揭露「topic = buffer + dispatch」雙重角色。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>Pub/Sub 進階主題：Dead-letter topic / Push vs Pull subscription。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Pub/Sub vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/cases/sqs-postnl-mission-critical-ebe/" data-link-title="3.C56 PostNL EBE：完整 DLQ &#43; retention &#43; redrive 設計" data-link-desc="PostNL 物流每天 1000 萬訊息、每 producer/consumer 隔離 stack、24h 內 100 次 retry、final DLQ 可 consumer redrive。">3.C56 PostNL EBE</a>（DLQ 設計對照）。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://engineering.mercari.com/en/blog/entry/20241212-mercaris-seamless-item-feed-integration-bridging-the-gap-between-systems/">Mercari&rsquo;s Seamless Item Feed Integration</a></li>
</ul>
]]></content:encoded></item><item><title>3.C65 Mercari LINE：Pull subscription 對齊外部 RPS</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-mercari-line-flow-control/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-mercari-line-flow-control/</guid><description>&lt;p>這個案例的核心責任是說明「下游有 RPS 限制」是 Pull subscription 勝過 push 的典型情境。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>Braze webhook 進來後轉成 Pub/Sub event、下游 LINE worker pull subscription「精確控制每秒處理訊息數」、因為外部 LINE API 有 RPS 限制。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>push 會把流量瞬間打到 endpoint、pull 可由 consumer 自行 throttle。揭露 push vs pull 不是「實作偏好」、是「下游能否接受 push 衝擊」的判讀。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>Pub/Sub 進階主題：Push vs Pull subscription。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Pub/Sub vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/sqs-twilio-webhook-buffer/" data-link-title="3.C58 Twilio：SQS 緩衝高流量 webhook" data-link-desc="Twilio 教用 SQS 緩衝 SMS / status callback webhook、分 queue（SMS vs callback）、long polling 減 cost、FIFO 300 TPS 上限要分片。">3.C58 Twilio webhook buffer&lt;/a>（webhook + buffer 對照）。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://engineering.mercari.com/en/blog/entry/20231212-flow-control-challenges-in-mercaris-line-integration/">Flow Control Challenges in Mercari&amp;rsquo;s LINE Integration&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明「下游有 RPS 限制」是 Pull subscription 勝過 push 的典型情境。</p>
<h2 id="觀察">觀察</h2>
<p>Braze webhook 進來後轉成 Pub/Sub event、下游 LINE worker pull subscription「精確控制每秒處理訊息數」、因為外部 LINE API 有 RPS 限制。</p>
<h2 id="判讀">判讀</h2>
<p>push 會把流量瞬間打到 endpoint、pull 可由 consumer 自行 throttle。揭露 push vs pull 不是「實作偏好」、是「下游能否接受 push 衝擊」的判讀。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>Pub/Sub 進階主題：Push vs Pull subscription。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Pub/Sub vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/cases/sqs-twilio-webhook-buffer/" data-link-title="3.C58 Twilio：SQS 緩衝高流量 webhook" data-link-desc="Twilio 教用 SQS 緩衝 SMS / status callback webhook、分 queue（SMS vs callback）、long polling 減 cost、FIFO 300 TPS 上限要分片。">3.C58 Twilio webhook buffer</a>（webhook + buffer 對照）。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://engineering.mercari.com/en/blog/entry/20231212-flow-control-challenges-in-mercaris-line-integration/">Flow Control Challenges in Mercari&rsquo;s LINE Integration</a></li>
</ul>
]]></content:encoded></item><item><title>3.C66 Mercari B2C：自建 PubSub gRPC Pusher</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-mercari-b2c-grpc-pusher/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-mercari-b2c-grpc-pusher/</guid><description>&lt;p>這個案例的核心責任是說明原生 push subscription 在特定場景的限制、逼出自建層的工程選擇。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>全球商品同步系統、自建 in-house「PubSub gRPC Pusher」（Pub/Sub 的 gRPC 版 push subscription）解決高吞吐 / 長 job / 彈性 RPS；同時用 message ID 做去重、timestamp 驗證解決重複 + 亂序。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>原生 HTTP push subscription 在「長 job + 高吞吐 + 動態 rate」場景的限制、逼出自建層的工程選擇。揭露 managed broker 的「原生功能」不是所有場景的終點。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>Pub/Sub 進階主題：Push vs Pull subscription / Ordering key（亂序的 application-level 處理）。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Pub/Sub vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計&lt;/a>。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://engineering.mercari.com/en/blog/entry/20251009-from-local-to-global-building-seamless-b2c-product-integration-at-mercari/">From Local to Global: Building Seamless B2C Product Integration at Mercari&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明原生 push subscription 在特定場景的限制、逼出自建層的工程選擇。</p>
<h2 id="觀察">觀察</h2>
<p>全球商品同步系統、自建 in-house「PubSub gRPC Pusher」（Pub/Sub 的 gRPC 版 push subscription）解決高吞吐 / 長 job / 彈性 RPS；同時用 message ID 做去重、timestamp 驗證解決重複 + 亂序。</p>
<h2 id="判讀">判讀</h2>
<p>原生 HTTP push subscription 在「長 job + 高吞吐 + 動態 rate」場景的限制、逼出自建層的工程選擇。揭露 managed broker 的「原生功能」不是所有場景的終點。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>Pub/Sub 進階主題：Push vs Pull subscription / Ordering key（亂序的 application-level 處理）。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Pub/Sub vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計</a>。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://engineering.mercari.com/en/blog/entry/20251009-from-local-to-global-building-seamless-b2c-product-integration-at-mercari/">From Local to Global: Building Seamless B2C Product Integration at Mercari</a></li>
</ul>
]]></content:encoded></item><item><title>3.C67 Niantic Pokémon GO：Pub/Sub 當 telemetry ingest</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-niantic-pokemon-go-telemetry/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-niantic-pokemon-go-telemetry/</guid><description>&lt;p>這個案例的核心責任是說明大規模遊戲 telemetry 的 ingest backbone 設計。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>Pokémon GO frontend 把玩家事件 publish 到 Pub/Sub topic 餵分析 pipeline、再進 BigQuery streaming；高峰 ~1M TPS、Pub/Sub 是 managed service 因此 SRE 維運成本低。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>Pub/Sub 在 publisher 突發流量下作為 elastic buffer、下游 BigQuery streaming 是常見組合。揭露「managed service 的 SRE 成本」是大規模遊戲場景的關鍵選型理由。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>Pub/Sub 進階主題：BigQuery subscription（原生 BQ subscription 出現前的 Dataflow pattern）。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Pub/Sub vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-wix-clickstream-dashboard/" data-link-title="3.C68 Wix：Pub/Sub decouple &amp;#43; Dataflow &amp;#43; BQ archive" data-link-desc="Wix App Engine 收 clickstream 進 Pub/Sub、Dataflow 進 Datastore &amp;lt; 100ms、BigQuery 並行存 raw recovery。">3.C68 Wix clickstream&lt;/a>（同類組合）。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://cloud.google.com/blog/topics/developers-practitioners/how-pok%C3%A9mon-go-scales-millions-requests">How Pokémon GO Scales to Millions of Requests&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明大規模遊戲 telemetry 的 ingest backbone 設計。</p>
<h2 id="觀察">觀察</h2>
<p>Pokémon GO frontend 把玩家事件 publish 到 Pub/Sub topic 餵分析 pipeline、再進 BigQuery streaming；高峰 ~1M TPS、Pub/Sub 是 managed service 因此 SRE 維運成本低。</p>
<h2 id="判讀">判讀</h2>
<p>Pub/Sub 在 publisher 突發流量下作為 elastic buffer、下游 BigQuery streaming 是常見組合。揭露「managed service 的 SRE 成本」是大規模遊戲場景的關鍵選型理由。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>Pub/Sub 進階主題：BigQuery subscription（原生 BQ subscription 出現前的 Dataflow pattern）。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Pub/Sub vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/cases/pubsub-wix-clickstream-dashboard/" data-link-title="3.C68 Wix：Pub/Sub decouple &#43; Dataflow &#43; BQ archive" data-link-desc="Wix App Engine 收 clickstream 進 Pub/Sub、Dataflow 進 Datastore &lt; 100ms、BigQuery 並行存 raw recovery。">3.C68 Wix clickstream</a>（同類組合）。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://cloud.google.com/blog/topics/developers-practitioners/how-pok%C3%A9mon-go-scales-millions-requests">How Pokémon GO Scales to Millions of Requests</a></li>
</ul>
]]></content:encoded></item><item><title>3.C68 Wix：Pub/Sub decouple + Dataflow + BQ archive</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-wix-clickstream-dashboard/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-wix-clickstream-dashboard/</guid><description>&lt;p>這個案例的核心責任是「Pub/Sub buffer + Dataflow stream processor + BQ archive」的教科書組合。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>App Engine 收 clickstream → 進 Cloud Pub/Sub queue、再由 Dataflow streaming 處理進 Datastore、dashboard 端到端 latency &amp;lt; 100ms；BigQuery 並行存 raw data 做 recovery。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>「Pub/Sub 當 decouple buffer + Dataflow 當 stream processor + BigQuery 當 raw archive」的 textbook 組合、可作為 BigQuery subscription 出現前的對比 case（為什麼後來原生 BQ subscription 能省掉 Dataflow 中介層）。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>Pub/Sub 進階主題：BigQuery subscription / Push vs Pull。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Pub/Sub vendor 頁&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-niantic-pokemon-go-telemetry/" data-link-title="3.C67 Niantic Pokémon GO：Pub/Sub 當 telemetry ingest" data-link-desc="Pokémon GO frontend publish 玩家事件、~1M TPS、Pub/Sub elastic buffer、下游 BigQuery streaming。">3.C67 Niantic Pokémon GO&lt;/a>（同類組合）。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://cloud.google.com/customers/wix">Wix Customer Story&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是「Pub/Sub buffer + Dataflow stream processor + BQ archive」的教科書組合。</p>
<h2 id="觀察">觀察</h2>
<p>App Engine 收 clickstream → 進 Cloud Pub/Sub queue、再由 Dataflow streaming 處理進 Datastore、dashboard 端到端 latency &lt; 100ms；BigQuery 並行存 raw data 做 recovery。</p>
<h2 id="判讀">判讀</h2>
<p>「Pub/Sub 當 decouple buffer + Dataflow 當 stream processor + BigQuery 當 raw archive」的 textbook 組合、可作為 BigQuery subscription 出現前的對比 case（為什麼後來原生 BQ subscription 能省掉 Dataflow 中介層）。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>Pub/Sub 進階主題：BigQuery subscription / Push vs Pull。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Pub/Sub vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/cases/pubsub-niantic-pokemon-go-telemetry/" data-link-title="3.C67 Niantic Pokémon GO：Pub/Sub 當 telemetry ingest" data-link-desc="Pokémon GO frontend publish 玩家事件、~1M TPS、Pub/Sub elastic buffer、下游 BigQuery streaming。">3.C67 Niantic Pokémon GO</a>（同類組合）。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://cloud.google.com/customers/wix">Wix Customer Story</a></li>
</ul>
]]></content:encoded></item><item><title>3.C69 Twitter Ad Engagement：把 stream 切成多 topic 做 partition</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-twitter-ad-engagement/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-twitter-ad-engagement/</guid><description>&lt;p>這個案例的核心責任是說明 Pub/Sub 沒有 Kafka-style partition 概念下的應對策略。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>Twitter 把 on-prem 服務的 Avro-formatted 訊息 push 到 Pub/Sub（兩條 stream、較不關鍵但量大的那條 ~80K msg/s 切成 6 個 topic）、下游用 Dataflow + Beam 處理進 Bigtable / BigQuery。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>「把單一 high-volume stream 切成多 topic 做 partition」是 Pub/Sub 沒有 Kafka-style partition 概念下的應對策略。揭露 Pub/Sub 跟 Kafka 的選型差異不是 feature parity、是不同的擴張模型。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>Pub/Sub 進階主題：Schema enforcement（Avro 是常見 schema 候選）/ Ordering key（topic 切分 vs ordering key 的取捨）。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Pub/Sub vendor 頁&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 vendor 頁&lt;/a>（partition 對照）。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://cloud.google.com/blog/products/data-analytics/modernizing-twitters-ad-engagement-analytics-platform">Modernizing Twitter&amp;rsquo;s Ad Engagement Analytics Platform&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 Pub/Sub 沒有 Kafka-style partition 概念下的應對策略。</p>
<h2 id="觀察">觀察</h2>
<p>Twitter 把 on-prem 服務的 Avro-formatted 訊息 push 到 Pub/Sub（兩條 stream、較不關鍵但量大的那條 ~80K msg/s 切成 6 個 topic）、下游用 Dataflow + Beam 處理進 Bigtable / BigQuery。</p>
<h2 id="判讀">判讀</h2>
<p>「把單一 high-volume stream 切成多 topic 做 partition」是 Pub/Sub 沒有 Kafka-style partition 概念下的應對策略。揭露 Pub/Sub 跟 Kafka 的選型差異不是 feature parity、是不同的擴張模型。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>Pub/Sub 進階主題：Schema enforcement（Avro 是常見 schema 候選）/ Ordering key（topic 切分 vs ordering key 的取捨）。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Pub/Sub vendor 頁</a> 與 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka vendor 頁</a>（partition 對照）。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://cloud.google.com/blog/products/data-analytics/modernizing-twitters-ad-engagement-analytics-platform">Modernizing Twitter&rsquo;s Ad Engagement Analytics Platform</a></li>
</ul>
]]></content:encoded></item><item><title>Consumer Group</title><link>https://tarrragon.github.io/blog/backend/knowledge-cards/consumer-group/</link><pubDate>Mon, 22 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/knowledge-cards/consumer-group/</guid><description>&lt;p>Consumer group 的核心概念是「一組 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer&lt;/a> 共同承擔某個 stream 或 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic&lt;/a> 的處理進度」。同一 group 內的 consumer 分攤工作（每筆訊息只被 group 內的一個 consumer 處理）；不同 group 可以各自獨立處理同一批事件，實現 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/fan-out/" data-link-title="Fan-out" data-link-desc="說明單一事件同時分發給多個下游的訊息拓撲">fan-out&lt;/a>。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>Consumer group 是事件流跟多服務訂閱的協調模型。分析服務、搜尋索引服務、通知服務可以用不同 group 讀同一 topic — 每個 group 有自己的 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/offset/" data-link-title="Offset" data-link-desc="說明 consumer 在事件流中的讀取位置與重放基準">offset&lt;/a> 進度跟 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer-lag/" data-link-title="Consumer Lag" data-link-desc="說明 consumer lag 如何反映訊息堆積、處理能力與容量風險">consumer lag&lt;/a>。&lt;/p>
&lt;p>在 Kafka 中，consumer group 是一級概念、由 group coordinator 管理 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/partition/" data-link-title="Partition" data-link-desc="說明事件流如何切分成多個可並行處理的有序片段">partition&lt;/a> 分配（rebalance）。在 Redis Streams 中對應 consumer group（XREADGROUP）。在 RabbitMQ 中沒有原生 consumer group — 多個 consumer 連到同一個 queue 就是 competing consumers、不同 queue 綁到同一個 exchange 就是 fan-out。&lt;/p>
&lt;h2 id="使用情境">使用情境&lt;/h2>
&lt;p>系統需要 consumer group 的訊號是同一事件要被多個系統各自處理。訂單事件同時給出貨、通知與報表 — 三個 consumer group 各自有自己的處理速度、錯誤率跟重放流程。&lt;/p>
&lt;p>Consumer group 的 rebalance（partition 重新分配）是 Kafka 生態的常見運維議題。Consumer 加入或離開 group 時觸發 rebalance、rebalance 期間 partition 暫時無人消費、造成短暫的處理停頓。Rebalance 時間跟 partition 數量、consumer 數量有關。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>Consumer group 要設計 group 名稱（跟服務名稱對齊、方便辨識）、offset / checkpoint 策略（auto-commit vs manual commit）、rebalance 行為（cooperative vs eager）、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer-lag/" data-link-title="Consumer Lag" data-link-desc="說明 consumer lag 如何反映訊息堆積、處理能力與容量風險">consumer lag&lt;/a> 告警閾值與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/replay-runbook/" data-link-title="Replay Runbook" data-link-desc="說明事件重放前需要控制的範圍、順序、驗證與副作用">replay runbook&lt;/a> 權限。不同 group 的失敗應分開觀測跟處理 — 通知 group 落後不應影響出貨 group 的監控判讀。&lt;/p></description><content:encoded><![CDATA[<p>Consumer group 的核心概念是「一組 <a href="/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer</a> 共同承擔某個 stream 或 <a href="/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic</a> 的處理進度」。同一 group 內的 consumer 分攤工作（每筆訊息只被 group 內的一個 consumer 處理）；不同 group 可以各自獨立處理同一批事件，實現 <a href="/blog/backend/knowledge-cards/fan-out/" data-link-title="Fan-out" data-link-desc="說明單一事件同時分發給多個下游的訊息拓撲">fan-out</a>。</p>
<h2 id="概念位置">概念位置</h2>
<p>Consumer group 是事件流跟多服務訂閱的協調模型。分析服務、搜尋索引服務、通知服務可以用不同 group 讀同一 topic — 每個 group 有自己的 <a href="/blog/backend/knowledge-cards/offset/" data-link-title="Offset" data-link-desc="說明 consumer 在事件流中的讀取位置與重放基準">offset</a> 進度跟 <a href="/blog/backend/knowledge-cards/consumer-lag/" data-link-title="Consumer Lag" data-link-desc="說明 consumer lag 如何反映訊息堆積、處理能力與容量風險">consumer lag</a>。</p>
<p>在 Kafka 中，consumer group 是一級概念、由 group coordinator 管理 <a href="/blog/backend/knowledge-cards/partition/" data-link-title="Partition" data-link-desc="說明事件流如何切分成多個可並行處理的有序片段">partition</a> 分配（rebalance）。在 Redis Streams 中對應 consumer group（XREADGROUP）。在 RabbitMQ 中沒有原生 consumer group — 多個 consumer 連到同一個 queue 就是 competing consumers、不同 queue 綁到同一個 exchange 就是 fan-out。</p>
<h2 id="使用情境">使用情境</h2>
<p>系統需要 consumer group 的訊號是同一事件要被多個系統各自處理。訂單事件同時給出貨、通知與報表 — 三個 consumer group 各自有自己的處理速度、錯誤率跟重放流程。</p>
<p>Consumer group 的 rebalance（partition 重新分配）是 Kafka 生態的常見運維議題。Consumer 加入或離開 group 時觸發 rebalance、rebalance 期間 partition 暫時無人消費、造成短暫的處理停頓。Rebalance 時間跟 partition 數量、consumer 數量有關。</p>
<h2 id="設計責任">設計責任</h2>
<p>Consumer group 要設計 group 名稱（跟服務名稱對齊、方便辨識）、offset / checkpoint 策略（auto-commit vs manual commit）、rebalance 行為（cooperative vs eager）、<a href="/blog/backend/knowledge-cards/consumer-lag/" data-link-title="Consumer Lag" data-link-desc="說明 consumer lag 如何反映訊息堆積、處理能力與容量風險">consumer lag</a> 告警閾值與 <a href="/blog/backend/knowledge-cards/replay-runbook/" data-link-title="Replay Runbook" data-link-desc="說明事件重放前需要控制的範圍、順序、驗證與副作用">replay runbook</a> 權限。不同 group 的失敗應分開觀測跟處理 — 通知 group 落後不應影響出貨 group 的監控判讀。</p>
]]></content:encoded></item><item><title>Partition</title><link>https://tarrragon.github.io/blog/backend/knowledge-cards/partition/</link><pubDate>Mon, 22 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/knowledge-cards/partition/</guid><description>&lt;p>Partition 的核心概念是「把事件流切分成多個可並行處理的片段」。同一 partition 內保留順序，不同 partition 可以平行處理。Partition 數量決定 consumer 的最大並行度 — 一個 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer-group/" data-link-title="Consumer Group" data-link-desc="說明一組 consumer 如何共同分攤 stream 或 topic 的處理責任">consumer group&lt;/a> 中 consumer 數量不能超過 partition 數量。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>Partition 是 throughput、ordering 與 hot key 之間的取捨核心。它跟 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic&lt;/a> 的關係是：topic 是邏輯分類（order events、payment events），partition 是 topic 內的物理分片。Partition key 決定同一類事件會落到哪個 partition；選錯 key 會造成 hot partition（單一 partition 過載）或讓需要順序的事件被拆散。&lt;/p>
&lt;p>在 Kafka 中 partition 是一級概念；RabbitMQ 沒有原生 partition（用多個 queue + consistent hash exchange 模擬）；SQS 沒有顯式 partition（內部自動分片）。&lt;/p>
&lt;h2 id="使用情境">使用情境&lt;/h2>
&lt;p>系統需要 partition 設計的訊號是事件量大且需要水平擴展處理能力。訂單事件可以用 order_id 作為 partition key，讓同一訂單的事件保留順序；若所有高流量商家的訂單都 hash 到同一個 partition，會形成 hot partition。&lt;/p>
&lt;p>Partition 數量也影響 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/offset/" data-link-title="Offset" data-link-desc="說明 consumer 在事件流中的讀取位置與重放基準">offset&lt;/a> 管理的複雜度 — 每個 partition 有獨立的 offset，consumer group 的 rebalance 要重新分配 partition ownership。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>Partition 設計要定義 partition key（通常是業務實體 ID）、partition 數量（建議初期設多一點，Kafka partition 數量只能增加不能減少）、順序需求（同 key 保序 vs 全域保序）與 lag 觀測（per-partition lag 能定位 hot partition）。重新分 partition 可能影響順序、consumer group 配置與 replay 範圍。&lt;/p></description><content:encoded><![CDATA[<p>Partition 的核心概念是「把事件流切分成多個可並行處理的片段」。同一 partition 內保留順序，不同 partition 可以平行處理。Partition 數量決定 consumer 的最大並行度 — 一個 <a href="/blog/backend/knowledge-cards/consumer-group/" data-link-title="Consumer Group" data-link-desc="說明一組 consumer 如何共同分攤 stream 或 topic 的處理責任">consumer group</a> 中 consumer 數量不能超過 partition 數量。</p>
<h2 id="概念位置">概念位置</h2>
<p>Partition 是 throughput、ordering 與 hot key 之間的取捨核心。它跟 <a href="/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic</a> 的關係是：topic 是邏輯分類（order events、payment events），partition 是 topic 內的物理分片。Partition key 決定同一類事件會落到哪個 partition；選錯 key 會造成 hot partition（單一 partition 過載）或讓需要順序的事件被拆散。</p>
<p>在 Kafka 中 partition 是一級概念；RabbitMQ 沒有原生 partition（用多個 queue + consistent hash exchange 模擬）；SQS 沒有顯式 partition（內部自動分片）。</p>
<h2 id="使用情境">使用情境</h2>
<p>系統需要 partition 設計的訊號是事件量大且需要水平擴展處理能力。訂單事件可以用 order_id 作為 partition key，讓同一訂單的事件保留順序；若所有高流量商家的訂單都 hash 到同一個 partition，會形成 hot partition。</p>
<p>Partition 數量也影響 <a href="/blog/backend/knowledge-cards/offset/" data-link-title="Offset" data-link-desc="說明 consumer 在事件流中的讀取位置與重放基準">offset</a> 管理的複雜度 — 每個 partition 有獨立的 offset，consumer group 的 rebalance 要重新分配 partition ownership。</p>
<h2 id="設計責任">設計責任</h2>
<p>Partition 設計要定義 partition key（通常是業務實體 ID）、partition 數量（建議初期設多一點，Kafka partition 數量只能增加不能減少）、順序需求（同 key 保序 vs 全域保序）與 lag 觀測（per-partition lag 能定位 hot partition）。重新分 partition 可能影響順序、consumer group 配置與 replay 範圍。</p>
]]></content:encoded></item><item><title>Offset</title><link>https://tarrragon.github.io/blog/backend/knowledge-cards/offset/</link><pubDate>Mon, 22 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/knowledge-cards/offset/</guid><description>&lt;p>Offset 的核心概念是「consumer 在事件流中的讀取位置」。它是 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer-group/" data-link-title="Consumer Group" data-link-desc="說明一組 consumer 如何共同分攤 stream 或 topic 的處理責任">consumer group&lt;/a> 的進度記錄，讓 consumer 知道自己已經處理到哪裡，也讓系統可以從某個位置繼續或重放。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>Offset 是 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer-group/" data-link-title="Consumer Group" data-link-desc="說明一組 consumer 如何共同分攤 stream 或 topic 的處理責任">consumer group&lt;/a> 的進度記錄、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer-lag/" data-link-title="Consumer Lag" data-link-desc="說明 consumer lag 如何反映訊息堆積、處理能力與容量風險">consumer lag&lt;/a> 的計算基準、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/replay-runbook/" data-link-title="Replay Runbook" data-link-desc="說明事件重放前需要控制的範圍、順序、驗證與副作用">replay runbook&lt;/a> 的起點定位。在 Kafka 中，offset 是每個 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/partition/" data-link-title="Partition" data-link-desc="說明事件流如何切分成多個可並行處理的有序片段">partition&lt;/a> 內的遞增整數；在 Redis Streams 中是 entry ID（timestamp-sequence）；在 SQS 中沒有顯式 offset，改用 visibility timeout 控制消費進度。&lt;/p>
&lt;p>Offset 提交太早（處理前就 commit）可能造成處理遺失 — consumer crash 後從已 commit 的位置繼續，跳過未完成的訊息。提交太晚（處理完成很久才 commit）可能造成重複處理 — consumer crash 後從舊 offset 重新開始，重複處理已完成的訊息。&lt;/p>
&lt;h2 id="使用情境">使用情境&lt;/h2>
&lt;p>系統需要理解 offset 的訊號是 consumer 重啟後需要接續處理。報表 consumer 處理到某個 offset 後 crash，重啟時要從安全位置繼續，並用 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency&lt;/a> 承受可能重複的事件。&lt;/p>
&lt;p>Offset 也是 replay 操作的控制參數。「重設 offset 到三天前」意味著 consumer group 會從三天前的位置重新處理所有事件 — 下游需要有 idempotent 設計才能承受重播。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>Offset 提交策略要和業務處理完成條件對齊。Auto-commit（定期自動提交）實作簡單但在 crash 時有遺失風險；manual commit（處理完成後手動提交）更安全但程式碼更複雜。Runbook 應說明如何查 current offset、committed offset、lag、重設 offset 的操作步驟與 replay 對下游的影響。&lt;/p></description><content:encoded><![CDATA[<p>Offset 的核心概念是「consumer 在事件流中的讀取位置」。它是 <a href="/blog/backend/knowledge-cards/consumer-group/" data-link-title="Consumer Group" data-link-desc="說明一組 consumer 如何共同分攤 stream 或 topic 的處理責任">consumer group</a> 的進度記錄，讓 consumer 知道自己已經處理到哪裡，也讓系統可以從某個位置繼續或重放。</p>
<h2 id="概念位置">概念位置</h2>
<p>Offset 是 <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/consumer-lag/" data-link-title="Consumer Lag" data-link-desc="說明 consumer lag 如何反映訊息堆積、處理能力與容量風險">consumer lag</a> 的計算基準、<a href="/blog/backend/knowledge-cards/replay-runbook/" data-link-title="Replay Runbook" data-link-desc="說明事件重放前需要控制的範圍、順序、驗證與副作用">replay runbook</a> 的起點定位。在 Kafka 中，offset 是每個 <a href="/blog/backend/knowledge-cards/partition/" data-link-title="Partition" data-link-desc="說明事件流如何切分成多個可並行處理的有序片段">partition</a> 內的遞增整數；在 Redis Streams 中是 entry ID（timestamp-sequence）；在 SQS 中沒有顯式 offset，改用 visibility timeout 控制消費進度。</p>
<p>Offset 提交太早（處理前就 commit）可能造成處理遺失 — consumer crash 後從已 commit 的位置繼續，跳過未完成的訊息。提交太晚（處理完成很久才 commit）可能造成重複處理 — consumer crash 後從舊 offset 重新開始，重複處理已完成的訊息。</p>
<h2 id="使用情境">使用情境</h2>
<p>系統需要理解 offset 的訊號是 consumer 重啟後需要接續處理。報表 consumer 處理到某個 offset 後 crash，重啟時要從安全位置繼續，並用 <a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a> 承受可能重複的事件。</p>
<p>Offset 也是 replay 操作的控制參數。「重設 offset 到三天前」意味著 consumer group 會從三天前的位置重新處理所有事件 — 下游需要有 idempotent 設計才能承受重播。</p>
<h2 id="設計責任">設計責任</h2>
<p>Offset 提交策略要和業務處理完成條件對齊。Auto-commit（定期自動提交）實作簡單但在 crash 時有遺失風險；manual commit（處理完成後手動提交）更安全但程式碼更複雜。Runbook 應說明如何查 current offset、committed offset、lag、重設 offset 的操作步驟與 replay 對下游的影響。</p>
]]></content:encoded></item><item><title>模組三案例正文</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/cases/</link><pubDate>Thu, 07 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/cases/</guid><description>&lt;p>這個資料夾的核心責任是把 broker、queue 與語義治理的轉換壓力落到可執行判讀、並提供各 vendor 的真實 production case 庫支撐撰寫。案例不是事後舉例、是寫作 finding 的 source — 章節該討論的議題從 case 反推、不是先寫章節再找案例填。&lt;/p>
&lt;h2 id="通用案例跨-vendor--反例--規模對照">通用案例（跨 vendor / 反例 / 規模對照）&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>章節&lt;/th>
 &lt;th>主題&lt;/th>
 &lt;th>核心責任&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/meta-foqs-global-migration/" data-link-title="3.C1 Meta：FOQS 從區域到全域佇列遷移" data-link-desc="佇列架構如何在不中斷下升級成 disaster-ready 模式。">3.C1&lt;/a>&lt;/td>
 &lt;td>Meta FOQS 全域遷移&lt;/td>
 &lt;td>區域佇列如何升級到 disaster-ready 架構&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/vmware-kafka-to-msk/" data-link-title="3.C2 VMware Tanzu CloudHealth：Kafka 轉 Amazon MSK" data-link-desc="自管 Kafka 遷移到託管平台時的治理重點。">3.C2&lt;/a>&lt;/td>
 &lt;td>VMware Kafka → MSK&lt;/td>
 &lt;td>自管 broker 轉 managed streaming 的治理重點&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/linkedin-topicgc-kafka-governance/" data-link-title="3.C3 LinkedIn：TopicGC 與 Kafka 治理轉換" data-link-desc="Kafka topic 從手動治理轉自動治理對叢集的影響。">3.C3&lt;/a>&lt;/td>
 &lt;td>LinkedIn TopicGC&lt;/td>
 &lt;td>topic 生命週期治理如何影響叢集可靠性&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/linkedin-kafka-tiered-clusters/" data-link-title="3.C4 LinkedIn：Kafka 分層叢集治理" data-link-desc="Kafka 從單叢集走向 tiered clusters 的轉換案例。">3.C4&lt;/a>&lt;/td>
 &lt;td>LinkedIn Kafka 分層&lt;/td>
 &lt;td>把單叢集使用模式轉成分層叢集治理&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/slack-job-queue-kafka-redis/" data-link-title="3.C5 Slack：Job Queue 演進到 Kafka &amp;#43; Redis" data-link-desc="背景工作通道在成長期如何從單一路徑演進成組合式架構。">3.C5&lt;/a>&lt;/td>
 &lt;td>Slack Job Queue&lt;/td>
 &lt;td>背景工作通道轉成 Kafka + Redis 組合&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/uber-kafka-infrastructure-evolution/" data-link-title="3.C6 Uber：Kafka 事件平台演進" data-link-desc="事件平台從團隊自管走向多租戶共享基礎設施。">3.C6&lt;/a>&lt;/td>
 &lt;td>Uber Kafka 基礎設施&lt;/td>
 &lt;td>把事件平台演進成多租戶共享能力&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/linkedin-kafka-self-healing-automation/" data-link-title="3.C7 LinkedIn：Kafka 自動修復治理" data-link-desc="Kafka 維運從人工處置轉向自動修復的案例。">3.C7&lt;/a>&lt;/td>
 &lt;td>LinkedIn Self-healing Kafka&lt;/td>
 &lt;td>把手動維運轉成自動修復治理&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/cloudflare-queues-global-delivery-model/" data-link-title="3.C8 Cloudflare：Queues 全球交付模型" data-link-desc="事件佇列服務在全球網路下的交付語義與治理案例。">3.C8&lt;/a>&lt;/td>
 &lt;td>Cloudflare Queues&lt;/td>
 &lt;td>把全球佇列傳遞模型轉成可治理交付路徑&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9&lt;/a>&lt;/td>
 &lt;td>反例：語義切換失敗&lt;/td>
 &lt;td>at-least-once / exactly-once 語義誤配造成資料錯亂&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/contrast-queue-model-by-scale/" data-link-title="3.C10 對照：規模差異下的佇列模型" data-link-desc="同一 queue 模型在不同規模下的治理與失敗邊界差異。">3.C10&lt;/a>&lt;/td>
 &lt;td>對照：規模差異下佇列模型&lt;/td>
 &lt;td>同一佇列模型在不同規模下有不同治理與失敗邊界&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="kafka-案例">Kafka 案例&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>章節&lt;/th>
 &lt;th>公司 / 主題&lt;/th>
 &lt;th>對應 Kafka 大綱章節&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-pinterest-tiered-storage/" data-link-title="3.C11 Pinterest：Kafka tiered storage broker-decoupled" data-link-desc="Pinterest 採 broker-decoupled tiered storage、把 ~200 TB/day 熱資料卸到 S3、broker 不再是熱路徑。">3.C11&lt;/a>&lt;/td>
 &lt;td>Pinterest Tiered Storage&lt;/td>
 &lt;td>Tiered storage&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-pinterest-shallow-mirror/" data-link-title="3.C12 Pinterest：Shallow Mirror 優化 MirrorMaker" data-link-desc="Pinterest 跨 3 region MirrorMaker、原版解壓&amp;#43;重壓造成 CPU/memory 2-10x spike、改 RecordBatch 層淺迭代。">3.C12&lt;/a>&lt;/td>
 &lt;td>Pinterest Shallow Mirror&lt;/td>
 &lt;td>Cross-region MirrorMaker&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-shopify-debezium-cdc/" data-link-title="3.C13 Shopify：Debezium CDC over sharded MySQL" data-link-desc="Shopify 100&amp;#43; MySQL shard、150 Debezium connector、Black Friday 100K records/sec P99 &amp;lt; 10s。">3.C13&lt;/a>&lt;/td>
 &lt;td>Shopify Debezium CDC&lt;/td>
 &lt;td>Kafka Connect / CDC&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-yelp-schematizer/" data-link-title="3.C14 Yelp：Schematizer 自建 Schema Registry" data-link-desc="Yelp data pipeline 強制所有 message 走 Avro、自建 Schematizer 做 schema evolution 與 topic 自動分配。">3.C14&lt;/a>&lt;/td>
 &lt;td>Yelp Schematizer&lt;/td>
 &lt;td>Schema Registry / Schema evolution&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-airbnb-spark-streaming-rebalance/" data-link-title="3.C15 Airbnb：Spark Streaming Kafka reader rebalance" data-link-desc="Airbnb logging pipeline 解 partition-task 1:1 造成的 data skew、catch-up 4 小時 lag 要再花 4 小時的反效率。">3.C15&lt;/a>&lt;/td>
 &lt;td>Airbnb Spark Streaming&lt;/td>
 &lt;td>Consumer 設計 / partition + consumer group&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-robinhood-faust-python-streaming/" data-link-title="3.C16 Robinhood：Faust Python stream processing" data-link-desc="Robinhood 每天 billions of events、Python 團隊不想用 JVM 生態、把 Kafka Streams 移植到 Python。">3.C16&lt;/a>&lt;/td>
 &lt;td>Robinhood Faust&lt;/td>
 &lt;td>跨語言 client / stream processing&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-walmart-mps-rebalance/" data-link-title="3.C17 Walmart：Messaging Proxy Service 解 rebalance storm" data-link-desc="Walmart 每天 trillions of message、25K&amp;#43; consumer 在 K8s、partition-consumer 1:1 模型撞到擴張極限。">3.C17&lt;/a>&lt;/td>
 &lt;td>Walmart MPS&lt;/td>
 &lt;td>Rebalance storm / consumer lag / multi-tenant&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-wix-greyhound-troubleshooting/" data-link-title="3.C18 Wix：Greyhound TLLSR 解 consumer 卡住" data-link-desc="Wix 2000&amp;#43; microservice 66B msg/day、自建 Greyhound 抽象、TLLSR 框架解 single-partition lag / poison pill / handler 卡住。">3.C18&lt;/a>&lt;/td>
 &lt;td>Wix Greyhound&lt;/td>
 &lt;td>Consumer lag / observability / poison message&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-wix-multi-cluster-migration/" data-link-title="3.C19 Wix：Multi-cluster Kafka zero-downtime 遷移" data-link-desc="Wix metadata 從 5K topic 漲到 20K topic / 200K partition、controller startup 跟 broker stability 受壓垮、分多 cluster 解決。">3.C19&lt;/a>&lt;/td>
 &lt;td>Wix Multi-cluster&lt;/td>
 &lt;td>Topic 生命週期 / 分層叢集&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-spotify-event-delivery-exodus/" data-link-title="3.C20 Spotify：Event Delivery 從 Kafka 遷出（反例）" data-link-desc="Spotify Kafka 0.7 MirrorMaker best-effort 會掉資料但回報成功、broker restart 後 producer 無法恢復、決定遷到 GCP Pub/Sub。">3.C20&lt;/a>&lt;/td>
 &lt;td>Spotify 遷出 Kafka（反例）&lt;/td>
 &lt;td>Replication 失敗模式 / producer 可靠性&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-goldman-sachs-msk-migration/" data-link-title="3.C21 Goldman Sachs：MSK 遷移 with MirrorMaker 2" data-link-desc="Goldman Sachs Global Investment Research 從 on-prem Kafka 遷到 MSK、用 MM2 同步 topic/ACL/offset、atomic cutover 7 小時完成。">3.C21&lt;/a>&lt;/td>
 &lt;td>Goldman Sachs MSK&lt;/td>
 &lt;td>Cross-region MirrorMaker / managed broker 遷移&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-trivago-keda-scale-to-zero/" data-link-title="3.C22 Trivago：KEDA scale-to-zero by Kafka lag" data-link-desc="Trivago 50&amp;#43; Kafka sink、CPU/mem autoscaling 無效（I/O bottleneck）、KEDA 以 consumer lag 為訊號達到 scale-to-zero。">3.C22&lt;/a>&lt;/td>
 &lt;td>Trivago KEDA&lt;/td>
 &lt;td>Consumer lag / autoscaling&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="rabbitmq-案例">RabbitMQ 案例&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>章節&lt;/th>
 &lt;th>公司 / 主題&lt;/th>
 &lt;th>對應 RabbitMQ 大綱章節&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/rabbitmq-bloomberg-multi-tenant-vhost/" data-link-title="3.C23 Bloomberg：多租戶 vhost &amp;#43; 自助平台化" data-link-desc="Bloomberg 從幾個團隊推到上百個團隊、靠自助 vhost 註冊跟專用叢集分離應用與 broker。">3.C23&lt;/a>&lt;/td>
 &lt;td>Bloomberg vhost 多租戶&lt;/td>
 &lt;td>多 vhost + 多租戶 / Erlang clustering&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/rabbitmq-soundcloud-fanout-audio/" data-link-title="3.C24 SoundCloud：AMQP fan-out 音訊處理 pipeline" data-link-desc="SoundCloud 每秒 20-30K persistent message、不同處理類型分開隊列、各自獨立 scale。">3.C24&lt;/a>&lt;/td>
 &lt;td>SoundCloud fan-out 音訊&lt;/td>
 &lt;td>Prefetch + consumer 併發 / Streams&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/rabbitmq-indeed-delay-dlq-escalation/" data-link-title="3.C25 Indeed：Delay queue &amp;#43; DLQ 三層 escalation" data-link-desc="Indeed 每天 35M&amp;#43; 職缺、設計 Requeue → Delay queue → DLQ 三層 escalation 避開 head-of-line blocking。">3.C25&lt;/a>&lt;/td>
 &lt;td>Indeed Delay + DLQ&lt;/td>
 &lt;td>Dead-letter exchange / retry 策略&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/rabbitmq-gocardless-hutch-service-mesh/" data-link-title="3.C26 GoCardless：Hutch &amp;#43; 單一 topic exchange service mesh" data-link-desc="GoCardless 單一 RabbitMQ cluster 作所有 service 通訊中樞、routing key 用 service.subject.action 格式、JSON 多語言可讀。">3.C26&lt;/a>&lt;/td>
 &lt;td>GoCardless Hutch service mesh&lt;/td>
 &lt;td>Exchange types / 多 vhost（反向）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/rabbitmq-zalando-aws-master-selection/" data-link-title="3.C27 Zalando：RabbitMQ on AWS 自動化 master selection" data-link-desc="Zalando 用 sidekick 服務查 AWS API 動態識別 cluster、指定最老 instance 當 master、跨版本升級用 federation 過渡。">3.C27&lt;/a>&lt;/td>
 &lt;td>Zalando AWS master selection&lt;/td>
 &lt;td>Erlang clustering / Federation / Operator&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/rabbitmq-wework-consistent-hash-ordering/" data-link-title="3.C28 WeWork：Consistent hash exchange 保證帳戶順序" data-link-desc="WeWork 固定數量 queue &amp;#43; account ID hash 路由、每 queue 一個 worker &amp;#43; exclusive consumer 保 partition-level ordering。">3.C28&lt;/a>&lt;/td>
 &lt;td>WeWork consistent hash&lt;/td>
 &lt;td>Exchange types / partition-level ordering&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/rabbitmq-wework-bunny-channel-pool/" data-link-title="3.C29 WeWork：Bunny &amp;#43; Puma 多執行緒 channel pool" data-link-desc="WeWork 從 Unicorn 切到 Puma 後遇 ConnectionClosedError、根因是 AMQP channel 跨執行緒共用、改用 connection_pool 管理。">3.C29&lt;/a>&lt;/td>
 &lt;td>WeWork Bunny channel pool&lt;/td>
 &lt;td>Prefetch + consumer 併發（client lib）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/rabbitmq-runtastic-mirrored-queue-bottleneck/" data-link-title="3.C30 Runtastic：Mirrored queue 網路負載瓶頸" data-link-desc="Runtastic 2020 lockdown 流量暴增、performance test 揭露 mirroring 邏輯把網路元件壓垮、調整 mirroring 配置消除瓶頸。">3.C30&lt;/a>&lt;/td>
 &lt;td>Runtastic mirrored queue 瓶頸&lt;/td>
 &lt;td>Mirrored queue → Quorum queue 遷移&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/rabbitmq-mozilla-pulse-naming-isolation/" data-link-title="3.C31 Mozilla Pulse：命名前綴 &amp;#43; ACL 取代 vhost 多租戶" data-link-desc="Mozilla Pulse 不用 vhost、改用權限 &amp;#43; 命名前綴 (exchange/{user}/*) 做隔離、CloudAMQP 託管、PulseGuardian 管使用者。">3.C31&lt;/a>&lt;/td>
 &lt;td>Mozilla Pulse naming isolation&lt;/td>
 &lt;td>多 vhost + 多租戶（反向：用 ACL + naming）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/rabbitmq-loyaltylion-monitoring-thousands/" data-link-title="3.C32 LoyaltyLion：監控數千 RabbitMQ queue" data-link-desc="LoyaltyLion 跑數千 queue、用 rabbitmqctl &amp;#43; statsd 推 Datadog、揭露大規模 queue 拓樸下原生 plugin API 不夠用。">3.C32&lt;/a>&lt;/td>
 &lt;td>LoyaltyLion 監控數千 queue&lt;/td>
 &lt;td>監控觀測 / Operator&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/rabbitmq-wargaming-game-portal-decoupling/" data-link-title="3.C33 Wargaming：World of Tanks 戰後 dossier 解耦" data-link-desc="Wargaming WoT server 全 Linux、戰後 dossier 寫 RabbitMQ、portal 顯示統計而不增 game server load。">3.C33&lt;/a>&lt;/td>
 &lt;td>Wargaming game portal 解耦&lt;/td>
 &lt;td>Federation + Shovel / 多 vhost&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="nats-案例">NATS 案例&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>章節&lt;/th>
 &lt;th>公司 / 主題&lt;/th>
 &lt;th>對應 NATS 大綱章節&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/nats-netlify-data-plane-fanout/" data-link-title="3.C34 Netlify：NATS 當全球 metrics/logs 統一資料平面" data-link-desc="Netlify 70K&amp;#43; 網站、10 億 PV/月、跨多雲、NATS 當 all-purpose data plane fan-out bus、超 RabbitMQ 評估。">3.C34&lt;/a>&lt;/td>
 &lt;td>Netlify 全球資料平面 fan-out&lt;/td>
 &lt;td>Core NATS vs JetStream / subject-based routing&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/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&amp;#43;JetStream 跨雲 6x 延遲改善。">3.C35&lt;/a>&lt;/td>
 &lt;td>Form3 多雲低延遲支付&lt;/td>
 &lt;td>Cluster + Supercluster + Leaf node / JetStream&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/nats-intelecy-industrial-iot/" data-link-title="3.C36 Intelecy：工業 IoT 即時感測 &amp;#43; 多租戶" data-link-desc="Intelecy 工廠 gateway 接數萬感測器、&amp;lt; 2 秒往返延遲做即時 ML、從 BoltDB 本地快取演進到 JetStream 持久化。">3.C36&lt;/a>&lt;/td>
 &lt;td>Intelecy 工業 IoT&lt;/td>
 &lt;td>JetStream stream / Subject-based ACL&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/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 &amp;#43; JetStream &amp;#43; KV &amp;#43; Object Store。">3.C37&lt;/a>&lt;/td>
 &lt;td>MachineMetrics edge to cloud&lt;/td>
 &lt;td>Leaf node / KV + Object Store / 多租戶 ACL&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&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。">3.C38&lt;/a>&lt;/td>
 &lt;td>Clarifai NATS Streaming ML&lt;/td>
 &lt;td>JetStream consumer 設計 / Queue groups&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/nats-choria-orchestration-fleet/" data-link-title="3.C39 Choria：NATS 管 50 萬 server fleet" data-link-desc="Choria 替代 Puppet MCollective、NATS 單 binary 無 Zookeeper、4GB node 可達 50 萬 server、wildcard &amp;#43; queue group 做 scatter-gather RPC。">3.C39&lt;/a>&lt;/td>
 &lt;td>Choria fleet orchestration&lt;/td>
 &lt;td>Request/Reply / Queue groups / Supercluster&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/nats-resgate-realtime-api-gateway/" data-link-title="3.C40 Resgate：WebSocket-to-NATS realtime API gateway" data-link-desc="Resgate 把 NATS subject 暴露成 REST &amp;#43; WebSocket、subject 階層當 schema、event 延遲 &amp;lt; 1ms、純 Core NATS。">3.C40&lt;/a>&lt;/td>
 &lt;td>Resgate WebSocket-to-NATS&lt;/td>
 &lt;td>Request/Reply / Subject ACL / Core NATS&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/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&amp;#43; OT/IT connector、客戶含 Bosch / Sto / Lenze、NATS 當邊緣到 central 整合 bus。">3.C41&lt;/a>&lt;/td>
 &lt;td>i-flow OT/IT 整合&lt;/td>
 &lt;td>Cluster + Supercluster + Leaf node&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="redis-streams-案例">Redis Streams 案例&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>章節&lt;/th>
 &lt;th>公司 / 主題&lt;/th>
 &lt;th>對應 Redis Streams 大綱章節&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/redis-streams-bitso-reliable-streams/" data-link-title="3.C42 Bitso：Reliable Redis Streams 抽象 &amp;#43; 自建 DLQ" data-link-desc="Bitso 加密交易所、千 msg/sec/stream &amp;#43; 亞毫秒延遲、自建 Reliable Streams 封裝 PEL &amp;#43; retry &amp;#43; DLQ、idempotent processing。">3.C42&lt;/a>&lt;/td>
 &lt;td>Bitso Reliable Streams + DLQ&lt;/td>
 &lt;td>Consumer group + PEL / XCLAIM / Sentinel&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/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。">3.C43&lt;/a>&lt;/td>
 &lt;td>Arcjet 取代 Kafka 省 6 位數 $&lt;/td>
 &lt;td>Retention / Memory 取捨&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/redis-streams-harness-event-driven-state/" data-link-title="3.C44 Harness：CD 微服務 async state transfer" data-link-desc="Harness CD 平台用 Redis Streams 解 brittle HTTP、揭露監控缺口 / MAXLEN truncation / head-of-line blocking 三類問題。">3.C44&lt;/a>&lt;/td>
 &lt;td>Harness CD async state transfer&lt;/td>
 &lt;td>Consumer group + PEL / XCLAIM / Memory&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/redis-streams-klaxit-rust-log-pipeline/" data-link-title="3.C45 Klaxit：Rust &amp;#43; Redis Streams 處理 Heroku Logplex" data-link-desc="Klaxit carpool 用 Redis Streams 處理 Heroku Logplex 匯流、自動偵測修復平台 perf 問題、6 個月 production Rust。">3.C45&lt;/a>&lt;/td>
 &lt;td>Klaxit Rust + Heroku Logplex&lt;/td>
 &lt;td>XADD / XREADGROUP / Consumer group&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/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&amp;#43;EBS 變 latency 痛點、退到 PostgreSQL。">3.C46&lt;/a>&lt;/td>
 &lt;td>Learning.com 退場（反例）&lt;/td>
 &lt;td>Memory + retention / Sentinel 可靠性&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/redis-streams-mateusz-php-microservices/" data-link-title="3.C47 PHP 微服務：Redis Streams &amp;#43; S3 hybrid storage" data-link-desc="PHP 雙微服務通訊、Kafka 在 PHP 生態工具薄弱、用 Redis Streams &amp;#43; payload compression &amp;#43; S3 hybrid 處理大訊息。">3.C47&lt;/a>&lt;/td>
 &lt;td>PHP 微服務 + S3 hybrid&lt;/td>
 &lt;td>XADD/XREAD / Retention / Memory&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="aws-sqs-案例">AWS SQS 案例&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>章節&lt;/th>
 &lt;th>公司 / 主題&lt;/th>
 &lt;th>對應 SQS 大綱章節&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/sqs-airbnb-dynein-delayed-jobs/" data-link-title="3.C48 Airbnb Dynein：SQS 分散式延遲任務排程" data-link-desc="Airbnb 用 SQS at-least-once &amp;#43; DLQ 取代 Resque 單 Redis 限制、每 scheduler 1000 QPS、SQS wrap DynamoDB 處理 &amp;gt; 15 分鐘 delay。">3.C48&lt;/a>&lt;/td>
 &lt;td>Airbnb Dynein 延遲任務&lt;/td>
 &lt;td>Standard vs FIFO / DLQ 設計&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/sqs-airbnb-inspekt-data-protection/" data-link-title="3.C49 Airbnb Inspekt：Visibility timeout 當 retry budget" data-link-desc="Airbnb Inspekt 隱私掃描器、scanner pull message、visibility timeout 自然觸發重現、用重現次數當 retry budget。">3.C49&lt;/a>&lt;/td>
 &lt;td>Airbnb Inspekt visibility timeout&lt;/td>
 &lt;td>Visibility timeout + in-flight&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/sqs-capital-one-visibility-timeout/" data-link-title="3.C50 Capital One：Visibility timeout 設計與 Lambda event source" data-link-desc="Capital One tech blog 講 SQS &amp;#43; Lambda：visibility timeout 應略高於最大處理時間、Lambda 初 5 個 long polling、可擴 60/min。">3.C50&lt;/a>&lt;/td>
 &lt;td>Capital One visibility timeout&lt;/td>
 &lt;td>Visibility timeout / SQS + Lambda&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/sqs-atlassian-jirt-kinesis-sqs/" data-link-title="3.C51 Atlassian JiRT：Kinesis &amp;#43; SQS subscription" data-link-desc="Atlassian StreamHub Kinesis 底層、每 consumer 自己一個 SQS queue、JiRT 把輪詢 1 min 改成秒級 event-driven。">3.C51&lt;/a>&lt;/td>
 &lt;td>Atlassian JiRT Kinesis + SQS&lt;/td>
 &lt;td>Standard vs FIFO / fan-out subscription&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/sqs-nielsen-spark-eks-dual-queue/" data-link-title="3.C52 Nielsen：Spark on EKS 雙 SQS 工作流" data-link-desc="Nielsen 每日 25TB / 30B event、work queue &amp;#43; completion queue 雙 SQS、queue depth autoscale EKS pod。">3.C52&lt;/a>&lt;/td>
 &lt;td>Nielsen Spark on EKS 雙 SQS&lt;/td>
 &lt;td>CloudWatch metric / autoscaling&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/sqs-finra-large-file-service/" data-link-title="3.C53 FINRA：S3 → SQS notification 大檔上傳" data-link-desc="FINRA 金融監管、broker 上傳大檔、S3 → SQS notification → LFS、KMS &amp;#43; bucket policy &amp;#43; queue policy 三層稽核。">3.C53&lt;/a>&lt;/td>
 &lt;td>FINRA S3 → SQS 合規&lt;/td>
 &lt;td>SQS + Lambda / IAM 多層&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/sqs-twitch-eventsub-fanout/" data-link-title="3.C54 Twitch EventSub：SNS&amp;#43;SQS fan-out 給第三方" data-link-desc="Twitch Event Bus ~1660 events/sec 進 SNS、EventSub 用 SQS 接收 &amp;#43; Dispatcher fan-out 給訂閱者。">3.C54&lt;/a>&lt;/td>
 &lt;td>Twitch EventSub SNS+SQS&lt;/td>
 &lt;td>Standard queue / SNS-SQS fan-out&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/sqs-smugmug-search-pipeline/" data-link-title="3.C55 SmugMug：SQS 驅動可重放搜尋管線" data-link-desc="SmugMug 用 SQS 兩種模式：DynamoDB scan-segment 平行 backfill &amp;#43; production query 鏡像 replay 到 replica。">3.C55&lt;/a>&lt;/td>
 &lt;td>SmugMug 搜尋管線 backfill&lt;/td>
 &lt;td>Standard queue / Long polling / Lambda&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/sqs-postnl-mission-critical-ebe/" data-link-title="3.C56 PostNL EBE：完整 DLQ &amp;#43; retention &amp;#43; redrive 設計" data-link-desc="PostNL 物流每天 1000 萬訊息、每 producer/consumer 隔離 stack、24h 內 100 次 retry、final DLQ 可 consumer redrive。">3.C56&lt;/a>&lt;/td>
 &lt;td>PostNL EBE 完整 DLQ + redrive&lt;/td>
 &lt;td>DLQ 設計 / CloudWatch alarm / Cost&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/sqs-lob-sqs-consumer-library/" data-link-title="3.C57 Lob：自家 fork @lob/sqs-consumer 修 FIFO bug" data-link-desc="Lob 原用 bbc/sqs-consumer 鎖 SDK v2、fork 出 @lob/sqs-consumer 支援 SDK v3 &amp;#43; TypeScript &amp;#43; 修 FIFO bug。">3.C57&lt;/a>&lt;/td>
 &lt;td>Lob @lob/sqs-consumer&lt;/td>
 &lt;td>Standard vs FIFO / Client library&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/sqs-twilio-webhook-buffer/" data-link-title="3.C58 Twilio：SQS 緩衝高流量 webhook" data-link-desc="Twilio 教用 SQS 緩衝 SMS / status callback webhook、分 queue（SMS vs callback）、long polling 減 cost、FIFO 300 TPS 上限要分片。">3.C58&lt;/a>&lt;/td>
 &lt;td>Twilio SQS 緩衝 webhook&lt;/td>
 &lt;td>Long polling / Standard vs FIFO&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/sqs-rapid7-scale-billion-messages/" data-link-title="3.C59 Rapid7：SQS 100 億 message/day 規模" data-link-desc="Rapid7 公開引述：SQS 撐 10s of billions of messages per day、是架構關鍵元件、scale 量級的具體參考。">3.C59&lt;/a>&lt;/td>
 &lt;td>Rapid7 100 億 msg/day 規模&lt;/td>
 &lt;td>Cost 模型 / Standard queue&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="google-pubsub-案例">Google Pub/Sub 案例&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>章節&lt;/th>
 &lt;th>公司 / 主題&lt;/th>
 &lt;th>對應 Pub/Sub 大綱章節&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-spotify-event-delivery-platform/" data-link-title="3.C60 Spotify：Event Delivery 從 Kafka 遷到 Pub/Sub" data-link-desc="Spotify 全球 event delivery 從 Kafka 遷到 Pub/Sub、~2500 VM、Q1 2019 8M events/s、350TB/day raw、自建 dedup。">3.C60&lt;/a>&lt;/td>
 &lt;td>Spotify Event Delivery 遷入&lt;/td>
 &lt;td>Pub/Sub vs Lite / Push vs Pull / Ack deadline&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-spotify-autoscaling-consumers/" data-link-title="3.C61 Spotify：Autoscaling Pub/Sub consumer 反效果" data-link-desc="Spotify 下游失敗時 consumer 不 ack 仍耗 CPU、autoscaling 越拉越高、解法是 exponential backoff 抑制 CPU。">3.C61&lt;/a>&lt;/td>
 &lt;td>Spotify Autoscaling 反效果&lt;/td>
 &lt;td>Ack deadline / autoscaling signal&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-spotify-cloud-storage-export/" data-link-title="3.C62 Spotify：Pub/Sub → GCS reliable export" data-link-desc="Spotify 用 Oldest Unacknowledged Message metric 判斷 hourly bucket 何時可安全關閉、ack 綁定下游 commit。">3.C62&lt;/a>&lt;/td>
 &lt;td>Spotify reliable GCS export&lt;/td>
 &lt;td>Ack deadline / Cloud Storage subscription&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-mercari-actionable-history/" data-link-title="3.C63 Mercari Actionable History：ack deadline 是 batch-level" data-link-desc="Merpay 支付流水帳用 Pub/Sub、ack deadline 是整批 batch 而非單訊息、acked 訊息會跟同批 expired 一起 redeliver。">3.C63&lt;/a>&lt;/td>
 &lt;td>Mercari ack deadline batch-level&lt;/td>
 &lt;td>Ack deadline / Push vs Pull / Ordering&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-mercari-item-feed-dlt/" data-link-title="3.C64 Mercari Item Feed：DLT 防 poison message 阻塞" data-link-desc="Mercari 商品 feed 同步、ack 整批 / nack 重送、重試多次仍失敗送 DLT、topic 同時當 load-leveling buffer。">3.C64&lt;/a>&lt;/td>
 &lt;td>Mercari Item Feed DLT&lt;/td>
 &lt;td>Dead-letter topic / Push vs Pull&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-mercari-line-flow-control/" data-link-title="3.C65 Mercari LINE：Pull subscription 對齊外部 RPS" data-link-desc="Mercari LINE webhook 轉 Pub/Sub、worker pull subscription 精確控制 RPS、應 LINE API 限制。">3.C65&lt;/a>&lt;/td>
 &lt;td>Mercari LINE 對齊外部 RPS&lt;/td>
 &lt;td>Push vs Pull subscription&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-mercari-b2c-grpc-pusher/" data-link-title="3.C66 Mercari B2C：自建 PubSub gRPC Pusher" data-link-desc="Mercari 全球商品同步、原生 HTTP push 在「長 job &amp;#43; 高吞吐 &amp;#43; 動態 RPS」場景受限、自建 gRPC 版 push。">3.C66&lt;/a>&lt;/td>
 &lt;td>Mercari B2C 自建 gRPC pusher&lt;/td>
 &lt;td>Push vs Pull / Ordering 應用層處理&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-niantic-pokemon-go-telemetry/" data-link-title="3.C67 Niantic Pokémon GO：Pub/Sub 當 telemetry ingest" data-link-desc="Pokémon GO frontend publish 玩家事件、~1M TPS、Pub/Sub elastic buffer、下游 BigQuery streaming。">3.C67&lt;/a>&lt;/td>
 &lt;td>Niantic Pokémon GO telemetry&lt;/td>
 &lt;td>BigQuery subscription（pattern 對照）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-wix-clickstream-dashboard/" data-link-title="3.C68 Wix：Pub/Sub decouple &amp;#43; Dataflow &amp;#43; BQ archive" data-link-desc="Wix App Engine 收 clickstream 進 Pub/Sub、Dataflow 進 Datastore &amp;lt; 100ms、BigQuery 並行存 raw recovery。">3.C68&lt;/a>&lt;/td>
 &lt;td>Wix clickstream + Dataflow + BQ&lt;/td>
 &lt;td>BigQuery subscription / Push vs Pull&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-twitter-ad-engagement/" data-link-title="3.C69 Twitter Ad Engagement：把 stream 切成多 topic 做 partition" data-link-desc="Twitter 把 80K msg/s stream 切成 6 個 topic 做 partition、Avro schema、Beam/Dataflow → Bigtable/BQ。">3.C69&lt;/a>&lt;/td>
 &lt;td>Twitter Ad Engagement topic 切分&lt;/td>
 &lt;td>Schema enforcement / Ordering key&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="案例覆蓋缺口待補">案例覆蓋缺口（待補）&lt;/h2>
&lt;p>下列大綱章節在本案例庫中&lt;strong>公開 customer-side case 偏弱或缺&lt;/strong>、撰寫正文時要明示「以下分析依官方文件 / KIP / 通用模式推導、非 case-driven」：&lt;/p></description><content:encoded><![CDATA[<p>這個資料夾的核心責任是把 broker、queue 與語義治理的轉換壓力落到可執行判讀、並提供各 vendor 的真實 production case 庫支撐撰寫。案例不是事後舉例、是寫作 finding 的 source — 章節該討論的議題從 case 反推、不是先寫章節再找案例填。</p>
<h2 id="通用案例跨-vendor--反例--規模對照">通用案例（跨 vendor / 反例 / 規模對照）</h2>
<table>
  <thead>
      <tr>
          <th>章節</th>
          <th>主題</th>
          <th>核心責任</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/meta-foqs-global-migration/" data-link-title="3.C1 Meta：FOQS 從區域到全域佇列遷移" data-link-desc="佇列架構如何在不中斷下升級成 disaster-ready 模式。">3.C1</a></td>
          <td>Meta FOQS 全域遷移</td>
          <td>區域佇列如何升級到 disaster-ready 架構</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/vmware-kafka-to-msk/" data-link-title="3.C2 VMware Tanzu CloudHealth：Kafka 轉 Amazon MSK" data-link-desc="自管 Kafka 遷移到託管平台時的治理重點。">3.C2</a></td>
          <td>VMware Kafka → MSK</td>
          <td>自管 broker 轉 managed streaming 的治理重點</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/linkedin-topicgc-kafka-governance/" data-link-title="3.C3 LinkedIn：TopicGC 與 Kafka 治理轉換" data-link-desc="Kafka topic 從手動治理轉自動治理對叢集的影響。">3.C3</a></td>
          <td>LinkedIn TopicGC</td>
          <td>topic 生命週期治理如何影響叢集可靠性</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/linkedin-kafka-tiered-clusters/" data-link-title="3.C4 LinkedIn：Kafka 分層叢集治理" data-link-desc="Kafka 從單叢集走向 tiered clusters 的轉換案例。">3.C4</a></td>
          <td>LinkedIn Kafka 分層</td>
          <td>把單叢集使用模式轉成分層叢集治理</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/slack-job-queue-kafka-redis/" data-link-title="3.C5 Slack：Job Queue 演進到 Kafka &#43; Redis" data-link-desc="背景工作通道在成長期如何從單一路徑演進成組合式架構。">3.C5</a></td>
          <td>Slack Job Queue</td>
          <td>背景工作通道轉成 Kafka + Redis 組合</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/uber-kafka-infrastructure-evolution/" data-link-title="3.C6 Uber：Kafka 事件平台演進" data-link-desc="事件平台從團隊自管走向多租戶共享基礎設施。">3.C6</a></td>
          <td>Uber Kafka 基礎設施</td>
          <td>把事件平台演進成多租戶共享能力</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/linkedin-kafka-self-healing-automation/" data-link-title="3.C7 LinkedIn：Kafka 自動修復治理" data-link-desc="Kafka 維運從人工處置轉向自動修復的案例。">3.C7</a></td>
          <td>LinkedIn Self-healing Kafka</td>
          <td>把手動維運轉成自動修復治理</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/cloudflare-queues-global-delivery-model/" data-link-title="3.C8 Cloudflare：Queues 全球交付模型" data-link-desc="事件佇列服務在全球網路下的交付語義與治理案例。">3.C8</a></td>
          <td>Cloudflare Queues</td>
          <td>把全球佇列傳遞模型轉成可治理交付路徑</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9</a></td>
          <td>反例：語義切換失敗</td>
          <td>at-least-once / exactly-once 語義誤配造成資料錯亂</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/contrast-queue-model-by-scale/" data-link-title="3.C10 對照：規模差異下的佇列模型" data-link-desc="同一 queue 模型在不同規模下的治理與失敗邊界差異。">3.C10</a></td>
          <td>對照：規模差異下佇列模型</td>
          <td>同一佇列模型在不同規模下有不同治理與失敗邊界</td>
      </tr>
  </tbody>
</table>
<h2 id="kafka-案例">Kafka 案例</h2>
<table>
  <thead>
      <tr>
          <th>章節</th>
          <th>公司 / 主題</th>
          <th>對應 Kafka 大綱章節</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/kafka-pinterest-tiered-storage/" data-link-title="3.C11 Pinterest：Kafka tiered storage broker-decoupled" data-link-desc="Pinterest 採 broker-decoupled tiered storage、把 ~200 TB/day 熱資料卸到 S3、broker 不再是熱路徑。">3.C11</a></td>
          <td>Pinterest Tiered Storage</td>
          <td>Tiered storage</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/kafka-pinterest-shallow-mirror/" data-link-title="3.C12 Pinterest：Shallow Mirror 優化 MirrorMaker" data-link-desc="Pinterest 跨 3 region MirrorMaker、原版解壓&#43;重壓造成 CPU/memory 2-10x spike、改 RecordBatch 層淺迭代。">3.C12</a></td>
          <td>Pinterest Shallow Mirror</td>
          <td>Cross-region MirrorMaker</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/kafka-shopify-debezium-cdc/" data-link-title="3.C13 Shopify：Debezium CDC over sharded MySQL" data-link-desc="Shopify 100&#43; MySQL shard、150 Debezium connector、Black Friday 100K records/sec P99 &lt; 10s。">3.C13</a></td>
          <td>Shopify Debezium CDC</td>
          <td>Kafka Connect / CDC</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/kafka-yelp-schematizer/" data-link-title="3.C14 Yelp：Schematizer 自建 Schema Registry" data-link-desc="Yelp data pipeline 強制所有 message 走 Avro、自建 Schematizer 做 schema evolution 與 topic 自動分配。">3.C14</a></td>
          <td>Yelp Schematizer</td>
          <td>Schema Registry / Schema evolution</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/kafka-airbnb-spark-streaming-rebalance/" data-link-title="3.C15 Airbnb：Spark Streaming Kafka reader rebalance" data-link-desc="Airbnb logging pipeline 解 partition-task 1:1 造成的 data skew、catch-up 4 小時 lag 要再花 4 小時的反效率。">3.C15</a></td>
          <td>Airbnb Spark Streaming</td>
          <td>Consumer 設計 / partition + consumer group</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/kafka-robinhood-faust-python-streaming/" data-link-title="3.C16 Robinhood：Faust Python stream processing" data-link-desc="Robinhood 每天 billions of events、Python 團隊不想用 JVM 生態、把 Kafka Streams 移植到 Python。">3.C16</a></td>
          <td>Robinhood Faust</td>
          <td>跨語言 client / stream processing</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/kafka-walmart-mps-rebalance/" data-link-title="3.C17 Walmart：Messaging Proxy Service 解 rebalance storm" data-link-desc="Walmart 每天 trillions of message、25K&#43; consumer 在 K8s、partition-consumer 1:1 模型撞到擴張極限。">3.C17</a></td>
          <td>Walmart MPS</td>
          <td>Rebalance storm / consumer lag / multi-tenant</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/kafka-wix-greyhound-troubleshooting/" data-link-title="3.C18 Wix：Greyhound TLLSR 解 consumer 卡住" data-link-desc="Wix 2000&#43; microservice 66B msg/day、自建 Greyhound 抽象、TLLSR 框架解 single-partition lag / poison pill / handler 卡住。">3.C18</a></td>
          <td>Wix Greyhound</td>
          <td>Consumer lag / observability / poison message</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/kafka-wix-multi-cluster-migration/" data-link-title="3.C19 Wix：Multi-cluster Kafka zero-downtime 遷移" data-link-desc="Wix metadata 從 5K topic 漲到 20K topic / 200K partition、controller startup 跟 broker stability 受壓垮、分多 cluster 解決。">3.C19</a></td>
          <td>Wix Multi-cluster</td>
          <td>Topic 生命週期 / 分層叢集</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/kafka-spotify-event-delivery-exodus/" data-link-title="3.C20 Spotify：Event Delivery 從 Kafka 遷出（反例）" data-link-desc="Spotify Kafka 0.7 MirrorMaker best-effort 會掉資料但回報成功、broker restart 後 producer 無法恢復、決定遷到 GCP Pub/Sub。">3.C20</a></td>
          <td>Spotify 遷出 Kafka（反例）</td>
          <td>Replication 失敗模式 / producer 可靠性</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/kafka-goldman-sachs-msk-migration/" data-link-title="3.C21 Goldman Sachs：MSK 遷移 with MirrorMaker 2" data-link-desc="Goldman Sachs Global Investment Research 從 on-prem Kafka 遷到 MSK、用 MM2 同步 topic/ACL/offset、atomic cutover 7 小時完成。">3.C21</a></td>
          <td>Goldman Sachs MSK</td>
          <td>Cross-region MirrorMaker / managed broker 遷移</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/kafka-trivago-keda-scale-to-zero/" data-link-title="3.C22 Trivago：KEDA scale-to-zero by Kafka lag" data-link-desc="Trivago 50&#43; Kafka sink、CPU/mem autoscaling 無效（I/O bottleneck）、KEDA 以 consumer lag 為訊號達到 scale-to-zero。">3.C22</a></td>
          <td>Trivago KEDA</td>
          <td>Consumer lag / autoscaling</td>
      </tr>
  </tbody>
</table>
<h2 id="rabbitmq-案例">RabbitMQ 案例</h2>
<table>
  <thead>
      <tr>
          <th>章節</th>
          <th>公司 / 主題</th>
          <th>對應 RabbitMQ 大綱章節</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/rabbitmq-bloomberg-multi-tenant-vhost/" data-link-title="3.C23 Bloomberg：多租戶 vhost &#43; 自助平台化" data-link-desc="Bloomberg 從幾個團隊推到上百個團隊、靠自助 vhost 註冊跟專用叢集分離應用與 broker。">3.C23</a></td>
          <td>Bloomberg vhost 多租戶</td>
          <td>多 vhost + 多租戶 / Erlang clustering</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/rabbitmq-soundcloud-fanout-audio/" data-link-title="3.C24 SoundCloud：AMQP fan-out 音訊處理 pipeline" data-link-desc="SoundCloud 每秒 20-30K persistent message、不同處理類型分開隊列、各自獨立 scale。">3.C24</a></td>
          <td>SoundCloud fan-out 音訊</td>
          <td>Prefetch + consumer 併發 / Streams</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/rabbitmq-indeed-delay-dlq-escalation/" data-link-title="3.C25 Indeed：Delay queue &#43; DLQ 三層 escalation" data-link-desc="Indeed 每天 35M&#43; 職缺、設計 Requeue → Delay queue → DLQ 三層 escalation 避開 head-of-line blocking。">3.C25</a></td>
          <td>Indeed Delay + DLQ</td>
          <td>Dead-letter exchange / retry 策略</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/rabbitmq-gocardless-hutch-service-mesh/" data-link-title="3.C26 GoCardless：Hutch &#43; 單一 topic exchange service mesh" data-link-desc="GoCardless 單一 RabbitMQ cluster 作所有 service 通訊中樞、routing key 用 service.subject.action 格式、JSON 多語言可讀。">3.C26</a></td>
          <td>GoCardless Hutch service mesh</td>
          <td>Exchange types / 多 vhost（反向）</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/rabbitmq-zalando-aws-master-selection/" data-link-title="3.C27 Zalando：RabbitMQ on AWS 自動化 master selection" data-link-desc="Zalando 用 sidekick 服務查 AWS API 動態識別 cluster、指定最老 instance 當 master、跨版本升級用 federation 過渡。">3.C27</a></td>
          <td>Zalando AWS master selection</td>
          <td>Erlang clustering / Federation / Operator</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/rabbitmq-wework-consistent-hash-ordering/" data-link-title="3.C28 WeWork：Consistent hash exchange 保證帳戶順序" data-link-desc="WeWork 固定數量 queue &#43; account ID hash 路由、每 queue 一個 worker &#43; exclusive consumer 保 partition-level ordering。">3.C28</a></td>
          <td>WeWork consistent hash</td>
          <td>Exchange types / partition-level ordering</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/rabbitmq-wework-bunny-channel-pool/" data-link-title="3.C29 WeWork：Bunny &#43; Puma 多執行緒 channel pool" data-link-desc="WeWork 從 Unicorn 切到 Puma 後遇 ConnectionClosedError、根因是 AMQP channel 跨執行緒共用、改用 connection_pool 管理。">3.C29</a></td>
          <td>WeWork Bunny channel pool</td>
          <td>Prefetch + consumer 併發（client lib）</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/rabbitmq-runtastic-mirrored-queue-bottleneck/" data-link-title="3.C30 Runtastic：Mirrored queue 網路負載瓶頸" data-link-desc="Runtastic 2020 lockdown 流量暴增、performance test 揭露 mirroring 邏輯把網路元件壓垮、調整 mirroring 配置消除瓶頸。">3.C30</a></td>
          <td>Runtastic mirrored queue 瓶頸</td>
          <td>Mirrored queue → Quorum queue 遷移</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/rabbitmq-mozilla-pulse-naming-isolation/" data-link-title="3.C31 Mozilla Pulse：命名前綴 &#43; ACL 取代 vhost 多租戶" data-link-desc="Mozilla Pulse 不用 vhost、改用權限 &#43; 命名前綴 (exchange/{user}/*) 做隔離、CloudAMQP 託管、PulseGuardian 管使用者。">3.C31</a></td>
          <td>Mozilla Pulse naming isolation</td>
          <td>多 vhost + 多租戶（反向：用 ACL + naming）</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/rabbitmq-loyaltylion-monitoring-thousands/" data-link-title="3.C32 LoyaltyLion：監控數千 RabbitMQ queue" data-link-desc="LoyaltyLion 跑數千 queue、用 rabbitmqctl &#43; statsd 推 Datadog、揭露大規模 queue 拓樸下原生 plugin API 不夠用。">3.C32</a></td>
          <td>LoyaltyLion 監控數千 queue</td>
          <td>監控觀測 / Operator</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/rabbitmq-wargaming-game-portal-decoupling/" data-link-title="3.C33 Wargaming：World of Tanks 戰後 dossier 解耦" data-link-desc="Wargaming WoT server 全 Linux、戰後 dossier 寫 RabbitMQ、portal 顯示統計而不增 game server load。">3.C33</a></td>
          <td>Wargaming game portal 解耦</td>
          <td>Federation + Shovel / 多 vhost</td>
      </tr>
  </tbody>
</table>
<h2 id="nats-案例">NATS 案例</h2>
<table>
  <thead>
      <tr>
          <th>章節</th>
          <th>公司 / 主題</th>
          <th>對應 NATS 大綱章節</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/nats-netlify-data-plane-fanout/" data-link-title="3.C34 Netlify：NATS 當全球 metrics/logs 統一資料平面" data-link-desc="Netlify 70K&#43; 網站、10 億 PV/月、跨多雲、NATS 當 all-purpose data plane fan-out bus、超 RabbitMQ 評估。">3.C34</a></td>
          <td>Netlify 全球資料平面 fan-out</td>
          <td>Core NATS vs JetStream / subject-based routing</td>
      </tr>
      <tr>
          <td><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</a></td>
          <td>Form3 多雲低延遲支付</td>
          <td>Cluster + Supercluster + Leaf node / JetStream</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/nats-intelecy-industrial-iot/" data-link-title="3.C36 Intelecy：工業 IoT 即時感測 &#43; 多租戶" data-link-desc="Intelecy 工廠 gateway 接數萬感測器、&lt; 2 秒往返延遲做即時 ML、從 BoltDB 本地快取演進到 JetStream 持久化。">3.C36</a></td>
          <td>Intelecy 工業 IoT</td>
          <td>JetStream stream / Subject-based ACL</td>
      </tr>
      <tr>
          <td><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</a></td>
          <td>MachineMetrics edge to cloud</td>
          <td>Leaf node / KV + Object Store / 多租戶 ACL</td>
      </tr>
      <tr>
          <td><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</a></td>
          <td>Clarifai NATS Streaming ML</td>
          <td>JetStream consumer 設計 / Queue groups</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/nats-choria-orchestration-fleet/" data-link-title="3.C39 Choria：NATS 管 50 萬 server fleet" data-link-desc="Choria 替代 Puppet MCollective、NATS 單 binary 無 Zookeeper、4GB node 可達 50 萬 server、wildcard &#43; queue group 做 scatter-gather RPC。">3.C39</a></td>
          <td>Choria fleet orchestration</td>
          <td>Request/Reply / Queue groups / Supercluster</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/nats-resgate-realtime-api-gateway/" data-link-title="3.C40 Resgate：WebSocket-to-NATS realtime API gateway" data-link-desc="Resgate 把 NATS subject 暴露成 REST &#43; WebSocket、subject 階層當 schema、event 延遲 &lt; 1ms、純 Core NATS。">3.C40</a></td>
          <td>Resgate WebSocket-to-NATS</td>
          <td>Request/Reply / Subject ACL / Core NATS</td>
      </tr>
      <tr>
          <td><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</a></td>
          <td>i-flow OT/IT 整合</td>
          <td>Cluster + Supercluster + Leaf node</td>
      </tr>
  </tbody>
</table>
<h2 id="redis-streams-案例">Redis Streams 案例</h2>
<table>
  <thead>
      <tr>
          <th>章節</th>
          <th>公司 / 主題</th>
          <th>對應 Redis Streams 大綱章節</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><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。">3.C42</a></td>
          <td>Bitso Reliable Streams + DLQ</td>
          <td>Consumer group + PEL / XCLAIM / Sentinel</td>
      </tr>
      <tr>
          <td><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。">3.C43</a></td>
          <td>Arcjet 取代 Kafka 省 6 位數 $</td>
          <td>Retention / Memory 取捨</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/redis-streams-harness-event-driven-state/" data-link-title="3.C44 Harness：CD 微服務 async state transfer" data-link-desc="Harness CD 平台用 Redis Streams 解 brittle HTTP、揭露監控缺口 / MAXLEN truncation / head-of-line blocking 三類問題。">3.C44</a></td>
          <td>Harness CD async state transfer</td>
          <td>Consumer group + PEL / XCLAIM / Memory</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/redis-streams-klaxit-rust-log-pipeline/" data-link-title="3.C45 Klaxit：Rust &#43; Redis Streams 處理 Heroku Logplex" data-link-desc="Klaxit carpool 用 Redis Streams 處理 Heroku Logplex 匯流、自動偵測修復平台 perf 問題、6 個月 production Rust。">3.C45</a></td>
          <td>Klaxit Rust + Heroku Logplex</td>
          <td>XADD / XREADGROUP / Consumer group</td>
      </tr>
      <tr>
          <td><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。">3.C46</a></td>
          <td>Learning.com 退場（反例）</td>
          <td>Memory + retention / Sentinel 可靠性</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/redis-streams-mateusz-php-microservices/" data-link-title="3.C47 PHP 微服務：Redis Streams &#43; S3 hybrid storage" data-link-desc="PHP 雙微服務通訊、Kafka 在 PHP 生態工具薄弱、用 Redis Streams &#43; payload compression &#43; S3 hybrid 處理大訊息。">3.C47</a></td>
          <td>PHP 微服務 + S3 hybrid</td>
          <td>XADD/XREAD / Retention / Memory</td>
      </tr>
  </tbody>
</table>
<h2 id="aws-sqs-案例">AWS SQS 案例</h2>
<table>
  <thead>
      <tr>
          <th>章節</th>
          <th>公司 / 主題</th>
          <th>對應 SQS 大綱章節</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/sqs-airbnb-dynein-delayed-jobs/" data-link-title="3.C48 Airbnb Dynein：SQS 分散式延遲任務排程" data-link-desc="Airbnb 用 SQS at-least-once &#43; DLQ 取代 Resque 單 Redis 限制、每 scheduler 1000 QPS、SQS wrap DynamoDB 處理 &gt; 15 分鐘 delay。">3.C48</a></td>
          <td>Airbnb Dynein 延遲任務</td>
          <td>Standard vs FIFO / DLQ 設計</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/sqs-airbnb-inspekt-data-protection/" data-link-title="3.C49 Airbnb Inspekt：Visibility timeout 當 retry budget" data-link-desc="Airbnb Inspekt 隱私掃描器、scanner pull message、visibility timeout 自然觸發重現、用重現次數當 retry budget。">3.C49</a></td>
          <td>Airbnb Inspekt visibility timeout</td>
          <td>Visibility timeout + in-flight</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/sqs-capital-one-visibility-timeout/" data-link-title="3.C50 Capital One：Visibility timeout 設計與 Lambda event source" data-link-desc="Capital One tech blog 講 SQS &#43; Lambda：visibility timeout 應略高於最大處理時間、Lambda 初 5 個 long polling、可擴 60/min。">3.C50</a></td>
          <td>Capital One visibility timeout</td>
          <td>Visibility timeout / SQS + Lambda</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/sqs-atlassian-jirt-kinesis-sqs/" data-link-title="3.C51 Atlassian JiRT：Kinesis &#43; SQS subscription" data-link-desc="Atlassian StreamHub Kinesis 底層、每 consumer 自己一個 SQS queue、JiRT 把輪詢 1 min 改成秒級 event-driven。">3.C51</a></td>
          <td>Atlassian JiRT Kinesis + SQS</td>
          <td>Standard vs FIFO / fan-out subscription</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/sqs-nielsen-spark-eks-dual-queue/" data-link-title="3.C52 Nielsen：Spark on EKS 雙 SQS 工作流" data-link-desc="Nielsen 每日 25TB / 30B event、work queue &#43; completion queue 雙 SQS、queue depth autoscale EKS pod。">3.C52</a></td>
          <td>Nielsen Spark on EKS 雙 SQS</td>
          <td>CloudWatch metric / autoscaling</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/sqs-finra-large-file-service/" data-link-title="3.C53 FINRA：S3 → SQS notification 大檔上傳" data-link-desc="FINRA 金融監管、broker 上傳大檔、S3 → SQS notification → LFS、KMS &#43; bucket policy &#43; queue policy 三層稽核。">3.C53</a></td>
          <td>FINRA S3 → SQS 合規</td>
          <td>SQS + Lambda / IAM 多層</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/sqs-twitch-eventsub-fanout/" data-link-title="3.C54 Twitch EventSub：SNS&#43;SQS fan-out 給第三方" data-link-desc="Twitch Event Bus ~1660 events/sec 進 SNS、EventSub 用 SQS 接收 &#43; Dispatcher fan-out 給訂閱者。">3.C54</a></td>
          <td>Twitch EventSub SNS+SQS</td>
          <td>Standard queue / SNS-SQS fan-out</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/sqs-smugmug-search-pipeline/" data-link-title="3.C55 SmugMug：SQS 驅動可重放搜尋管線" data-link-desc="SmugMug 用 SQS 兩種模式：DynamoDB scan-segment 平行 backfill &#43; production query 鏡像 replay 到 replica。">3.C55</a></td>
          <td>SmugMug 搜尋管線 backfill</td>
          <td>Standard queue / Long polling / Lambda</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/sqs-postnl-mission-critical-ebe/" data-link-title="3.C56 PostNL EBE：完整 DLQ &#43; retention &#43; redrive 設計" data-link-desc="PostNL 物流每天 1000 萬訊息、每 producer/consumer 隔離 stack、24h 內 100 次 retry、final DLQ 可 consumer redrive。">3.C56</a></td>
          <td>PostNL EBE 完整 DLQ + redrive</td>
          <td>DLQ 設計 / CloudWatch alarm / Cost</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/sqs-lob-sqs-consumer-library/" data-link-title="3.C57 Lob：自家 fork @lob/sqs-consumer 修 FIFO bug" data-link-desc="Lob 原用 bbc/sqs-consumer 鎖 SDK v2、fork 出 @lob/sqs-consumer 支援 SDK v3 &#43; TypeScript &#43; 修 FIFO bug。">3.C57</a></td>
          <td>Lob @lob/sqs-consumer</td>
          <td>Standard vs FIFO / Client library</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/sqs-twilio-webhook-buffer/" data-link-title="3.C58 Twilio：SQS 緩衝高流量 webhook" data-link-desc="Twilio 教用 SQS 緩衝 SMS / status callback webhook、分 queue（SMS vs callback）、long polling 減 cost、FIFO 300 TPS 上限要分片。">3.C58</a></td>
          <td>Twilio SQS 緩衝 webhook</td>
          <td>Long polling / Standard vs FIFO</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/sqs-rapid7-scale-billion-messages/" data-link-title="3.C59 Rapid7：SQS 100 億 message/day 規模" data-link-desc="Rapid7 公開引述：SQS 撐 10s of billions of messages per day、是架構關鍵元件、scale 量級的具體參考。">3.C59</a></td>
          <td>Rapid7 100 億 msg/day 規模</td>
          <td>Cost 模型 / Standard queue</td>
      </tr>
  </tbody>
</table>
<h2 id="google-pubsub-案例">Google Pub/Sub 案例</h2>
<table>
  <thead>
      <tr>
          <th>章節</th>
          <th>公司 / 主題</th>
          <th>對應 Pub/Sub 大綱章節</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/pubsub-spotify-event-delivery-platform/" data-link-title="3.C60 Spotify：Event Delivery 從 Kafka 遷到 Pub/Sub" data-link-desc="Spotify 全球 event delivery 從 Kafka 遷到 Pub/Sub、~2500 VM、Q1 2019 8M events/s、350TB/day raw、自建 dedup。">3.C60</a></td>
          <td>Spotify Event Delivery 遷入</td>
          <td>Pub/Sub vs Lite / Push vs Pull / Ack deadline</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/pubsub-spotify-autoscaling-consumers/" data-link-title="3.C61 Spotify：Autoscaling Pub/Sub consumer 反效果" data-link-desc="Spotify 下游失敗時 consumer 不 ack 仍耗 CPU、autoscaling 越拉越高、解法是 exponential backoff 抑制 CPU。">3.C61</a></td>
          <td>Spotify Autoscaling 反效果</td>
          <td>Ack deadline / autoscaling signal</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/pubsub-spotify-cloud-storage-export/" data-link-title="3.C62 Spotify：Pub/Sub → GCS reliable export" data-link-desc="Spotify 用 Oldest Unacknowledged Message metric 判斷 hourly bucket 何時可安全關閉、ack 綁定下游 commit。">3.C62</a></td>
          <td>Spotify reliable GCS export</td>
          <td>Ack deadline / Cloud Storage subscription</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/pubsub-mercari-actionable-history/" data-link-title="3.C63 Mercari Actionable History：ack deadline 是 batch-level" data-link-desc="Merpay 支付流水帳用 Pub/Sub、ack deadline 是整批 batch 而非單訊息、acked 訊息會跟同批 expired 一起 redeliver。">3.C63</a></td>
          <td>Mercari ack deadline batch-level</td>
          <td>Ack deadline / Push vs Pull / Ordering</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/pubsub-mercari-item-feed-dlt/" data-link-title="3.C64 Mercari Item Feed：DLT 防 poison message 阻塞" data-link-desc="Mercari 商品 feed 同步、ack 整批 / nack 重送、重試多次仍失敗送 DLT、topic 同時當 load-leveling buffer。">3.C64</a></td>
          <td>Mercari Item Feed DLT</td>
          <td>Dead-letter topic / Push vs Pull</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/pubsub-mercari-line-flow-control/" data-link-title="3.C65 Mercari LINE：Pull subscription 對齊外部 RPS" data-link-desc="Mercari LINE webhook 轉 Pub/Sub、worker pull subscription 精確控制 RPS、應 LINE API 限制。">3.C65</a></td>
          <td>Mercari LINE 對齊外部 RPS</td>
          <td>Push vs Pull subscription</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/pubsub-mercari-b2c-grpc-pusher/" data-link-title="3.C66 Mercari B2C：自建 PubSub gRPC Pusher" data-link-desc="Mercari 全球商品同步、原生 HTTP push 在「長 job &#43; 高吞吐 &#43; 動態 RPS」場景受限、自建 gRPC 版 push。">3.C66</a></td>
          <td>Mercari B2C 自建 gRPC pusher</td>
          <td>Push vs Pull / Ordering 應用層處理</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/pubsub-niantic-pokemon-go-telemetry/" data-link-title="3.C67 Niantic Pokémon GO：Pub/Sub 當 telemetry ingest" data-link-desc="Pokémon GO frontend publish 玩家事件、~1M TPS、Pub/Sub elastic buffer、下游 BigQuery streaming。">3.C67</a></td>
          <td>Niantic Pokémon GO telemetry</td>
          <td>BigQuery subscription（pattern 對照）</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/pubsub-wix-clickstream-dashboard/" data-link-title="3.C68 Wix：Pub/Sub decouple &#43; Dataflow &#43; BQ archive" data-link-desc="Wix App Engine 收 clickstream 進 Pub/Sub、Dataflow 進 Datastore &lt; 100ms、BigQuery 並行存 raw recovery。">3.C68</a></td>
          <td>Wix clickstream + Dataflow + BQ</td>
          <td>BigQuery subscription / Push vs Pull</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/pubsub-twitter-ad-engagement/" data-link-title="3.C69 Twitter Ad Engagement：把 stream 切成多 topic 做 partition" data-link-desc="Twitter 把 80K msg/s stream 切成 6 個 topic 做 partition、Avro schema、Beam/Dataflow → Bigtable/BQ。">3.C69</a></td>
          <td>Twitter Ad Engagement topic 切分</td>
          <td>Schema enforcement / Ordering key</td>
      </tr>
  </tbody>
</table>
<h2 id="案例覆蓋缺口待補">案例覆蓋缺口（待補）</h2>
<p>下列大綱章節在本案例庫中<strong>公開 customer-side case 偏弱或缺</strong>、撰寫正文時要明示「以下分析依官方文件 / KIP / 通用模式推導、非 case-driven」：</p>
<ul>
<li><strong>Kafka KRaft</strong>：缺 customer-side 一手案例、目前依官方 KIP-833 / Confluent 公告為準</li>
<li><strong>RabbitMQ MQTT plugin + 多協議</strong>：缺 IoT 廠商 customer case、可補 RabbitMQ 官方 native MQTT blog</li>
<li><strong>RabbitMQ Cluster Operator（K8s）</strong>：缺直接案例、Zalando 案例是 pre-K8s 對照</li>
<li><strong>Redis Streams + Functions（Redis 7+）</strong>：公開 customer case 幾乎沒有</li>
<li><strong>Redis Cluster on Streams</strong>：公開 case 多在 single-instance / Sentinel 規模、Cluster 案例少</li>
<li><strong>Pub/Sub IAM + Service Account</strong>：customer engineering blog 著墨少、建議依 GCP 官方 IAM 文件 + 通用安全原則</li>
</ul>
<p>案例庫總計 69 個、其中 Kafka 17 個（含通用層 5）、RabbitMQ 11 個、NATS 8 個、Redis Streams 6 個、SQS 12 個（含通用層 1）、Pub/Sub 10 個、純通用 / 反例 4 個。</p>
]]></content:encoded></item><item><title>訊息佇列 Vendor 清單</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/</link><pubDate>Fri, 01 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/</guid><description>&lt;p>訊息佇列 Vendor 清單的核心責任是把 broker 名稱放回 delivery semantics、processing semantics、replay boundary 與操作治理的判斷。每個服務頁先回答它提供哪種投遞與消費模型，再討論 ordering、retention、consumer group、DLQ、managed 邊界與案例回寫。選 broker 之前、佇列這塊能力先過一次買 vs 建：自管 broker（RabbitMQ、Kafka）自己扛 ordering、retention、DLQ 的運維、managed（SQS、SNS、MSK、Confluent Cloud）把這層交出去、雲端原生事件匯流更省 — 逐能力的判讀見 &lt;a href="https://tarrragon.github.io/blog/backend/00-service-selection/capability-buy-vs-build/" data-link-title="0.22 能力級買 vs 建：feature-as-a-service 與 BaaS bundle 選型" data-link-desc="在交付形態決定整個系統要不要自建之後、逐能力判斷該外包還是自建：辨識 managed 基礎設施、feature SaaS 與 BaaS bundle 三種外包深度、no-code 到 dev-tool 的服務光譜、買 vs 建判準與權重浮動、整合接縫與遷出代價">0.22 能力級買 vs 建&lt;/a>。&lt;/p>
&lt;h2 id="讀法">讀法&lt;/h2>
&lt;p>佇列服務要從處理語意進入。讀者如果要處理一般工作佇列，先回到 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/durable-queue/" data-link-title="3.2 durable queue 與重試策略" data-link-desc="整理持久化佇列、DLQ 與重試流程">3.2 durable queue&lt;/a>；如果要處理事件流與 replay，先回到 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer design&lt;/a>；如果問題是資料庫交易與事件發布一致性，先回到 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/outbox-pattern/" data-link-title="3.3 outbox pattern 與發佈一致性" data-link-desc="把 transaction 與 event publish 分離">3.3 outbox pattern&lt;/a>。&lt;/p>
&lt;h2 id="教學順序同步">教學順序同步&lt;/h2>
&lt;p>佇列服務頁的教學順序是先建立 work queue baseline，再進入 event log、managed delivery、lightweight messaging 與 embedded stream。這個順序對齊 checkout E3：讀者先理解 delivery、processing、recovery 三層語意，再比較 broker、managed queue、pub/sub 與 stream 如何影響 retry、DLQ、ordering 與 replay。&lt;/p>
&lt;h2 id="t1-服務頁大綱">T1 服務頁大綱&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>服務&lt;/th>
 &lt;th>類型&lt;/th>
 &lt;th>頁面要回答的核心問題&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&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;/td>
 &lt;td>Classic broker&lt;/td>
 &lt;td>exchange、routing、ack/nack 與 DLQ 如何支援工作分派&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Apache Kafka&lt;/a>&lt;/td>
 &lt;td>Event streaming&lt;/td>
 &lt;td>partition、offset、retention 與 replay 如何支援事件流&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&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>&lt;/td>
 &lt;td>Messaging / stream&lt;/td>
 &lt;td>subject、JetStream、low-latency 與 durability 如何取捨&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&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;/td>
 &lt;td>Embedded stream&lt;/td>
 &lt;td>Redis 生態中的 stream、consumer group 與 pending entry 邊界&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/aws-sqs/" data-link-title="AWS SQS" data-link-desc="AWS managed queue、簡單可靠、無 ordering（standard）">AWS SQS&lt;/a>&lt;/td>
 &lt;td>Managed queue&lt;/td>
 &lt;td>standard / FIFO、visibility timeout 與 DLQ 如何支援 managed delivery&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Google Cloud Pub/Sub&lt;/a>&lt;/td>
 &lt;td>Managed pub/sub&lt;/td>
 &lt;td>topic / subscription、push / pull 與 global delivery 如何取捨&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="內容覆蓋進度">內容覆蓋進度&lt;/h2>
&lt;p>每個 vendor 服務頁下會擴充兩類文章：deep article（vendor 自身的配置、故障、容量、走 &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 系列）的實證。">6-section 模板&lt;/a>）跟 migration playbook（跨 vendor 遷移流程、走 &lt;a href="https://tarrragon.github.io/blog/posts/migration-playbook-%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84stage-0-variant-%E8%A6%8F%E5%8A%83%E6%8A%8A-collapse-%E7%8E%87%E5%BE%9E-60-%E9%99%8D%E5%88%B0-0/" data-link-title="Migration Playbook 方法論的演化紀錄：Stage 0 variant 規劃把 collapse 率從 60% 降到 0%" data-link-desc="跨 vendor migration playbook 需要獨立寫作方法論的依據，以及這套方法論從三輪 batch dogfood 中演化出來的驗證證據。">6-type 結構&lt;/a>）。「→ X」代表遷移到 X 的 playbook、「↔ X」代表雙向遷移、其他形式代表 same-vendor 的 topology / version / config 變動。&lt;/p></description><content:encoded><![CDATA[<p>訊息佇列 Vendor 清單的核心責任是把 broker 名稱放回 delivery semantics、processing semantics、replay boundary 與操作治理的判斷。每個服務頁先回答它提供哪種投遞與消費模型，再討論 ordering、retention、consumer group、DLQ、managed 邊界與案例回寫。選 broker 之前、佇列這塊能力先過一次買 vs 建：自管 broker（RabbitMQ、Kafka）自己扛 ordering、retention、DLQ 的運維、managed（SQS、SNS、MSK、Confluent Cloud）把這層交出去、雲端原生事件匯流更省 — 逐能力的判讀見 <a href="/blog/backend/00-service-selection/capability-buy-vs-build/" data-link-title="0.22 能力級買 vs 建：feature-as-a-service 與 BaaS bundle 選型" data-link-desc="在交付形態決定整個系統要不要自建之後、逐能力判斷該外包還是自建：辨識 managed 基礎設施、feature SaaS 與 BaaS bundle 三種外包深度、no-code 到 dev-tool 的服務光譜、買 vs 建判準與權重浮動、整合接縫與遷出代價">0.22 能力級買 vs 建</a>。</p>
<h2 id="讀法">讀法</h2>
<p>佇列服務要從處理語意進入。讀者如果要處理一般工作佇列，先回到 <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>；如果要處理事件流與 replay，先回到 <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/outbox-pattern/" data-link-title="3.3 outbox pattern 與發佈一致性" data-link-desc="把 transaction 與 event publish 分離">3.3 outbox pattern</a>。</p>
<h2 id="教學順序同步">教學順序同步</h2>
<p>佇列服務頁的教學順序是先建立 work queue baseline，再進入 event log、managed delivery、lightweight messaging 與 embedded stream。這個順序對齊 checkout E3：讀者先理解 delivery、processing、recovery 三層語意，再比較 broker、managed queue、pub/sub 與 stream 如何影響 retry、DLQ、ordering 與 replay。</p>
<h2 id="t1-服務頁大綱">T1 服務頁大綱</h2>
<table>
  <thead>
      <tr>
          <th>服務</th>
          <th>類型</th>
          <th>頁面要回答的核心問題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/backend/03-message-queue/vendors/rabbitmq/" data-link-title="RabbitMQ" data-link-desc="Classic message broker、AMQP routing 為主">RabbitMQ</a></td>
          <td>Classic broker</td>
          <td>exchange、routing、ack/nack 與 DLQ 如何支援工作分派</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Apache Kafka</a></td>
          <td>Event streaming</td>
          <td>partition、offset、retention 與 replay 如何支援事件流</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/vendors/nats/" data-link-title="NATS" data-link-desc="Lightweight messaging、JetStream 加持久化與 streams">NATS</a></td>
          <td>Messaging / stream</td>
          <td>subject、JetStream、low-latency 與 durability 如何取捨</td>
      </tr>
      <tr>
          <td><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></td>
          <td>Embedded stream</td>
          <td>Redis 生態中的 stream、consumer group 與 pending entry 邊界</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/vendors/aws-sqs/" data-link-title="AWS SQS" data-link-desc="AWS managed queue、簡單可靠、無 ordering（standard）">AWS SQS</a></td>
          <td>Managed queue</td>
          <td>standard / FIFO、visibility timeout 與 DLQ 如何支援 managed delivery</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Google Cloud Pub/Sub</a></td>
          <td>Managed pub/sub</td>
          <td>topic / subscription、push / pull 與 global delivery 如何取捨</td>
      </tr>
  </tbody>
</table>
<h2 id="內容覆蓋進度">內容覆蓋進度</h2>
<p>每個 vendor 服務頁下會擴充兩類文章：deep article（vendor 自身的配置、故障、容量、走 <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 系列）的實證。">6-section 模板</a>）跟 migration playbook（跨 vendor 遷移流程、走 <a href="/blog/posts/migration-playbook-%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84stage-0-variant-%E8%A6%8F%E5%8A%83%E6%8A%8A-collapse-%E7%8E%87%E5%BE%9E-60-%E9%99%8D%E5%88%B0-0/" data-link-title="Migration Playbook 方法論的演化紀錄：Stage 0 variant 規劃把 collapse 率從 60% 降到 0%" data-link-desc="跨 vendor migration playbook 需要獨立寫作方法論的依據，以及這套方法論從三輪 batch dogfood 中演化出來的驗證證據。">6-type 結構</a>）。「→ X」代表遷移到 X 的 playbook、「↔ X」代表雙向遷移、其他形式代表 same-vendor 的 topology / version / config 變動。</p>
<table>
  <thead>
      <tr>
          <th>Vendor</th>
          <th>Deep article</th>
          <th>Migration playbook</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="kafka/">Kafka</a></td>
          <td><a href="kafka/consumer-rebalance-lag-diagnosis/">rebalance/lag</a> / <a href="kafka/replication-isr-exactly-once/">replication/ISR</a> / <a href="kafka/retention-tiered-storage/">retention/tiered</a> / <a href="kafka/schema-registry-evolution/">schema registry</a> / <a href="kafka/multi-tenant-quota-acl/">multi-tenant</a></td>
          <td><a href="kafka/migrate-from-to-nats/">↔ NATS</a> / <a href="kafka/migrate-to-msk/">→ MSK</a></td>
      </tr>
      <tr>
          <td><a href="rabbitmq/">RabbitMQ</a></td>
          <td><a href="rabbitmq/queue-types-classic-quorum-stream/">queue 模型選型</a> / <a href="rabbitmq/network-partition-clustering/">network partition</a> / <a href="rabbitmq/dlq-retry-escalation/">DLQ retry escalation</a></td>
          <td><a href="rabbitmq/migrate-to-kafka/">→ Kafka</a> / <a href="rabbitmq/migrate-to-aws-sqs/">→ AWS SQS</a></td>
      </tr>
      <tr>
          <td><a href="nats/">NATS</a></td>
          <td><a href="nats/jetstream-supercluster-design/">JetStream/supercluster</a> / <a href="nats/jetstream-durability-consumer/">JetStream durability/consumer</a></td>
          <td>↔ Kafka（見 Kafka 列）</td>
      </tr>
      <tr>
          <td><a href="redis-streams/">Redis Streams</a></td>
          <td><a href="redis-streams/xclaim-pel-recovery/">XCLAIM/PEL</a></td>
          <td><a href="redis-streams/migrate-to-kafka/">→ Kafka</a></td>
      </tr>
      <tr>
          <td><a href="aws-sqs/">AWS SQS</a></td>
          <td><a href="aws-sqs/visibility-polling-lambda-cost/">visibility/polling/Lambda</a></td>
          <td><a href="aws-sqs/migrate-to-google-pubsub/">→ Google Pub/Sub</a></td>
      </tr>
      <tr>
          <td><a href="google-pubsub/">Google Pub/Sub</a></td>
          <td><a href="google-pubsub/ordering-dlt-schema/">ordering/DLT/schema</a> / <a href="google-pubsub/push-pull-ack-flow-control/">push/pull/ack flow control</a></td>
          <td><a href="google-pubsub/migrate-from-kafka/">← Kafka</a></td>
      </tr>
  </tbody>
</table>
<p>deep article 走 <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 系列）的實證。">6-section 模板</a>、指令均經 Docker / emulator 實機驗證（驗不了的標 caveat）；migration playbook 走 <a href="/blog/posts/migration-playbook-%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84stage-0-variant-%E8%A6%8F%E5%8A%83%E6%8A%8A-collapse-%E7%8E%87%E5%BE%9E-60-%E9%99%8D%E5%88%B0-0/" data-link-title="Migration Playbook 方法論的演化紀錄：Stage 0 variant 規劃把 collapse 率從 60% 降到 0%" data-link-desc="跨 vendor migration playbook 需要獨立寫作方法論的依據，以及這套方法論從三輪 batch dogfood 中演化出來的驗證證據。">6-type 結構</a>。</p>
<p>main 與 feat/backend_03 兩批平行撰寫過 03 deep article、重疊主題已去重：RabbitMQ quorum、Redis Streams PEL、AWS SQS visibility 三組各保留涵蓋較完整、經實機驗證的一篇（主題框架已併入）。NATS 保留兩篇互補定位——<a href="nats/jetstream-durability-consumer/">core 到 JetStream 邊界</a> 是採用決策入口、<a href="nats/jetstream-supercluster-design/">JetStream 設計與 supercluster/leaf</a> 是完整實作。後續候選見上方「T1 服務頁大綱」段、各 vendor <code>_index.md</code> 進階主題段與下方「後續候選」表。</p>
<h2 id="服務頁撰寫欄位">服務頁撰寫欄位</h2>
<table>
  <thead>
      <tr>
          <th>欄位</th>
          <th>佇列服務頁要保留的問題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>服務責任</td>
          <td>它承擔 work queue、event log、pub/sub、stream 還是 workflow handoff</td>
      </tr>
      <tr>
          <td>適用壓力</td>
          <td>throughput、ordering、fan-out、retention、replay、managed operation 哪個壓力最明顯</td>
      </tr>
      <tr>
          <td>替代邊界</td>
          <td>broker、event streaming、managed queue、workflow engine 的機會成本</td>
      </tr>
      <tr>
          <td>操作成本</td>
          <td>partition、consumer lag、DLQ drain、schema、ACL、upgrade、quota</td>
      </tr>
      <tr>
          <td>Evidence</td>
          <td>publish rate、consume rate、lag、redelivery、DLQ depth、replay window</td>
      </tr>
      <tr>
          <td>案例回寫</td>
          <td>Meta FOQS、VMware MSK、LinkedIn TopicGC 如何提供治理判準</td>
      </tr>
  </tbody>
</table>
<p>服務責任段要先分辨投遞成功與處理成功。Broker 可以保存訊息與重新投遞，但 consumer 的 idempotency、side effect、checkpoint 與補償流程才決定業務結果是否可恢復。</p>
<p>適用壓力段要保留副作用語言。寄信、轉檔、invoice、search index sync、webhook fan-out 與 audit event 的 retry、ordering、DLQ 與 replay 條件不同，服務頁要分別展開。</p>
<h2 id="服務頁標準章節">服務頁標準章節</h2>
<table>
  <thead>
      <tr>
          <th>章節</th>
          <th>佇列服務頁要補的內容</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>服務定位</td>
          <td>它是 work queue、event log、pub/sub、embedded stream 還是 workflow engine</td>
      </tr>
      <tr>
          <td>本章目標</td>
          <td>讀者能判斷 delivery、processing、recovery、ordering 與 replay 邊界</td>
      </tr>
      <tr>
          <td>最短判讀路徑</td>
          <td>用「是否需要 durable retry、fan-out、ordering、replay」快速定位工具類型</td>
      </tr>
      <tr>
          <td>日常操作與決策形狀</td>
          <td>ack/nack、visibility timeout、DLQ、consumer group、schema、quota</td>
      </tr>
      <tr>
          <td>核心取捨表</td>
          <td>RabbitMQ、Kafka、SQS、Pub/Sub、NATS、Redis Streams 的機會成本</td>
      </tr>
      <tr>
          <td>進階主題</td>
          <td>partition、retention、exactly-once claims、multi-region、managed quota</td>
      </tr>
      <tr>
          <td>排錯與失敗快速判讀</td>
          <td>lag、redelivery、DLQ depth、poison message、consumer pause、offset</td>
      </tr>
      <tr>
          <td>何時改走其他服務</td>
          <td>human workflow 轉 workflow engine、同步查詢回 API、正式狀態回 database</td>
      </tr>
      <tr>
          <td>不在本頁內的主題</td>
          <td>完整 client API、framework adapter、所有 broker plugin</td>
      </tr>
      <tr>
          <td>案例回寫與下一步路由</td>
          <td>回到 3.C cases、6.12 replay verification、8.19 decision log</td>
      </tr>
  </tbody>
</table>
<h2 id="跨-vendor-議題對照">跨 vendor 議題對照</h2>
<p>橫向議題在不同 vendor 用不同旋鈕達成。本表把同一議題在 6 個 vendor 的對應位置列出、確保大綱不缺漏議題、且讀者跨 vendor 查找對照位置時有索引。</p>
<table>
  <thead>
      <tr>
          <th>議題</th>
          <th>Kafka</th>
          <th>RabbitMQ</th>
          <th>NATS</th>
          <th>Redis Streams</th>
          <th>AWS SQS</th>
          <th>Pub/Sub</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>多租戶配額 / 隔離</td>
          <td>quota + ACL</td>
          <td>vhost + user permission</td>
          <td>account + subject ACL</td>
          <td>Redis ACL</td>
          <td>IAM policy</td>
          <td>IAM + Service Account</td>
      </tr>
      <tr>
          <td>跨區 / 全球交付</td>
          <td>MirrorMaker 2</td>
          <td>Federation / Shovel</td>
          <td>Supercluster + Leaf node</td>
          <td>Redis Cluster（受限）</td>
          <td>Cross-region replication</td>
          <td>內建 global routing</td>
      </tr>
      <tr>
          <td>Topic 生命週期治理</td>
          <td>TopicGC、auto-cleanup</td>
          <td>vhost / queue lifecycle</td>
          <td>Stream lifecycle</td>
          <td>MAXLEN / XTRIM</td>
          <td>DLQ + redrive policy</td>
          <td>Subscription expiration</td>
      </tr>
      <tr>
          <td>自動修復</td>
          <td>Self-healing automation</td>
          <td>cluster_partition_handling</td>
          <td>JetStream raft</td>
          <td>Sentinel / Cluster failover</td>
          <td>managed 內建</td>
          <td>managed 內建</td>
      </tr>
      <tr>
          <td>Delivery 機制</td>
          <td>acks + idempotence + ISR</td>
          <td>manual ack + DLX</td>
          <td>JetStream ack + AckWait</td>
          <td>XACK + XCLAIM + PEL</td>
          <td>visibility timeout + DLQ</td>
          <td>ack deadline + DLT</td>
      </tr>
      <tr>
          <td>路由模型</td>
          <td>partition + key</td>
          <td>exchange + routing key</td>
          <td>subject + wildcard</td>
          <td>stream key（無 partition）</td>
          <td>queue URL</td>
          <td>topic + subscription</td>
      </tr>
      <tr>
          <td>持久化模型</td>
          <td>log + retention policy</td>
          <td>durable queue + TTL</td>
          <td>JetStream storage</td>
          <td>append-only log（RAM）</td>
          <td>managed durable</td>
          <td>managed durable</td>
      </tr>
      <tr>
          <td>Schema 治理</td>
          <td>Schema Registry</td>
          <td>（無原生）</td>
          <td>（無原生、靠 JSON Schema 慣例）</td>
          <td>（無）</td>
          <td>（無）</td>
          <td>Schema enforcement</td>
      </tr>
      <tr>
          <td>主討論案例</td>
          <td>C1/C3-C7 + C11-C22</td>
          <td>C23-C33</td>
          <td>C34-C41</td>
          <td>C42-C47</td>
          <td>C48-C59 + C2 反面</td>
          <td>C60-C69</td>
      </tr>
  </tbody>
</table>
<p>對照表的用途有三：</p>
<ul>
<li>寫某 vendor 頁時、檢查橫向議題是否都有對應的進階主題子段、避免缺漏</li>
<li>讀者在 vendor 間遷移時、知道對應旋鈕在另一個 vendor 叫什麼</li>
<li>未來擴充案例時、依 <a href="/blog/backend/03-message-queue/cases/" data-link-title="模組三案例正文" data-link-desc="訊息佇列與事件傳遞的轉換案例入口、含通用案例與 6 個 vendor 的真實 production case 庫。">cases/_index 的「案例覆蓋缺口」段</a> 判定優先補的章節</li>
</ul>
<p>下面 8 段把對照表的每行展開、避免單純的表格成為「終點」。每段先解釋議題本質、再展開不同 vendor 的 mechanism 差異、最後給選型判讀。</p>
<h3 id="路由模型">路由模型</h3>
<p>路由模型決定「訊息怎麼送到對的 consumer」、不是同概念換名字。<strong>Kafka</strong> partition + key 透過 hash 把訊息落在固定 partition、consumer group 靠 rebalance 綁定 partition 跟 consumer；<strong>RabbitMQ</strong> exchange + routing key 透過 binding rule 比對、可 broadcast（fanout）/ 精準（direct）/ pattern（topic + <code>*</code> 單層 / <code>#</code> 多層）；<strong>NATS</strong> subject + wildcard（<code>*</code> 單層、<code>&gt;</code> 多層）讓 subscriber 用 pattern 訂閱主題層級；<strong>Redis Streams</strong> 是單一 stream key、無 partition、跨 shard 要靠 hash tag 強制分散；<strong>SQS</strong> queue URL 直接對應、無 routing 邏輯；<strong>Pub/Sub</strong> topic + subscription、subscription 是 first-class entity（跟 Kafka topic + consumer group 不同）。</p>
<p>選型判讀：需要 fan-out 多 subscriber → fanout exchange / subject pattern / multi-subscription；需要 per-key ordering → Kafka partition+key / RabbitMQ consistent hash exchange / NATS queue group；不需 routing 邏輯 → SQS 最簡單。</p>
<h3 id="delivery-機制">Delivery 機制</h3>
<p>Delivery 機制是「broker 怎麼保證訊息被處理」、不同 vendor 用不同協議層級達成同語意。詳見 <a href="/blog/backend/03-message-queue/broker-basics/#%e8%aa%9e%e6%84%8f%e4%bf%9d%e8%ad%89%e7%9a%84%e4%b8%8d%e5%90%8c%e5%af%a6%e4%bd%9c%e6%a9%9f%e5%88%b6" data-link-title="3.1 broker 基礎與投遞模型" data-link-desc="先理解 broker、queue、consumer 與 delivery semantics">3.1 broker-basics 的「語意保證的不同實作機制」</a>。三層核心旋鈕：<strong>Kafka</strong> acks（0/1/all）+ idempotence + ISR（min.insync.replicas）；<strong>RabbitMQ</strong> manual ack + DLX + prefetch；<strong>NATS</strong> JetStream ack + AckWait + MaxDeliver；<strong>Redis Streams</strong> XACK + XCLAIM + PEL；<strong>SQS</strong> visibility timeout + DLQ + maxReceiveCount；<strong>Pub/Sub</strong> ack deadline + DLT + ack extension。</p>
<p>選型判讀：寫入即承諾（事件流）→ Kafka acks=all + ISR；處理即承諾（任務隊列）→ RabbitMQ manual ack / SQS visibility timeout / Pub/Sub ack deadline；wire-level handshake（device 端）→ MQTT QoS（透過 RabbitMQ MQTT plugin 或 EMQX）。</p>
<h3 id="持久化模型">持久化模型</h3>
<p>持久化模型決定「訊息能保留多久、能不能 replay」。<strong>Kafka</strong> log + retention policy（time / size、compact / delete）— 訊息保留到 retention 過期、consumer 可任意 offset replay；<strong>RabbitMQ</strong> durable queue + TTL — 訊息持久化但 ack 後即刪、不能 replay；<strong>NATS</strong> JetStream storage（file / memory、配 MaxMsgs / MaxBytes / MaxAge）— 介於 log 跟 queue 之間；<strong>Redis Streams</strong> append-only log 但受 RAM 限制 — retention 短期、replay 視 MAXLEN 設定；<strong>SQS / Pub/Sub</strong> managed durable — SQS 最長 14 天、Pub/Sub 7 天、不適合長期 archive。</p>
<p>選型判讀：需要事件 replay（多 consumer 各自進度、長期保留）→ Kafka / Pulsar / JetStream；任務處理即刪（worker pool）→ RabbitMQ / SQS / Pub/Sub；中期 stream 但已在 Redis 生態 → Redis Streams + MAXLEN。</p>
<h3 id="topic-生命週期治理">Topic 生命週期治理</h3>
<p>當 topic / queue 數量上萬、metadata 本身變成 broker 壓力。<strong>Kafka</strong> 早期靠人工管 topic、規模化後需 TopicGC（自動清理 unused topic）+ partition 數量上限；<strong>RabbitMQ</strong> vhost / queue lifecycle 通常手動、queue auto-delete + TTL 是常見 pattern；<strong>NATS</strong> JetStream stream 有 lifecycle policy（DiscardPolicy / MaxAge）；<strong>Redis Streams</strong> MAXLEN / XTRIM 手動修剪、無自動 GC；<strong>SQS</strong> DLQ + redrive policy 是 lifecycle 核心、queue 本身不自動刪；<strong>Pub/Sub</strong> subscription expiration policy（閒置 N 天自動刪）。</p>
<p>選型判讀：metadata 量大（topic 數 / partition 數）→ 需 Kafka TopicGC 模式；任務隊列 → 需 DLQ + redrive 規範；長期 stream → 需明示 retention policy。</p>
<h3 id="自動修復">自動修復</h3>
<p>自動修復把 SRE 從人工值班轉到自動化、但層次不同。<strong>Kafka</strong> Self-healing（disk full / broker offline / under-replicated partition 自動處理）；<strong>RabbitMQ</strong> cluster_partition_handling（ignore / autoheal / pause_minority）— 偏向「腦裂處理策略」、不是全自動 SRE；<strong>NATS</strong> JetStream raft 自動 leader election + replica sync；<strong>Redis Streams</strong> 靠 Sentinel / Cluster failover、failover 期間 PEL 可能不一致；<strong>SQS / Pub/Sub</strong> managed 內建、不需用戶管。</p>
<p>選型判讀：自管要 24/7 → Kafka self-healing 或 NATS raft；不要值班 → managed（SQS / Pub/Sub）；中等規模容忍人工 → RabbitMQ cluster_partition_handling。</p>
<h3 id="多租戶配額--隔離">多租戶配額 / 隔離</h3>
<p>隔離粒度跟 mechanism 不同。<strong>Kafka</strong> quota（byte rate / request rate）+ ACL（principal / resource / operation）— 流量級 + identity 級；<strong>RabbitMQ</strong> vhost + user permission — namespace 級隔離（最強）；<strong>NATS</strong> account + subject ACL — account 是 namespace、subject ACL 是細粒度權限；<strong>Redis Streams</strong> Redis ACL — command-level 權限；<strong>SQS / Pub/Sub</strong> IAM policy + Service Account — identity 級、無 namespace 概念。</p>
<p>選型判讀：跨 team 共用 cluster → 需 namespace 隔離（vhost / account）；多 client app → identity 隔離（IAM）；流量公平 → 需 quota（Kafka quota / 自建 rate limit）。</p>
<h3 id="跨區--全球交付">跨區 / 全球交付</h3>
<p>跨區拓樸三類：mesh（broker 自己同步）vs hub-spoke（單向轉發）vs managed global。<strong>Kafka</strong> MirrorMaker 2 是 mesh（active-active / active-passive）；<strong>RabbitMQ</strong> Federation 是 hub-spoke（upstream → downstream 鬆耦合）、Shovel 是點對點搬運；<strong>NATS</strong> Supercluster + Leaf node 是 mesh + edge（適合 IoT 廠區）；<strong>Redis Cluster</strong> 跨區受限（Cluster 是 shard、不是 region）；<strong>SQS</strong> Cross-region replication（managed）；<strong>Pub/Sub</strong> 內建 global routing — 無需設定。</p>
<p>選型判讀：自管要 mesh → MirrorMaker 2 / NATS Supercluster；hub-spoke 簡單 → Federation；不想處理跨區 → Pub/Sub global 或 SQS replication。</p>
<h3 id="schema-治理">Schema 治理</h3>
<p>Schema 強制度跨 vendor 差異最大。<strong>Kafka</strong> Schema Registry（Confluent / Apicurio）+ Avro / Protobuf — 強制 producer 帶 schema ID、enforce compatibility；<strong>RabbitMQ</strong> 無原生 schema 機制 — 靠 application 層約定；<strong>NATS</strong> 無原生、靠 JSON Schema 慣例；<strong>Redis Streams</strong> 無 schema 概念；<strong>SQS</strong> message attribute + body string — 無 enforce；<strong>Pub/Sub</strong> Schema enforcement（topic 綁 Avro / Protobuf schema）。</p>
<p>選型判讀：跨服務契約嚴 → Kafka + Schema Registry / Pub/Sub Schema enforcement；內部簡單通訊 → RabbitMQ / NATS 靠慣例；schema 演進頻繁 → 需 forward / backward / full compatibility 規範。</p>
<h2 id="服務頁大綱對齊">服務頁大綱對齊</h2>
<p>6 個 vendor 頁套同樣的章節結構、方便讀者跨 vendor 跳讀。對齊參考 <a href="/blog/llm/01-local-llm-services/ollama/" data-link-title="1.0 Ollama：主流推論伺服器" data-link-desc="一行 brew 裝完、ollama run 一鍵跑 Gemma 4 MTP、OpenAI 相容 API on localhost:11434">LLM 模組 1.0 Ollama</a> 的「觀念 → 原理 → 操作指令」分層寫法：</p>
<ol>
<li><strong>服務定位</strong>（段首段、3 個責任 + 設計取捨）</li>
<li><strong>本章目標</strong>（5 條可驗證能力 checklist）</li>
<li><strong>最短路徑</strong>（5 分鐘可跑通的 install + verify、bash 範例 placeholder）</li>
<li><strong>日常操作與決策形狀</strong>（CLI / API、路由設計、ack 策略三個子段）</li>
<li><strong>進階主題</strong>（按需閱讀、每子段對應一個 case 或 vendor 專長議題）</li>
<li><strong>排錯快速判讀</strong>（每情境：操作原則 + 指令 + 解法）</li>
<li><strong>何時改走其他服務</strong>（對照表）</li>
<li><strong>不在本頁內的主題</strong>（明確邊界）</li>
<li><strong>案例回寫</strong>（cases/ 引用 + 主討論議題）</li>
<li><strong>下一步路由</strong>（上游概念 / 平行 vendor / 下游能力）</li>
</ol>
<p>每個章節「要回答的問題」「要包含的指令範例 placeholder」「對應 case」都已寫在各 vendor 頁的大綱、但未寫實際正文 — 等到撰寫批次（見下節）開始時才展開。</p>
<h2 id="撰寫批次">撰寫批次</h2>
<table>
  <thead>
      <tr>
          <th>批次</th>
          <th>服務頁</th>
          <th>撰寫目的</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Q1</td>
          <td>RabbitMQ</td>
          <td>建立 work queue、routing、ack/nack 與 DLQ baseline</td>
      </tr>
      <tr>
          <td>Q2</td>
          <td>Kafka</td>
          <td>建立 event log、partition、retention 與 replay 判準</td>
      </tr>
      <tr>
          <td>Q3</td>
          <td>AWS SQS / Google Pub/Sub</td>
          <td>建立 managed delivery、visibility timeout 與 cloud pub/sub 邊界</td>
      </tr>
      <tr>
          <td>Q4</td>
          <td>NATS / Redis Streams</td>
          <td>建立 lightweight messaging 與 embedded stream 的邊界</td>
      </tr>
      <tr>
          <td>Q5</td>
          <td>Pulsar / Kinesis / Temporal</td>
          <td>補 multi-tenant streaming、managed stream 與 workflow engine 對照</td>
      </tr>
  </tbody>
</table>
<h2 id="後續候選">後續候選</h2>
<table>
  <thead>
      <tr>
          <th>類型</th>
          <th>候選服務</th>
          <th>寫作重點</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Streaming</td>
          <td>Apache Pulsar、Redpanda、AWS Kinesis、Confluent Cloud / MSK</td>
          <td>retention、partition、managed Kafka、serverless stream</td>
      </tr>
      <tr>
          <td>Managed event bus</td>
          <td>AWS SNS、EventBridge、Azure Event Grid</td>
          <td>fan-out、event routing、schema、cloud-native integration</td>
      </tr>
      <tr>
          <td>Enterprise queue</td>
          <td>Azure Service Bus、ActiveMQ、IBM MQ</td>
          <td>enterprise integration、session、routing、DLQ</td>
      </tr>
      <tr>
          <td>Workflow engine</td>
          <td>Temporal、Cadence</td>
          <td>durable workflow、activity retry、human / machine workflow 邊界</td>
      </tr>
      <tr>
          <td>Lightweight</td>
          <td>NSQ、ZeroMQ</td>
          <td>simple broker、library messaging、durability trade-off</td>
      </tr>
      <tr>
          <td>IoT messaging</td>
          <td>MQTT、EMQX、HiveMQ、Mosquitto</td>
          <td>device connection、QoS、topic hierarchy、edge constraints</td>
      </tr>
  </tbody>
</table>
<p>主流覆蓋檢查的重點是分開 queue、stream、event bus、workflow 與 device messaging。Kafka / Pulsar / Kinesis 解 event stream；SQS / Service Bus 解 managed queue；SNS / EventBridge / Event Grid 解 cloud event routing；Temporal 解 workflow state；MQTT broker 解 IoT device delivery。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>上游：<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>
<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></li>
<li>案例：<a href="/blog/backend/03-message-queue/cases/" data-link-title="模組三案例正文" data-link-desc="訊息佇列與事件傳遞的轉換案例入口、含通用案例與 6 個 vendor 的真實 production case 庫。">3.C 佇列案例正文</a></li>
<li>服務路徑：<a href="/blog/backend/03-message-queue/queue-consumer-retry-replay-handoff/" data-link-title="3.8 Queue Consumer Retry 與 Replay Handoff（實作示範）" data-link-desc="以 order_created consumer 示範 queue 路徑如何交付 idempotency evidence、DLQ handling、replay runbook 與 incident decision log。">3.8 Queue Consumer Retry 與 Replay Handoff</a></li>
</ul>
]]></content:encoded></item><item><title>Queue</title><link>https://tarrragon.github.io/blog/backend/knowledge-cards/queue/</link><pubDate>Mon, 22 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/knowledge-cards/queue/</guid><description>&lt;p>Queue 的核心概念是「把等待處理的工作依序放入一個可觀測的等待區」。它讓 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/producer/" data-link-title="Producer" data-link-desc="說明 producer 如何把工作、事件或資料送入後續處理路徑">producer&lt;/a> 和 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer&lt;/a> 在時間上解耦，也讓系統可以用等待長度、等待時間與處理速率評估容量壓力。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>Queue 可以存在於 application 內部（&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/in-process-channel/" data-link-title="In-Process Channel" data-link-desc="說明單一 process 內用來傳遞工作的 channel 或 queue abstraction">in-process channel&lt;/a> + &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/worker-pool/" data-link-title="Worker Pool" data-link-desc="說明一組 worker 如何限制同時處理量並保護下游資源">worker pool&lt;/a>），也可以由 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker&lt;/a>、database table 或 stream platform 提供。Application 內部的 queue 隨 process 生命週期消失；跨 process、需要保存與重放的 queue 通常需要 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/durable-queue/" data-link-title="Durable Queue" data-link-desc="說明可持久化的 queue 如何在重啟與失敗後保留待處理工作">durable queue&lt;/a> 或 broker。&lt;/p>
&lt;p>Queue 跟 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic&lt;/a> 的差異：queue 的語意通常是「一筆訊息被一個 consumer 處理」（competing consumers），topic 的語意是「一筆訊息可以被多個 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer-group/" data-link-title="Consumer Group" data-link-desc="說明一組 consumer 如何共同分攤 stream 或 topic 的處理責任">consumer group&lt;/a> 各自處理」（&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/fan-out/" data-link-title="Fan-out" data-link-desc="說明單一事件同時分發給多個下游的訊息拓撲">fan-out&lt;/a>）。但不同 broker 的術語定義不同 — RabbitMQ 的 queue 跟 Kafka 的 partition 在消費語意上有本質差異。&lt;/p>
&lt;h2 id="使用情境">使用情境&lt;/h2>
&lt;p>系統需要 queue 的訊號是進入速度跟處理速度會短暫不一致。寄信、報表匯出、圖片轉檔、訂單狀態同步都適合先排入 queue，再由 consumer 依照容量處理。&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue-depth/" data-link-title="Queue Depth" data-link-desc="說明 queue 中等待處理的訊息數如何反映 backlog 與容量壓力">Queue depth&lt;/a> 跟 oldest item age 會反映延遲壓力 — queue depth 持續增長代表 consumer 來不及消化，需要擴展 consumer 或降低進入速率。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>Queue 要定義容量上限、排序語意（FIFO / priority / delay）、保存期限（&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/retention/" data-link-title="Retention" data-link-desc="說明資料或事件保留多久，以及保留期限如何影響重放與成本">retention&lt;/a>）、消費模式（pull vs push）、失敗處理（&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/retry-policy/" data-link-title="Retry Policy" data-link-desc="說明重試策略如何區分暫時性錯誤、永久錯誤與副作用風險">retry policy&lt;/a> + &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/dead-letter-queue/" data-link-title="Dead-Letter Queue" data-link-desc="說明 dead-letter queue 如何隔離多次處理失敗的訊息">dead-letter queue&lt;/a>）、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure&lt;/a> 策略（滿了怎麼辦 — block / drop / reject）與觀測欄位。設計上要區分「等待可以接受」跟「等待會傷害產品結果」— 付款入帳能短暫排隊，互動式 API response 通常需要更短的等待期限與更明確的拒絕策略。&lt;/p></description><content:encoded><![CDATA[<p>Queue 的核心概念是「把等待處理的工作依序放入一個可觀測的等待區」。它讓 <a href="/blog/backend/knowledge-cards/producer/" data-link-title="Producer" data-link-desc="說明 producer 如何把工作、事件或資料送入後續處理路徑">producer</a> 和 <a href="/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer</a> 在時間上解耦，也讓系統可以用等待長度、等待時間與處理速率評估容量壓力。</p>
<h2 id="概念位置">概念位置</h2>
<p>Queue 可以存在於 application 內部（<a href="/blog/backend/knowledge-cards/in-process-channel/" data-link-title="In-Process Channel" data-link-desc="說明單一 process 內用來傳遞工作的 channel 或 queue abstraction">in-process channel</a> + <a href="/blog/backend/knowledge-cards/worker-pool/" data-link-title="Worker Pool" data-link-desc="說明一組 worker 如何限制同時處理量並保護下游資源">worker pool</a>），也可以由 <a href="/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker</a>、database table 或 stream platform 提供。Application 內部的 queue 隨 process 生命週期消失；跨 process、需要保存與重放的 queue 通常需要 <a href="/blog/backend/knowledge-cards/durable-queue/" data-link-title="Durable Queue" data-link-desc="說明可持久化的 queue 如何在重啟與失敗後保留待處理工作">durable queue</a> 或 broker。</p>
<p>Queue 跟 <a href="/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic</a> 的差異：queue 的語意通常是「一筆訊息被一個 consumer 處理」（competing consumers），topic 的語意是「一筆訊息可以被多個 <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/fan-out/" data-link-title="Fan-out" data-link-desc="說明單一事件同時分發給多個下游的訊息拓撲">fan-out</a>）。但不同 broker 的術語定義不同 — RabbitMQ 的 queue 跟 Kafka 的 partition 在消費語意上有本質差異。</p>
<h2 id="使用情境">使用情境</h2>
<p>系統需要 queue 的訊號是進入速度跟處理速度會短暫不一致。寄信、報表匯出、圖片轉檔、訂單狀態同步都適合先排入 queue，再由 consumer 依照容量處理。<a href="/blog/backend/knowledge-cards/queue-depth/" data-link-title="Queue Depth" data-link-desc="說明 queue 中等待處理的訊息數如何反映 backlog 與容量壓力">Queue depth</a> 跟 oldest item age 會反映延遲壓力 — queue depth 持續增長代表 consumer 來不及消化，需要擴展 consumer 或降低進入速率。</p>
<h2 id="設計責任">設計責任</h2>
<p>Queue 要定義容量上限、排序語意（FIFO / priority / delay）、保存期限（<a href="/blog/backend/knowledge-cards/retention/" data-link-title="Retention" data-link-desc="說明資料或事件保留多久，以及保留期限如何影響重放與成本">retention</a>）、消費模式（pull vs push）、失敗處理（<a href="/blog/backend/knowledge-cards/retry-policy/" data-link-title="Retry Policy" data-link-desc="說明重試策略如何區分暫時性錯誤、永久錯誤與副作用風險">retry policy</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/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a> 策略（滿了怎麼辦 — block / drop / reject）與觀測欄位。設計上要區分「等待可以接受」跟「等待會傷害產品結果」— 付款入帳能短暫排隊，互動式 API response 通常需要更短的等待期限與更明確的拒絕策略。</p>
]]></content:encoded></item><item><title>Consumer</title><link>https://tarrragon.github.io/blog/backend/knowledge-cards/consumer/</link><pubDate>Mon, 22 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/knowledge-cards/consumer/</guid><description>&lt;p>Consumer 的核心概念是「從等待區取得工作、事件或資料並執行處理的角色」。它可以從 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/stream-pipeline/" data-link-title="Stream Pipeline" data-link-desc="說明連續資料流經多個處理階段時如何管理吞吐、順序與 backpressure ">stream pipeline&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/database/" data-link-title="Database" data-link-desc="說明 database 在後端系統中如何承擔正式狀態、查詢與一致性責任">database&lt;/a> table 或 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/in-process-channel/" data-link-title="In-Process Channel" data-link-desc="說明單一 process 內用來傳遞工作的 channel 或 queue abstraction">in-process channel&lt;/a> 取得資料，再更新狀態、呼叫外部服務或產生衍生資料。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>Consumer 位在資料流的下游。它跟 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/producer/" data-link-title="Producer" data-link-desc="說明 producer 如何把工作、事件或資料送入後續處理路徑">producer&lt;/a> 構成 MQ 的基本角色對 — producer 負責把工作送進等待區，consumer 負責取出並處理。&lt;/p>
&lt;p>多個 consumer 組成 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer-group/" data-link-title="Consumer Group" data-link-desc="說明一組 consumer 如何共同分攤 stream 或 topic 的處理責任">consumer group&lt;/a> 來分攤處理負載。Consumer 的處理速度跟錯誤行為直接影響 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer-lag/" data-link-title="Consumer Lag" data-link-desc="說明 consumer lag 如何反映訊息堆積、處理能力與容量風險">consumer lag&lt;/a>（積壓深度）跟 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/dead-letter-queue/" data-link-title="Dead-Letter Queue" data-link-desc="說明 dead-letter queue 如何隔離多次處理失敗的訊息">dead-letter queue&lt;/a>（無法處理的訊息去處）。&lt;/p>
&lt;h2 id="使用情境">使用情境&lt;/h2>
&lt;p>系統需要辨識 consumer 的訊號是資料已經送入系統但產品結果還沒完成。付款事件送入後，入帳 consumer 要更新帳務狀態；通知事件送入後，寄信 consumer 要呼叫郵件服務。兩者都要清楚記錄處理成功、暫時失敗與永久拒絕。&lt;/p>
&lt;p>Consumer 的處理模式影響系統的可靠性保證。&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">Ack / nack&lt;/a> 的時機決定「訊息什麼時候算處理完成」；&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency&lt;/a> 決定「重複收到同一筆訊息時是否會產生副作用」。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>Consumer 要定義併發數、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">ack / nack&lt;/a> 條件、錯誤分類（暫時性 vs 永久性）、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/retry-policy/" data-link-title="Retry Policy" data-link-desc="說明重試策略如何區分暫時性錯誤、永久錯誤與副作用風險">retry policy&lt;/a>、隔離區、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">graceful shutdown&lt;/a> 與觀測欄位。&lt;/p>
&lt;p>操作面要能觀測：處理速率（messages/sec）、失敗類型分布、oldest unprocessed message age、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer-lag/" data-link-title="Consumer Lag" data-link-desc="說明 consumer lag 如何反映訊息堆積、處理能力與容量風險">consumer lag&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/dead-letter-queue/" data-link-title="Dead-Letter Queue" data-link-desc="說明 dead-letter queue 如何隔離多次處理失敗的訊息">dead-letter queue&lt;/a> 累積量與下游 dependency latency。Consumer lag 持續增長是容量不足的 leading indicator。&lt;/p></description><content:encoded><![CDATA[<p>Consumer 的核心概念是「從等待區取得工作、事件或資料並執行處理的角色」。它可以從 <a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a>、<a href="/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker</a>、<a href="/blog/backend/knowledge-cards/stream-pipeline/" data-link-title="Stream Pipeline" data-link-desc="說明連續資料流經多個處理階段時如何管理吞吐、順序與 backpressure ">stream pipeline</a>、<a href="/blog/backend/knowledge-cards/database/" data-link-title="Database" data-link-desc="說明 database 在後端系統中如何承擔正式狀態、查詢與一致性責任">database</a> table 或 <a href="/blog/backend/knowledge-cards/in-process-channel/" data-link-title="In-Process Channel" data-link-desc="說明單一 process 內用來傳遞工作的 channel 或 queue abstraction">in-process channel</a> 取得資料，再更新狀態、呼叫外部服務或產生衍生資料。</p>
<h2 id="概念位置">概念位置</h2>
<p>Consumer 位在資料流的下游。它跟 <a href="/blog/backend/knowledge-cards/producer/" data-link-title="Producer" data-link-desc="說明 producer 如何把工作、事件或資料送入後續處理路徑">producer</a> 構成 MQ 的基本角色對 — producer 負責把工作送進等待區，consumer 負責取出並處理。</p>
<p>多個 consumer 組成 <a href="/blog/backend/knowledge-cards/consumer-group/" data-link-title="Consumer Group" data-link-desc="說明一組 consumer 如何共同分攤 stream 或 topic 的處理責任">consumer group</a> 來分攤處理負載。Consumer 的處理速度跟錯誤行為直接影響 <a href="/blog/backend/knowledge-cards/consumer-lag/" data-link-title="Consumer Lag" data-link-desc="說明 consumer lag 如何反映訊息堆積、處理能力與容量風險">consumer lag</a>（積壓深度）跟 <a href="/blog/backend/knowledge-cards/dead-letter-queue/" data-link-title="Dead-Letter Queue" data-link-desc="說明 dead-letter queue 如何隔離多次處理失敗的訊息">dead-letter queue</a>（無法處理的訊息去處）。</p>
<h2 id="使用情境">使用情境</h2>
<p>系統需要辨識 consumer 的訊號是資料已經送入系統但產品結果還沒完成。付款事件送入後，入帳 consumer 要更新帳務狀態；通知事件送入後，寄信 consumer 要呼叫郵件服務。兩者都要清楚記錄處理成功、暫時失敗與永久拒絕。</p>
<p>Consumer 的處理模式影響系統的可靠性保證。<a href="/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">Ack / nack</a> 的時機決定「訊息什麼時候算處理完成」；<a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a> 決定「重複收到同一筆訊息時是否會產生副作用」。</p>
<h2 id="設計責任">設計責任</h2>
<p>Consumer 要定義併發數、<a href="/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">ack / nack</a> 條件、錯誤分類（暫時性 vs 永久性）、<a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a>、<a href="/blog/backend/knowledge-cards/retry-policy/" data-link-title="Retry Policy" data-link-desc="說明重試策略如何區分暫時性錯誤、永久錯誤與副作用風險">retry policy</a>、隔離區、<a href="/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">graceful shutdown</a> 與觀測欄位。</p>
<p>操作面要能觀測：處理速率（messages/sec）、失敗類型分布、oldest unprocessed message age、<a href="/blog/backend/knowledge-cards/consumer-lag/" data-link-title="Consumer Lag" data-link-desc="說明 consumer lag 如何反映訊息堆積、處理能力與容量風險">consumer lag</a>、<a href="/blog/backend/knowledge-cards/dead-letter-queue/" data-link-title="Dead-Letter Queue" data-link-desc="說明 dead-letter queue 如何隔離多次處理失敗的訊息">dead-letter queue</a> 累積量與下游 dependency latency。Consumer lag 持續增長是容量不足的 leading indicator。</p>
]]></content:encoded></item><item><title>Topic</title><link>https://tarrragon.github.io/blog/backend/knowledge-cards/topic/</link><pubDate>Mon, 22 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/knowledge-cards/topic/</guid><description>&lt;p>Topic 的核心概念是「用主題名稱描述一類事件或訊息」。&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/producer/" data-link-title="Producer" data-link-desc="說明 producer 如何把工作、事件或資料送入後續處理路徑">Producer&lt;/a> 把事件發布到 topic，&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker&lt;/a> 再依照訂閱關係、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/routing-rule/" data-link-title="Routing Rule" data-link-desc="說明訊息系統如何依規則把訊息送到不同處理路徑">routing rule&lt;/a> 或 stream 模型把事件交給對應 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer&lt;/a>。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>Topic 是事件分流的命名邊界。它讓訂單、付款、會員、通知、庫存等事件可以被不同服務訂閱，也讓團隊用事件種類思考資料流與責任範圍。&lt;/p>
&lt;p>Topic 跟 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/partition/" data-link-title="Partition" data-link-desc="說明事件流如何切分成多個可並行處理的有序片段">partition&lt;/a> 的關係是：topic 是邏輯命名空間，partition 是 topic 內的物理分片。Topic 跟 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/fan-out/" data-link-title="Fan-out" data-link-desc="說明單一事件同時分發給多個下游的訊息拓撲">fan-out&lt;/a> 的關係是：多個 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer-group/" data-link-title="Consumer Group" data-link-desc="說明一組 consumer 如何共同分攤 stream 或 topic 的處理責任">consumer group&lt;/a> 訂閱同一個 topic，每個 group 各自消費全量事件，實現 fan-out。&lt;/p>
&lt;p>在 RabbitMQ 生態中，topic 對應 exchange + routing key 的組合；在 NATS 中 topic 對應 subject。概念相同但術語跟語意細節不同。&lt;/p>
&lt;h2 id="使用情境">使用情境&lt;/h2>
&lt;p>系統需要 topic 設計的訊號是同一個事件來源會供多個 downstream 使用。付款完成事件可以給出貨、通知、報表與風控使用；所有事件都混在同一條 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> 時，consumer 會承擔更多過濾與相容性成本。&lt;/p>
&lt;p>Topic 命名規則影響長期治理。&lt;code>orders.payment.completed&lt;/code> 比 &lt;code>event_1&lt;/code> 更容易被搜尋跟管理。命名規則要在團隊間統一、進 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue-contract/" data-link-title="Queue Contract" data-link-desc="說明佇列工作在重試、確認與重複投遞上的約定">queue contract&lt;/a> 管理。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>Topic 設計要定義命名規則、事件 schema、相容性策略（schema evolution）、權限控制（誰能 publish / subscribe）、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/retention/" data-link-title="Retention" data-link-desc="說明資料或事件保留多久，以及保留期限如何影響重放與成本">retention&lt;/a> 期限、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/replay-runbook/" data-link-title="Replay Runbook" data-link-desc="說明事件重放前需要控制的範圍、順序、驗證與副作用">replay runbook&lt;/a> 範圍與 ownership（哪個團隊負責這個 topic）。操作面要能依 topic 查看 publish rate、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer-lag/" data-link-title="Consumer Lag" data-link-desc="說明 consumer lag 如何反映訊息堆積、處理能力與容量風險">consumer lag&lt;/a>、錯誤率與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/dead-letter-queue/" data-link-title="Dead-Letter Queue" data-link-desc="說明 dead-letter queue 如何隔離多次處理失敗的訊息">dead-letter queue&lt;/a> 數量。&lt;/p></description><content:encoded><![CDATA[<p>Topic 的核心概念是「用主題名稱描述一類事件或訊息」。<a href="/blog/backend/knowledge-cards/producer/" data-link-title="Producer" data-link-desc="說明 producer 如何把工作、事件或資料送入後續處理路徑">Producer</a> 把事件發布到 topic，<a href="/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker</a> 再依照訂閱關係、<a href="/blog/backend/knowledge-cards/routing-rule/" data-link-title="Routing Rule" data-link-desc="說明訊息系統如何依規則把訊息送到不同處理路徑">routing rule</a> 或 stream 模型把事件交給對應 <a href="/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer</a>。</p>
<h2 id="概念位置">概念位置</h2>
<p>Topic 是事件分流的命名邊界。它讓訂單、付款、會員、通知、庫存等事件可以被不同服務訂閱，也讓團隊用事件種類思考資料流與責任範圍。</p>
<p>Topic 跟 <a href="/blog/backend/knowledge-cards/partition/" data-link-title="Partition" data-link-desc="說明事件流如何切分成多個可並行處理的有序片段">partition</a> 的關係是：topic 是邏輯命名空間，partition 是 topic 內的物理分片。Topic 跟 <a href="/blog/backend/knowledge-cards/fan-out/" data-link-title="Fan-out" data-link-desc="說明單一事件同時分發給多個下游的訊息拓撲">fan-out</a> 的關係是：多個 <a href="/blog/backend/knowledge-cards/consumer-group/" data-link-title="Consumer Group" data-link-desc="說明一組 consumer 如何共同分攤 stream 或 topic 的處理責任">consumer group</a> 訂閱同一個 topic，每個 group 各自消費全量事件，實現 fan-out。</p>
<p>在 RabbitMQ 生態中，topic 對應 exchange + routing key 的組合；在 NATS 中 topic 對應 subject。概念相同但術語跟語意細節不同。</p>
<h2 id="使用情境">使用情境</h2>
<p>系統需要 topic 設計的訊號是同一個事件來源會供多個 downstream 使用。付款完成事件可以給出貨、通知、報表與風控使用；所有事件都混在同一條 <a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> 時，consumer 會承擔更多過濾與相容性成本。</p>
<p>Topic 命名規則影響長期治理。<code>orders.payment.completed</code> 比 <code>event_1</code> 更容易被搜尋跟管理。命名規則要在團隊間統一、進 <a href="/blog/backend/knowledge-cards/queue-contract/" data-link-title="Queue Contract" data-link-desc="說明佇列工作在重試、確認與重複投遞上的約定">queue contract</a> 管理。</p>
<h2 id="設計責任">設計責任</h2>
<p>Topic 設計要定義命名規則、事件 schema、相容性策略（schema evolution）、權限控制（誰能 publish / subscribe）、<a href="/blog/backend/knowledge-cards/retention/" data-link-title="Retention" data-link-desc="說明資料或事件保留多久，以及保留期限如何影響重放與成本">retention</a> 期限、<a href="/blog/backend/knowledge-cards/replay-runbook/" data-link-title="Replay Runbook" data-link-desc="說明事件重放前需要控制的範圍、順序、驗證與副作用">replay runbook</a> 範圍與 ownership（哪個團隊負責這個 topic）。操作面要能依 topic 查看 publish rate、<a href="/blog/backend/knowledge-cards/consumer-lag/" data-link-title="Consumer Lag" data-link-desc="說明 consumer lag 如何反映訊息堆積、處理能力與容量風險">consumer lag</a>、錯誤率與 <a href="/blog/backend/knowledge-cards/dead-letter-queue/" data-link-title="Dead-Letter Queue" data-link-desc="說明 dead-letter queue 如何隔離多次處理失敗的訊息">dead-letter queue</a> 數量。</p>
]]></content:encoded></item><item><title>Fan-out</title><link>https://tarrragon.github.io/blog/backend/knowledge-cards/fan-out/</link><pubDate>Mon, 22 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/knowledge-cards/fan-out/</guid><description>&lt;p>Fan-out 的核心概念是「一個事件被多個訂閱者各自獨立處理」。它讓單一 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/producer/" data-link-title="Producer" data-link-desc="說明 producer 如何把工作、事件或資料送入後續處理路徑">producer&lt;/a> 發布一次事件，多個下游各自消費、各自處理、各自管理進度跟錯誤。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>Fan-out 常搭配 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/pub-sub/" data-link-title="Pub/Sub" data-link-desc="說明 publish-subscribe 如何把事件即時分發給多個訂閱者">pub/sub&lt;/a> 模型、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic&lt;/a> 跟 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer-group/" data-link-title="Consumer Group" data-link-desc="說明一組 consumer 如何共同分攤 stream 或 topic 的處理責任">consumer group&lt;/a> 實作。在 Kafka 中，多個 consumer group 訂閱同一個 topic 就是 fan-out — 每個 group 各自從 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/offset/" data-link-title="Offset" data-link-desc="說明 consumer 在事件流中的讀取位置與重放基準">offset&lt;/a> 0 開始消費。在 RabbitMQ 中，fanout exchange 把訊息複製到所有綁定的 queue。在 GCP Pub/Sub 中，多個 subscription 訂閱同一個 topic。&lt;/p>
&lt;p>Fan-out 跟 fan-in（多個來源合併成一個流）是相反的拓撲。兩者可以組合成事件處理管線。&lt;/p>
&lt;h2 id="使用情境">使用情境&lt;/h2>
&lt;p>&lt;code>order.paid&lt;/code> 事件同時觸發出貨準備（物流服務）、交易通知（通知服務）、營收紀錄（報表服務）與風控評估（風控服務）。Producer 不需要知道有哪些 consumer — 加減 consumer 不影響 producer 的程式碼。&lt;/p>
&lt;p>Fan-out 降低了 producer 跟 consumer 之間的耦合，但擴大了排障範圍 — 一筆事件的處理結果散落在多個 consumer，需要用 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/trace-context/" data-link-title="Trace Context" data-link-desc="說明跨服務 request 如何用 trace context 串起路徑與耗時">trace context&lt;/a> 或 correlation id 串連。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>設計 fan-out 時要為每個訂閱者定義可靠性等級跟回復策略。通知服務短暫失敗可以 retry；報表服務落後可以批次追補；但出貨服務的失敗可能需要人工介入。把所有下游綁成同一個失敗域（一個 consumer 卡住就全部暫停）會讓 fan-out 的解耦價值消失。每個 consumer group 應該獨立管理 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer-lag/" data-link-title="Consumer Lag" data-link-desc="說明 consumer lag 如何反映訊息堆積、處理能力與容量風險">consumer lag&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/dead-letter-queue/" data-link-title="Dead-Letter Queue" data-link-desc="說明 dead-letter queue 如何隔離多次處理失敗的訊息">dead-letter queue&lt;/a> 跟 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/replay-runbook/" data-link-title="Replay Runbook" data-link-desc="說明事件重放前需要控制的範圍、順序、驗證與副作用">replay runbook&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>Fan-out 的核心概念是「一個事件被多個訂閱者各自獨立處理」。它讓單一 <a href="/blog/backend/knowledge-cards/producer/" data-link-title="Producer" data-link-desc="說明 producer 如何把工作、事件或資料送入後續處理路徑">producer</a> 發布一次事件，多個下游各自消費、各自處理、各自管理進度跟錯誤。</p>
<h2 id="概念位置">概念位置</h2>
<p>Fan-out 常搭配 <a href="/blog/backend/knowledge-cards/pub-sub/" data-link-title="Pub/Sub" data-link-desc="說明 publish-subscribe 如何把事件即時分發給多個訂閱者">pub/sub</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> 實作。在 Kafka 中，多個 consumer group 訂閱同一個 topic 就是 fan-out — 每個 group 各自從 <a href="/blog/backend/knowledge-cards/offset/" data-link-title="Offset" data-link-desc="說明 consumer 在事件流中的讀取位置與重放基準">offset</a> 0 開始消費。在 RabbitMQ 中，fanout exchange 把訊息複製到所有綁定的 queue。在 GCP Pub/Sub 中，多個 subscription 訂閱同一個 topic。</p>
<p>Fan-out 跟 fan-in（多個來源合併成一個流）是相反的拓撲。兩者可以組合成事件處理管線。</p>
<h2 id="使用情境">使用情境</h2>
<p><code>order.paid</code> 事件同時觸發出貨準備（物流服務）、交易通知（通知服務）、營收紀錄（報表服務）與風控評估（風控服務）。Producer 不需要知道有哪些 consumer — 加減 consumer 不影響 producer 的程式碼。</p>
<p>Fan-out 降低了 producer 跟 consumer 之間的耦合，但擴大了排障範圍 — 一筆事件的處理結果散落在多個 consumer，需要用 <a href="/blog/backend/knowledge-cards/trace-context/" data-link-title="Trace Context" data-link-desc="說明跨服務 request 如何用 trace context 串起路徑與耗時">trace context</a> 或 correlation id 串連。</p>
<h2 id="設計責任">設計責任</h2>
<p>設計 fan-out 時要為每個訂閱者定義可靠性等級跟回復策略。通知服務短暫失敗可以 retry；報表服務落後可以批次追補；但出貨服務的失敗可能需要人工介入。把所有下游綁成同一個失敗域（一個 consumer 卡住就全部暫停）會讓 fan-out 的解耦價值消失。每個 consumer group 應該獨立管理 <a href="/blog/backend/knowledge-cards/consumer-lag/" data-link-title="Consumer Lag" data-link-desc="說明 consumer lag 如何反映訊息堆積、處理能力與容量風險">consumer lag</a>、<a href="/blog/backend/knowledge-cards/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/replay-runbook/" data-link-title="Replay Runbook" data-link-desc="說明事件重放前需要控制的範圍、順序、驗證與副作用">replay runbook</a>。</p>
]]></content:encoded></item><item><title>終端機訊息佇列客戶端：Kafka 的 kaskade/yozefu/ktea 與 Redis 的 iredis</title><link>https://tarrragon.github.io/blog/linux/tools/cli/message-queue-tui-clients/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/linux/tools/cli/message-queue-tui-clients/</guid><description>&lt;p>終端機訊息佇列客戶端把 broker 的 topic、partition、consumer group 與訊息內容做成可導航的文字介面，讓遠端只有終端機時也能瀏覽訊息流、消費單一 topic、看消費進度，取代把連線資訊餵給桌面工具（Kafka 的 Conduktor、Redis 的 RedisInsight）的需求。它跟 broker 自帶的純指令工具（&lt;code>kafka-topics.sh&lt;/code>、&lt;code>rabbitmqctl&lt;/code>、&lt;code>redis-cli&lt;/code>）互補：指令工具適合腳本與一次性查詢，TUI 適合「邊看 topic 清單邊翻訊息內容」這種互動探索。&lt;/p>
&lt;p>本文承接 &lt;a href="https://tarrragon.github.io/blog/linux/tools/cli/cli-graphical-tools-overview/" data-link-title="終端機圖形化工具總覽：遠端操作下的 TUI、文字圖表與多工器" data-link-desc="在純文字終端機裡用 ASCII 與製圖字元做出監控儀表板、資料圖表與多視窗操作的工具總覽，並針對 SSH 伺服器、手機平板、低頻寬三種遠端情境給出選型判讀。">終端機圖形化工具總覽&lt;/a> 的訊息佇列客戶端分類。broker 端的純指令操作與 vendor 選型見 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/redis-streams/" data-link-title="Redis Streams" data-link-desc="Redis 生態內的 streams、append-only log &amp;#43; consumer group">Redis Streams&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/rabbitmq/" data-link-title="RabbitMQ" data-link-desc="Classic message broker、AMQP routing 為主">RabbitMQ&lt;/a> 服務頁。&lt;/p>
&lt;h2 id="跟-sql-客戶端最大的不同多半綁單一-broker-協議">跟 SQL 客戶端最大的不同：多半綁單一 broker 協議&lt;/h2>
&lt;p>訊息佇列 TUI 幾乎都綁定單一 broker 協議，這是選型要先認清的一點，也跟 SQL 客戶端剛好相反。&lt;a href="https://tarrragon.github.io/blog/linux/tools/cli/sql-database-clients/" data-link-title="終端機 SQL 客戶端：harlequin、lazysql 與 pgcli/litecli 的選型" data-link-desc="在純文字終端機連資料庫、跑查詢、看結果的客戶端：全螢幕 TUI（harlequin IDE 風、lazysql 瀏覽器風）與增強型 REPL（pgcli/litecli）兩種範式，以及遠端連線的 SSL driver gotcha。">SQL 客戶端&lt;/a> 一個工具靠 adapter 連 Postgres、MySQL、SQLite 多種資料庫；訊息佇列這邊，Kafka 的 TUI 說的是 Kafka protocol、不認 AMQP，RabbitMQ 的 TUI 走 management API、也不讀 Kafka topic。能同時連多種 broker 的工具是少數例外（見後文 queuepeek）。&lt;/p>
&lt;p>所以選型順序是先定 broker、再挑該 broker 生態的工具。實機盤點下來，Kafka 的 TUI 生態最成熟（多個活躍專案、安裝管道齊全），Redis 有強的增強型 REPL，RabbitMQ 與跨 broker 工具仍在早期。&lt;/p>
&lt;h2 id="兩種範式全螢幕-tui-與增強型-repl">兩種範式：全螢幕 TUI 與增強型 REPL&lt;/h2>
&lt;p>訊息佇列客戶端沿用跟 SQL 客戶端同一組範式區分。全螢幕 TUI（&lt;code>kaskade&lt;/code> / &lt;code>yozefu&lt;/code> / &lt;code>ktea&lt;/code>）把 topic 清單、訊息內容、consumer 狀態排進多個面板，鍵盤導航瀏覽；增強型 REPL（&lt;code>iredis&lt;/code>）仍是一行行打指令，但加上補全、語法高亮與型別感知輸出，是原生 client 的升級版。&lt;/p>
&lt;p>選哪種看工作型態：要在多個 topic 間翻訊息、看 partition 與 consumer group 全貌，用全螢幕 TUI；要快速接上跑幾條指令、或塞進腳本，用增強型 REPL。&lt;/p>
&lt;h2 id="kafka-全螢幕-tuikaskadeyozefuktea">Kafka 全螢幕 TUI：kaskade、yozefu、ktea&lt;/h2>
&lt;p>Kafka 有三個定位不同的全螢幕 TUI，互動模型與連線設定各異。&lt;/p>
&lt;p>&lt;code>kaskade&lt;/code>（Python、Textual 寫，實測 4.0.7）分 admin 與 consumer 兩個子命令，連線參數走 &lt;code>-b&lt;/code>。&lt;code>kaskade admin -b localhost:9092&lt;/code> 進管理模式，實測連上 broker 後渲染出 topics 面板，欄位是 name、partitions、replicas、in sync、groups、members、records，一頁看完叢集的 topic 全貌。&lt;code>kaskade consumer -b localhost:9092 -t orders --from-beginning&lt;/code> 進消費模式翻單一 topic 的訊息，&lt;code>-v json&lt;/code> 與 &lt;code>-v registry&lt;/code> 切 payload 解碼方式，後者配 &lt;code>--registry url=http://localhost:8081&lt;/code> 接 Schema Registry。SSL / SASL 不走 &lt;code>-b&lt;/code>，要用 &lt;code>--config security.protocol=SSL&lt;/code> 逐項帶或 &lt;code>--config-file kafka.properties&lt;/code> 餵設定檔。&lt;/p>
&lt;p>&lt;code>yozefu&lt;/code>（Rust 寫、binary 名是 &lt;code>yozf&lt;/code>，MAIF 維護）主打跨 topic 的搜尋查詢，把找特定 record 當成核心場景。它的查詢語言是 SQL 風的，預設 &lt;code>initial_query&lt;/code> 是 &lt;code>from end - 10&lt;/code>（從尾端往回取 10 筆），search filter 還能用 WebAssembly 自訂（&lt;code>create-filter&lt;/code> / &lt;code>import-filter&lt;/code> 子命令）。連線走 config 模型而非純 flag：&lt;code>yozf config&lt;/code> 會印出設定（檔案在 &lt;code>~/Library/Application Support/io.maif.yozefu/config.json&lt;/code>），每個 cluster 在裡面定義 &lt;code>bootstrap.servers&lt;/code>、&lt;code>security.protocol&lt;/code> 與 schema registry，再用 &lt;code>yozf -c &amp;lt;cluster&amp;gt; -t &amp;lt;topics&amp;gt;&lt;/code> 指定要連哪個。&lt;/p></description><content:encoded><![CDATA[<p>終端機訊息佇列客戶端把 broker 的 topic、partition、consumer group 與訊息內容做成可導航的文字介面，讓遠端只有終端機時也能瀏覽訊息流、消費單一 topic、看消費進度，取代把連線資訊餵給桌面工具（Kafka 的 Conduktor、Redis 的 RedisInsight）的需求。它跟 broker 自帶的純指令工具（<code>kafka-topics.sh</code>、<code>rabbitmqctl</code>、<code>redis-cli</code>）互補：指令工具適合腳本與一次性查詢，TUI 適合「邊看 topic 清單邊翻訊息內容」這種互動探索。</p>
<p>本文承接 <a href="/blog/linux/tools/cli/cli-graphical-tools-overview/" data-link-title="終端機圖形化工具總覽：遠端操作下的 TUI、文字圖表與多工器" data-link-desc="在純文字終端機裡用 ASCII 與製圖字元做出監控儀表板、資料圖表與多視窗操作的工具總覽，並針對 SSH 伺服器、手機平板、低頻寬三種遠端情境給出選型判讀。">終端機圖形化工具總覽</a> 的訊息佇列客戶端分類。broker 端的純指令操作與 vendor 選型見 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka</a>、<a href="/blog/backend/03-message-queue/vendors/redis-streams/" data-link-title="Redis Streams" data-link-desc="Redis 生態內的 streams、append-only log &#43; consumer group">Redis Streams</a>、<a href="/blog/backend/03-message-queue/vendors/rabbitmq/" data-link-title="RabbitMQ" data-link-desc="Classic message broker、AMQP routing 為主">RabbitMQ</a> 服務頁。</p>
<h2 id="跟-sql-客戶端最大的不同多半綁單一-broker-協議">跟 SQL 客戶端最大的不同：多半綁單一 broker 協議</h2>
<p>訊息佇列 TUI 幾乎都綁定單一 broker 協議，這是選型要先認清的一點，也跟 SQL 客戶端剛好相反。<a href="/blog/linux/tools/cli/sql-database-clients/" data-link-title="終端機 SQL 客戶端：harlequin、lazysql 與 pgcli/litecli 的選型" data-link-desc="在純文字終端機連資料庫、跑查詢、看結果的客戶端：全螢幕 TUI（harlequin IDE 風、lazysql 瀏覽器風）與增強型 REPL（pgcli/litecli）兩種範式，以及遠端連線的 SSL driver gotcha。">SQL 客戶端</a> 一個工具靠 adapter 連 Postgres、MySQL、SQLite 多種資料庫；訊息佇列這邊，Kafka 的 TUI 說的是 Kafka protocol、不認 AMQP，RabbitMQ 的 TUI 走 management API、也不讀 Kafka topic。能同時連多種 broker 的工具是少數例外（見後文 queuepeek）。</p>
<p>所以選型順序是先定 broker、再挑該 broker 生態的工具。實機盤點下來，Kafka 的 TUI 生態最成熟（多個活躍專案、安裝管道齊全），Redis 有強的增強型 REPL，RabbitMQ 與跨 broker 工具仍在早期。</p>
<h2 id="兩種範式全螢幕-tui-與增強型-repl">兩種範式：全螢幕 TUI 與增強型 REPL</h2>
<p>訊息佇列客戶端沿用跟 SQL 客戶端同一組範式區分。全螢幕 TUI（<code>kaskade</code> / <code>yozefu</code> / <code>ktea</code>）把 topic 清單、訊息內容、consumer 狀態排進多個面板，鍵盤導航瀏覽；增強型 REPL（<code>iredis</code>）仍是一行行打指令，但加上補全、語法高亮與型別感知輸出，是原生 client 的升級版。</p>
<p>選哪種看工作型態：要在多個 topic 間翻訊息、看 partition 與 consumer group 全貌，用全螢幕 TUI；要快速接上跑幾條指令、或塞進腳本，用增強型 REPL。</p>
<h2 id="kafka-全螢幕-tuikaskadeyozefuktea">Kafka 全螢幕 TUI：kaskade、yozefu、ktea</h2>
<p>Kafka 有三個定位不同的全螢幕 TUI，互動模型與連線設定各異。</p>
<p><code>kaskade</code>（Python、Textual 寫，實測 4.0.7）分 admin 與 consumer 兩個子命令，連線參數走 <code>-b</code>。<code>kaskade admin -b localhost:9092</code> 進管理模式，實測連上 broker 後渲染出 topics 面板，欄位是 name、partitions、replicas、in sync、groups、members、records，一頁看完叢集的 topic 全貌。<code>kaskade consumer -b localhost:9092 -t orders --from-beginning</code> 進消費模式翻單一 topic 的訊息，<code>-v json</code> 與 <code>-v registry</code> 切 payload 解碼方式，後者配 <code>--registry url=http://localhost:8081</code> 接 Schema Registry。SSL / SASL 不走 <code>-b</code>，要用 <code>--config security.protocol=SSL</code> 逐項帶或 <code>--config-file kafka.properties</code> 餵設定檔。</p>
<p><code>yozefu</code>（Rust 寫、binary 名是 <code>yozf</code>，MAIF 維護）主打跨 topic 的搜尋查詢，把找特定 record 當成核心場景。它的查詢語言是 SQL 風的，預設 <code>initial_query</code> 是 <code>from end - 10</code>（從尾端往回取 10 筆），search filter 還能用 WebAssembly 自訂（<code>create-filter</code> / <code>import-filter</code> 子命令）。連線走 config 模型而非純 flag：<code>yozf config</code> 會印出設定（檔案在 <code>~/Library/Application Support/io.maif.yozefu/config.json</code>），每個 cluster 在裡面定義 <code>bootstrap.servers</code>、<code>security.protocol</code> 與 schema registry，再用 <code>yozf -c &lt;cluster&gt; -t &lt;topics&gt;</code> 指定要連哪個。</p>
<p><code>ktea</code>（Go 寫，Homebrew 0.8.0）同樣是 config-based，cluster 連線設定走首次啟動的互動流程而非命令列旗標。啟動旗標有 <code>-debug</code> 與 <code>-plain-fonts</code>，後者在終端機沒裝 NerdFonts、圖示顯示成亂碼時關掉圖示。本機裝起來、啟動旗標確認過，cluster 連線與深層瀏覽走互動設定流程、未逐步驗證。</p>
<p>判讀：要一頁看完 topic / consumer group 狀態、或邊看邊消費，選 <code>kaskade</code>；要在大量 topic 裡用查詢撈特定 record，選 <code>yozefu</code> 的搜尋模型；<code>ktea</code> 是另一個 Go 單 binary 選擇、偏好互動式設定 cluster 的可評估。</p>
<h2 id="增強型-repliredisredis-與-redis-streams">增強型 REPL：iredis（Redis 與 Redis Streams）</h2>
<p><code>iredis</code>（Python 寫，實測 1.16.1）是 <code>redis-cli</code> 的增強版，補上指令補全、語法高亮與型別感知輸出，手感仍是 REPL。它跟 dbcli 家族的 <code>pgcli</code> / <code>litecli</code> 同一類定位。實測非互動可跑，把指令用管線餵進去就回結果：<code>echo &quot;DBSIZE&quot; | iredis -h localhost -p 6390</code>，適合塞腳本。</p>
<p>它對 Redis Streams（<a href="/blog/backend/03-message-queue/vendors/redis-streams/" data-link-title="Redis Streams" data-link-desc="Redis 生態內的 streams、append-only log &#43; consumer group">03 的 vendor 之一</a>）的檢視特別省事。<code>peek &lt;key&gt;</code> 會先看型別再自動取值，string 顯示 strlen 與內容、stream 走 <code>XINFO</code>；實測對一個 stream 跑 <code>XINFO STREAM</code> 直接回 length、last-generated-id 等欄位，不必先 <code>TYPE</code> 再決定下哪個讀取指令。它是通用 Redis client、不是 stream 專用工具，但 Redis Streams 的 consumer group 操作（<code>XPENDING</code>、<code>XCLAIM</code>、<code>XINFO GROUPS</code>）都在這套指令補全範圍內。</p>
<h2 id="rabbitmq-與跨-broker生態仍在早期">RabbitMQ 與跨 broker：生態仍在早期</h2>
<p>RabbitMQ 與「一個工具連多種 broker」這兩塊目前缺乏可直接安裝驗證的成熟工具，列出供參考、本機未實機驗證。</p>
<blockquote>
<p>RabbitMQ 的 TUI 候選有 <code>rabbitui</code>（走 RabbitMQ management API）與 <code>rabbithole</code>（帶 exchange / binding 的 topology browser、支援 Protobuf 解碼）。兩者都不在 Homebrew 與 crates.io 的發佈管道，本機未安裝驗證。在缺 TUI 的情況下，RabbitMQ 的互動瀏覽仍以內建的 Management UI（web，預設 15672 埠）為主，純終端機則回到 <code>rabbitmqctl</code> 與 <code>rabbitmqadmin</code>。</p></blockquote>
<blockquote>
<p>跨 broker 的 <code>queuepeek</code>（Rust 寫，宣稱同時連 RabbitMQ、Kafka、MQTT）對應 SQL 類裡 <code>usql</code> 的「一個工具連多種後端」定位。本機 <code>cargo install queuepeek</code> 在編譯 <code>rdkafka-sys</code>（綁定原生 librdkafka）階段失敗、未能驗證。</p></blockquote>
<h2 id="gotcha實測">gotcha（實測）</h2>
<ul>
<li><code>yozefu</code> 預設帶一個名為 <code>localhost</code> 的 cluster、指向 <code>localhost:9092</code>。連非預設 port（例如本機測試的 9093）要先 <code>yozf configure</code> 改掉 <code>bootstrap.servers</code>，直接用 flag 覆寫不會生效。</li>
<li><code>kaskade</code> 的 <code>-b</code> 只接 bootstrap server；SSL / SASL 等安全設定一律走 <code>--config key=value</code> 或 <code>--config-file</code>，混在 <code>-b</code> 裡會被當成 broker 位址。</li>
<li><code>ktea</code> 的 <code>-plain-fonts</code>：終端機沒裝 NerdFonts 時圖示會顯示成亂碼方塊，加這個旗標關掉圖示就恢復可讀。</li>
</ul>
<h2 id="同類其他選擇">同類其他選擇</h2>
<p>Redis 的全螢幕 TUI（如 <code>redis-tui</code>）與其他 Kafka TUI（如 <code>kafka-tui</code>）未在本輪實機驗證、列出供參考。Kafka TUI 這塊專案數量較多，挑選時以發佈管道（Homebrew / pip / crates.io 直接可裝）與維護活躍度篩選，不追求窮舉。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>broker 端純指令工具與 vendor 選型：<a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka</a>、<a href="/blog/backend/03-message-queue/vendors/redis-streams/" data-link-title="Redis Streams" data-link-desc="Redis 生態內的 streams、append-only log &#43; consumer group">Redis Streams</a>、<a href="/blog/backend/03-message-queue/vendors/rabbitmq/" data-link-title="RabbitMQ" data-link-desc="Classic message broker、AMQP routing 為主">RabbitMQ</a> 服務頁。</li>
<li>同範式的資料庫客戶端對照：<a href="/blog/linux/tools/cli/sql-database-clients/" data-link-title="終端機 SQL 客戶端：harlequin、lazysql 與 pgcli/litecli 的選型" data-link-desc="在純文字終端機連資料庫、跑查詢、看結果的客戶端：全螢幕 TUI（harlequin IDE 風、lazysql 瀏覽器風）與增強型 REPL（pgcli/litecli）兩種範式，以及遠端連線的 SSL driver gotcha。">終端機 SQL 客戶端</a>。</li>
<li>把客戶端擺進可持久化的多工器 pane：<a href="/blog/linux/tools/cli/tmux-persistence-and-basics/" data-link-title="tmux 基礎：遠端 session 持久化與基本操作" data-link-desc="tmux 終端機多工器的遠端使用核心：detach/reattach 讓 session 脫離連線生命週期、prefix key 與 window/pane 操作、手機友善的快捷鍵調校，以及 tmux 與 zellij 的選型對照。">tmux 基礎</a>。</li>
<li>訊息佇列客戶端在遠端工具分類中的定位：<a href="/blog/linux/tools/cli/cli-graphical-tools-overview/" data-link-title="終端機圖形化工具總覽：遠端操作下的 TUI、文字圖表與多工器" data-link-desc="在純文字終端機裡用 ASCII 與製圖字元做出監控儀表板、資料圖表與多視窗操作的工具總覽，並針對 SSH 伺服器、手機平板、低頻寬三種遠端情境給出選型判讀。">終端機圖形化工具總覽</a>。</li>
</ul>
]]></content:encoded></item></channel></rss>