<?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>模組三：訊息佇列與事件傳遞 on Tarragon</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/</link><description>Recent content in 模組三：訊息佇列與事件傳遞 on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Wed, 22 Apr 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/backend/03-message-queue/index.xml" rel="self" type="application/rss+xml"/><item><title>3.1 broker 基礎與投遞模型</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/broker-basics/</link><pubDate>Thu, 23 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/broker-basics/</guid><description>&lt;p>這一章先建立訊息佇列的基本模型，後面的 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/durable-queue/" data-link-title="Durable Queue" data-link-desc="說明可持久化的 queue 如何在重啟與失敗後保留待處理工作">durable queue&lt;/a>、outbox 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer&lt;/a> 設計都會建立在這些語意上。&lt;/p>
&lt;p>訊息代理（broker）的核心責任是解耦 producer 與 consumer，讓非同步工作具備可排隊、可重試、可隔離的傳遞路徑。它定位在傳遞與協調層。&lt;/p>
&lt;h2 id="broker-跟-protocol-是兩個獨立的軸">broker 跟 protocol 是兩個獨立的軸&lt;/h2>
&lt;p>Broker 是訊息分發的具體實作產品（RabbitMQ、Kafka、NATS、EMQX）、protocol 是訊息交換的線路規格（AMQP、MQTT、STOMP、Kafka wire protocol）。兩個軸獨立、形成多對多關係：&lt;/p>
&lt;ul>
&lt;li>一個 broker 可實作多個 protocol：RabbitMQ 主走 AMQP、透過 plugin 也支援 MQTT 跟 STOMP；NATS 主走自家 protocol、JetStream 額外提供 KV 與 Object Store API&lt;/li>
&lt;li>一個 protocol 可被多個 broker 實作：MQTT 由 EMQX / HiveMQ / Mosquitto / RabbitMQ MQTT plugin 各自實作；AMQP 主要是 RabbitMQ 跟 Apache Qpid&lt;/li>
&lt;/ul>
&lt;p>選型討論時要分清「我需要的是 protocol（如 device 端要 MQTT 因為輕量 / IoT 標準）」還是「broker 產品（如 RabbitMQ vs EMQX 的運維 / 生態取捨）」。當 protocol 跟 broker 都需要、會出現 protocol 橋接場景 — 例：device 端透過 MQTT 連 RabbitMQ MQTT plugin、broker 內部把 MQTT topic 自動映射成 AMQP routing key、AMQP-side consumer 用 routing key 訂閱。&lt;/p>
&lt;p>這層分離也影響故障判讀：device 連不上是 protocol 層問題、broker 之間 routing 錯是 broker 內部 plugin / mapping 問題、consumer 收不到是 AMQP binding 問題 — 三層各自獨立、不能混為一談。&lt;/p>
&lt;h2 id="brokerqueueconsumer-的分工">broker、queue、consumer 的分工&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker&lt;/a> 管理訊息儲存、分發與確認流程；queue 或 topic 承載傳遞單位；consumer 承擔業務處理。分工清楚後，故障判讀才能定位在正確層級：投遞故障、消費故障或下游依賴故障。&lt;/p>
&lt;p>producer 發送成功只代表 broker 已接收（&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/publisher-confirm/" data-link-title="Publisher Confirm" data-link-desc="說明 producer 如何確認 broker 已接收並承擔訊息">publisher confirm&lt;/a>），不代表業務結果完成。業務完成需要 consumer 提交副作用並確認進度。&lt;/p>
&lt;h2 id="push-與-pull-模型">push 與 pull 模型&lt;/h2>
&lt;p>push 模型由 broker 主動推送訊息，適合低延遲場景；pull 模型由 consumer 主動拉取，適合吞吐控制與批次處理。實務上常結合使用：broker 管理可見性與重試，consumer 控制節流與併發。&lt;/p>
&lt;p>模型選擇重點是背壓控制。當下游變慢時，系統是否能限制消費速率並保留恢復空間，是穩定性的關鍵。&lt;/p>
&lt;h2 id="傳遞語意delivery-semantics">傳遞語意（delivery semantics）&lt;/h2>
&lt;p>三種常見 delivery semantics：&lt;/p>
&lt;ol>
&lt;li>at-most-once：可能丟失，不重送，低延遲低成本。&lt;/li>
&lt;li>at-least-once：可能重複，需冪等保護，最常見實務語意。&lt;/li>
&lt;li>exactly-once：語意成本高，通常在特定邊界內成立，需要嚴格協議與系統支持。&lt;/li>
&lt;/ol>
&lt;p>實務上多數後端系統採 at-least-once，再用 consumer 去重與補償達到業務可接受結果。&lt;/p>
&lt;h2 id="ack--nack-流程">ack / nack 流程&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">ack/nack&lt;/a> 是 delivery 控制點。ack 代表該訊息可從待處理集合移除；nack 代表稍後重試或分流。ack 時機過早會造成資料遺失，過晚會造成重複處理與堆積。&lt;/p>
&lt;p>穩定流程是：完成核心副作用後再 ack，暫時故障走受控重試，持續故障走 DLQ 隔離。&lt;/p></description><content:encoded><![CDATA[<p>這一章先建立訊息佇列的基本模型，後面的 <a href="/blog/backend/knowledge-cards/durable-queue/" data-link-title="Durable Queue" data-link-desc="說明可持久化的 queue 如何在重啟與失敗後保留待處理工作">durable queue</a>、outbox 與 <a href="/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer</a> 設計都會建立在這些語意上。</p>
<p>訊息代理（broker）的核心責任是解耦 producer 與 consumer，讓非同步工作具備可排隊、可重試、可隔離的傳遞路徑。它定位在傳遞與協調層。</p>
<h2 id="broker-跟-protocol-是兩個獨立的軸">broker 跟 protocol 是兩個獨立的軸</h2>
<p>Broker 是訊息分發的具體實作產品（RabbitMQ、Kafka、NATS、EMQX）、protocol 是訊息交換的線路規格（AMQP、MQTT、STOMP、Kafka wire protocol）。兩個軸獨立、形成多對多關係：</p>
<ul>
<li>一個 broker 可實作多個 protocol：RabbitMQ 主走 AMQP、透過 plugin 也支援 MQTT 跟 STOMP；NATS 主走自家 protocol、JetStream 額外提供 KV 與 Object Store API</li>
<li>一個 protocol 可被多個 broker 實作：MQTT 由 EMQX / HiveMQ / Mosquitto / RabbitMQ MQTT plugin 各自實作；AMQP 主要是 RabbitMQ 跟 Apache Qpid</li>
</ul>
<p>選型討論時要分清「我需要的是 protocol（如 device 端要 MQTT 因為輕量 / IoT 標準）」還是「broker 產品（如 RabbitMQ vs EMQX 的運維 / 生態取捨）」。當 protocol 跟 broker 都需要、會出現 protocol 橋接場景 — 例：device 端透過 MQTT 連 RabbitMQ MQTT plugin、broker 內部把 MQTT topic 自動映射成 AMQP routing key、AMQP-side consumer 用 routing key 訂閱。</p>
<p>這層分離也影響故障判讀：device 連不上是 protocol 層問題、broker 之間 routing 錯是 broker 內部 plugin / mapping 問題、consumer 收不到是 AMQP binding 問題 — 三層各自獨立、不能混為一談。</p>
<h2 id="brokerqueueconsumer-的分工">broker、queue、consumer 的分工</h2>
<p><a href="/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker</a> 管理訊息儲存、分發與確認流程；queue 或 topic 承載傳遞單位；consumer 承擔業務處理。分工清楚後，故障判讀才能定位在正確層級：投遞故障、消費故障或下游依賴故障。</p>
<p>producer 發送成功只代表 broker 已接收（<a href="/blog/backend/knowledge-cards/publisher-confirm/" data-link-title="Publisher Confirm" data-link-desc="說明 producer 如何確認 broker 已接收並承擔訊息">publisher confirm</a>），不代表業務結果完成。業務完成需要 consumer 提交副作用並確認進度。</p>
<h2 id="push-與-pull-模型">push 與 pull 模型</h2>
<p>push 模型由 broker 主動推送訊息，適合低延遲場景；pull 模型由 consumer 主動拉取，適合吞吐控制與批次處理。實務上常結合使用：broker 管理可見性與重試，consumer 控制節流與併發。</p>
<p>模型選擇重點是背壓控制。當下游變慢時，系統是否能限制消費速率並保留恢復空間，是穩定性的關鍵。</p>
<h2 id="傳遞語意delivery-semantics">傳遞語意（delivery semantics）</h2>
<p>三種常見 delivery semantics：</p>
<ol>
<li>at-most-once：可能丟失，不重送，低延遲低成本。</li>
<li>at-least-once：可能重複，需冪等保護，最常見實務語意。</li>
<li>exactly-once：語意成本高，通常在特定邊界內成立，需要嚴格協議與系統支持。</li>
</ol>
<p>實務上多數後端系統採 at-least-once，再用 consumer 去重與補償達到業務可接受結果。</p>
<h2 id="ack--nack-流程">ack / nack 流程</h2>
<p><a href="/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">ack/nack</a> 是 delivery 控制點。ack 代表該訊息可從待處理集合移除；nack 代表稍後重試或分流。ack 時機過早會造成資料遺失，過晚會造成重複處理與堆積。</p>
<p>穩定流程是：完成核心副作用後再 ack，暫時故障走受控重試，持續故障走 DLQ 隔離。</p>
<h2 id="語意保證的不同實作機制">語意保證的不同實作機制</h2>
<p>同一層 <a href="/blog/backend/knowledge-cards/delivery-semantics/" data-link-title="Delivery Semantics" data-link-desc="說明事件投遞語意如何定義遺失、重複、順序與補償策略">delivery semantics</a>、不同 broker 用不同協議機制達成。讀懂 broker 行為的關鍵、是辨認「at-least-once」這個語意承諾、底下是哪種具體機制負責 — 故障訊號跟操作旋鈕跟著不同。</p>
<p>三種常見實作機制：</p>
<table>
  <thead>
      <tr>
          <th>機制</th>
          <th>代表 broker</th>
          <th>達成方式</th>
          <th>主要操作旋鈕</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>QoS handshake</td>
          <td>MQTT 系列</td>
          <td>client 與 broker 之間的多次握手（QoS 0 / 1 / 2）</td>
          <td>QoS 等級、session persistence、retained message</td>
      </tr>
      <tr>
          <td>Broker ACK + retry</td>
          <td>RabbitMQ、SQS、NATS</td>
          <td>consumer 處理後回 ack、未 ack 由 broker 重新投遞</td>
          <td>ack / visibility timeout、prefetch、DLQ</td>
      </tr>
      <tr>
          <td>Replication + commit</td>
          <td>Kafka、Pulsar</td>
          <td>producer 寫入後等待 replica commit、consumer 用 offset</td>
          <td>acks 等級（0 / 1 / all）、min.insync.replicas、ISR</td>
      </tr>
  </tbody>
</table>
<p>三個機制的工程含義不同。QoS handshake 把可靠性責任拉到 wire protocol 層、適合 device-to-broker 場景但 broker-to-consumer 還要另外處理；broker ACK 把責任放在 consumer 處理完才確認、適合「處理即承諾」的任務隊列；replication 把責任放在訊息已被多份保存、適合「寫入即承諾」的事件流。</p>
<h3 id="機制差異的故障訊號">機制差異的故障訊號</h3>
<p>機制決定故障表現。同樣是「訊息重複投遞」、不同機制要看不同訊號：</p>
<ul>
<li>QoS handshake：QoS 1 重傳是設計、QoS 2 重傳代表握手失敗 — 看 broker 端的 PUBREL / PUBCOMP 完成率</li>
<li>Broker ACK：ack timeout 觸發 <a href="/blog/backend/knowledge-cards/redelivery/" data-link-title="Redelivery" data-link-desc="說明 broker 重新投遞訊息時 consumer 需要承擔的重入責任">redelivery</a> 是設計、頻繁 redelivery 代表 consumer 處理慢或下游卡 — 看 consumer 處理時間 vs ack timeout、視訊號為 <a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a></li>
<li>Replication：producer retry 觸發 duplicate 是設計、ISR shrink 代表 broker 副本不穩 — 看 ISR 狀態 vs producer acks 設定</li>
</ul>
<h3 id="機制差異的操作旋鈕">機制差異的操作旋鈕</h3>
<p>挑 broker 等同於挑「可調的旋鈕集合」。把「業務需要的語意」轉成「實際要調的旋鈕」、是 broker 選型落地的關鍵步驟：</p>
<ul>
<li>想保證「不丟」用 MQTT：QoS 等級提到 2、開 session persistence</li>
<li>想保證「不丟」用 RabbitMQ：consumer 走 manual ack、配 <a href="/blog/backend/knowledge-cards/dead-letter-queue/" data-link-title="Dead-Letter Queue" data-link-desc="說明 dead-letter queue 如何隔離多次處理失敗的訊息">DLQ</a>、設 prefetch 限併發</li>
<li>想保證「不丟」用 Kafka：producer acks=all、min.insync.replicas ≥ 2、consumer commit-after-process</li>
</ul>
<p>機制不同、可調旋鈕不同、operator 要熟悉的訊號也不同。這是「broker 系統複雜度」的真實來源 — 不是「broker 難安裝」、而是「broker 旋鈕集合的學習與調校曲線」。</p>
<h2 id="判讀訊號">判讀訊號</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>判讀重點</th>
          <th>對應動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>producer 發送成功但業務結果缺漏</td>
          <td>投遞成功與處理成功語意混淆</td>
          <td>補 consumer 確認與結果對帳</td>
      </tr>
      <tr>
          <td>queue depth 穩定但延遲持續上升</td>
          <td>消費速率不足或重試佔用主通道</td>
          <td>分離重試隊列、調整併發與節流</td>
      </tr>
      <tr>
          <td>ack 成功率高但 duplicate 增加</td>
          <td>ack 時機與副作用提交順序不對齊</td>
          <td>延後 ack、補 idempotency</td>
      </tr>
      <tr>
          <td>nack 事件集中在同類訊息</td>
          <td>payload 或下游契約失配</td>
          <td>分流到 DLQ、修復契約後定向重播</td>
      </tr>
      <tr>
          <td>消費重啟後堆積迅速擴大</td>
          <td>背壓與可見性控制不足</td>
          <td>限制拉取窗口、調整重試間隔</td>
      </tr>
  </tbody>
</table>
<h2 id="常見誤區">常見誤區</h2>
<p>把 broker 當成保證業務正確性的元件，會把消費責任與補償責任遺漏。broker 保證傳遞語意，業務正確性要由 consumer 設計承擔。</p>
<p>把 exactly-once 當成預設目標，也容易過度設計。先定義可接受失敗代價，再選擇對應語意，通常更符合實務。</p>
<h2 id="broker-規模化的角色變化">Broker 規模化的角色變化</h2>
<p>Broker 在規模化服務承擔的責任從「單隊列工具」轉到「平台治理問題」— 容量規劃焦點從擴 broker 變成多租戶隔離、配額管理、跨團隊觀測標準化。</p>
<p>對應 <a href="/blog/backend/03-message-queue/cases/uber-kafka-infrastructure-evolution/" data-link-title="3.C6 Uber：Kafka 事件平台演進" data-link-desc="事件平台從團隊自管走向多租戶共享基礎設施。">3.C6 Uber Kafka Infrastructure Evolution</a> — Uber 事件平台服務眾多團隊、focus 從 broker 容量是否充足轉到 team 之間的隔離邊界。對應 <a href="/blog/backend/03-message-queue/cases/linkedin-kafka-tiered-clusters/" data-link-title="3.C4 LinkedIn：Kafka 分層叢集治理" data-link-desc="Kafka 從單叢集走向 tiered clusters 的轉換案例。">3.C4 LinkedIn Tiered Clusters</a> — 規模化必然分層 cluster、按業務特性跟可靠性需求分配不同叢集、高優先 workload 跟低優先 workload 各自獨立。</p>
<p><strong>規模化的三個角色階段</strong>（依據 3.C6 / 3.C4 / 早期服務對照、整理出三個典型階段）：</p>
<ul>
<li><strong>單隊列工具</strong>（規模尚小階段）：一個 Kafka cluster、所有 service 共用、broker 擴容是主要工作、團隊各自管理自己的 topic</li>
<li><strong>多租戶平台</strong>（中大型階段）：跨團隊共用 cluster、平台 team 設定 quota、topic 命名規範、容量配額、觀測標準。3.C6 描述 Uber 在這階段「標準化 topic 治理與故障處理流程」、把跨團隊運維責任收斂到平台層</li>
<li><strong>分層治理平台</strong>（規模化階段）：不同業務特性走不同 cluster（critical / standard / experimental）、跨 cluster 路由跟治理變主要工作。3.C4 描述 LinkedIn「依流量與可靠性需求分層」、高優先 workload 提供獨立保護</li>
</ul>
<p>判讀含義：當 broker incident 影響多個 team 不相關業務、屬於該分層的訊號。規模化後焦點要轉向跨 team 隔離跟跨 cluster 治理、單純擴 broker 處理不了多租戶共擠的結構性問題。攻擊面跟控制面見 <a href="/blog/backend/03-message-queue/red-team-delivery-layer/" data-link-title="3.5 攻擊者視角（紅隊）：傳遞層弱點判讀" data-link-desc="從重複投遞、重放濫用、毒訊息與容量壓力，盤點 message delivery 的主要弱點">3.5 紅隊章 Multi-tenant broker 隔離邊界</a>。</p>
<h2 id="queue-變跨區關鍵路徑的特殊挑戰">Queue 變跨區關鍵路徑的特殊挑戰</h2>
<p>當 queue 變成跨區關鍵路徑（payment、order、notification 都靠它）、容量規劃焦點從 throughput 變成 <em>discoverability</em> 跟 <em>routing freshness</em>。</p>
<p>對應 <a href="/blog/backend/03-message-queue/cases/meta-foqs-global-migration/" data-link-title="3.C1 Meta：FOQS 從區域到全域佇列遷移" data-link-desc="佇列架構如何在不中斷下升級成 disaster-ready 模式。">3.C1 Meta FOQS</a> — FOQS 從區域升級到全域、目標是讓災害期間 queue 仍可被存取、控制遷移期間的延遲跟可用性風險。Focus 從 queue 吞吐量轉到災害時的 broker 可達性、routing 狀態新鮮度、tenant 遷移節奏。</p>
<p><strong>跨區 queue 的設計挑戰</strong>：</p>
<ul>
<li><strong>Discoverability</strong>：client 在 region failover 後需透過 service discovery + DNS / health check 動態解析 broker endpoint、找到新 primary broker</li>
<li><strong>Routing freshness</strong>：broker topology 變更後、client 多久能拿到新 routing 表、stale routing 期間 message 流向錯 broker、要設定 routing TTL + 主動 refresh</li>
<li><strong>Tenant 遷移節奏</strong>：規模化跨區 queue 採分批 cutover、保留 client 連續性</li>
<li><strong>Stale routing 補貨延遲治理</strong>：routing 過時造成 message 累積在錯誤 broker、要設定 timeout + 重新發現機制、讓 client 重新發現新 broker 並切換到健康路徑</li>
</ul>
<h2 id="案例回寫">案例回寫</h2>
<p>投遞語意可用 <a href="/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9 反例</a> 做回寫。先判讀事件是 delivery 層失配，還是 processing/recovery 層失配，再回到本章檢查 ack 時機、重試節奏與隔離策略是否清楚。
這個案例主要支撐的是「語意分層與投遞責任」判讀，不直接支撐資料庫 schema 演進或 LB timeout；若問題在資料模型或連線生命週期，應轉到 1.2 或 5.3。</p>
<p>若投遞成功但業務結果缺漏，先補齊語意分層，再分別回寫 <a href="/blog/backend/03-message-queue/durable-queue/" data-link-title="3.2 durable queue 與重試策略" data-link-desc="整理持久化佇列、DLQ 與重試流程">3.2 durable queue</a> 與 <a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計</a>。</p>
<h2 id="跨模組路由">跨模組路由</h2>
<ol>
<li>與 3.2 的交接：持久化與重試節奏回到 <a href="/blog/backend/03-message-queue/durable-queue/" data-link-title="3.2 durable queue 與重試策略" data-link-desc="整理持久化佇列、DLQ 與重試流程">durable queue 與重試策略</a>。</li>
<li>與 3.4 的交接：消費恢復與去重回到 <a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">consumer 設計與去重</a>。</li>
<li>與 4.20 的交接：投遞與消費訊號納入 <a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">Observability Evidence Package</a>。</li>
<li>與 6.12 的交接：重播與冪等驗證回到 <a href="/blog/backend/06-reliability/idempotency-replay/" data-link-title="6.12 Idempotency 與 Replay 驗證" data-link-desc="把重試、重播與冪等性從口頭約定變成可驗證屬性">Idempotency 與 Replay 驗證</a>。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<p>要進一步處理持久化與重試控制，接著讀 <a href="/blog/backend/03-message-queue/durable-queue/" data-link-title="3.2 durable queue 與重試策略" data-link-desc="整理持久化佇列、DLQ 與重試流程">3.2 durable queue 與重試策略</a>。要處理交易與發佈一致性，接著讀 <a href="/blog/backend/03-message-queue/outbox-pattern/" data-link-title="3.3 outbox pattern 與發佈一致性" data-link-desc="把 transaction 與 event publish 分離">3.3 outbox pattern 與發佈一致性</a>。</p>
]]></content:encoded></item><item><title>3.2 durable queue 與重試策略</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/durable-queue/</link><pubDate>Thu, 23 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/durable-queue/</guid><description>&lt;p>持久化佇列（&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/durable-queue/" data-link-title="Durable Queue" data-link-desc="說明可持久化的 queue 如何在重啟與失敗後保留待處理工作">durable queue&lt;/a>）的核心責任是讓非同步工作在 process、節點或網路故障後仍可被恢復處理。它讓業務動作在失敗後仍有可追蹤、可重試、可隔離的路徑。&lt;/p>
&lt;h2 id="durable-與-ephemeral-的差異">durable 與 ephemeral 的差異&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> 在語意上可分 durable 與 ephemeral。ephemeral queue 側重低延遲與短暫協調，適合可丟失任務；durable queue 側重故障後可恢復，適合正式狀態相關副作用，例如付款通知、發票產生、庫存同步與合規事件記錄。&lt;/p>
&lt;p>這個選擇本質上是失敗代價選擇。若任務丟失可接受，ephemeral 可降低成本；若任務丟失會造成金流、合約或審計問題，durable 是必要基線。&lt;/p>
&lt;h2 id="重試策略">重試策略&lt;/h2>
&lt;p>重試策略的責任是把暫時性故障和系統性故障分開。durable queue 常見的重試組合是：有限次重試、指數退避、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/jitter/" data-link-title="Jitter" data-link-desc="說明重試或排程加入隨機偏移如何降低同步尖峰">jitter&lt;/a> 分散峰值、超過門檻後分流到 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/dead-letter-queue/" data-link-title="Dead-Letter Queue" data-link-desc="說明 dead-letter queue 如何隔離多次處理失敗的訊息">dead-letter queue&lt;/a>。&lt;/p>
&lt;p>重試上限與間隔要由下游承載能力決定。重試太快會形成故障放大，重試太慢會拖長恢復時間。穩定做法是把重試策略當成服務容量控制的一部分，而不是固定平台預設值。&lt;/p>
&lt;h2 id="dlq-與-requeue-風險">DLQ 與 requeue 風險&lt;/h2>
&lt;p>DLQ 的責任是隔離異常訊息，避免拖垮主消費流程。DLQ 是診斷與修復入口，把它當終點會讓問題沉積。每個進入 DLQ 的訊息，都應能回答：失敗原因是 payload 錯誤、下游不可用、版本不相容，還是消費邏輯缺陷。&lt;/p>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/requeue/" data-link-title="Requeue" data-link-desc="說明處理失敗的訊息重新排回 queue 時的風險與控制條件">requeue&lt;/a> 需要明確條件。直接把異常訊息無限 requeue，通常會造成隊列震盪與延遲累積。穩定做法是先隔離、分群、修復，再批次回放。&lt;/p>
&lt;h2 id="ordering-與吞吐取捨">ordering 與吞吐取捨&lt;/h2>
&lt;p>durable queue 在順序與吞吐之間需要明確取捨。全域順序通常成本極高，實務上多採用分區內順序：同一 key 保持順序，不同 key 可並行。這能兼顧一致性需求與處理吞吐。&lt;/p>
&lt;p>順序要求越高，恢復流程越需要明確 checkpoint 與補償策略。否則故障後的重播容易造成亂序副作用，放大修復成本。&lt;/p>
&lt;h2 id="判讀訊號">判讀訊號&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>訊號&lt;/th>
 &lt;th>判讀重點&lt;/th>
 &lt;th>對應動作&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>queue depth 持續上升&lt;/td>
 &lt;td>輸入速率高於消費能力&lt;/td>
 &lt;td>擴消費能力、調整重試節奏、分流高成本任務&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>retry ratio 升高且成功率下降&lt;/td>
 &lt;td>故障從暫時性轉為系統性&lt;/td>
 &lt;td>降級下游、縮小重試並啟動隔離策略&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>DLQ 量快速增加&lt;/td>
 &lt;td>payload/版本/邏輯異常集中爆發&lt;/td>
 &lt;td>分群診斷、修復邏輯、定向重播&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>requeue 循環導致延遲尖峰&lt;/td>
 &lt;td>缺少隔離邊界與停損機制&lt;/td>
 &lt;td>停止盲目 requeue、先隔離後回放&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>消費恢復後出現大量重複副作用&lt;/td>
 &lt;td>去重與冪等保護不足&lt;/td>
 &lt;td>補 idempotency key 與 side-effect guard&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="常見誤區">常見誤區&lt;/h2>
&lt;p>把 durable queue 視為「寫進去就安全」，會忽略消費與恢復責任。持久化只保證訊息可取回，不保證業務結果已正確提交。&lt;/p>
&lt;p>把 DLQ 當成長期倉庫，也會讓問題持續累積。DLQ 的工程價值在於快速定位異常類型並回到修復流程。&lt;/p>
&lt;h2 id="訊息系統的通知-vs-訊息分類">訊息系統的「通知 vs 訊息」分類&lt;/h2>
&lt;p>訊息系統設計區分兩種 SLO 不同的傳遞責任：&lt;em>transactional 通知&lt;/em> 承擔業務副作用的可靠送達、&lt;em>broadcast 訊息&lt;/em> 承擔大量低成本傳播。兩者用不同 storage、不同重試策略、不同投遞保證。&lt;/p>
&lt;p>對應 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/paypay-mobile-payment-messaging/" data-link-title="9.C26 PayPay：行動支付每日 3 億訊息的 DynamoDB 後端" data-link-desc="日本最大行動支付 PayPay 每日 3 億訊息、用 DynamoDB 處理通知與訊息功能、支撐次秒級反應">9.C26 PayPay&lt;/a> — 行動支付每日 3 億訊息、付款通知承擔「確認交易完成」的業務責任、SLO 包含秒級延遲跟高投遞率（用戶付完款後若 30 秒沒收到通知會打客服、產生重複扣款風險）。這層需求嚴於 OTA 推播、需要 durable queue + retry + 重複偵測。&lt;/p>
&lt;p>&lt;strong>分類設計&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Transactional 通知&lt;/strong>（付款收據、訂單狀態變更、配額警告）：承擔業務副作用確認、需 durable + idempotency key 去重、SLO 通常是 &lt;em>秒級延遲 + 99.99% 投遞率&lt;/em>&lt;/li>
&lt;li>&lt;strong>Broadcast 訊息&lt;/strong>（行銷推播、新片發布通知、社群動態）：承擔大量低成本傳播、SLO 是 &lt;em>吞吐量&lt;/em> 跟覆蓋率、允許 best-effort retry&lt;/li>
&lt;/ul>
&lt;p>判讀含義：規模化訊息系統的容量規劃要按類別分開、避免套同一個 broker capacity。3 億訊息 / 天看似一致、但 &lt;em>通知&lt;/em> 跟 &lt;em>訊息&lt;/em> 的工程負擔差數量級。&lt;/p>
&lt;h2 id="下游推送是隱性瓶頸">下游推送是隱性瓶頸&lt;/h2>
&lt;p>訊息系統的真正瓶頸常落在 &lt;em>下游推送通道&lt;/em>（APNs、FCM、SMS gateway、email provider）、不在 broker。下游 quota 是 hard ceiling、超過會被 throttle、訊息積壓回 broker 形成 backlog。&lt;/p>
&lt;p>對應 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/paypay-mobile-payment-messaging/" data-link-title="9.C26 PayPay：行動支付每日 3 億訊息的 DynamoDB 後端" data-link-desc="日本最大行動支付 PayPay 每日 3 億訊息、用 DynamoDB 處理通知與訊息功能、支撐次秒級反應">9.C26 PayPay&lt;/a> — DynamoDB 寫入可以撐 3K msg/sec 平均（PayPay 本身用 DynamoDB 作訊息後端、不是傳統 broker）、但 APNs 推送額度成為事故當下的隱性瓶頸。容量規劃要把下游 quota 算進去、不只看訊息後端吞吐。&lt;/p></description><content:encoded><![CDATA[<p>持久化佇列（<a href="/blog/backend/knowledge-cards/durable-queue/" data-link-title="Durable Queue" data-link-desc="說明可持久化的 queue 如何在重啟與失敗後保留待處理工作">durable queue</a>）的核心責任是讓非同步工作在 process、節點或網路故障後仍可被恢復處理。它讓業務動作在失敗後仍有可追蹤、可重試、可隔離的路徑。</p>
<h2 id="durable-與-ephemeral-的差異">durable 與 ephemeral 的差異</h2>
<p><a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> 在語意上可分 durable 與 ephemeral。ephemeral queue 側重低延遲與短暫協調，適合可丟失任務；durable queue 側重故障後可恢復，適合正式狀態相關副作用，例如付款通知、發票產生、庫存同步與合規事件記錄。</p>
<p>這個選擇本質上是失敗代價選擇。若任務丟失可接受，ephemeral 可降低成本；若任務丟失會造成金流、合約或審計問題，durable 是必要基線。</p>
<h2 id="重試策略">重試策略</h2>
<p>重試策略的責任是把暫時性故障和系統性故障分開。durable queue 常見的重試組合是：有限次重試、指數退避、<a href="/blog/backend/knowledge-cards/jitter/" data-link-title="Jitter" data-link-desc="說明重試或排程加入隨機偏移如何降低同步尖峰">jitter</a> 分散峰值、超過門檻後分流到 <a href="/blog/backend/knowledge-cards/dead-letter-queue/" data-link-title="Dead-Letter Queue" data-link-desc="說明 dead-letter queue 如何隔離多次處理失敗的訊息">dead-letter queue</a>。</p>
<p>重試上限與間隔要由下游承載能力決定。重試太快會形成故障放大，重試太慢會拖長恢復時間。穩定做法是把重試策略當成服務容量控制的一部分，而不是固定平台預設值。</p>
<h2 id="dlq-與-requeue-風險">DLQ 與 requeue 風險</h2>
<p>DLQ 的責任是隔離異常訊息，避免拖垮主消費流程。DLQ 是診斷與修復入口，把它當終點會讓問題沉積。每個進入 DLQ 的訊息，都應能回答：失敗原因是 payload 錯誤、下游不可用、版本不相容，還是消費邏輯缺陷。</p>
<p><a href="/blog/backend/knowledge-cards/requeue/" data-link-title="Requeue" data-link-desc="說明處理失敗的訊息重新排回 queue 時的風險與控制條件">requeue</a> 需要明確條件。直接把異常訊息無限 requeue，通常會造成隊列震盪與延遲累積。穩定做法是先隔離、分群、修復，再批次回放。</p>
<h2 id="ordering-與吞吐取捨">ordering 與吞吐取捨</h2>
<p>durable queue 在順序與吞吐之間需要明確取捨。全域順序通常成本極高，實務上多採用分區內順序：同一 key 保持順序，不同 key 可並行。這能兼顧一致性需求與處理吞吐。</p>
<p>順序要求越高，恢復流程越需要明確 checkpoint 與補償策略。否則故障後的重播容易造成亂序副作用，放大修復成本。</p>
<h2 id="判讀訊號">判讀訊號</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>判讀重點</th>
          <th>對應動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>queue depth 持續上升</td>
          <td>輸入速率高於消費能力</td>
          <td>擴消費能力、調整重試節奏、分流高成本任務</td>
      </tr>
      <tr>
          <td>retry ratio 升高且成功率下降</td>
          <td>故障從暫時性轉為系統性</td>
          <td>降級下游、縮小重試並啟動隔離策略</td>
      </tr>
      <tr>
          <td>DLQ 量快速增加</td>
          <td>payload/版本/邏輯異常集中爆發</td>
          <td>分群診斷、修復邏輯、定向重播</td>
      </tr>
      <tr>
          <td>requeue 循環導致延遲尖峰</td>
          <td>缺少隔離邊界與停損機制</td>
          <td>停止盲目 requeue、先隔離後回放</td>
      </tr>
      <tr>
          <td>消費恢復後出現大量重複副作用</td>
          <td>去重與冪等保護不足</td>
          <td>補 idempotency key 與 side-effect guard</td>
      </tr>
  </tbody>
</table>
<h2 id="常見誤區">常見誤區</h2>
<p>把 durable queue 視為「寫進去就安全」，會忽略消費與恢復責任。持久化只保證訊息可取回，不保證業務結果已正確提交。</p>
<p>把 DLQ 當成長期倉庫，也會讓問題持續累積。DLQ 的工程價值在於快速定位異常類型並回到修復流程。</p>
<h2 id="訊息系統的通知-vs-訊息分類">訊息系統的「通知 vs 訊息」分類</h2>
<p>訊息系統設計區分兩種 SLO 不同的傳遞責任：<em>transactional 通知</em> 承擔業務副作用的可靠送達、<em>broadcast 訊息</em> 承擔大量低成本傳播。兩者用不同 storage、不同重試策略、不同投遞保證。</p>
<p>對應 <a href="/blog/backend/09-performance-capacity/cases/paypay-mobile-payment-messaging/" data-link-title="9.C26 PayPay：行動支付每日 3 億訊息的 DynamoDB 後端" data-link-desc="日本最大行動支付 PayPay 每日 3 億訊息、用 DynamoDB 處理通知與訊息功能、支撐次秒級反應">9.C26 PayPay</a> — 行動支付每日 3 億訊息、付款通知承擔「確認交易完成」的業務責任、SLO 包含秒級延遲跟高投遞率（用戶付完款後若 30 秒沒收到通知會打客服、產生重複扣款風險）。這層需求嚴於 OTA 推播、需要 durable queue + retry + 重複偵測。</p>
<p><strong>分類設計</strong>：</p>
<ul>
<li><strong>Transactional 通知</strong>（付款收據、訂單狀態變更、配額警告）：承擔業務副作用確認、需 durable + idempotency key 去重、SLO 通常是 <em>秒級延遲 + 99.99% 投遞率</em></li>
<li><strong>Broadcast 訊息</strong>（行銷推播、新片發布通知、社群動態）：承擔大量低成本傳播、SLO 是 <em>吞吐量</em> 跟覆蓋率、允許 best-effort retry</li>
</ul>
<p>判讀含義：規模化訊息系統的容量規劃要按類別分開、避免套同一個 broker capacity。3 億訊息 / 天看似一致、但 <em>通知</em> 跟 <em>訊息</em> 的工程負擔差數量級。</p>
<h2 id="下游推送是隱性瓶頸">下游推送是隱性瓶頸</h2>
<p>訊息系統的真正瓶頸常落在 <em>下游推送通道</em>（APNs、FCM、SMS gateway、email provider）、不在 broker。下游 quota 是 hard ceiling、超過會被 throttle、訊息積壓回 broker 形成 backlog。</p>
<p>對應 <a href="/blog/backend/09-performance-capacity/cases/paypay-mobile-payment-messaging/" data-link-title="9.C26 PayPay：行動支付每日 3 億訊息的 DynamoDB 後端" data-link-desc="日本最大行動支付 PayPay 每日 3 億訊息、用 DynamoDB 處理通知與訊息功能、支撐次秒級反應">9.C26 PayPay</a> — DynamoDB 寫入可以撐 3K msg/sec 平均（PayPay 本身用 DynamoDB 作訊息後端、不是傳統 broker）、但 APNs 推送額度成為事故當下的隱性瓶頸。容量規劃要把下游 quota 算進去、不只看訊息後端吞吐。</p>
<p><strong>設計含義</strong>：</p>
<ul>
<li><strong>下游 quota 視為容量上限</strong>：APNs / FCM / SMS 的 daily quota 是 hard ceiling、訊息後端規劃要對應</li>
<li><strong>下游通道多元化</strong>：用 APNs / FCM / SMS / in-app notification 多通道分攤 quota 壓力、單通道飽和時其他通道仍可送出（具體降級策略需依各組織業務規則設計）</li>
<li><strong>重試節奏跟下游容量對齊</strong>：consumer 重試節奏依下游剩餘 quota 動態調整、讓重試節奏跟容量同步</li>
</ul>
<p>判讀重點：訊息系統事故當下、先看下游推送通道狀態（APNs status、FCM error rate）、再看訊息後端。下游 throttle 引發 backlog 是規模化訊息系統最常見的瓶頸來源。下游推送 quota 的攻擊面對照見 <a href="/blog/backend/03-message-queue/red-team-delivery-layer/" data-link-title="3.5 攻擊者視角（紅隊）：傳遞層弱點判讀" data-link-desc="從重複投遞、重放濫用、毒訊息與容量壓力，盤點 message delivery 的主要弱點">3.5 multi-tenant broker 配額耗盡</a>。</p>
<h2 id="案例回寫">案例回寫</h2>
<p>durable queue 的重試與隔離節奏可用 <a href="/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9 反例</a> 回寫。先看事件中的 backlog、retry、DLQ 變化，再回到本章判讀是重試策略失衡，還是隔離邊界不清楚。
這個案例主要支撐的是「重試隔離與停損門檻」判讀，不直接支撐 outbox 交易切分；若事件核心是資料提交與發布不一致，應轉到 3.3 與 1.3。</p>
<p>當重試量上升且主隊列延遲同步拉高時，先拆分重試通道並收斂 DLQ 分流條件，再把停損門檻接到 <a href="/blog/backend/06-reliability/rule-rollout-safety-gate/" data-link-title="6.24 規則推送安全閘門" data-link-desc="把規則、策略與控制面配置推送從部署步驟升級為可靠性 gate，避免小變更在秒級擴散成全網事故。">6.24 規則推送安全閘門</a>。</p>
<h2 id="跨模組路由">跨模組路由</h2>
<p>durable queue 是非同步可靠性的起點，不是終點。</p>
<ol>
<li>與 3.4 的交接：消費與恢復語意落在 <a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">consumer 設計與去重</a>。</li>
<li>與 3.3 的交接：發布一致性落在 <a href="/blog/backend/03-message-queue/outbox-pattern/" data-link-title="3.3 outbox pattern 與發佈一致性" data-link-desc="把 transaction 與 event publish 分離">outbox pattern</a>。</li>
<li>與 4.20 的交接：queue depth、retry、DLQ 指標進入 <a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">Observability Evidence Package</a>。</li>
<li>與 6.12 的交接：重試與重播驗證進入 <a href="/blog/backend/06-reliability/idempotency-replay/" data-link-title="6.12 Idempotency 與 Replay 驗證" data-link-desc="把重試、重播與冪等性從口頭約定變成可驗證屬性">Idempotency 與 Replay 驗證</a>。</li>
<li>與 8.19 的交接：故障隔離與回放決策進入 <a href="/blog/backend/08-incident-response/incident-decision-log/" data-link-title="8.19 Incident Decision Log" data-link-desc="把事中假設、決策、證據、回退條件與責任人留下可復盤紀錄">Incident Decision Log</a>。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<p>要從投遞語意往消費語意延伸，接著讀 <a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計與去重</a>。要看 queue 切換失敗模式，接著讀 <a href="/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9 反例</a>。</p>
]]></content:encoded></item><item><title>3.3 outbox pattern 與發佈一致性</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/outbox-pattern/</link><pubDate>Thu, 23 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/outbox-pattern/</guid><description>&lt;p>這一章處理 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction&lt;/a> 與訊息發佈之間的一致性問題，後續可以再延伸到 polling、relay 與 failure recovery。&lt;/p>
&lt;p>外部發件箱模式（&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/outbox-pattern/" data-link-title="Outbox Pattern" data-link-desc="說明資料庫狀態變更與事件發布如何透過 outbox 維持一致">outbox pattern&lt;/a>）的核心責任是讓資料提交與事件發布在失敗時保持可恢復一致。它把重複發布轉成可判讀、可去重、可補償的治理問題。&lt;/p>
&lt;h2 id="基本流程">基本流程&lt;/h2>
&lt;p>transaction outbox 的典型流程是：在同一資料庫交易內，同時寫入業務資料與 outbox 記錄；交易提交後，由 relay worker 讀取 outbox 並發布到 broker；發布成功後標記或刪除 outbox 記錄。&lt;/p>
&lt;p>這個流程把一致性問題從「跨系統兩段提交」改成「單系統交易 + 非同步重送」，讓失敗路徑更可控。&lt;/p>
&lt;h2 id="relay-worker">relay worker&lt;/h2>
&lt;p>relay worker 的責任是穩定發布與可恢復進度。worker 需要具備批次拉取、順序控制、重試策略與停損條件。進度管理要明確，避免重啟後漏發或重複失控。&lt;/p>
&lt;p>當流量上升時，relay 吞吐會成為關鍵瓶頸。穩定做法是分 shard 處理、限制批次大小、對重試與正常發布做通道分流。&lt;/p>
&lt;h2 id="發布失敗與補償">發布失敗與補償&lt;/h2>
&lt;p>發布失敗通常分為暫時性與系統性。暫時性故障走有限重試，系統性故障走隔離與告警。關鍵是保留 outbox 記錄與發布狀態，讓恢復時可重播。&lt;/p>
&lt;p>duplicate publish 在 outbox 模式下屬於預期現象。消費端需要配合 idempotency 機制，確保重複事件不會產生重複業務結果。&lt;/p>
&lt;h2 id="判讀訊號">判讀訊號&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>訊號&lt;/th>
 &lt;th>判讀重點&lt;/th>
 &lt;th>對應動作&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>outbox backlog 持續堆積&lt;/td>
 &lt;td>relay 吞吐不足或下游故障持續&lt;/td>
 &lt;td>擴充 worker、分流重試、啟動降級流程&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>業務資料已更新但下游狀態延遲明顯&lt;/td>
 &lt;td>發布延遲超出可接受窗口&lt;/td>
 &lt;td>提升 relay 優先級、補告警與可視化&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>duplicate consume 比例上升&lt;/td>
 &lt;td>重試與重播增加，去重壓力上升&lt;/td>
 &lt;td>強化 consumer idempotency 與去重儲存&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>relay 重啟後出現漏發&lt;/td>
 &lt;td>進度標記與交易邊界設計不穩&lt;/td>
 &lt;td>收斂進度策略、補恢復測試&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>同步交易延遲上升且 outbox 寫入增加&lt;/td>
 &lt;td>outbox 表設計與索引不足&lt;/td>
 &lt;td>調整索引與分表策略、拆分熱路徑&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="常見誤區">常見誤區&lt;/h2>
&lt;p>把 outbox 當作「一次解決一致性」的銀彈，會忽略消費端冪等與補償責任。outbox 保證的是發布可恢復，不是端到端結果自動正確。&lt;/p>
&lt;p>把 outbox 表當一般業務表無上限累積，也會放大查詢與維護成本。需要定義保留與清理節奏，並確保稽核需求有對應方案。&lt;/p>
&lt;h2 id="self-managed-vs-managed-broker-的長期-tco">Self-managed vs Managed broker 的長期 TCO&lt;/h2>
&lt;p>Broker 選型本質是 long-term TCO 決策、需評估雲端費用 + 工程稅 + 治理負擔三層成本。Self-managed Kafka 的容量規劃 + broker 數量 + 副本因子 + disk + ZooKeeper / KRaft 治理是長期工程 tax、每次擴容是工程專案。&lt;/p>
&lt;p>對應 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/spotify-kafka-to-pubsub-migration-gcp/" data-link-title="9.C9 Spotify：從自管 Kafka 遷移到 GCP Pub/Sub 的事件交付系統" data-link-desc="Spotify 把自管 Kafka 事件系統遷移到 Google Cloud Pub/Sub、避免自管 broker 的容量規劃成本">9.C9 Spotify Kafka → Pub/Sub Migration&lt;/a> — Spotify 從自管 Kafka 遷到 Google Cloud Pub/Sub、動機是 &lt;em>容量規劃的工程成本&lt;/em> 在 sustained growth 下變得不划算、非 Kafka 效能不足。對 7500 萬用戶的事件交付系統、把 broker 容量規劃跟運維負擔卸給 vendor、釋放工程團隊 capacity。&lt;/p>
&lt;p>&lt;strong>TCO 評估的真實成本項&lt;/strong>（9.C9 case 列前 4 項 + 雲端費用、第 5 項屬跨案例綜合）：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Broker 雲端費用&lt;/strong>：明面成本、相對小&lt;/li>
&lt;li>&lt;strong>容量規劃工程&lt;/strong>：每季 partition planning、每年容量擴張專案&lt;/li>
&lt;li>&lt;strong>故障處理人力&lt;/strong>：broker 故障 oncall、ZooKeeper / KRaft 故障診斷&lt;/li>
&lt;li>&lt;strong>升級遷移成本&lt;/strong>：Kafka 每個 major version 升級是專案&lt;/li>
&lt;li>&lt;strong>跨團隊治理&lt;/strong>（從 3.C6 Uber 跨案例補充）：規模化後的 multi-tenant 隔離、quota 管理、observability 建設&lt;/li>
&lt;/ul>
&lt;p>判讀含義：Self-managed Kafka 在中小團隊可能比 Pub/Sub 便宜（雲端費用低）；但規模化後人力成本壓過雲端費用差、managed service 反而划算。對應 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/vmware-kafka-to-msk/" data-link-title="3.C2 VMware Tanzu CloudHealth：Kafka 轉 Amazon MSK" data-link-desc="自管 Kafka 遷移到託管平台時的治理重點。">3.C2 VMware Tanzu Kafka → MSK&lt;/a> 同樣是「自管 → managed」的決策。&lt;/p></description><content:encoded><![CDATA[<p>這一章處理 <a href="/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction</a> 與訊息發佈之間的一致性問題，後續可以再延伸到 polling、relay 與 failure recovery。</p>
<p>外部發件箱模式（<a href="/blog/backend/knowledge-cards/outbox-pattern/" data-link-title="Outbox Pattern" data-link-desc="說明資料庫狀態變更與事件發布如何透過 outbox 維持一致">outbox pattern</a>）的核心責任是讓資料提交與事件發布在失敗時保持可恢復一致。它把重複發布轉成可判讀、可去重、可補償的治理問題。</p>
<h2 id="基本流程">基本流程</h2>
<p>transaction outbox 的典型流程是：在同一資料庫交易內，同時寫入業務資料與 outbox 記錄；交易提交後，由 relay worker 讀取 outbox 並發布到 broker；發布成功後標記或刪除 outbox 記錄。</p>
<p>這個流程把一致性問題從「跨系統兩段提交」改成「單系統交易 + 非同步重送」，讓失敗路徑更可控。</p>
<h2 id="relay-worker">relay worker</h2>
<p>relay worker 的責任是穩定發布與可恢復進度。worker 需要具備批次拉取、順序控制、重試策略與停損條件。進度管理要明確，避免重啟後漏發或重複失控。</p>
<p>當流量上升時，relay 吞吐會成為關鍵瓶頸。穩定做法是分 shard 處理、限制批次大小、對重試與正常發布做通道分流。</p>
<h2 id="發布失敗與補償">發布失敗與補償</h2>
<p>發布失敗通常分為暫時性與系統性。暫時性故障走有限重試，系統性故障走隔離與告警。關鍵是保留 outbox 記錄與發布狀態，讓恢復時可重播。</p>
<p>duplicate publish 在 outbox 模式下屬於預期現象。消費端需要配合 idempotency 機制，確保重複事件不會產生重複業務結果。</p>
<h2 id="判讀訊號">判讀訊號</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>判讀重點</th>
          <th>對應動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>outbox backlog 持續堆積</td>
          <td>relay 吞吐不足或下游故障持續</td>
          <td>擴充 worker、分流重試、啟動降級流程</td>
      </tr>
      <tr>
          <td>業務資料已更新但下游狀態延遲明顯</td>
          <td>發布延遲超出可接受窗口</td>
          <td>提升 relay 優先級、補告警與可視化</td>
      </tr>
      <tr>
          <td>duplicate consume 比例上升</td>
          <td>重試與重播增加，去重壓力上升</td>
          <td>強化 consumer idempotency 與去重儲存</td>
      </tr>
      <tr>
          <td>relay 重啟後出現漏發</td>
          <td>進度標記與交易邊界設計不穩</td>
          <td>收斂進度策略、補恢復測試</td>
      </tr>
      <tr>
          <td>同步交易延遲上升且 outbox 寫入增加</td>
          <td>outbox 表設計與索引不足</td>
          <td>調整索引與分表策略、拆分熱路徑</td>
      </tr>
  </tbody>
</table>
<h2 id="常見誤區">常見誤區</h2>
<p>把 outbox 當作「一次解決一致性」的銀彈，會忽略消費端冪等與補償責任。outbox 保證的是發布可恢復，不是端到端結果自動正確。</p>
<p>把 outbox 表當一般業務表無上限累積，也會放大查詢與維護成本。需要定義保留與清理節奏，並確保稽核需求有對應方案。</p>
<h2 id="self-managed-vs-managed-broker-的長期-tco">Self-managed vs Managed broker 的長期 TCO</h2>
<p>Broker 選型本質是 long-term TCO 決策、需評估雲端費用 + 工程稅 + 治理負擔三層成本。Self-managed Kafka 的容量規劃 + broker 數量 + 副本因子 + disk + ZooKeeper / KRaft 治理是長期工程 tax、每次擴容是工程專案。</p>
<p>對應 <a href="/blog/backend/09-performance-capacity/cases/spotify-kafka-to-pubsub-migration-gcp/" data-link-title="9.C9 Spotify：從自管 Kafka 遷移到 GCP Pub/Sub 的事件交付系統" data-link-desc="Spotify 把自管 Kafka 事件系統遷移到 Google Cloud Pub/Sub、避免自管 broker 的容量規劃成本">9.C9 Spotify Kafka → Pub/Sub Migration</a> — Spotify 從自管 Kafka 遷到 Google Cloud Pub/Sub、動機是 <em>容量規劃的工程成本</em> 在 sustained growth 下變得不划算、非 Kafka 效能不足。對 7500 萬用戶的事件交付系統、把 broker 容量規劃跟運維負擔卸給 vendor、釋放工程團隊 capacity。</p>
<p><strong>TCO 評估的真實成本項</strong>（9.C9 case 列前 4 項 + 雲端費用、第 5 項屬跨案例綜合）：</p>
<ul>
<li><strong>Broker 雲端費用</strong>：明面成本、相對小</li>
<li><strong>容量規劃工程</strong>：每季 partition planning、每年容量擴張專案</li>
<li><strong>故障處理人力</strong>：broker 故障 oncall、ZooKeeper / KRaft 故障診斷</li>
<li><strong>升級遷移成本</strong>：Kafka 每個 major version 升級是專案</li>
<li><strong>跨團隊治理</strong>（從 3.C6 Uber 跨案例補充）：規模化後的 multi-tenant 隔離、quota 管理、observability 建設</li>
</ul>
<p>判讀含義：Self-managed Kafka 在中小團隊可能比 Pub/Sub 便宜（雲端費用低）；但規模化後人力成本壓過雲端費用差、managed service 反而划算。對應 <a href="/blog/backend/03-message-queue/cases/vmware-kafka-to-msk/" data-link-title="3.C2 VMware Tanzu CloudHealth：Kafka 轉 Amazon MSK" data-link-desc="自管 Kafka 遷移到託管平台時的治理重點。">3.C2 VMware Tanzu Kafka → MSK</a> 同樣是「自管 → managed」的決策。</p>
<p><strong>Managed service 的取捨</strong>：</p>
<ul>
<li>Pub/Sub 自動 scaling、伴隨 vendor lock-in、cost-per-message 累積、message ordering / latency 特性跟 Kafka 差異</li>
<li>業務語意對映（Kafka partition / offset / consumer group 在 Pub/Sub 對映成 subscription / ordering key / message attribute）需重新校準、見 <a href="/blog/backend/03-message-queue/event-contract-replay-boundary/" data-link-title="3.7 Event Contract 與 Replay Boundary" data-link-desc="說明 event schema、idempotency key、replay window 與補償如何先於 broker 選型。">3.7 跨 broker 業務語意對映</a></li>
<li>遷移本身需驗證業務語意 — 對應 <a href="/blog/backend/01-database/schema-migration-rollout-evidence/" data-link-title="1.7 Schema Migration Rollout 證據（Schema Migration Rollout Evidence）實作示範" data-link-desc="以訂單付款狀態欄位演進示範 schema migration 如何產出 evidence、release gate 與 incident decision log。">1.7 schema migration rollout evidence</a> 的同類流程</li>
</ul>
<h2 id="broker-遷移的階段流程">Broker 遷移的階段流程</h2>
<p>對應 <a href="/blog/backend/09-performance-capacity/cases/spotify-kafka-to-pubsub-migration-gcp/" data-link-title="9.C9 Spotify：從自管 Kafka 遷移到 GCP Pub/Sub 的事件交付系統" data-link-desc="Spotify 把自管 Kafka 事件系統遷移到 Google Cloud Pub/Sub、避免自管 broker 的容量規劃成本">9.C9 Spotify</a> — broker 遷移屬高併發容量工程、需維持 producer 連續寫入、保證 message 不丟。Spotify case 列三階段（dual write → shadow → cutover）、本章補第四階段（Decommission）作為清理收尾。replay 模型差異見 <a href="/blog/backend/03-message-queue/processing-recovery-semantics/" data-link-title="3.6 Processing Semantics 與 Recovery Semantics" data-link-desc="說明投遞成功、處理成功與恢復成功為何是三個不同判斷。">3.6 Replay 跟 Idempotency 共設計</a>。</p>
<ol>
<li><strong>Dual-write</strong>：producer 同時寫兩個 broker、確保 cutover 前新 broker 有完整資料</li>
<li><strong>Shadow consume</strong>：新 broker 有獨立 consumer group 消費、驗證業務結果跟舊 broker 一致</li>
<li><strong>Cutover</strong>：流量逐步切到新 broker、保留舊 broker 為 fallback</li>
<li><strong>Decommission</strong>（本章補充、case 未明文）：確認新 broker 穩定後關掉舊 broker、清理舊架構</li>
</ol>
<p>遷移期容量規劃含義：</p>
<ul>
<li>Dual-write 期間 broker 雙倍流量（writer side）</li>
<li>Shadow consume 期間 consumer 雙倍負載（reader side）</li>
<li>業務驗證（mismatch tracking）期間有額外的對帳工作量</li>
</ul>
<p>跟 <a href="/blog/backend/01-database/large-scale-db-migration/" data-link-title="1.12 大規模 DB 遷移實戰" data-link-desc="跨 DB 遷移的 dual-write、[shadow read](/backend/knowledge-cards/shadow-read/)、cutover、rollback 流程 — 從實戰案例提煉的工程做法">1.12 大規模 DB 遷移</a> 是同類流程、流程細節跟 evidence chain 可互相參考。</p>
<h2 id="案例回寫">案例回寫</h2>
<p>outbox 一致性可用 <a href="/blog/backend/08-incident-response/cases/github/2018-oct21-mysql-topology-incident/" data-link-title="GitHub 2018 Oct21 MySQL Topology Incident" data-link-desc="2018-10-21 GitHub 因 network partition 觸發跨區資料庫拓撲異常的事故解析：資料一致性優先、fail-forward 決策與長時間恢復。">GitHub 2018 Oct21 MySQL Topology Incident</a> 的恢復段落回寫。先看資料寫入與下游狀態同步是否脫節，再回到本章檢查 outbox backlog、relay 進度與重播策略。
這個案例主要支撐的是「提交後發布一致性」判讀，不直接支撐 broker 的底層投遞參數；若問題是 ack/partition 策略，應回到 3.1/3.2。</p>
<p>當資料已提交但事件遲到，或重播後副作用重複時，先調整 relay 節流與 consumer 冪等，再把驗證證據對齊 <a href="/blog/backend/06-reliability/verification-evidence-handoff/" data-link-title="6.23 Verification Evidence Handoff" data-link-desc="把 SLO、load、chaos、DR 與 readiness 結果包成 release / incident 可用證據">6.23 Verification Evidence Handoff</a>。</p>
<h2 id="跨模組路由">跨模組路由</h2>
<ol>
<li>與 1.3 的交接：交易邊界語意回到 <a href="/blog/backend/01-database/transaction-boundary/" data-link-title="1.3 Transaction 與一致性邊界" data-link-desc="交易邊界、isolation level、retry 策略、distributed transaction（2PC、Saga）與跨 region 強一致取捨">transaction 與一致性邊界</a>。</li>
<li>與 3.2 的交接：發布後重試與隔離回到 <a href="/blog/backend/03-message-queue/durable-queue/" data-link-title="3.2 durable queue 與重試策略" data-link-desc="整理持久化佇列、DLQ 與重試流程">durable queue 與重試策略</a>。</li>
<li>與 3.4 的交接：消費冪等與重播回到 <a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">consumer 設計與去重</a>。</li>
<li>與 6.12 的交接：一致性驗證與重播演練回到 <a href="/blog/backend/06-reliability/idempotency-replay/" data-link-title="6.12 Idempotency 與 Replay 驗證" data-link-desc="把重試、重播與冪等性從口頭約定變成可驗證屬性">Idempotency 與 Replay 驗證</a>。</li>
<li>與 8.19 的交接：發布故障決策回到 <a href="/blog/backend/08-incident-response/incident-decision-log/" data-link-title="8.19 Incident Decision Log" data-link-desc="把事中假設、決策、證據、回退條件與責任人留下可復盤紀錄">Incident Decision Log</a>。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<p>要從 outbox 延伸到消費恢復，接著讀 <a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計與去重</a>。要看 queue 切換失敗時的一致性風險，接著讀 <a href="/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9 反例</a>。</p>
]]></content:encoded></item><item><title>3.4 consumer 設計與去重</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/consumer-design/</link><pubDate>Thu, 23 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/consumer-design/</guid><description>&lt;p>消費者設計（consumer design）的核心責任是把訊息投遞結果轉成可恢復的業務結果。queue 層提供 delivery 保證，consumer 層提供 processing 與 recovery 保證；三者對齊後，非同步流程才具備可預期性。&lt;/p>
&lt;h2 id="三層語意">三層語意&lt;/h2>
&lt;p>consumer 端需要同時處理三層語意：&lt;/p>
&lt;ol>
&lt;li>delivery semantics：訊息是否被成功投遞與確認，包含 ack/nack、retry、DLQ。&lt;/li>
&lt;li>processing semantics：業務副作用是否可承受重複、亂序與部分失敗。&lt;/li>
&lt;li>recovery semantics：故障後是否能重播、補償與回復到一致狀態。&lt;/li>
&lt;/ol>
&lt;p>這三層拆開後，才能看清問題落在哪一層。訊息送達不代表副作用完成；副作用完成不代表系統可恢復。&lt;/p>
&lt;h2 id="consumer-grouppartition-與順序責任">consumer group、partition 與順序責任&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer-group/" data-link-title="Consumer Group" data-link-desc="說明一組 consumer 如何共同分攤 stream 或 topic 的處理責任">consumer group&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/partition/" data-link-title="Partition" data-link-desc="說明事件流如何切分成多個可並行處理的有序片段">partition&lt;/a> 定義了並行與順序邊界。順序要求高的流程要把同一鍵值固定在同一 partition；吞吐優先的流程可提高 partition 數並分散處理。&lt;/p>
&lt;p>分區策略會直接影響恢復成本。分區鍵混亂時，重播與補償很難限定範圍，事故期間容易擴大影響面。&lt;/p>
&lt;h2 id="checkpointoffset-與-idempotency">checkpoint、offset 與 idempotency&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/checkpoint/" data-link-title="Checkpoint" data-link-desc="說明長時間處理流程如何記錄可恢復進度">checkpoint&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/offset/" data-link-title="Offset" data-link-desc="說明 consumer 在事件流中的讀取位置與重放基準">offset&lt;/a> 的責任是標記「處理到哪裡」，不是「業務一定完成」。寫 checkpoint 的時機要晚於副作用提交，避免進度前移導致資料遺漏。&lt;/p>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency&lt;/a> key 的責任是讓重試與重播可重入。付款、發票、通知、庫存變更都需要明確冪等鍵與去重儲存策略，讓「至少一次投遞」不會變成「多次業務結果」。&lt;/p>
&lt;h2 id="replay-safety">replay safety&lt;/h2>
&lt;p>replay safety 的核心是先定義可重播範圍，再定義副作用控制。常見做法包含：&lt;/p>
&lt;ol>
&lt;li>限定 replay window，避免一次重播跨越多個版本邊界。&lt;/li>
&lt;li>將副作用拆成可比對與可補償動作，保留對帳路徑。&lt;/li>
&lt;li>對 replay 期間的下游壓力設置節流與停損條件。&lt;/li>
&lt;/ol>
&lt;p>poison message 要獨立隔離。持續重試同一壞訊息會壓垮整體吞吐，穩定做法是送入 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/dead-letter-queue/" data-link-title="Dead-Letter Queue" data-link-desc="說明 dead-letter queue 如何隔離多次處理失敗的訊息">dead-letter queue&lt;/a>，再走診斷與修復流程。&lt;/p>
&lt;h2 id="queue-語意誤配是-broker-遷移最常見的失敗模式">Queue 語意誤配是 broker 遷移最常見的失敗模式&lt;/h2>
&lt;p>Broker 遷移失敗的根因通常是 &lt;em>consumer 對舊 broker 行為的隱式依賴&lt;/em>、不是 broker 本身效能。表面上訊息仍被送達、但業務資料開始出現重複扣款、重複寄信、狀態漏更新。&lt;/p>
&lt;p>對應 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9 反例：Queue Semantics Mismatch Cutover&lt;/a> — case 揭露切換後語意誤配三個方向：consumer 依賴特定 offset 行為、依賴特定重試節奏、依賴特定 idempotency 行為。失敗重播時、新系統即使提供相近 delivery semantics、結果可能不同。語意誤配會沿著下游資料寫入擴散、難以靠 queue depth 判斷。&lt;/p>
&lt;p>&lt;strong>典型誤配場景&lt;/strong>（基於通用 broker 行為知識展開、非 3.C9 case 原文具體列舉）：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>At-least-once 假設變成 exactly-once 依賴&lt;/strong>：consumer 假設 broker 僅送一次、靠記憶單次處理；新 broker 重送同一 message、consumer 處理兩次&lt;/li>
&lt;li>&lt;strong>Offset 跳號處理差異&lt;/strong>：舊系統重啟後 offset 從特定位置開始、新系統可能從 latest / earliest 不同位置開始&lt;/li>
&lt;li>&lt;strong>Consumer group rebalance 行為差異&lt;/strong>：rebalance 期間舊系統會 pause 處理、新系統可能繼續處理、產生並發寫入衝突&lt;/li>
&lt;li>&lt;strong>DLQ retry 節奏差異&lt;/strong>：舊系統 DLQ message 預設不重試、新系統可能自動重試、製造重複副作用&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>回退判讀&lt;/strong>：回退前要先確認哪一段資料已經被新語意處理過。直接切回舊 broker 可能讓同一批事件再次被處理。穩定做法是先凍結新 consumer、保留 offset 對照與 replay 範圍、再決定補償或重播。&lt;/p>
&lt;p>詳細處理 / 恢復語意分層見 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/processing-recovery-semantics/" data-link-title="3.6 Processing Semantics 與 Recovery Semantics" data-link-desc="說明投遞成功、處理成功與恢復成功為何是三個不同判斷。">3.6 processing-recovery-semantics&lt;/a>。規模差異判讀（小 / 中 / 大型服務的 job queue 治理重點）見 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/queue-consumer-retry-replay-handoff/" data-link-title="3.8 Queue Consumer Retry 與 Replay Handoff（實作示範）" data-link-desc="以 order_created consumer 示範 queue 路徑如何交付 idempotency evidence、DLQ handling、replay runbook 與 incident decision log。">3.8 queue-consumer-retry-replay-handoff&lt;/a> — 中型服務常見問題是 lag/DLQ 長期累積、需具備定向 replay 能力、否則退回全 topic 重播會放大下游壓力。&lt;/p></description><content:encoded><![CDATA[<p>消費者設計（consumer design）的核心責任是把訊息投遞結果轉成可恢復的業務結果。queue 層提供 delivery 保證，consumer 層提供 processing 與 recovery 保證；三者對齊後，非同步流程才具備可預期性。</p>
<h2 id="三層語意">三層語意</h2>
<p>consumer 端需要同時處理三層語意：</p>
<ol>
<li>delivery semantics：訊息是否被成功投遞與確認，包含 ack/nack、retry、DLQ。</li>
<li>processing semantics：業務副作用是否可承受重複、亂序與部分失敗。</li>
<li>recovery semantics：故障後是否能重播、補償與回復到一致狀態。</li>
</ol>
<p>這三層拆開後，才能看清問題落在哪一層。訊息送達不代表副作用完成；副作用完成不代表系統可恢復。</p>
<h2 id="consumer-grouppartition-與順序責任">consumer group、partition 與順序責任</h2>
<p><a href="/blog/backend/knowledge-cards/consumer-group/" data-link-title="Consumer Group" data-link-desc="說明一組 consumer 如何共同分攤 stream 或 topic 的處理責任">consumer group</a> 與 <a href="/blog/backend/knowledge-cards/partition/" data-link-title="Partition" data-link-desc="說明事件流如何切分成多個可並行處理的有序片段">partition</a> 定義了並行與順序邊界。順序要求高的流程要把同一鍵值固定在同一 partition；吞吐優先的流程可提高 partition 數並分散處理。</p>
<p>分區策略會直接影響恢復成本。分區鍵混亂時，重播與補償很難限定範圍，事故期間容易擴大影響面。</p>
<h2 id="checkpointoffset-與-idempotency">checkpoint、offset 與 idempotency</h2>
<p><a href="/blog/backend/knowledge-cards/checkpoint/" data-link-title="Checkpoint" data-link-desc="說明長時間處理流程如何記錄可恢復進度">checkpoint</a> 與 <a href="/blog/backend/knowledge-cards/offset/" data-link-title="Offset" data-link-desc="說明 consumer 在事件流中的讀取位置與重放基準">offset</a> 的責任是標記「處理到哪裡」，不是「業務一定完成」。寫 checkpoint 的時機要晚於副作用提交，避免進度前移導致資料遺漏。</p>
<p><a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a> key 的責任是讓重試與重播可重入。付款、發票、通知、庫存變更都需要明確冪等鍵與去重儲存策略，讓「至少一次投遞」不會變成「多次業務結果」。</p>
<h2 id="replay-safety">replay safety</h2>
<p>replay safety 的核心是先定義可重播範圍，再定義副作用控制。常見做法包含：</p>
<ol>
<li>限定 replay window，避免一次重播跨越多個版本邊界。</li>
<li>將副作用拆成可比對與可補償動作，保留對帳路徑。</li>
<li>對 replay 期間的下游壓力設置節流與停損條件。</li>
</ol>
<p>poison message 要獨立隔離。持續重試同一壞訊息會壓垮整體吞吐，穩定做法是送入 <a href="/blog/backend/knowledge-cards/dead-letter-queue/" data-link-title="Dead-Letter Queue" data-link-desc="說明 dead-letter queue 如何隔離多次處理失敗的訊息">dead-letter queue</a>，再走診斷與修復流程。</p>
<h2 id="queue-語意誤配是-broker-遷移最常見的失敗模式">Queue 語意誤配是 broker 遷移最常見的失敗模式</h2>
<p>Broker 遷移失敗的根因通常是 <em>consumer 對舊 broker 行為的隱式依賴</em>、不是 broker 本身效能。表面上訊息仍被送達、但業務資料開始出現重複扣款、重複寄信、狀態漏更新。</p>
<p>對應 <a href="/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9 反例：Queue Semantics Mismatch Cutover</a> — case 揭露切換後語意誤配三個方向：consumer 依賴特定 offset 行為、依賴特定重試節奏、依賴特定 idempotency 行為。失敗重播時、新系統即使提供相近 delivery semantics、結果可能不同。語意誤配會沿著下游資料寫入擴散、難以靠 queue depth 判斷。</p>
<p><strong>典型誤配場景</strong>（基於通用 broker 行為知識展開、非 3.C9 case 原文具體列舉）：</p>
<ul>
<li><strong>At-least-once 假設變成 exactly-once 依賴</strong>：consumer 假設 broker 僅送一次、靠記憶單次處理；新 broker 重送同一 message、consumer 處理兩次</li>
<li><strong>Offset 跳號處理差異</strong>：舊系統重啟後 offset 從特定位置開始、新系統可能從 latest / earliest 不同位置開始</li>
<li><strong>Consumer group rebalance 行為差異</strong>：rebalance 期間舊系統會 pause 處理、新系統可能繼續處理、產生並發寫入衝突</li>
<li><strong>DLQ retry 節奏差異</strong>：舊系統 DLQ message 預設不重試、新系統可能自動重試、製造重複副作用</li>
</ul>
<p><strong>回退判讀</strong>：回退前要先確認哪一段資料已經被新語意處理過。直接切回舊 broker 可能讓同一批事件再次被處理。穩定做法是先凍結新 consumer、保留 offset 對照與 replay 範圍、再決定補償或重播。</p>
<p>詳細處理 / 恢復語意分層見 <a href="/blog/backend/03-message-queue/processing-recovery-semantics/" data-link-title="3.6 Processing Semantics 與 Recovery Semantics" data-link-desc="說明投遞成功、處理成功與恢復成功為何是三個不同判斷。">3.6 processing-recovery-semantics</a>。規模差異判讀（小 / 中 / 大型服務的 job queue 治理重點）見 <a href="/blog/backend/03-message-queue/queue-consumer-retry-replay-handoff/" data-link-title="3.8 Queue Consumer Retry 與 Replay Handoff（實作示範）" data-link-desc="以 order_created consumer 示範 queue 路徑如何交付 idempotency evidence、DLQ handling、replay runbook 與 incident decision log。">3.8 queue-consumer-retry-replay-handoff</a> — 中型服務常見問題是 lag/DLQ 長期累積、需具備定向 replay 能力、否則退回全 topic 重播會放大下游壓力。</p>
<h2 id="三個工程議題要一起設計">三個工程議題要一起設計</h2>
<p><code>Consumer idempotency</code> + <code>重播流程</code> + <code>下游承載能力</code> 三件事是 consumer design 的鐵三角、需同步落地。缺一個會在規模化時暴露成事故：</p>
<ul>
<li><strong>Consumer idempotency 不完整</strong>：DLQ replay 後產生重複副作用、即使 broker 切換成功、業務帳本仍然錯亂</li>
<li><strong>重播流程不完整</strong>：事故當下需具備定向 replay 能力、否則退回全 topic 重播會放大下游壓力</li>
<li><strong>下游承載能力不足</strong>：consumer 跟 broker 都健康、但下游 DB / API 撐不住 replay 速率、形成新事故</li>
</ul>
<p>Job queue 的拓樸分工是另一個獨立議題、跟鐵三角互補但不重疊 — 詳見 <a href="/blog/backend/03-message-queue/queue-consumer-retry-replay-handoff/" data-link-title="3.8 Queue Consumer Retry 與 Replay Handoff（實作示範）" data-link-desc="以 order_created consumer 示範 queue 路徑如何交付 idempotency evidence、DLQ handling、replay runbook 與 incident decision log。">3.8 Job queue 拓樸分工</a>、主寫 Slack Kafka + Redis 案例。consumer 內部三件事要做好之外、不同類工作（高吞吐 / 即時 / 持久）也應專注單一目標、其他目標拆到對應路徑。</p>
<h2 id="判讀訊號">判讀訊號</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>判讀重點</th>
          <th>對應動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/consumer-lag/" data-link-title="Consumer Lag" data-link-desc="說明 consumer lag 如何反映訊息堆積、處理能力與容量風險">consumer lag</a> 持續上升</td>
          <td>consumer 吞吐低於輸入速率</td>
          <td>提升併發、拆分 partition、檢查下游瓶頸</td>
      </tr>
      <tr>
          <td>retry count 上升且成功率下降</td>
          <td>錯誤已從暫時性轉為系統性</td>
          <td>啟動降級、切換路由、保留重播窗口</td>
      </tr>
      <tr>
          <td>duplicate side effect 增加</td>
          <td>冪等鍵或去重流程失效</td>
          <td>修正 idempotency store、暫停高風險副作用</td>
      </tr>
      <tr>
          <td>DLQ 量快速增加</td>
          <td>payload 或版本相容性問題集中爆發</td>
          <td>分批隔離、加 schema 檢查、修復後定向重播</td>
      </tr>
      <tr>
          <td>replay 期間下游 timeout 同步上升</td>
          <td>重播速率超出依賴容量</td>
          <td>節流 replay、分段回放、加 backpressure 控制</td>
      </tr>
  </tbody>
</table>
<h2 id="常見誤區">常見誤區</h2>
<p>把 consumer 設計等同於「把 handler 寫完」，會漏掉恢復責任。consumer 的工程價值在於故障後仍可追蹤、可補償、可重播。</p>
<p>把 DLQ 當成終點，會讓問題在下次事件再出現。DLQ 的責任是隔離與診斷入口，最終要回到 schema、邏輯或依賴治理。</p>
<h2 id="案例回寫">案例回寫</h2>
<p>consumer 恢復語意可用 <a href="/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9 反例</a> 與 <a href="/blog/backend/03-message-queue/cases/linkedin-topicgc-kafka-governance/" data-link-title="3.C3 LinkedIn：TopicGC 與 Kafka 治理轉換" data-link-desc="Kafka topic 從手動治理轉自動治理對叢集的影響。">3.C3 LinkedIn：TopicGC</a> 對照回寫。先判讀問題是 idempotency 失效、checkpoint 前移，還是 replay 邊界失控，再對應本章的 processing/recovery 段落。
這組案例主要支撐的是「處理恢復語意」判讀，不直接支撐 deployment drain 或 cache eviction；若根因在切流順序或快取容量，應轉到 5.3 或 2.3。</p>
<p>若重播成功但業務狀態仍不一致，先補副作用補償與對帳路徑，並把決策證據同步到 <a href="/blog/backend/08-incident-response/incident-decision-log/" data-link-title="8.19 Incident Decision Log" data-link-desc="把事中假設、決策、證據、回退條件與責任人留下可復盤紀錄">8.19 Incident Decision Log</a>。</p>
<h2 id="跨模組路由">跨模組路由</h2>
<p>consumer 設計是 01/03/04/06/08 的交界點。</p>
<ol>
<li>與 03 內部的交接：processing/recovery 語意完整定義在 <a href="/blog/backend/03-message-queue/processing-recovery-semantics/" data-link-title="3.6 Processing Semantics 與 Recovery Semantics" data-link-desc="說明投遞成功、處理成功與恢復成功為何是三個不同判斷。">3.6 processing-recovery-semantics</a>；event contract 跟 replay boundary 在 <a href="/blog/backend/03-message-queue/event-contract-replay-boundary/" data-link-title="3.7 Event Contract 與 Replay Boundary" data-link-desc="說明 event schema、idempotency key、replay window 與補償如何先於 broker 選型。">3.7</a>；規模差異判讀跟 job queue 拓樸分工在 <a href="/blog/backend/03-message-queue/queue-consumer-retry-replay-handoff/" data-link-title="3.8 Queue Consumer Retry 與 Replay Handoff（實作示範）" data-link-desc="以 order_created consumer 示範 queue 路徑如何交付 idempotency evidence、DLQ handling、replay runbook 與 incident decision log。">3.8</a>。</li>
<li>與 01 的交接：交易與發布一致性回到 <a href="/blog/backend/03-message-queue/outbox-pattern/" data-link-title="3.3 outbox pattern 與發佈一致性" data-link-desc="把 transaction 與 event publish 分離">3.3 outbox pattern</a> 與 <a href="/blog/backend/01-database/transaction-boundary/" data-link-title="1.3 Transaction 與一致性邊界" data-link-desc="交易邊界、isolation level、retry 策略、distributed transaction（2PC、Saga）與跨 region 強一致取捨">1.3 transaction boundary</a>。</li>
<li>與 04 的交接：lag、retry、DLQ、duplicate 指標進入 <a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">Observability Evidence Package</a>。</li>
<li>與 06 的交接：重試與重播驗證進入 <a href="/blog/backend/06-reliability/idempotency-replay/" data-link-title="6.12 Idempotency 與 Replay 驗證" data-link-desc="把重試、重播與冪等性從口頭約定變成可驗證屬性">6.12 Idempotency 與 Replay 驗證</a>。</li>
<li>與 08 的交接：pause consumer、replay 決策與補償判斷記錄到 <a href="/blog/backend/08-incident-response/incident-decision-log/" data-link-title="8.19 Incident Decision Log" data-link-desc="把事中假設、決策、證據、回退條件與責任人留下可復盤紀錄">Incident Decision Log</a>。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<p>要看 processing / recovery 三層語意完整定義、接著讀 <a href="/blog/backend/03-message-queue/processing-recovery-semantics/" data-link-title="3.6 Processing Semantics 與 Recovery Semantics" data-link-desc="說明投遞成功、處理成功與恢復成功為何是三個不同判斷。">3.6 processing-recovery-semantics</a>。要建立 broker 層投遞模型，接著讀 <a href="/blog/backend/03-message-queue/broker-basics/" data-link-title="3.1 broker 基礎與投遞模型" data-link-desc="先理解 broker、queue、consumer 與 delivery semantics">3.1 broker 基礎與投遞模型</a> 與 <a href="/blog/backend/03-message-queue/durable-queue/" data-link-title="3.2 durable queue 與重試策略" data-link-desc="整理持久化佇列、DLQ 與重試流程">3.2 durable queue</a>。要看錯誤切換案例，接著讀 <a href="/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9 反例</a>。</p>
]]></content:encoded></item><item><title>3.5 攻擊者視角（紅隊）：傳遞層弱點判讀</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/red-team-delivery-layer/</link><pubDate>Fri, 24 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/red-team-delivery-layer/</guid><description>&lt;p>傳遞層紅隊判讀的核心目標是確認「訊息如何被重送、重放、放大與耗盡資源」。這裡的紅隊指攻擊者視角的風險檢查：先找可被放大的傳遞路徑，再回推控制面。只要系統採用 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker&lt;/a> 或 stream，弱點就會同時落在 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/delivery-semantics/" data-link-title="Delivery Semantics" data-link-desc="說明事件投遞語意如何定義遺失、重複、順序與補償策略">delivery semantics&lt;/a>、consumer 容量與回復流程。&lt;/p>
&lt;h2 id="判讀傳遞層弱點的主要軸線">【判讀】傳遞層弱點的主要軸線&lt;/h2>
&lt;p>傳遞層弱點可分成三條軸線：投遞語意、處理語意、回復語意。投遞語意看 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">ack/nack&lt;/a> 與重送條件；處理語意看 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency&lt;/a> 與 side effect；回復語意看 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/dead-letter-queue/" data-link-title="Dead-Letter Queue" data-link-desc="說明 dead-letter queue 如何隔離多次處理失敗的訊息">dead-letter queue&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/replay-runbook/" data-link-title="Replay Runbook" data-link-desc="說明事件重放前需要控制的範圍、順序、驗證與副作用">replay runbook&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/data-reconciliation/" data-link-title="Data Reconciliation" data-link-desc="說明多個資料來源不一致時如何比對、修復與留下證據">data reconciliation&lt;/a>。&lt;/p>
&lt;h2 id="可觀察訊號何時要提高紅隊檢查優先級">【可觀察訊號】何時要提高紅隊檢查優先級&lt;/h2>
&lt;p>下列訊號出現時，傳遞層通常需要先做弱點盤點：&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer-lag/" data-link-title="Consumer Lag" data-link-desc="說明 consumer lag 如何反映訊息堆積、處理能力與容量風險">consumer lag&lt;/a> 持續增加，且重試量同步升高&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/dead-letter-queue/" data-link-title="Dead-Letter Queue" data-link-desc="說明 dead-letter queue 如何隔離多次處理失敗的訊息">DLQ&lt;/a> 累積速度高於排空速度&lt;/li>
&lt;li>同一事件會被多路 consumer 讀取並觸發多個下游 side effect&lt;/li>
&lt;li>回放流程缺少操作邊界與審核節點&lt;/li>
&lt;/ul>
&lt;h2 id="失敗代價傳遞層弱點的代價型態">【失敗代價】傳遞層弱點的代價型態&lt;/h2>
&lt;p>傳遞層弱點會把局部錯誤放大成系統性壓力。重複投遞會造成重複扣款、重複通知或重複建單；毒訊息會阻塞分區與 worker；重放策略缺少邊界會把歷史事件再次推進生產流程。這些問題的共同代價是資料偏移、事故窗口延長與操作風險上升。&lt;/p>
&lt;h2 id="最低控制面進入服務實體前要先定義">【最低控制面】進入服務實體前要先定義&lt;/h2>
&lt;p>傳遞層在討論具體服務前，先定義四個控制面最穩定：&lt;/p>
&lt;ol>
&lt;li>投遞保證模型：哪些流程接受 at-least-once、哪些流程需要更嚴格保證。&lt;/li>
&lt;li>去重與副作用模型：哪些操作必須具備 idempotency，如何界定重複。&lt;/li>
&lt;li>重試與降載模型：重試節奏、上限、退避與壓力保護機制。&lt;/li>
&lt;li>回復與重放模型：DLQ 分流、回放準入條件與結果校正流程。&lt;/li>
&lt;/ol>
&lt;h2 id="多租戶-broker-的隔離邊界">多租戶 broker 的隔離邊界&lt;/h2>
&lt;p>Multi-tenant broker 的隔離邊界承擔「單租戶故障不放大到其他租戶」的責任。Multi-tenant broker 的紅隊重點是跨租戶邊界能否擋住攻擊放大跟資源耗盡。3.1 已建立規模化分層討論、本段聚焦攻擊面跟控制面。&lt;/p>
&lt;p>對應 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/uber-kafka-infrastructure-evolution/" data-link-title="3.C6 Uber：Kafka 事件平台演進" data-link-desc="事件平台從團隊自管走向多租戶共享基礎設施。">3.C6 Uber Kafka Infrastructure Evolution&lt;/a> — case 提出方向：定義租戶隔離、配額規則、標準化 topic 治理、平台指標治理。對應 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/linkedin-kafka-tiered-clusters/" data-link-title="3.C4 LinkedIn：Kafka 分層叢集治理" data-link-desc="Kafka 從單叢集走向 tiered clusters 的轉換案例。">3.C4 LinkedIn Tiered Clusters&lt;/a> — 規模化分層 cluster、高優先 workload 跟低優先 workload 各自獨立、降低 noisy neighbor 風險。以下攻擊面 taxonomy 基於通用 multi-tenant broker 知識展開、非 case 原文列舉。&lt;/p>
&lt;p>&lt;strong>Multi-tenant broker 的攻擊面&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>配額耗盡&lt;/strong>：單一 tenant 大量 publish 占光 broker bandwidth / storage、其他 tenant 投遞延遲拉長。對應控制是 &lt;em>per-tenant quota&lt;/em> + &lt;em>rate limit&lt;/em>。下游推送 quota 作為硬上限見 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/durable-queue/" data-link-title="3.2 durable queue 與重試策略" data-link-desc="整理持久化佇列、DLQ 與重試流程">3.2 下游推送是隱性瓶頸&lt;/a>&lt;/li>
&lt;li>&lt;strong>Topic 命名衝突 / 越權&lt;/strong>：tenant A 透過命名衝突或缺失 ACL 取得 tenant B topic 存取權限。對應控制是 &lt;em>namespace 強制隔離&lt;/em> + &lt;em>IAM topic-level ACL&lt;/em>&lt;/li>
&lt;li>&lt;strong>DLQ 跨租戶污染&lt;/strong>：tenant A 的 poison message 進共用 DLQ、影響 tenant B 的 DLQ 處理流程。對應控制是 &lt;em>per-tenant DLQ&lt;/em> + &lt;em>獨立排空策略&lt;/em>&lt;/li>
&lt;li>&lt;strong>Consumer group 命名衝突&lt;/strong>：意外或惡意註冊跟其他 tenant 同名的 consumer group、搶 partition 分配。對應控制是 &lt;em>consumer group naming convention&lt;/em> + &lt;em>prefix-based ACL&lt;/em>&lt;/li>
&lt;/ul>
&lt;p>判讀重點：multi-tenant broker 的紅隊不只看 broker 容量是否充足、還要看單一 tenant 出事時其他 tenant 是否受影響。單一租戶事件擴散到其他租戶屬隔離失敗、非 broker 效能問題。&lt;/p></description><content:encoded><![CDATA[<p>傳遞層紅隊判讀的核心目標是確認「訊息如何被重送、重放、放大與耗盡資源」。這裡的紅隊指攻擊者視角的風險檢查：先找可被放大的傳遞路徑，再回推控制面。只要系統採用 <a href="/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker</a> 或 stream，弱點就會同時落在 <a href="/blog/backend/knowledge-cards/delivery-semantics/" data-link-title="Delivery Semantics" data-link-desc="說明事件投遞語意如何定義遺失、重複、順序與補償策略">delivery semantics</a>、consumer 容量與回復流程。</p>
<h2 id="判讀傳遞層弱點的主要軸線">【判讀】傳遞層弱點的主要軸線</h2>
<p>傳遞層弱點可分成三條軸線：投遞語意、處理語意、回復語意。投遞語意看 <a href="/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">ack/nack</a> 與重送條件；處理語意看 <a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a> 與 side effect；回復語意看 <a href="/blog/backend/knowledge-cards/dead-letter-queue/" data-link-title="Dead-Letter Queue" data-link-desc="說明 dead-letter queue 如何隔離多次處理失敗的訊息">dead-letter queue</a>、<a href="/blog/backend/knowledge-cards/replay-runbook/" data-link-title="Replay Runbook" data-link-desc="說明事件重放前需要控制的範圍、順序、驗證與副作用">replay runbook</a> 與 <a href="/blog/backend/knowledge-cards/data-reconciliation/" data-link-title="Data Reconciliation" data-link-desc="說明多個資料來源不一致時如何比對、修復與留下證據">data reconciliation</a>。</p>
<h2 id="可觀察訊號何時要提高紅隊檢查優先級">【可觀察訊號】何時要提高紅隊檢查優先級</h2>
<p>下列訊號出現時，傳遞層通常需要先做弱點盤點：</p>
<ul>
<li><a href="/blog/backend/knowledge-cards/consumer-lag/" data-link-title="Consumer Lag" data-link-desc="說明 consumer lag 如何反映訊息堆積、處理能力與容量風險">consumer lag</a> 持續增加，且重試量同步升高</li>
<li><a href="/blog/backend/knowledge-cards/dead-letter-queue/" data-link-title="Dead-Letter Queue" data-link-desc="說明 dead-letter queue 如何隔離多次處理失敗的訊息">DLQ</a> 累積速度高於排空速度</li>
<li>同一事件會被多路 consumer 讀取並觸發多個下游 side effect</li>
<li>回放流程缺少操作邊界與審核節點</li>
</ul>
<h2 id="失敗代價傳遞層弱點的代價型態">【失敗代價】傳遞層弱點的代價型態</h2>
<p>傳遞層弱點會把局部錯誤放大成系統性壓力。重複投遞會造成重複扣款、重複通知或重複建單；毒訊息會阻塞分區與 worker；重放策略缺少邊界會把歷史事件再次推進生產流程。這些問題的共同代價是資料偏移、事故窗口延長與操作風險上升。</p>
<h2 id="最低控制面進入服務實體前要先定義">【最低控制面】進入服務實體前要先定義</h2>
<p>傳遞層在討論具體服務前，先定義四個控制面最穩定：</p>
<ol>
<li>投遞保證模型：哪些流程接受 at-least-once、哪些流程需要更嚴格保證。</li>
<li>去重與副作用模型：哪些操作必須具備 idempotency，如何界定重複。</li>
<li>重試與降載模型：重試節奏、上限、退避與壓力保護機制。</li>
<li>回復與重放模型：DLQ 分流、回放準入條件與結果校正流程。</li>
</ol>
<h2 id="多租戶-broker-的隔離邊界">多租戶 broker 的隔離邊界</h2>
<p>Multi-tenant broker 的隔離邊界承擔「單租戶故障不放大到其他租戶」的責任。Multi-tenant broker 的紅隊重點是跨租戶邊界能否擋住攻擊放大跟資源耗盡。3.1 已建立規模化分層討論、本段聚焦攻擊面跟控制面。</p>
<p>對應 <a href="/blog/backend/03-message-queue/cases/uber-kafka-infrastructure-evolution/" data-link-title="3.C6 Uber：Kafka 事件平台演進" data-link-desc="事件平台從團隊自管走向多租戶共享基礎設施。">3.C6 Uber Kafka Infrastructure Evolution</a> — case 提出方向：定義租戶隔離、配額規則、標準化 topic 治理、平台指標治理。對應 <a href="/blog/backend/03-message-queue/cases/linkedin-kafka-tiered-clusters/" data-link-title="3.C4 LinkedIn：Kafka 分層叢集治理" data-link-desc="Kafka 從單叢集走向 tiered clusters 的轉換案例。">3.C4 LinkedIn Tiered Clusters</a> — 規模化分層 cluster、高優先 workload 跟低優先 workload 各自獨立、降低 noisy neighbor 風險。以下攻擊面 taxonomy 基於通用 multi-tenant broker 知識展開、非 case 原文列舉。</p>
<p><strong>Multi-tenant broker 的攻擊面</strong>：</p>
<ul>
<li><strong>配額耗盡</strong>：單一 tenant 大量 publish 占光 broker bandwidth / storage、其他 tenant 投遞延遲拉長。對應控制是 <em>per-tenant quota</em> + <em>rate limit</em>。下游推送 quota 作為硬上限見 <a href="/blog/backend/03-message-queue/durable-queue/" data-link-title="3.2 durable queue 與重試策略" data-link-desc="整理持久化佇列、DLQ 與重試流程">3.2 下游推送是隱性瓶頸</a></li>
<li><strong>Topic 命名衝突 / 越權</strong>：tenant A 透過命名衝突或缺失 ACL 取得 tenant B topic 存取權限。對應控制是 <em>namespace 強制隔離</em> + <em>IAM topic-level ACL</em></li>
<li><strong>DLQ 跨租戶污染</strong>：tenant A 的 poison message 進共用 DLQ、影響 tenant B 的 DLQ 處理流程。對應控制是 <em>per-tenant DLQ</em> + <em>獨立排空策略</em></li>
<li><strong>Consumer group 命名衝突</strong>：意外或惡意註冊跟其他 tenant 同名的 consumer group、搶 partition 分配。對應控制是 <em>consumer group naming convention</em> + <em>prefix-based ACL</em></li>
</ul>
<p>判讀重點：multi-tenant broker 的紅隊不只看 broker 容量是否充足、還要看單一 tenant 出事時其他 tenant 是否受影響。單一租戶事件擴散到其他租戶屬隔離失敗、非 broker 效能問題。</p>
<h2 id="replay-攻擊跟-dlq-濫用">Replay 攻擊跟 DLQ 濫用</h2>
<p>Replay 機制是事故恢復工具、也是攻擊面。攻擊者可能濫用 replay 重複觸發副作用（重複退款、重複送通知、重複下單）、或讓 DLQ 變成 backdoor 通道。以下 3 個攻擊向量基於通用紅隊知識展開、非 case 原文列舉。</p>
<p><strong>Replay 攻擊向量</strong>：</p>
<ul>
<li><strong>未授權 replay 觸發</strong>：攻擊者拿到 replay 控制權、replay 舊事件造成重複副作用。對應控制是 <em>replay 授權需獨立審核</em> + <em>audit trail 記錄誰 replay 什麼</em></li>
<li><strong>Replay window 越界</strong>：replay 跨越 idempotency 紀錄到期、舊事件被當新事件處理。對應控制是 <em>replay window 上限 = idempotency 保留期</em>、見 <a href="/blog/backend/03-message-queue/processing-recovery-semantics/" data-link-title="3.6 Processing Semantics 與 Recovery Semantics" data-link-desc="說明投遞成功、處理成功與恢復成功為何是三個不同判斷。">3.6 processing-recovery-semantics 的 replay 跟 idempotency 共設計</a></li>
<li><strong>DLQ message 注入</strong>：攻擊者把惡意 message 直接寫進 DLQ、繞過主通道驗證、等 replay 時觸發副作用。對應控制是 <em>DLQ 寫入權限獨立於主通道</em> + <em>replay 前 schema 重新驗證</em></li>
</ul>
<p>判讀重點：replay 屬 production 操作、跟 <a href="/blog/backend/01-database/reconciliation-data-repair/" data-link-title="1.9 Reconciliation 與 Data Repair" data-link-desc="資料不一致的分類、偵測模式、修復策略、audit trail、跟 backup / PITR 整合">1.9 reconciliation 修復權限管理</a> 同層級、要 audit trail + 審核流程。合規 replay 路徑應具備 audit trail + window 上限 + DLQ 寫入隔離三層控制、把 replay 從事故工具升級為可稽核的 production 操作。</p>
<h2 id="案例對照">【案例對照】</h2>
<table>
  <thead>
      <tr>
          <th>案例</th>
          <th>紅隊視角重點</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/uber-kafka-infrastructure-evolution/" data-link-title="3.C6 Uber：Kafka 事件平台演進" data-link-desc="事件平台從團隊自管走向多租戶共享基礎設施。">3.C6 Uber Kafka Infrastructure</a></td>
          <td>治理視角、反推 multi-tenant 隔離攻擊面</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/linkedin-kafka-tiered-clusters/" data-link-title="3.C4 LinkedIn：Kafka 分層叢集治理" data-link-desc="Kafka 從單叢集走向 tiered clusters 的轉換案例。">3.C4 LinkedIn Tiered Clusters</a></td>
          <td>治理視角、反推分層 cluster 跟 workload 隔離防護</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9 反例 Queue Semantics Mismatch</a></td>
          <td>切換語意誤配引發重複副作用、replay 跟 idempotency 失準</td>
      </tr>
  </tbody>
</table>
<p>以上 3.C6 / 3.C4 屬治理視角案例、紅隊章節做反推使用（從控制面反推攻擊面）。</p>
<h2 id="跨模組路由">跨模組路由</h2>
<ol>
<li>與 03 內部：規模化分層治理回 <a href="/blog/backend/03-message-queue/broker-basics/" data-link-title="3.1 broker 基礎與投遞模型" data-link-desc="先理解 broker、queue、consumer 與 delivery semantics">3.1 broker-basics</a>；下游推送 quota 攻擊面跟 <a href="/blog/backend/03-message-queue/durable-queue/" data-link-title="3.2 durable queue 與重試策略" data-link-desc="整理持久化佇列、DLQ 與重試流程">3.2 durable-queue 下游推送是隱性瓶頸</a> 互補；replay 跟 idempotency 共設計回 <a href="/blog/backend/03-message-queue/processing-recovery-semantics/" data-link-title="3.6 Processing Semantics 與 Recovery Semantics" data-link-desc="說明投遞成功、處理成功與恢復成功為何是三個不同判斷。">3.6</a></li>
<li>與 01 的交接：replay / 補償權限管理回 <a href="/blog/backend/01-database/reconciliation-data-repair/" data-link-title="1.9 Reconciliation 與 Data Repair" data-link-desc="資料不一致的分類、偵測模式、修復策略、audit trail、跟 backup / PITR 整合">1.9 reconciliation 修復權限管理</a></li>
<li>與 04 的交接：紅隊偵測訊號（DLQ 速率、retry storm、duplicate）進 <a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">4.20 Observability Evidence Package</a></li>
<li>與 06 的交接：rule rollout 安全閘門進 <a href="/blog/backend/06-reliability/rule-rollout-safety-gate/" data-link-title="6.24 規則推送安全閘門" data-link-desc="把規則、策略與控制面配置推送從部署步驟升級為可靠性 gate，避免小變更在秒級擴散成全網事故。">6.24 rule-rollout-safety-gate</a></li>
<li>與 08 的交接：事故當下決策進 <a href="/blog/backend/08-incident-response/incident-decision-log/" data-link-title="8.19 Incident Decision Log" data-link-desc="把事中假設、決策、證據、回退條件與責任人留下可復盤紀錄">8.19 Incident Decision Log</a></li>
</ol>
<h2 id="關聯卡片">【關聯卡片】</h2>
<ul>
<li><a href="/blog/backend/knowledge-cards/poison-message/" data-link-title="Poison Message" data-link-desc="說明特定訊息內容如何穩定造成 consumer 失敗">Poison Message</a></li>
<li><a href="/blog/backend/knowledge-cards/duplicate-delivery/" data-link-title="Duplicate Delivery" data-link-desc="說明同一個訊息被處理多次時如何保持結果穩定">Duplicate Delivery</a></li>
<li><a href="/blog/backend/knowledge-cards/retry-storm/" data-link-title="Retry Storm" data-link-desc="說明大量重試如何把局部故障放大成系統壓力">Retry Storm</a></li>
<li><a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">Backpressure</a></li>
<li><a href="/blog/backend/knowledge-cards/runbook/" data-link-title="Runbook" data-link-desc="說明 runbook 如何把事故判斷與操作步驟標準化">Runbook</a></li>
</ul>
]]></content:encoded></item><item><title>3.6 Processing Semantics 與 Recovery Semantics</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/processing-recovery-semantics/</link><pubDate>Mon, 11 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/processing-recovery-semantics/</guid><description>&lt;p>Processing semantics 與 recovery semantics 的核心責任是把訊息送達、業務副作用完成、故障後可恢復三件事分開判斷。進入 Kafka、RabbitMQ、SQS、NATS 或 Redis Streams 前，讀者需要先知道 broker 保證主要落在傳遞語意的一部分。&lt;/p>
&lt;h2 id="delivery--processing--recovery">Delivery / Processing / Recovery&lt;/h2>
&lt;p>三層語意的責任不同：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>語意層&lt;/th>
 &lt;th>負責問題&lt;/th>
 &lt;th>主要訊號&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Delivery semantics&lt;/td>
 &lt;td>訊息是否被 broker 投遞、確認、重送或隔離&lt;/td>
 &lt;td>ack、nack、redelivery、DLQ&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Processing semantics&lt;/td>
 &lt;td>consumer 副作用是否能承受重複、亂序與部分失敗&lt;/td>
 &lt;td>idempotency、side effect、ordering&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Recovery semantics&lt;/td>
 &lt;td>故障後是否能重播、補償與恢復一致&lt;/td>
 &lt;td>replay、checkpoint、reconciliation&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/delivery-semantics/" data-link-title="Delivery Semantics" data-link-desc="說明事件投遞語意如何定義遺失、重複、順序與補償策略">delivery semantics&lt;/a> 成立不代表 processing 成立。訊息被 ack 也不代表發票、email、search index 或 webhook 都已完成。&lt;/p>
&lt;p>Delivery 層的判讀重點是 broker 是否還能掌握訊息位置。Processing 層的判讀重點是 consumer 是否已經完成業務副作用。Recovery 層的判讀重點是事故後能否用 replay、checkpoint 與 reconciliation 回到一致狀態。這三層拆開後，隊列工具選型才會對到真正問題。&lt;/p>
&lt;h2 id="processing-semantics">Processing Semantics&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/processing-semantics/" data-link-title="Processing Semantics" data-link-desc="說明 consumer 處理事件後業務結果是否正確，與投遞成功分屬不同責任">Processing semantics&lt;/a> 的責任是讓 consumer 副作用在重複投遞與部分失敗下仍可控。常見副作用包含寫資料庫、呼叫外部 API、寄信、建立發票、更新 search index。&lt;/p>
&lt;p>每個副作用都要先回答：&lt;/p>
&lt;ol>
&lt;li>idempotency key 是什麼。&lt;/li>
&lt;li>副作用完成後如何記錄。&lt;/li>
&lt;li>重複執行時結果是否穩定。&lt;/li>
&lt;li>部分成功時如何補償。&lt;/li>
&lt;/ol>
&lt;p>缺少這些答案時，at-least-once delivery 會轉成多次業務結果。&lt;/p>
&lt;h2 id="recovery-semantics">Recovery Semantics&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/recovery-semantics/" data-link-title="Recovery Semantics" data-link-desc="說明事件處理失敗後能否透過 replay、checkpoint 與補償重建正確狀態並驗證">Recovery semantics&lt;/a> 的責任是讓系統在 consumer crash、DLQ 爆量、下游故障或資料修復後能恢復一致。它依賴 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/replay-window/" data-link-title="Replay Window" data-link-desc="說明事件可重播的時間或 offset 範圍邊界，由 retention 與 checkpoint 決定">replay window&lt;/a>、checkpoint、offset、去重紀錄與對帳查詢。&lt;/p>
&lt;p>恢復流程要先分範圍。按時間、tenant、partition、schema version 或 event type 分段，能降低 replay 造成的下游壓力與重複副作用。&lt;/p>
&lt;h2 id="checkpoint-與-side-effect">Checkpoint 與 Side Effect&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/checkpoint/" data-link-title="Checkpoint" data-link-desc="說明長時間處理流程如何記錄可恢復進度">checkpoint&lt;/a> 的責任是標記處理進度，業務完成則要由副作用紀錄與對帳證據證明。若 checkpoint 早於副作用提交，consumer crash 後可能漏做副作用；若 checkpoint 太晚，重啟後會造成重複處理。&lt;/p>
&lt;p>穩定設計通常讓副作用具備 idempotency，再把 checkpoint 放在可恢復的位置。checkpoint 與 idempotency 是一組設計，需要一起審查。&lt;/p>
&lt;h2 id="poison-message-的處理層次">Poison Message 的處理層次&lt;/h2>
&lt;p>Poison message 屬於觸發 consumer 持續失敗、需要被隔離處理的訊息類型。處理流程從 &lt;em>偵測 / 隔離 / 診斷 / 修復&lt;/em> 四個層次設計、屬於 DLQ 之後的延伸責任。&lt;/p>
&lt;p>對應 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9 反例：Queue Semantics Mismatch&lt;/a> — case 提供切換後 DLQ 激增的觀察方向、是 broker 遷移時 consumer 沒對齊 processing/recovery 語意的訊號、poison message 是其下游表徵之一。&lt;/p>
&lt;p>&lt;strong>四個處理層次&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>偵測&lt;/strong>：retry count 超過組織自定閾值後識別為 poison candidate。早期偵測訊號是 retry rate 升高但 success rate 沒同步上升、單一 consumer 反覆失敗&lt;/li>
&lt;li>&lt;strong>隔離&lt;/strong>：把 poison message 移出主通道、進 DLQ 或 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/poison-message-quarantine/" data-link-title="Poison-Message Quarantine" data-link-desc="說明把毒訊息從主處理路徑隔離出來的機制，讓正常訊息繼續前進">quarantine queue&lt;/a>。隔離要即時、避免持續占用主通道吞吐&lt;/li>
&lt;li>&lt;strong>診斷&lt;/strong>：DLQ 內 poison message 要分群分析、找出共同 failure pattern（payload schema 不符、外部 API 永久失敗、邏輯 bug）&lt;/li>
&lt;li>&lt;strong>修復&lt;/strong>：依據 root cause 修 consumer / contract / 邏輯後、再&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/dlq-drain/" data-link-title="DLQ Drain" data-link-desc="說明把 dead-letter queue 累積的訊息重新處理或排空的受控流程">定向回放 DLQ&lt;/a> 內 poison message、避免 zombie cycle（同一 message 反覆進 DLQ）&lt;/li>
&lt;/ul>
&lt;p>判讀重點：DLQ size 持續增加但沒有對應修復 commit、表示處理流程斷在「隔離」這層、要回到「診斷 / 修復」。release gate 加「DLQ 排空速率 &amp;gt;= 流入速率」的條件、讓 DLQ 維持診斷入口的角色。未授權 replay 跟 window 越界攻擊面見 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/red-team-delivery-layer/" data-link-title="3.5 攻擊者視角（紅隊）：傳遞層弱點判讀" data-link-desc="從重複投遞、重放濫用、毒訊息與容量壓力，盤點 message delivery 的主要弱點">3.5 紅隊章 Replay 攻擊&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>Processing semantics 與 recovery semantics 的核心責任是把訊息送達、業務副作用完成、故障後可恢復三件事分開判斷。進入 Kafka、RabbitMQ、SQS、NATS 或 Redis Streams 前，讀者需要先知道 broker 保證主要落在傳遞語意的一部分。</p>
<h2 id="delivery--processing--recovery">Delivery / Processing / Recovery</h2>
<p>三層語意的責任不同：</p>
<table>
  <thead>
      <tr>
          <th>語意層</th>
          <th>負責問題</th>
          <th>主要訊號</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Delivery semantics</td>
          <td>訊息是否被 broker 投遞、確認、重送或隔離</td>
          <td>ack、nack、redelivery、DLQ</td>
      </tr>
      <tr>
          <td>Processing semantics</td>
          <td>consumer 副作用是否能承受重複、亂序與部分失敗</td>
          <td>idempotency、side effect、ordering</td>
      </tr>
      <tr>
          <td>Recovery semantics</td>
          <td>故障後是否能重播、補償與恢復一致</td>
          <td>replay、checkpoint、reconciliation</td>
      </tr>
  </tbody>
</table>
<p><a href="/blog/backend/knowledge-cards/delivery-semantics/" data-link-title="Delivery Semantics" data-link-desc="說明事件投遞語意如何定義遺失、重複、順序與補償策略">delivery semantics</a> 成立不代表 processing 成立。訊息被 ack 也不代表發票、email、search index 或 webhook 都已完成。</p>
<p>Delivery 層的判讀重點是 broker 是否還能掌握訊息位置。Processing 層的判讀重點是 consumer 是否已經完成業務副作用。Recovery 層的判讀重點是事故後能否用 replay、checkpoint 與 reconciliation 回到一致狀態。這三層拆開後，隊列工具選型才會對到真正問題。</p>
<h2 id="processing-semantics">Processing Semantics</h2>
<p><a href="/blog/backend/knowledge-cards/processing-semantics/" data-link-title="Processing Semantics" data-link-desc="說明 consumer 處理事件後業務結果是否正確，與投遞成功分屬不同責任">Processing semantics</a> 的責任是讓 consumer 副作用在重複投遞與部分失敗下仍可控。常見副作用包含寫資料庫、呼叫外部 API、寄信、建立發票、更新 search index。</p>
<p>每個副作用都要先回答：</p>
<ol>
<li>idempotency key 是什麼。</li>
<li>副作用完成後如何記錄。</li>
<li>重複執行時結果是否穩定。</li>
<li>部分成功時如何補償。</li>
</ol>
<p>缺少這些答案時，at-least-once delivery 會轉成多次業務結果。</p>
<h2 id="recovery-semantics">Recovery Semantics</h2>
<p><a href="/blog/backend/knowledge-cards/recovery-semantics/" data-link-title="Recovery Semantics" data-link-desc="說明事件處理失敗後能否透過 replay、checkpoint 與補償重建正確狀態並驗證">Recovery semantics</a> 的責任是讓系統在 consumer crash、DLQ 爆量、下游故障或資料修復後能恢復一致。它依賴 <a href="/blog/backend/knowledge-cards/replay-window/" data-link-title="Replay Window" data-link-desc="說明事件可重播的時間或 offset 範圍邊界，由 retention 與 checkpoint 決定">replay window</a>、checkpoint、offset、去重紀錄與對帳查詢。</p>
<p>恢復流程要先分範圍。按時間、tenant、partition、schema version 或 event type 分段，能降低 replay 造成的下游壓力與重複副作用。</p>
<h2 id="checkpoint-與-side-effect">Checkpoint 與 Side Effect</h2>
<p><a href="/blog/backend/knowledge-cards/checkpoint/" data-link-title="Checkpoint" data-link-desc="說明長時間處理流程如何記錄可恢復進度">checkpoint</a> 的責任是標記處理進度，業務完成則要由副作用紀錄與對帳證據證明。若 checkpoint 早於副作用提交，consumer crash 後可能漏做副作用；若 checkpoint 太晚，重啟後會造成重複處理。</p>
<p>穩定設計通常讓副作用具備 idempotency，再把 checkpoint 放在可恢復的位置。checkpoint 與 idempotency 是一組設計，需要一起審查。</p>
<h2 id="poison-message-的處理層次">Poison Message 的處理層次</h2>
<p>Poison message 屬於觸發 consumer 持續失敗、需要被隔離處理的訊息類型。處理流程從 <em>偵測 / 隔離 / 診斷 / 修復</em> 四個層次設計、屬於 DLQ 之後的延伸責任。</p>
<p>對應 <a href="/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9 反例：Queue Semantics Mismatch</a> — case 提供切換後 DLQ 激增的觀察方向、是 broker 遷移時 consumer 沒對齊 processing/recovery 語意的訊號、poison message 是其下游表徵之一。</p>
<p><strong>四個處理層次</strong>：</p>
<ul>
<li><strong>偵測</strong>：retry count 超過組織自定閾值後識別為 poison candidate。早期偵測訊號是 retry rate 升高但 success rate 沒同步上升、單一 consumer 反覆失敗</li>
<li><strong>隔離</strong>：把 poison message 移出主通道、進 DLQ 或 <a href="/blog/backend/knowledge-cards/poison-message-quarantine/" data-link-title="Poison-Message Quarantine" data-link-desc="說明把毒訊息從主處理路徑隔離出來的機制，讓正常訊息繼續前進">quarantine queue</a>。隔離要即時、避免持續占用主通道吞吐</li>
<li><strong>診斷</strong>：DLQ 內 poison message 要分群分析、找出共同 failure pattern（payload schema 不符、外部 API 永久失敗、邏輯 bug）</li>
<li><strong>修復</strong>：依據 root cause 修 consumer / contract / 邏輯後、再<a href="/blog/backend/knowledge-cards/dlq-drain/" data-link-title="DLQ Drain" data-link-desc="說明把 dead-letter queue 累積的訊息重新處理或排空的受控流程">定向回放 DLQ</a> 內 poison message、避免 zombie cycle（同一 message 反覆進 DLQ）</li>
</ul>
<p>判讀重點：DLQ size 持續增加但沒有對應修復 commit、表示處理流程斷在「隔離」這層、要回到「診斷 / 修復」。release gate 加「DLQ 排空速率 &gt;= 流入速率」的條件、讓 DLQ 維持診斷入口的角色。未授權 replay 跟 window 越界攻擊面見 <a href="/blog/backend/03-message-queue/red-team-delivery-layer/" data-link-title="3.5 攻擊者視角（紅隊）：傳遞層弱點判讀" data-link-desc="從重複投遞、重放濫用、毒訊息與容量壓力，盤點 message delivery 的主要弱點">3.5 紅隊章 Replay 攻擊</a>。</p>
<h2 id="replay-跟-idempotency-的共設計">Replay 跟 Idempotency 的共設計</h2>
<p>Replay safety 跟 idempotency 屬於同一個設計階段、需共設計並落地後才能上線。replay window 設多大、idempotency key 怎麼定、checkpoint 何時提交、三者互相影響、任一改動都會破壞其他。</p>
<p><strong>共設計的判讀順序</strong>：</p>
<ol>
<li><strong>先定 idempotency key</strong>：什麼欄位組合能唯一標記副作用（event_id、entity_id + version、business operation id）</li>
<li><strong>再定 idempotency 儲存策略</strong>：去重紀錄存多久（決定 replay window 上限）、儲存在 cache / DB / 應用層 memory</li>
<li><strong>依儲存策略反推 replay window</strong>：去重紀錄保留 7 天、replay window 上限就是 7 天、超過會出現重複副作用</li>
<li><strong>再依 replay window 反推 checkpoint 策略</strong>：checkpoint 落地時機要保證 crash 後 replay window 內可恢復</li>
</ol>
<p>對應 <a href="/blog/backend/09-performance-capacity/cases/spotify-kafka-to-pubsub-migration-gcp/" data-link-title="9.C9 Spotify：從自管 Kafka 遷移到 GCP Pub/Sub 的事件交付系統" data-link-desc="Spotify 把自管 Kafka 事件系統遷移到 Google Cloud Pub/Sub、避免自管 broker 的容量規劃成本">9.C9 Spotify Kafka → Pub/Sub</a> — broker 遷移要驗證業務語意跟新 broker 兼容、replay 模型在 Kafka（offset）跟 Pub/Sub（snapshot + seek）不同、idempotency 策略要重新校準。</p>
<p>判讀重點：replay window 由 idempotency 儲存策略反推、不是 broker 設定值。先看 idempotency key 跟去重儲存、再決定 replay window 安全範圍。順序顛倒會踩到「replay 跨越去重紀錄到期」的事故、表現是 replay 後出現本來該被去重的重複副作用。</p>
<h2 id="選型前判準">選型前判準</h2>
<p>Queue 選型前要先回答：</p>
<ol>
<li>需要保證的是投遞、處理還是恢復。</li>
<li>哪些副作用必須 idempotent。</li>
<li>哪些事件需要順序，順序邊界是全域、tenant、entity 還是 partition。</li>
<li>Replay 時下游能承受多少吞吐。</li>
<li>DLQ 是診斷入口還是已經變成長期倉庫。</li>
</ol>
<p>這些答案會決定後續比較 Kafka、RabbitMQ、SQS、NATS 或 Redis Streams 時該看哪些能力。</p>
<h2 id="實體服務討論承接點">實體服務討論承接點</h2>
<p>實體 queue/broker 文章要承接本篇的 processing 與 recovery semantics。Kafka、RabbitMQ、SQS、NATS 或 Redis Streams 的比較，應先問服務需要什麼投遞、處理與恢復責任，再比較 topic、queue、partition、consumer group、DLQ 或 retention。</p>
<p>若主問題是高吞吐事件流，後續文章要比較 partition、retention、consumer lag 與 replay 能力。若主問題是工作派發，後續文章要比較 ack/nack、routing、DLQ 與 retry。若主問題是受管服務操作成本，後續文章要比較可觀測性、IAM、區域能力與 failure mode。</p>
<h2 id="跨模組路由">跨模組路由</h2>
<ol>
<li>與 03 內部：consumer 端去重跟 ack timing 詳見 <a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer-design</a>；event payload 跟 replay 邊界寫入事件契約見 <a href="/blog/backend/03-message-queue/event-contract-replay-boundary/" data-link-title="3.7 Event Contract 與 Replay Boundary" data-link-desc="說明 event schema、idempotency key、replay window 與補償如何先於 broker 選型。">3.7</a>；規模差異判讀跟 job queue 拓樸分工見 <a href="/blog/backend/03-message-queue/queue-consumer-retry-replay-handoff/" data-link-title="3.8 Queue Consumer Retry 與 Replay Handoff（實作示範）" data-link-desc="以 order_created consumer 示範 queue 路徑如何交付 idempotency evidence、DLQ handling、replay runbook 與 incident decision log。">3.8</a></li>
<li>與 04 的交接：lag、retry、DLQ、duplicate 訊號進 <a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">4.20 Observability Evidence Package</a></li>
<li>與 06 的交接：idempotency 跟 replay 驗證進 <a href="/blog/backend/06-reliability/idempotency-replay/" data-link-title="6.12 Idempotency 與 Replay 驗證" data-link-desc="把重試、重播與冪等性從口頭約定變成可驗證屬性">6.12 Idempotency 與 Replay 驗證</a></li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<p>要把 event payload 跟 replay 邊界寫進事件契約、接著讀 <a href="/blog/backend/03-message-queue/event-contract-replay-boundary/" data-link-title="3.7 Event Contract 與 Replay Boundary" data-link-desc="說明 event schema、idempotency key、replay window 與補償如何先於 broker 選型。">3.7 Event Contract 與 Replay Boundary</a>。要建立 broker 投遞模型，接著讀 <a href="/blog/backend/03-message-queue/broker-basics/" data-link-title="3.1 broker 基礎與投遞模型" data-link-desc="先理解 broker、queue、consumer 與 delivery semantics">3.1 broker 基礎與投遞模型</a>。要把三層語意放進完整服務路徑，接著讀 <a href="/blog/backend/03-message-queue/queue-consumer-retry-replay-handoff/" data-link-title="3.8 Queue Consumer Retry 與 Replay Handoff（實作示範）" data-link-desc="以 order_created consumer 示範 queue 路徑如何交付 idempotency evidence、DLQ handling、replay runbook 與 incident decision log。">3.8 Queue Consumer Retry 與 Replay Handoff</a>。</p>
]]></content:encoded></item><item><title>3.7 Event Contract 與 Replay Boundary</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/event-contract-replay-boundary/</link><pubDate>Mon, 11 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/event-contract-replay-boundary/</guid><description>&lt;p>Event contract 與 replay boundary 的核心責任是讓事件在版本演進、重試與重播時仍可被理解與驗證。進入具體 broker 前，讀者需要先知道事件 payload 是跨服務副作用的契約。&lt;/p>
&lt;h2 id="event-contract">Event Contract&lt;/h2>
&lt;p>Event contract 的責任是定義 producer 發出的事實、consumer 能依賴的欄位，以及版本演進時的相容窗口。最小 contract 包含 event id、schema version、occurred time、producer、entity id、dedup key 與資料保護範圍。&lt;/p>
&lt;p>event id 讓訊息可追蹤；schema version 讓版本演進可判斷；occurred time 讓 replay 可分時間窗；dedup key 讓 consumer 可去重；PII scope 讓事件能接到資料保護。&lt;/p>
&lt;p>event id 支撐 incident timeline 與重複投遞判讀。schema version 支撐新舊 consumer 共存。occurred time 支撐 replay window 與對帳查詢。dedup key 支撐 idempotency。PII scope 支撐 audit 與資料保護。這些欄位先成立，broker &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/retention/" data-link-title="Retention" data-link-desc="說明資料或事件保留多久，以及保留期限如何影響重放與成本">retention&lt;/a> 或 partition 設計才有可依附的語意。&lt;/p>
&lt;h2 id="schema-compatibility">Schema Compatibility&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/event-schema-compatibility/" data-link-title="Event Schema Compatibility" data-link-desc="說明 event schema 演進時，新舊 producer 與 consumer 能否互通的相容性等級">Schema compatibility&lt;/a> 的責任是讓 producer 與 consumer 可以分批升級。新增欄位要保留 optional，移除欄位要有相容窗口，語意改變要用新 version 或新 event type。&lt;/p>
&lt;p>序列化能解析是相容性的第一層。若欄位仍存在但語意改變，consumer 仍可能產生錯誤副作用。這類變更需要在 release gate 中驗證。&lt;/p>
&lt;h2 id="replay-boundary">Replay Boundary&lt;/h2>
&lt;p>Replay boundary 的責任是限制重播範圍，避免修復動作擴大事故。Replay 要能指定 time range、tenant、partition、event type、schema version 與 downstream capacity。&lt;/p>
&lt;p>replay window 要和 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/time-range/" data-link-title="Time Range" data-link-desc="說明證據、查詢與事故判讀如何用時間窗保留可回放上下文">time range&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/query-link/" data-link-title="Query Link" data-link-desc="說明證據包如何保存可重跑查詢入口，而不是只保留截圖或口頭結論">query link&lt;/a> 對齊，讓事後能回放當時重播的是哪一批事件。&lt;/p>
&lt;h2 id="compensation">Compensation&lt;/h2>
&lt;p>Compensation 的責任是處理副作用已經發生但結果不正確的情況。寄信、發票、付款通知與 webhook 都可能需要補償，重播是其中一種恢復方式。&lt;/p>
&lt;p>補償前要先判斷副作用是否可逆、是否會通知使用者、是否需要人工審核。不可逆副作用要比可重播副作用更早接到 &lt;a href="https://tarrragon.github.io/blog/backend/08-incident-response/incident-decision-log/" data-link-title="8.19 Incident Decision Log" data-link-desc="把事中假設、決策、證據、回退條件與責任人留下可復盤紀錄">Incident Decision Log&lt;/a>。&lt;/p>
&lt;h2 id="跨-broker-業務語意對映">跨 broker 業務語意對映&lt;/h2>
&lt;p>跨 broker migration 的工程責任是維持業務語意對映、broker 吞吐是次要驗證項。同一份 event contract 在 Kafka、Pub/Sub、SQS、NATS 的對映概念不同、需要逐項校準。&lt;/p>
&lt;p>對應 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/spotify-kafka-to-pubsub-migration-gcp/" data-link-title="9.C9 Spotify：從自管 Kafka 遷移到 GCP Pub/Sub 的事件交付系統" data-link-desc="Spotify 把自管 Kafka 事件系統遷移到 Google Cloud Pub/Sub、避免自管 broker 的容量規劃成本">9.C9 Spotify Kafka → Pub/Sub Migration&lt;/a> — Spotify 7500 萬用戶事件交付系統遷移、case 明確點出 Kafka 的 partition / offset / consumer group 對映成 Pub/Sub 的 subscription / ordering key / message attribute、需要校準業務語意而非直接搬。&lt;/p>
&lt;p>&lt;strong>典型概念對映差異&lt;/strong>（依據 9.C9 case 列出的三組對映展開、Pub/Sub 實際 API 細節為文章補充）：&lt;/p></description><content:encoded><![CDATA[<p>Event contract 與 replay boundary 的核心責任是讓事件在版本演進、重試與重播時仍可被理解與驗證。進入具體 broker 前，讀者需要先知道事件 payload 是跨服務副作用的契約。</p>
<h2 id="event-contract">Event Contract</h2>
<p>Event contract 的責任是定義 producer 發出的事實、consumer 能依賴的欄位，以及版本演進時的相容窗口。最小 contract 包含 event id、schema version、occurred time、producer、entity id、dedup key 與資料保護範圍。</p>
<p>event id 讓訊息可追蹤；schema version 讓版本演進可判斷；occurred time 讓 replay 可分時間窗；dedup key 讓 consumer 可去重；PII scope 讓事件能接到資料保護。</p>
<p>event id 支撐 incident timeline 與重複投遞判讀。schema version 支撐新舊 consumer 共存。occurred time 支撐 replay window 與對帳查詢。dedup key 支撐 idempotency。PII scope 支撐 audit 與資料保護。這些欄位先成立，broker <a href="/blog/backend/knowledge-cards/retention/" data-link-title="Retention" data-link-desc="說明資料或事件保留多久，以及保留期限如何影響重放與成本">retention</a> 或 partition 設計才有可依附的語意。</p>
<h2 id="schema-compatibility">Schema Compatibility</h2>
<p><a href="/blog/backend/knowledge-cards/event-schema-compatibility/" data-link-title="Event Schema Compatibility" data-link-desc="說明 event schema 演進時，新舊 producer 與 consumer 能否互通的相容性等級">Schema compatibility</a> 的責任是讓 producer 與 consumer 可以分批升級。新增欄位要保留 optional，移除欄位要有相容窗口，語意改變要用新 version 或新 event type。</p>
<p>序列化能解析是相容性的第一層。若欄位仍存在但語意改變，consumer 仍可能產生錯誤副作用。這類變更需要在 release gate 中驗證。</p>
<h2 id="replay-boundary">Replay Boundary</h2>
<p>Replay boundary 的責任是限制重播範圍，避免修復動作擴大事故。Replay 要能指定 time range、tenant、partition、event type、schema version 與 downstream capacity。</p>
<p>replay window 要和 <a href="/blog/backend/knowledge-cards/time-range/" data-link-title="Time Range" data-link-desc="說明證據、查詢與事故判讀如何用時間窗保留可回放上下文">time range</a> 與 <a href="/blog/backend/knowledge-cards/query-link/" data-link-title="Query Link" data-link-desc="說明證據包如何保存可重跑查詢入口，而不是只保留截圖或口頭結論">query link</a> 對齊，讓事後能回放當時重播的是哪一批事件。</p>
<h2 id="compensation">Compensation</h2>
<p>Compensation 的責任是處理副作用已經發生但結果不正確的情況。寄信、發票、付款通知與 webhook 都可能需要補償，重播是其中一種恢復方式。</p>
<p>補償前要先判斷副作用是否可逆、是否會通知使用者、是否需要人工審核。不可逆副作用要比可重播副作用更早接到 <a href="/blog/backend/08-incident-response/incident-decision-log/" data-link-title="8.19 Incident Decision Log" data-link-desc="把事中假設、決策、證據、回退條件與責任人留下可復盤紀錄">Incident Decision Log</a>。</p>
<h2 id="跨-broker-業務語意對映">跨 broker 業務語意對映</h2>
<p>跨 broker migration 的工程責任是維持業務語意對映、broker 吞吐是次要驗證項。同一份 event contract 在 Kafka、Pub/Sub、SQS、NATS 的對映概念不同、需要逐項校準。</p>
<p>對應 <a href="/blog/backend/09-performance-capacity/cases/spotify-kafka-to-pubsub-migration-gcp/" data-link-title="9.C9 Spotify：從自管 Kafka 遷移到 GCP Pub/Sub 的事件交付系統" data-link-desc="Spotify 把自管 Kafka 事件系統遷移到 Google Cloud Pub/Sub、避免自管 broker 的容量規劃成本">9.C9 Spotify Kafka → Pub/Sub Migration</a> — Spotify 7500 萬用戶事件交付系統遷移、case 明確點出 Kafka 的 partition / offset / consumer group 對映成 Pub/Sub 的 subscription / ordering key / message attribute、需要校準業務語意而非直接搬。</p>
<p><strong>典型概念對映差異</strong>（依據 9.C9 case 列出的三組對映展開、Pub/Sub 實際 API 細節為文章補充）：</p>
<ul>
<li><strong>Partition (Kafka) 跟 Subscription (Pub/Sub)</strong>：Kafka partition 是物理分片 + 順序邊界；Pub/Sub subscription 是邏輯 <a href="/blog/backend/knowledge-cards/fan-out/" data-link-title="Fan-out" data-link-desc="說明單一事件同時分發給多個下游的訊息拓撲">fan-out</a>、無物理分片概念。靠 Kafka partition 保證 per-key 順序的 consumer、遷到 Pub/Sub 改用 ordering key</li>
<li><strong>Offset (Kafka) 對映成 message attribute (Pub/Sub)</strong>：9.C9 case 原文對映方向；replay 模型差異上、Kafka offset 是位置指標、可任意回放到某個 offset；Pub/Sub 用 Snapshot + Seek API 達成類似 replay 能力、模型不同</li>
<li><strong>Consumer Group (Kafka) 跟 Subscription (Pub/Sub)</strong>：Kafka consumer group 內部 rebalance 自動分 partition；Pub/Sub subscription 自動分 message、語意接近但 rebalance 細節差異會影響 <a href="/blog/backend/knowledge-cards/in-flight/" data-link-title="In-Flight Work" data-link-desc="目前已接收但尚未完成處理的工作量">in-flight</a> message 處理順序</li>
</ul>
<p><strong>遷移評估要驗證的業務語意</strong>：</p>
<ul>
<li>順序保證：原系統靠 partition / consumer group 保證什麼順序、新系統能否複製</li>
<li>Replay 模型：原系統 replay 方式、新系統的 replay 工具能否達成同範圍。replay window 上限由 idempotency 保留期反推、見 <a href="/blog/backend/03-message-queue/processing-recovery-semantics/" data-link-title="3.6 Processing Semantics 與 Recovery Semantics" data-link-desc="說明投遞成功、處理成功與恢復成功為何是三個不同判斷。">3.6 Replay 跟 Idempotency 共設計</a></li>
<li>失敗模式：consumer 故障時、原系統的 rebalance / redelivery 行為、新系統會不會差異</li>
</ul>
<p>判讀重點：broker migration 屬語意對映工程、吞吐能力比較是次要驗證項。對應 <a href="/blog/backend/03-message-queue/outbox-pattern/" data-link-title="3.3 outbox pattern 與發佈一致性" data-link-desc="把 transaction 與 event publish 分離">3.3 outbox pattern 的「Broker 遷移階段流程」</a>、實作面用 dual-write + shadow consume + cutover、驗證面靠 event contract 跟 replay 邊界做對帳。</p>
<h2 id="跨模組路由">跨模組路由</h2>
<ol>
<li>與 03 內部：replay window 跟 idempotency 共設計回到 <a href="/blog/backend/03-message-queue/processing-recovery-semantics/" data-link-title="3.6 Processing Semantics 與 Recovery Semantics" data-link-desc="說明投遞成功、處理成功與恢復成功為何是三個不同判斷。">3.6 processing-recovery-semantics</a>；broker 遷移階段流程回到 <a href="/blog/backend/03-message-queue/outbox-pattern/" data-link-title="3.3 outbox pattern 與發佈一致性" data-link-desc="把 transaction 與 event publish 分離">3.3 outbox pattern</a></li>
<li>與 04 的交接：event contract 演進 + replay 邊界進 <a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">4.20 Observability Evidence Package</a></li>
<li>與 06 的交接：event contract 跟 replay 驗證進 <a href="/blog/backend/06-reliability/idempotency-replay/" data-link-title="6.12 Idempotency 與 Replay 驗證" data-link-desc="把重試、重播與冪等性從口頭約定變成可驗證屬性">6.12 Idempotency 與 Replay 驗證</a> 跟 <a href="/blog/backend/06-reliability/verification-evidence-handoff/" data-link-title="6.23 Verification Evidence Handoff" data-link-desc="把 SLO、load、chaos、DR 與 readiness 結果包成 release / incident 可用證據">6.23 Verification Evidence Handoff</a></li>
<li>與 07 的交接：event payload 的 PII / audit 邊界進 <a href="/blog/backend/07-security-data-protection/data-protection-and-masking-governance/" data-link-title="7.4 資料保護與遮罩治理" data-link-desc="以問題驅動方式整理資料分級、遮罩、匯出與備份治理">7.4 data protection and masking</a></li>
</ol>
<h2 id="選型前判準">選型前判準</h2>
<p>Broker 選型前要先回答：</p>
<ol>
<li>event contract 是否能支援版本相容。</li>
<li>consumer 是否能用 dedup key 判斷重複。</li>
<li>replay window 是否能用查詢與指標證明。</li>
<li>不可逆副作用是否有補償流程。</li>
<li>event payload 是否包含 PII 或 audit-sensitive 欄位。</li>
</ol>
<p>這些問題決定後續要比較 broker retention、schema registry、DLQ、partition 與 replay 工具，並把吞吐放回服務語意下判讀。</p>
<h2 id="實體服務討論承接點">實體服務討論承接點</h2>
<p>實體 broker 文章要承接本篇的 event contract 與 replay boundary。Kafka 的長期 retention、RabbitMQ 的 routing 與 DLQ、SQS 的 <a href="/blog/backend/knowledge-cards/visibility-timeout/" data-link-title="Visibility Timeout" data-link-desc="說明訊息被取走後對其他 consumer 暫時不可見的時間窗，timeout 後重新投遞">visibility timeout</a>、NATS JetStream 的 stream/consumer 模型，都要放回事件契約與重播邊界下判讀。</p>
<p>若事件需要長期 replay，後續文章要比較 retention、offset、partition 與 schema evolution。若事件主要是工作任務，後續文章要比較 visibility、ack、DLQ 與重試治理。若事件包含 PII 或高風險副作用，後續文章要比較 audit、encryption、access control 與補償流程。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>要處理 outbox 與事件發布一致性，接著讀 <a href="/blog/backend/03-message-queue/outbox-pattern/" data-link-title="3.3 outbox pattern 與發佈一致性" data-link-desc="把 transaction 與 event publish 分離">3.3 outbox pattern 與發佈一致性</a>。要處理 consumer 端去重與重播，接著讀 <a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計與去重</a>。</p>
]]></content:encoded></item><item><title>3.8 Queue Consumer Retry 與 Replay Handoff（實作示範）</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/queue-consumer-retry-replay-handoff/</link><pubDate>Mon, 11 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/queue-consumer-retry-replay-handoff/</guid><description>&lt;p>Queue consumer retry 與 replay handoff 的核心責任是把 request 外副作用做成可重試、可去重、可隔離、可重播的服務流程。這篇以 &lt;code>order_created&lt;/code> consumer 為例，示範 delivery、processing、recovery 三層語意如何交接到 evidence package、release gate 與 incident decision log。&lt;/p>
&lt;h2 id="服務路徑與語意分層">服務路徑與語意分層&lt;/h2>
&lt;p>這條路徑是 &lt;code>order-service -&amp;gt; broker -&amp;gt; order-created-consumer -&amp;gt; invoice/email/search/webhook&lt;/code>。Producer 把事件交給 broker 後，真正的業務完成要看 consumer 是否正確提交副作用。&lt;/p>
&lt;p>這篇先固定三層語意：&lt;/p>
&lt;ol>
&lt;li>Delivery semantics：訊息是否投遞與確認。&lt;/li>
&lt;li>Processing semantics：副作用是否可承受重複與部分失敗。&lt;/li>
&lt;li>Recovery semantics：故障後是否可重播並恢復一致。&lt;/li>
&lt;/ol>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">ack/nack&lt;/a> 成功只代表 delivery 進度，不代表發票與通知已完成。&lt;/p>
&lt;h2 id="event-contract-與相容邊界">Event Contract 與相容邊界&lt;/h2>
&lt;p>Event contract 的責任是讓 producer 與 consumer 在版本演進時仍可互通，且可被觀測與回放。&lt;/p>
&lt;p>&lt;code>order_created&lt;/code> 最小欄位：&lt;/p>
&lt;ol>
&lt;li>&lt;code>event_id&lt;/code>：全域唯一識別。&lt;/li>
&lt;li>&lt;code>schema_version&lt;/code>：事件版本。&lt;/li>
&lt;li>&lt;code>occurred_at&lt;/code>：事件發生時間。&lt;/li>
&lt;li>&lt;code>order_id&lt;/code>、&lt;code>tenant_id&lt;/code>：業務定位。&lt;/li>
&lt;li>&lt;code>idempotency_key&lt;/code>：副作用去重鍵。&lt;/li>
&lt;li>&lt;code>pii_scope&lt;/code>：敏感欄位範圍。&lt;/li>
&lt;/ol>
&lt;p>版本演進採向後相容優先：新增欄位可選、舊欄位保留窗口。schema 演進前要先確認 consumer 端 fallback 解析邏輯存在，避免切版後整批進 DLQ。&lt;/p>
&lt;h2 id="retry--dlq--quarantine">Retry / DLQ / Quarantine&lt;/h2>
&lt;p>Retry 的責任是吸收暫時性故障，不把短暫抖動升級成事故。這條路徑使用有限重試 + backoff + jitter：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>階段&lt;/th>
 &lt;th>判讀重點&lt;/th>
 &lt;th>動作&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>即時重試&lt;/td>
 &lt;td>下游短暫 timeout 或限流&lt;/td>
 &lt;td>在主通道重試少量次數&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>延遲重試&lt;/td>
 &lt;td>故障持續但可恢復&lt;/td>
 &lt;td>延長 backoff，避免重試風暴&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>DLQ 隔離&lt;/td>
 &lt;td>payload 或版本異常、長時故障&lt;/td>
 &lt;td>轉入 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/dead-letter-queue/" data-link-title="Dead-Letter Queue" data-link-desc="說明 dead-letter queue 如何隔離多次處理失敗的訊息">dead-letter queue&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Quarantine&lt;/td>
 &lt;td>同型 poison message 連續爆發&lt;/td>
 &lt;td>停主通道回放，先分群診斷&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>DLQ 的責任是隔離與診斷，不是永久儲存。重點是把異常訊息分群後對應修法，修完再定向回放。&lt;/p>
&lt;h2 id="idempotency-與-ack-timing">Idempotency 與 Ack Timing&lt;/h2>
&lt;p>Idempotency 的責任是把 at-least-once 交付轉成可接受業務結果。副作用如發票、email、webhook 都要以 &lt;code>idempotency_key&lt;/code> 做去重。&lt;/p>
&lt;p>Ack timing 的原則是「核心副作用提交後再 ack」：&lt;/p>
&lt;ol>
&lt;li>先執行副作用或落地可追蹤結果。&lt;/li>
&lt;li>成功後寫去重紀錄或 checkpoint。&lt;/li>
&lt;li>最後 ack broker。&lt;/li>
&lt;/ol>
&lt;p>先 ack 再副作用會造成資料遺失；副作用成功但去重紀錄失敗，則要由 recovery 層補償。&lt;/p>
&lt;h2 id="replay-runbook">Replay Runbook&lt;/h2>
&lt;p>Replay 的責任是故障後在可控範圍內恢復，不把修復變成第二次事故。&lt;/p>
&lt;p>這條路徑的 replay runbook：&lt;/p>
&lt;ol>
&lt;li>選定 replay window：依 &lt;code>occurred_at&lt;/code> 與 &lt;code>schema_version&lt;/code> 分段。&lt;/li>
&lt;li>Dry run：先在影子通道跑去重與下游容量驗證。&lt;/li>
&lt;li>限速回放：按 tenant 或 partition 分批，監控下游錯誤率。&lt;/li>
&lt;li>Reconciliation：對帳發票、通知、索引結果。&lt;/li>
&lt;li>Stop condition：duplicate side-effect、downstream timeout、DLQ 再爆發即停。&lt;/li>
&lt;/ol>
&lt;p>replay window 要能被明確描述與回放，不可用「重播昨天全部」這種不可驗證句子。&lt;/p>
&lt;h2 id="job-queue-的拓樸分工">Job queue 的拓樸分工&lt;/h2>
&lt;p>當背景工作同時要 &lt;em>高吞吐&lt;/em> 跟 &lt;em>快速反應&lt;/em>、單一通道模型會變成瓶頸。job queue 的擴展通常是 &lt;em>拓樸重整&lt;/em>、把不同工作類型切到不同傳遞路徑、而非單點替換。&lt;/p>
&lt;p>對應 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/slack-job-queue-kafka-redis/" data-link-title="3.C5 Slack：Job Queue 演進到 Kafka &amp;#43; Redis" data-link-desc="背景工作通道在成長期如何從單一路徑演進成組合式架構。">3.C5 Slack Job Queue 演進到 Kafka + Redis&lt;/a> — Slack 在 job queue 擴展時把工作切到不同傳遞路徑、Kafka 跟 Redis 分別承擔持久性跟即時性目標、分開治理 lag、重試跟失敗重播。&lt;/p></description><content:encoded><![CDATA[<p>Queue consumer retry 與 replay handoff 的核心責任是把 request 外副作用做成可重試、可去重、可隔離、可重播的服務流程。這篇以 <code>order_created</code> consumer 為例，示範 delivery、processing、recovery 三層語意如何交接到 evidence package、release gate 與 incident decision log。</p>
<h2 id="服務路徑與語意分層">服務路徑與語意分層</h2>
<p>這條路徑是 <code>order-service -&gt; broker -&gt; order-created-consumer -&gt; invoice/email/search/webhook</code>。Producer 把事件交給 broker 後，真正的業務完成要看 consumer 是否正確提交副作用。</p>
<p>這篇先固定三層語意：</p>
<ol>
<li>Delivery semantics：訊息是否投遞與確認。</li>
<li>Processing semantics：副作用是否可承受重複與部分失敗。</li>
<li>Recovery semantics：故障後是否可重播並恢復一致。</li>
</ol>
<p><a href="/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">ack/nack</a> 成功只代表 delivery 進度，不代表發票與通知已完成。</p>
<h2 id="event-contract-與相容邊界">Event Contract 與相容邊界</h2>
<p>Event contract 的責任是讓 producer 與 consumer 在版本演進時仍可互通，且可被觀測與回放。</p>
<p><code>order_created</code> 最小欄位：</p>
<ol>
<li><code>event_id</code>：全域唯一識別。</li>
<li><code>schema_version</code>：事件版本。</li>
<li><code>occurred_at</code>：事件發生時間。</li>
<li><code>order_id</code>、<code>tenant_id</code>：業務定位。</li>
<li><code>idempotency_key</code>：副作用去重鍵。</li>
<li><code>pii_scope</code>：敏感欄位範圍。</li>
</ol>
<p>版本演進採向後相容優先：新增欄位可選、舊欄位保留窗口。schema 演進前要先確認 consumer 端 fallback 解析邏輯存在，避免切版後整批進 DLQ。</p>
<h2 id="retry--dlq--quarantine">Retry / DLQ / Quarantine</h2>
<p>Retry 的責任是吸收暫時性故障，不把短暫抖動升級成事故。這條路徑使用有限重試 + backoff + jitter：</p>
<table>
  <thead>
      <tr>
          <th>階段</th>
          <th>判讀重點</th>
          <th>動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>即時重試</td>
          <td>下游短暫 timeout 或限流</td>
          <td>在主通道重試少量次數</td>
      </tr>
      <tr>
          <td>延遲重試</td>
          <td>故障持續但可恢復</td>
          <td>延長 backoff，避免重試風暴</td>
      </tr>
      <tr>
          <td>DLQ 隔離</td>
          <td>payload 或版本異常、長時故障</td>
          <td>轉入 <a href="/blog/backend/knowledge-cards/dead-letter-queue/" data-link-title="Dead-Letter Queue" data-link-desc="說明 dead-letter queue 如何隔離多次處理失敗的訊息">dead-letter queue</a></td>
      </tr>
      <tr>
          <td>Quarantine</td>
          <td>同型 poison message 連續爆發</td>
          <td>停主通道回放，先分群診斷</td>
      </tr>
  </tbody>
</table>
<p>DLQ 的責任是隔離與診斷，不是永久儲存。重點是把異常訊息分群後對應修法，修完再定向回放。</p>
<h2 id="idempotency-與-ack-timing">Idempotency 與 Ack Timing</h2>
<p>Idempotency 的責任是把 at-least-once 交付轉成可接受業務結果。副作用如發票、email、webhook 都要以 <code>idempotency_key</code> 做去重。</p>
<p>Ack timing 的原則是「核心副作用提交後再 ack」：</p>
<ol>
<li>先執行副作用或落地可追蹤結果。</li>
<li>成功後寫去重紀錄或 checkpoint。</li>
<li>最後 ack broker。</li>
</ol>
<p>先 ack 再副作用會造成資料遺失；副作用成功但去重紀錄失敗，則要由 recovery 層補償。</p>
<h2 id="replay-runbook">Replay Runbook</h2>
<p>Replay 的責任是故障後在可控範圍內恢復，不把修復變成第二次事故。</p>
<p>這條路徑的 replay runbook：</p>
<ol>
<li>選定 replay window：依 <code>occurred_at</code> 與 <code>schema_version</code> 分段。</li>
<li>Dry run：先在影子通道跑去重與下游容量驗證。</li>
<li>限速回放：按 tenant 或 partition 分批，監控下游錯誤率。</li>
<li>Reconciliation：對帳發票、通知、索引結果。</li>
<li>Stop condition：duplicate side-effect、downstream timeout、DLQ 再爆發即停。</li>
</ol>
<p>replay window 要能被明確描述與回放，不可用「重播昨天全部」這種不可驗證句子。</p>
<h2 id="job-queue-的拓樸分工">Job queue 的拓樸分工</h2>
<p>當背景工作同時要 <em>高吞吐</em> 跟 <em>快速反應</em>、單一通道模型會變成瓶頸。job queue 的擴展通常是 <em>拓樸重整</em>、把不同工作類型切到不同傳遞路徑、而非單點替換。</p>
<p>對應 <a href="/blog/backend/03-message-queue/cases/slack-job-queue-kafka-redis/" data-link-title="3.C5 Slack：Job Queue 演進到 Kafka &#43; Redis" data-link-desc="背景工作通道在成長期如何從單一路徑演進成組合式架構。">3.C5 Slack Job Queue 演進到 Kafka + Redis</a> — Slack 在 job queue 擴展時把工作切到不同傳遞路徑、Kafka 跟 Redis 分別承擔持久性跟即時性目標、分開治理 lag、重試跟失敗重播。</p>
<p><strong>拓樸分工的判讀</strong>（基於 Slack case 揭露的雙通道分工方向）：</p>
<ul>
<li><strong>持久性主導的 job</strong>（發票、付款通知、合規記錄）→ Kafka / 持久 queue、保證 at-least-once</li>
<li><strong>即時性主導的 job</strong>（線上提醒、playback control、UI 更新）→ Redis / 輕量 queue、low latency</li>
</ul>
<p>設計含義：同一 consumer 應專注單一目標（高吞吐 / 即時 / 持久擇一）、其他目標拆到對應路徑。對應 <a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer-design 三個工程議題鐵三角</a> — idempotency / 重播流程 / 下游承載能力是 consumer 內部設計、拓樸分工是 <em>跨 consumer</em> 的責任拆分、兩者互補。</p>
<h2 id="job-queue-規模差異的治理重點">Job queue 規模差異的治理重點</h2>
<p>不同規模服務的 job queue 治理問題差異大、SSoT 在本章。對應 <a href="/blog/backend/03-message-queue/cases/contrast-queue-model-by-scale/" data-link-title="3.C10 對照：規模差異下的佇列模型" data-link-desc="同一 queue 模型在不同規模下的治理與失敗邊界差異。">3.C10 對照：規模差異下的佇列模型</a>：</p>
<ul>
<li><strong>小型服務</strong>：優先用 managed queue（SQS / Pub/Sub）、運維成本最低。最容易忽略的是語意邊界（重試次數、死信規則、重播責任）、規模一上來會出現資料重複與漏處理。<strong>升級訊號</strong>：team 數超 3-5 個、各自寫 consumer 開始出現 idempotency 不一致、進中型階段</li>
<li><strong>中型服務</strong>：常見問題是 lag 與 DLQ 長期累積。原因是 consumer idempotency + 重播流程 + 下游承載能力沒一起設計。對應前段 Job queue 拓樸分工。<strong>升級訊號</strong>：DLQ 累積速度高於排空速度連續 7 天、單一 tenant 流量尖峰拖垮其他 tenant、進大型階段</li>
<li><strong>大型服務</strong>：需要處理跨租戶跟跨區壓力。單叢集思維會讓任何一類流量尖峰拖垮整體。對應 <a href="/blog/backend/03-message-queue/cases/linkedin-kafka-tiered-clusters/" data-link-title="3.C4 LinkedIn：Kafka 分層叢集治理" data-link-desc="Kafka 從單叢集走向 tiered clusters 的轉換案例。">3.C4 LinkedIn Tiered Clusters</a> 跟 <a href="/blog/backend/03-message-queue/broker-basics/" data-link-title="3.1 broker 基礎與投遞模型" data-link-desc="先理解 broker、queue、consumer 與 delivery semantics">3.1 broker-basics 分層治理平台</a>、重點從「怎麼送訊息」轉成「怎麼隔離失敗」</li>
</ul>
<p>判讀重點：當前服務規模決定要處理的 <em>主要</em> 問題。規模尚小的服務硬上 multi-tenant 隔離治理屬過度設計、規模化服務應同時考慮 broker 容量是否充足跟隔離邊界是否完整。判斷自己在哪個階段、看 <em>升級訊號</em> 對應的指標。</p>
<h2 id="evidence-package">Evidence Package</h2>
<p>Queue evidence 的責任是證明「投遞可達」與「處理可恢復」兩者同時成立。</p>
<table>
  <thead>
      <tr>
          <th>欄位</th>
          <th>內容</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Source</td>
          <td>broker metric、consumer metric、DLQ log、reconciliation query</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/time-range/" data-link-title="Time Range" data-link-desc="說明證據、查詢與事故判讀如何用時間窗保留可回放上下文">Time range</a></td>
          <td>retry/replay 批次窗口</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/query-link/" data-link-title="Query Link" data-link-desc="說明證據包如何保存可重跑查詢入口，而不是只保留截圖或口頭結論">Query link</a></td>
          <td>lag、retry count、DLQ count、duplicate side-effect、throughput</td>
      </tr>
      <tr>
          <td>Owner</td>
          <td>queue owner、consumer owner、downstream owner</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/data-quality/" data-link-title="Data Quality" data-link-desc="說明證據欄位如何標示 completeness、freshness、sampling 與資料限制">Data quality</a></td>
          <td>指標延遲、抽樣缺口、對帳覆蓋率</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/confidence/" data-link-title="Confidence" data-link-desc="說明證據包如何標示 confirmed、suspected 或 needs follow-up 的判讀信心">Confidence</a></td>
          <td>confirmed / suspected / needs follow-up</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/known-gap/" data-link-title="Known Gap" data-link-desc="說明證據包如何明確保存已知缺口，避免下游高估證據完整性">Known gap</a></td>
          <td>尚未驗證之下游 webhook 供應商、低流量 tenant replay</td>
      </tr>
  </tbody>
</table>
<p>這份 evidence 要對齊 <a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">4.20 Observability Evidence Package</a> 與 <a href="/blog/backend/06-reliability/verification-evidence-handoff/" data-link-title="6.23 Verification Evidence Handoff" data-link-desc="把 SLO、load、chaos、DR 與 readiness 結果包成 release / incident 可用證據">6.23 Verification Evidence Handoff</a>。</p>
<h2 id="release-gate">Release Gate</h2>
<p>Queue release gate 的責任是決定是否擴大回放或恢復主通道，而不是只看單一 lag 指標。</p>
<table>
  <thead>
      <tr>
          <th>Gate 欄位</th>
          <th>最小內容</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/gate-decision/" data-link-title="Gate Decision" data-link-desc="說明 release gate 如何把證據轉成放行、暫停、回退或補證據的決策">Gate decision</a></td>
          <td>放行下一批 replay、維持觀察、暫停 consumer</td>
      </tr>
      <tr>
          <td>Checks</td>
          <td>idempotency proof、DLQ drain 結果、下游容量、duplicate 比例</td>
      </tr>
      <tr>
          <td>Stop condition</td>
          <td>retry storm、DLQ 再爆發、下游錯誤率超門檻</td>
      </tr>
      <tr>
          <td>Rollback window</td>
          <td>replay 可中止窗口、主通道可回切時間</td>
      </tr>
      <tr>
          <td>Owner</td>
          <td>queue on-call、business owner</td>
      </tr>
  </tbody>
</table>
<p>這組欄位對齊 <a href="/blog/backend/06-reliability/idempotency-replay/" data-link-title="6.12 Idempotency 與 Replay 驗證" data-link-desc="把重試、重播與冪等性從口頭約定變成可驗證屬性">6.12 Idempotency 與 Replay 驗證</a> 與 <a href="/blog/backend/06-reliability/release-gate/" data-link-title="6.8 Release Gate 與變更節奏" data-link-desc="把驗證、migration、相容性納入放行判準">6.8 Release Gate</a>。</p>
<h2 id="incident-decision-log">Incident Decision Log</h2>
<p>pause consumer、drain DLQ、啟動 replay、停止 replay、執行補償都屬事故決策，需寫入 <a href="/blog/backend/08-incident-response/incident-decision-log/" data-link-title="8.19 Incident Decision Log" data-link-desc="把事中假設、決策、證據、回退條件與責任人留下可復盤紀錄">8.19 Incident Decision Log</a>。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nt">incident_decision</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w">  </span><span class="nt">timestamp</span><span class="p">:</span><span class="w"> </span><span class="ld">2026-05-11T13:18:00Z</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">  </span><span class="nt">decision</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;pause invoice consumer and start scoped replay for tenant A&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">  </span><span class="nt">context</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;duplicate invoices increased after consumer version rollout&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">  </span><span class="nt">evidence</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">    </span>- <span class="nt">query</span><span class="p">:</span><span class="w"> </span><span class="l">duplicate_invoice_ratio_tenant_a</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">    </span>- <span class="nt">query</span><span class="p">:</span><span class="w"> </span><span class="l">dlq_events_by_schema_version</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">  </span><span class="nt">owner</span><span class="p">:</span><span class="w"> </span><span class="l">queue-incident-commander</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">  </span><span class="nt">expected_effect</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;stop duplicate side effects and restore invoice consistency&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">  </span><span class="nt">rollback_condition</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;duplicate ratio does not decrease within two replay batches&#34;</span></span></span></code></pre></div><h2 id="case-write-back-與邊界">Case Write-back 與邊界</h2>
<p>這篇回寫對齊 <a href="/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9 反例</a>，重點是切換時語意分層混淆導致 delivery 成功但業務結果失真。</p>
<p>這篇不處理同步 API latency、cache TTL 或 deployment drain。若風險在同步交易壓力、快取失效或流量切換，路由到 <a href="/blog/backend/04-observability/checkout-api-evidence-package/" data-link-title="4.22 Checkout API Evidence Package 實作示範" data-link-desc="用 checkout 路徑示範 evidence package 如何交接給 release gate 與 incident decision。">4.22 Checkout API Evidence Package</a>、<a href="/blog/backend/02-cache-redis/cache-migration-stampede-rollback/" data-link-title="2.9 Cache Migration 與 Stampede Rollback（實作示範）" data-link-desc="以商品詳情與價格快取示範 cache migration 如何交付 evidence package、release gate 與 incident decision log。">2.9 Cache Migration 與 Stampede Rollback</a> 或 <a href="/blog/backend/05-deployment-platform/deployment-rollout-drain-rollback/" data-link-title="5.8 Deployment Rollout with Drain and Rollback（實作示範）" data-link-desc="以 checkout service 示範部署切換如何交付 canary evidence、drain signal、release gate 與 incident decision log。">5.8 Deployment Rollout with Drain and Rollback</a>。</p>
]]></content:encoded></item></channel></rss>