<?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>Rabbitmq on Tarragon</title><link>https://tarrragon.github.io/blog/tags/rabbitmq/</link><description>Recent content in Rabbitmq on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Tue, 16 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/rabbitmq/index.xml" rel="self" type="application/rss+xml"/><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>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>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>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.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>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></channel></rss>