<?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>Retry on Tarragon</title><link>https://tarrragon.github.io/blog/tags/retry/</link><description>Recent content in Retry on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Fri, 19 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/retry/index.xml" rel="self" type="application/rss+xml"/><item><title>Retry 機制 UX</title><link>https://tarrragon.github.io/blog/ux-design/04-error-recovery/retry-mechanism-ux/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/04-error-recovery/retry-mechanism-ux/</guid><description>&lt;p>重試是錯誤恢復的第一手段。重試策略的選擇取決於兩個因素：失敗是否可能自行恢復（暫時性網路中斷 vs 伺服器不存在），以及使用者是否願意等待（前景操作 vs 背景同步）。&lt;/p>
&lt;h2 id="自動重試-vs-手動重試">自動重試 vs 手動重試&lt;/h2>
&lt;h3 id="自動重試">自動重試&lt;/h3>
&lt;p>系統在失敗後自動重新嘗試，使用者不需要手動操作。適合背景操作（資料同步、事件上報、心跳檢查）和暫時性失敗（網路閃斷、server 短暫過載）。&lt;/p>
&lt;p>自動重試的 UX 要求：使用者需要知道系統正在重試。「連線中斷，正在重新連線（第 2 次嘗試）」比靜默重試更透明。如果使用者不知道系統在重試，靜默的等待會被解讀為「系統卡住了」。&lt;/p>
&lt;p>自動重試必須有上限。無限重試在不可恢復的失敗場景中（伺服器已關閉、認證已過期）浪費資源和電量，且使用者無法察覺問題。&lt;/p>
&lt;h3 id="手動重試">手動重試&lt;/h3>
&lt;p>使用者點擊「重試」按鈕觸發重新嘗試。適合前景操作（使用者主動發起的連線、提交、搜尋）和需要使用者確認意圖的場景。&lt;/p>
&lt;p>手動重試的 UX 要求：重試按鈕在 error 畫面上明顯可見，旁邊有退出路徑（返回按鈕）。使用者可以選擇重試或放棄。&lt;/p>
&lt;h3 id="混合策略">混合策略&lt;/h3>
&lt;p>先自動重試 N 次，失敗後切換到手動重試。這是連線類操作的常見模式 — WebSocket 斷線後自動重連 3 次，3 次都失敗後顯示「連線失敗」+ 手動重連按鈕。&lt;/p>
&lt;h2 id="重試間隔策略">重試間隔策略&lt;/h2>
&lt;h3 id="立即重試">立即重試&lt;/h3>
&lt;p>失敗後立即重新嘗試，中間沒有等待。適合極短暫的瞬態失敗（DNS 解析偶發失敗、TCP 連線被 reset）。&lt;/p>
&lt;p>立即重試的風險是在 server 過載時加劇問題 — 多個 client 同時立即重試產生 thundering herd 效應。&lt;/p>
&lt;h3 id="固定間隔重試">固定間隔重試&lt;/h3>
&lt;p>每次重試間隔固定時間（例如每 5 秒重試一次）。簡單可預測，使用者能估算等待時間。&lt;/p>
&lt;h3 id="指數退避exponential-backoff">指數退避（exponential backoff）&lt;/h3>
&lt;p>每次重試的間隔加倍。第一次 1 秒、第二次 2 秒、第三次 4 秒、第四次 8 秒。加上隨機抖動（jitter）避免多個 client 同步重試。&lt;/p>
&lt;p>指數退避適合 server 端過載或暫時不可用的場景。間隔越來越長給 server 恢復的時間，同時減少 client 的資源消耗。&lt;/p>
&lt;p>指數退避的 UX 挑戰是使用者感知到的等待越來越長。第四次重試等 8 秒時使用者可能已經失去耐心。解法是顯示倒數計時（「12 秒後自動重試」）和手動重試按鈕（使用者可以跳過等待立即重試）。&lt;/p>
&lt;h2 id="重試狀態的-ui-呈現">重試狀態的 UI 呈現&lt;/h2>
&lt;p>使用者需要知道三件事：系統正在重試、已經重試了幾次、下一次重試在什麼時候。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">連線失敗，正在重新連線...
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">第 2 次嘗試（共 5 次上限）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">下次重試：8 秒後 [立即重試] [返回首頁]&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>重試達到上限後，UI 從「重試中」切換到「失敗」狀態，顯示手動重試和退出路徑。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>部分功能不可用的降級設計 → &lt;a href="https://tarrragon.github.io/blog/ux-design/04-error-recovery/degraded-mode-design/" data-link-title="Degraded mode 設計" data-link-desc="部分功能不可用時怎麼告知使用者 — 靜默隱藏 vs 明確標示 vs 替代方案的設計取捨">Degraded mode 設計&lt;/a>&lt;/li>
&lt;li>重試循環的逃生口 → &lt;a href="https://tarrragon.github.io/blog/ux-design/04-error-recovery/error-loop-escape/" data-link-title="error → retry → error 循環的逃生口設計" data-link-desc="當重試持續失敗時，使用者需要第二條路 — 逃生口設計讓使用者能離開失敗循環而非被困住">error → retry → error 循環的逃生口&lt;/a>&lt;/li>
&lt;li>Gate 失敗的 fallback → &lt;a href="https://tarrragon.github.io/blog/ux-design/02-gate-fallback/" data-link-title="模組二：Gate 與 Fallback 設計" data-link-desc="Biometric / Network / Auth / Permission — 每個 gate 成功時做什麼、失敗時做什麼、使用者不知道發生什麼時做什麼">ux-design 模組二 Gate 與 Fallback&lt;/a>&lt;/li>
&lt;li>Server 端的限速機制（影響 retry 策略設計）→ &lt;a href="https://tarrragon.github.io/blog/devops/03-traffic-management/" data-link-title="模組三：流量管控" data-link-desc="收到的流量超過處理能力時怎麼辦 — 背壓、rate limit、熔斷、bulkhead 四種防護機制">DevOps 流量管控&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>重試是錯誤恢復的第一手段。重試策略的選擇取決於兩個因素：失敗是否可能自行恢復（暫時性網路中斷 vs 伺服器不存在），以及使用者是否願意等待（前景操作 vs 背景同步）。</p>
<h2 id="自動重試-vs-手動重試">自動重試 vs 手動重試</h2>
<h3 id="自動重試">自動重試</h3>
<p>系統在失敗後自動重新嘗試，使用者不需要手動操作。適合背景操作（資料同步、事件上報、心跳檢查）和暫時性失敗（網路閃斷、server 短暫過載）。</p>
<p>自動重試的 UX 要求：使用者需要知道系統正在重試。「連線中斷，正在重新連線（第 2 次嘗試）」比靜默重試更透明。如果使用者不知道系統在重試，靜默的等待會被解讀為「系統卡住了」。</p>
<p>自動重試必須有上限。無限重試在不可恢復的失敗場景中（伺服器已關閉、認證已過期）浪費資源和電量，且使用者無法察覺問題。</p>
<h3 id="手動重試">手動重試</h3>
<p>使用者點擊「重試」按鈕觸發重新嘗試。適合前景操作（使用者主動發起的連線、提交、搜尋）和需要使用者確認意圖的場景。</p>
<p>手動重試的 UX 要求：重試按鈕在 error 畫面上明顯可見，旁邊有退出路徑（返回按鈕）。使用者可以選擇重試或放棄。</p>
<h3 id="混合策略">混合策略</h3>
<p>先自動重試 N 次，失敗後切換到手動重試。這是連線類操作的常見模式 — WebSocket 斷線後自動重連 3 次，3 次都失敗後顯示「連線失敗」+ 手動重連按鈕。</p>
<h2 id="重試間隔策略">重試間隔策略</h2>
<h3 id="立即重試">立即重試</h3>
<p>失敗後立即重新嘗試，中間沒有等待。適合極短暫的瞬態失敗（DNS 解析偶發失敗、TCP 連線被 reset）。</p>
<p>立即重試的風險是在 server 過載時加劇問題 — 多個 client 同時立即重試產生 thundering herd 效應。</p>
<h3 id="固定間隔重試">固定間隔重試</h3>
<p>每次重試間隔固定時間（例如每 5 秒重試一次）。簡單可預測，使用者能估算等待時間。</p>
<h3 id="指數退避exponential-backoff">指數退避（exponential backoff）</h3>
<p>每次重試的間隔加倍。第一次 1 秒、第二次 2 秒、第三次 4 秒、第四次 8 秒。加上隨機抖動（jitter）避免多個 client 同步重試。</p>
<p>指數退避適合 server 端過載或暫時不可用的場景。間隔越來越長給 server 恢復的時間，同時減少 client 的資源消耗。</p>
<p>指數退避的 UX 挑戰是使用者感知到的等待越來越長。第四次重試等 8 秒時使用者可能已經失去耐心。解法是顯示倒數計時（「12 秒後自動重試」）和手動重試按鈕（使用者可以跳過等待立即重試）。</p>
<h2 id="重試狀態的-ui-呈現">重試狀態的 UI 呈現</h2>
<p>使用者需要知道三件事：系統正在重試、已經重試了幾次、下一次重試在什麼時候。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">連線失敗，正在重新連線...
</span></span><span class="line"><span class="ln">2</span><span class="cl">第 2 次嘗試（共 5 次上限）
</span></span><span class="line"><span class="ln">3</span><span class="cl">下次重試：8 秒後 [立即重試] [返回首頁]</span></span></code></pre></div><p>重試達到上限後，UI 從「重試中」切換到「失敗」狀態，顯示手動重試和退出路徑。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>部分功能不可用的降級設計 → <a href="/blog/ux-design/04-error-recovery/degraded-mode-design/" data-link-title="Degraded mode 設計" data-link-desc="部分功能不可用時怎麼告知使用者 — 靜默隱藏 vs 明確標示 vs 替代方案的設計取捨">Degraded mode 設計</a></li>
<li>重試循環的逃生口 → <a href="/blog/ux-design/04-error-recovery/error-loop-escape/" data-link-title="error → retry → error 循環的逃生口設計" data-link-desc="當重試持續失敗時，使用者需要第二條路 — 逃生口設計讓使用者能離開失敗循環而非被困住">error → retry → error 循環的逃生口</a></li>
<li>Gate 失敗的 fallback → <a href="/blog/ux-design/02-gate-fallback/" data-link-title="模組二：Gate 與 Fallback 設計" data-link-desc="Biometric / Network / Auth / Permission — 每個 gate 成功時做什麼、失敗時做什麼、使用者不知道發生什麼時做什麼">ux-design 模組二 Gate 與 Fallback</a></li>
<li>Server 端的限速機制（影響 retry 策略設計）→ <a href="/blog/devops/03-traffic-management/" data-link-title="模組三：流量管控" data-link-desc="收到的流量超過處理能力時怎麼辦 — 背壓、rate limit、熔斷、bulkhead 四種防護機制">DevOps 流量管控</a></li>
</ul>
]]></content:encoded></item><item><title>模組四：錯誤狀態與回復</title><link>https://tarrragon.github.io/blog/ux-design/04-error-recovery/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/04-error-recovery/</guid><description>&lt;p>回答「出錯時使用者能做什麼」。&lt;/p>
&lt;h2 id="待寫章節">待寫章節&lt;/h2>
&lt;ul>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 錯誤訊息撰寫原則（使用者能讀懂 + 能行動）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Retry 機制 UX（自動 vs 手動 / 指數退避 vs 立即重試）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Degraded mode 設計（部分功能不可用時怎麼告知）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> error → retry → error 循環的逃生口設計&lt;/li>
&lt;/ul>
&lt;h2 id="跨分類引用">跨分類引用&lt;/h2>
&lt;ul>
&lt;li>← &lt;a href="https://tarrragon.github.io/blog/ux-design/01-screen-state-machine/" data-link-title="模組一：畫面狀態機設計" data-link-desc="畫面狀態矩陣（顯示 / 操作 / 進入 / 退出）— 退出路徑為空 = UX 死胡同">ux-design 模組一&lt;/a>：error 狀態在狀態矩陣中的退出路徑&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/testing/01-test-strategy-layers/" data-link-title="模組一：測試策略分層" data-link-desc="Unit / Protocol Integration / Screen State 三層測試各自的職責、盲區和判斷原則">testing 模組一&lt;/a>：error 回復路徑需要 widget test 覆蓋&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/monitoring/01-mental-model/" data-link-title="模組一：監控心智模型" data-link-desc="四類事件（event / error / metric / lifecycle）的分類與收集策略">monitoring 模組一&lt;/a>：error 事件是四類事件之一&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>回答「出錯時使用者能做什麼」。</p>
<h2 id="待寫章節">待寫章節</h2>
<ul>
<li><input checked="" disabled="" type="checkbox"> 錯誤訊息撰寫原則（使用者能讀懂 + 能行動）</li>
<li><input checked="" disabled="" type="checkbox"> Retry 機制 UX（自動 vs 手動 / 指數退避 vs 立即重試）</li>
<li><input checked="" disabled="" type="checkbox"> Degraded mode 設計（部分功能不可用時怎麼告知）</li>
<li><input checked="" disabled="" type="checkbox"> error → retry → error 循環的逃生口設計</li>
</ul>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>← <a href="/blog/ux-design/01-screen-state-machine/" data-link-title="模組一：畫面狀態機設計" data-link-desc="畫面狀態矩陣（顯示 / 操作 / 進入 / 退出）— 退出路徑為空 = UX 死胡同">ux-design 模組一</a>：error 狀態在狀態矩陣中的退出路徑</li>
<li>→ <a href="/blog/testing/01-test-strategy-layers/" data-link-title="模組一：測試策略分層" data-link-desc="Unit / Protocol Integration / Screen State 三層測試各自的職責、盲區和判斷原則">testing 模組一</a>：error 回復路徑需要 widget test 覆蓋</li>
<li>→ <a href="/blog/monitoring/01-mental-model/" data-link-title="模組一：監控心智模型" data-link-desc="四類事件（event / error / metric / lifecycle）的分類與收集策略">monitoring 模組一</a>：error 事件是四類事件之一</li>
</ul>
]]></content:encoded></item><item><title>離線 buffer 與重試</title><link>https://tarrragon.github.io/blog/monitoring/03-sdk-design/offline-buffer/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/03-sdk-design/offline-buffer/</guid><description>&lt;p>離線 buffer 處理的是「事件產生時網路不可用」的場景。記憶體 buffer 有容量上限，離線時間超過 buffer 容量時需要決策：丟棄舊事件、持久化到本地儲存、或兩者混合。每種策略有不同的複雜度和資料保留量的取捨。&lt;/p>
&lt;h2 id="三種策略">三種策略&lt;/h2>
&lt;h3 id="fifo-丟棄最簡單">FIFO 丟棄（最簡單）&lt;/h3>
&lt;p>Buffer 滿時丟棄最舊的事件，保留最新的。整個 buffer 在記憶體中，不做本地 persistence。&lt;/p>
&lt;p>優點：實作最簡單（array + 容量檢查），不需要檔案系統存取，不增加磁碟 I/O。&lt;/p>
&lt;p>代價：離線超過 buffer 容量時，較舊的事件永久遺失。如果離線 30 分鐘、buffer 容量 200 筆、事件產生速率每分鐘 10 筆，前 100 筆（前 10 分鐘）的事件被丟棄。&lt;/p>
&lt;p>適合場景：自用工具（離線場景少、遺失部分事件影響低）、SDK 初期版本（先用最簡單的策略上線）。&lt;/p>
&lt;h3 id="本地-persistence最完整">本地 persistence（最完整）&lt;/h3>
&lt;p>Buffer 滿時把事件寫入本地檔案（SQLite、JSONL 檔案、SharedPreferences / UserDefaults）。網路恢復後從本地檔案讀取並補發。&lt;/p>
&lt;p>優點：離線期間的事件不會遺失（在本地儲存容量內）。&lt;/p>
&lt;p>代價：實作複雜度高 — 需要處理檔案讀寫、並發存取（多執行緒安全）、本地儲存容量管理（磁碟空間上限）、補發時的去重（同一筆事件可能已在記憶體 buffer 中被 flush 過）。&lt;/p>
&lt;p>適合場景：商業產品（使用者在地鐵、電梯、飛航模式下使用）、離線時間長且事件不可遺失的需求。&lt;/p>
&lt;h3 id="混合策略">混合策略&lt;/h3>
&lt;p>記憶體 buffer 處理正常情況和短暫離線。離線超過記憶體 buffer 容量時，溢出的事件寫入本地檔案。網路恢復後先 flush 記憶體 buffer（最新事件），再補發本地檔案中的事件（較舊事件）。&lt;/p>
&lt;p>混合策略的實作複雜度介於兩者之間。本地檔案只在溢出時使用，正常情況下不產生磁碟 I/O。&lt;/p>
&lt;h2 id="恢復後補發">恢復後補發&lt;/h2>
&lt;p>網路恢復後補發離線期間累積的事件，需要處理三個問題：&lt;/p>
&lt;h3 id="補發順序">補發順序&lt;/h3>
&lt;p>離線事件按 timestamp 順序補發，保持事件的時間順序。Collector 端收到的事件 timestamp 可能比當前時間早數小時 — 這是正常的離線補發，collector 應該根據事件的 timestamp 處理，不依賴收到時間。&lt;/p>
&lt;h3 id="補發速率">補發速率&lt;/h3>
&lt;p>一次送出大量離線事件可能讓 collector 過載。分批補發（每批 50-100 筆，間隔 1-2 秒），讓 collector 有時間處理。&lt;/p>
&lt;h3 id="去重">去重&lt;/h3>
&lt;p>同一筆事件可能同時存在於記憶體 buffer 和本地檔案中（寫入本地檔案時 buffer 中也有一份）。Collector 端用事件的唯一識別（timestamp + session_id + name 的組合，或 SDK 產生的 event_id UUID）做去重。&lt;/p>
&lt;h2 id="本地儲存容量管理">本地儲存容量管理&lt;/h2>
&lt;p>本地 persistence 需要設定磁碟使用上限。上限取決於事件大小和保留時間。&lt;/p>
&lt;p>以平均每筆事件 500 bytes 估算：&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>1 MB&lt;/td>
 &lt;td>~2,000&lt;/td>
 &lt;td>約 3 小時（每分鐘 10 筆）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>10 MB&lt;/td>
 &lt;td>~20,000&lt;/td>
 &lt;td>約 33 小時&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>50 MB&lt;/td>
 &lt;td>~100,000&lt;/td>
 &lt;td>約 7 天&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>自用工具 1 MB 足夠（離線場景少）。行動 app 10-50 MB 合理（使用者可能整天離線）。超過上限時用 FIFO 丟棄最舊的本地檔案。&lt;/p>
&lt;h2 id="各平台的本地儲存路徑">各平台的本地儲存路徑&lt;/h2>
&lt;p>本地 persistence 的檔案路徑和格式因平台而異。MVP 階段全用記憶體 FIFO（最簡單策略），本地 persistence 標為第二階段。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>平台&lt;/th>
 &lt;th>建議路徑&lt;/th>
 &lt;th>檔案格式&lt;/th>
 &lt;th>備註&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Flutter&lt;/td>
 &lt;td>&lt;code>getApplicationSupportDirectory()&lt;/code>&lt;/td>
 &lt;td>JSONL&lt;/td>
 &lt;td>不會被 iCloud 備份（和 Documents 不同）、不會被系統自動清理&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Python&lt;/td>
 &lt;td>&lt;code>~/.cache/monitor/&lt;/code> 或 &lt;code>platformdirs.user_cache_dir('monitor')&lt;/code>&lt;/td>
 &lt;td>JSONL&lt;/td>
 &lt;td>遵循 XDG 標準、&lt;code>platformdirs&lt;/code> 套件處理跨平台&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>JS/Web&lt;/td>
 &lt;td>&lt;code>localStorage&lt;/code> 或 &lt;code>IndexedDB&lt;/code>&lt;/td>
 &lt;td>JSON&lt;/td>
 &lt;td>localStorage 有 5MB 限制、IndexedDB 更大但 API 較複雜&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>App 被強制終止時（iOS 的 &lt;code>kill&lt;/code>、Android 的 process death），記憶體 buffer 中未 flush 的事件會遺失。Flutter 的 &lt;code>AppLifecycleState.detached&lt;/code> 不保證有時間執行 flush。接受這個遺失 — 強制終止是極端情境，下次啟動時 SDK 重新開始收集。&lt;/p></description><content:encoded><![CDATA[<p>離線 buffer 處理的是「事件產生時網路不可用」的場景。記憶體 buffer 有容量上限，離線時間超過 buffer 容量時需要決策：丟棄舊事件、持久化到本地儲存、或兩者混合。每種策略有不同的複雜度和資料保留量的取捨。</p>
<h2 id="三種策略">三種策略</h2>
<h3 id="fifo-丟棄最簡單">FIFO 丟棄（最簡單）</h3>
<p>Buffer 滿時丟棄最舊的事件，保留最新的。整個 buffer 在記憶體中，不做本地 persistence。</p>
<p>優點：實作最簡單（array + 容量檢查），不需要檔案系統存取，不增加磁碟 I/O。</p>
<p>代價：離線超過 buffer 容量時，較舊的事件永久遺失。如果離線 30 分鐘、buffer 容量 200 筆、事件產生速率每分鐘 10 筆，前 100 筆（前 10 分鐘）的事件被丟棄。</p>
<p>適合場景：自用工具（離線場景少、遺失部分事件影響低）、SDK 初期版本（先用最簡單的策略上線）。</p>
<h3 id="本地-persistence最完整">本地 persistence（最完整）</h3>
<p>Buffer 滿時把事件寫入本地檔案（SQLite、JSONL 檔案、SharedPreferences / UserDefaults）。網路恢復後從本地檔案讀取並補發。</p>
<p>優點：離線期間的事件不會遺失（在本地儲存容量內）。</p>
<p>代價：實作複雜度高 — 需要處理檔案讀寫、並發存取（多執行緒安全）、本地儲存容量管理（磁碟空間上限）、補發時的去重（同一筆事件可能已在記憶體 buffer 中被 flush 過）。</p>
<p>適合場景：商業產品（使用者在地鐵、電梯、飛航模式下使用）、離線時間長且事件不可遺失的需求。</p>
<h3 id="混合策略">混合策略</h3>
<p>記憶體 buffer 處理正常情況和短暫離線。離線超過記憶體 buffer 容量時，溢出的事件寫入本地檔案。網路恢復後先 flush 記憶體 buffer（最新事件），再補發本地檔案中的事件（較舊事件）。</p>
<p>混合策略的實作複雜度介於兩者之間。本地檔案只在溢出時使用，正常情況下不產生磁碟 I/O。</p>
<h2 id="恢復後補發">恢復後補發</h2>
<p>網路恢復後補發離線期間累積的事件，需要處理三個問題：</p>
<h3 id="補發順序">補發順序</h3>
<p>離線事件按 timestamp 順序補發，保持事件的時間順序。Collector 端收到的事件 timestamp 可能比當前時間早數小時 — 這是正常的離線補發，collector 應該根據事件的 timestamp 處理，不依賴收到時間。</p>
<h3 id="補發速率">補發速率</h3>
<p>一次送出大量離線事件可能讓 collector 過載。分批補發（每批 50-100 筆，間隔 1-2 秒），讓 collector 有時間處理。</p>
<h3 id="去重">去重</h3>
<p>同一筆事件可能同時存在於記憶體 buffer 和本地檔案中（寫入本地檔案時 buffer 中也有一份）。Collector 端用事件的唯一識別（timestamp + session_id + name 的組合，或 SDK 產生的 event_id UUID）做去重。</p>
<h2 id="本地儲存容量管理">本地儲存容量管理</h2>
<p>本地 persistence 需要設定磁碟使用上限。上限取決於事件大小和保留時間。</p>
<p>以平均每筆事件 500 bytes 估算：</p>
<table>
  <thead>
      <tr>
          <th>上限</th>
          <th>可儲存事件數</th>
          <th>備註</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1 MB</td>
          <td>~2,000</td>
          <td>約 3 小時（每分鐘 10 筆）</td>
      </tr>
      <tr>
          <td>10 MB</td>
          <td>~20,000</td>
          <td>約 33 小時</td>
      </tr>
      <tr>
          <td>50 MB</td>
          <td>~100,000</td>
          <td>約 7 天</td>
      </tr>
  </tbody>
</table>
<p>自用工具 1 MB 足夠（離線場景少）。行動 app 10-50 MB 合理（使用者可能整天離線）。超過上限時用 FIFO 丟棄最舊的本地檔案。</p>
<h2 id="各平台的本地儲存路徑">各平台的本地儲存路徑</h2>
<p>本地 persistence 的檔案路徑和格式因平台而異。MVP 階段全用記憶體 FIFO（最簡單策略），本地 persistence 標為第二階段。</p>
<table>
  <thead>
      <tr>
          <th>平台</th>
          <th>建議路徑</th>
          <th>檔案格式</th>
          <th>備註</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Flutter</td>
          <td><code>getApplicationSupportDirectory()</code></td>
          <td>JSONL</td>
          <td>不會被 iCloud 備份（和 Documents 不同）、不會被系統自動清理</td>
      </tr>
      <tr>
          <td>Python</td>
          <td><code>~/.cache/monitor/</code> 或 <code>platformdirs.user_cache_dir('monitor')</code></td>
          <td>JSONL</td>
          <td>遵循 XDG 標準、<code>platformdirs</code> 套件處理跨平台</td>
      </tr>
      <tr>
          <td>JS/Web</td>
          <td><code>localStorage</code> 或 <code>IndexedDB</code></td>
          <td>JSON</td>
          <td>localStorage 有 5MB 限制、IndexedDB 更大但 API 較複雜</td>
      </tr>
  </tbody>
</table>
<p>App 被強制終止時（iOS 的 <code>kill</code>、Android 的 process death），記憶體 buffer 中未 flush 的事件會遺失。Flutter 的 <code>AppLifecycleState.detached</code> 不保證有時間執行 flush。接受這個遺失 — 強制終止是極端情境，下次啟動時 SDK 重新開始收集。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>攢批送出策略 → <a href="/blog/monitoring/03-sdk-design/batch-flush/" data-link-title="攢批送出策略" data-link-desc="flush interval / buffer size / flush on close 三個控制點決定事件何時離開 SDK — 平衡即時性和網路效率">攢批送出策略</a></li>
<li>SDK 端的資料脫敏 → <a href="/blog/monitoring/03-sdk-design/redaction-helper/" data-link-title="SDK redaction helper" data-link-desc="在事件離開 SDK 前移除敏感資訊 — 預設 redaction rule 處理常見 pattern，自訂 rule 處理業務特定的 secret">SDK redaction helper</a></li>
<li>Collector 端如何處理補發事件 → <a href="/blog/monitoring/04-collector/" data-link-title="模組四：Collector 設計" data-link-desc="收 → 驗 → 存 → 查 → 觸發的完整鏈路 — Go 單一 binary、可插拔 Storage Backend、rule engine">模組四 Collector 設計</a></li>
<li>從 SDK 到 storage 的端到端資料損失地圖 → <a href="/blog/monitoring/04-collector/data-integrity/" data-link-title="端到端資料完整性" data-link-desc="從 SDK 到 storage 的資料損失地圖 — 每個環節的損失類型、控制策略、完整性指標、被自己 SDK DDoS 的防護">端到端資料完整性</a></li>
</ul>
]]></content:encoded></item><item><title>RabbitMQ DLQ 與分層 retry：別把失敗訊息 requeue 回隊首</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/rabbitmq/dlq-retry-escalation/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/rabbitmq/dlq-retry-escalation/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/rabbitmq/" data-link-title="RabbitMQ" data-link-desc="Classic message broker、AMQP routing 為主">RabbitMQ&lt;/a> overview 的 implementation-layer deep article。選型層（RabbitMQ vs Kafka / SQS、何時選 RabbitMQ）見 overview；本文只處理「決定用 RabbitMQ 後，失敗訊息怎麼 retry 才不會卡死隊列」。DLX 拓樸實機驗證於 rabbitmq:3-management、最後檢查日 2026-06-16；機制以 &lt;a href="https://www.rabbitmq.com/docs/dlx">RabbitMQ DLX 官方文件&lt;/a> 為準。&lt;/p>&lt;/blockquote>
&lt;h2 id="失敗訊息-requeue-回隊首會卡住整條隊列">失敗訊息 requeue 回隊首，會卡住整條隊列&lt;/h2>
&lt;p>消費一則訊息失敗了——下游 API 超時、資料還沒就緒、暫時性錯誤。最直覺的處理是 &lt;code>nack&lt;/code> 加 &lt;code>requeue=true&lt;/code>，讓它重新排隊再試一次。問題是 RabbitMQ 的 requeue 把訊息放回&lt;strong>原隊列的隊首&lt;/strong>，於是它立刻又被同一個 consumer 取出、再次失敗、再 requeue……在「下游還沒恢復」的那段時間裡，這則訊息反覆佔據隊首，後面所有正常訊息全被卡住。這就是 head-of-line blocking：一則毒訊息（poison message）拖垮整條隊列。&lt;/p>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/rabbitmq-indeed-delay-dlq-escalation/" data-link-title="3.C25 Indeed：Delay queue &amp;#43; DLQ 三層 escalation" data-link-desc="Indeed 每天 35M&amp;#43; 職缺、設計 Requeue → Delay queue → DLQ 三層 escalation 避開 head-of-line blocking。">Indeed 每天處理 35M+ 職缺訊息&lt;/a>，原本的架構正是把失敗訊息 requeue 回隊首，造成阻塞。他們的解法是設計 &lt;strong>Requeue → Delay queue → Dead Letter Queue 三層 escalation&lt;/strong>：retry 幾次後讓訊息進延遲隊列（隔一段時間再試）、再失敗幾次才進 DLQ（停止重試、留待人工或專門處理）。這個案例揭露的核心原則是——&lt;strong>retry 策略要跟隊列拓樸一起設計，不是純 client 端的 backoff&lt;/strong>。&lt;/p>
&lt;p>本文展開 RabbitMQ 實現這套分層 retry 的機制（dead-letter exchange + TTL）、實機驗證的拓樸、以及把它寫成事故的踩坑。&lt;/p>
&lt;h2 id="核心概念dead-letter-exchange-的求值模型">核心概念：dead-letter exchange 的求值模型&lt;/h2>
&lt;p>RabbitMQ 的失敗訊息處理建立在 dead-letter exchange（DLX）上。理解它要抓住「訊息在什麼條件下被 dead-letter、去哪裡」。&lt;/p>
&lt;p>&lt;strong>訊息在三種情況被 dead-letter&lt;/strong>。一則訊息會從它所在的隊列被轉送到該隊列設定的 DLX：(1) 被 consumer &lt;code>nack&lt;/code> / &lt;code>reject&lt;/code> 且 &lt;code>requeue=false&lt;/code>；(2) 訊息 TTL 到期（&lt;code>x-message-ttl&lt;/code> 或 per-message expiration）；(3) 隊列達到長度上限（&lt;code>x-max-length&lt;/code>）被擠掉。這三種 reason 會記在訊息的 &lt;code>x-death&lt;/code> header 裡。&lt;/p>
&lt;p>&lt;strong>DLX 是隊列的屬性、不是訊息的&lt;/strong>。在宣告隊列時用 &lt;code>x-dead-letter-exchange&lt;/code> 指定這個隊列的「死信要送去哪個 exchange」，搭配 &lt;code>x-dead-letter-routing-key&lt;/code> 指定送過去時用什麼 routing key。死信被當成一則新訊息發布到那個 exchange，再依綁定路由到 DLQ。&lt;/p>
&lt;p>&lt;strong>TTL + DLX 組出「延遲隊列」&lt;/strong>。RabbitMQ 沒有原生的延遲投遞，但可以用「一個沒有 consumer、只設 TTL + DLX 的隊列」模擬：訊息進這個隊列、躺到 TTL 到期、被 dead-letter 回工作 exchange——等於延遲了 TTL 那麼久才重新可被消費。這是分層 retry 的關鍵積木。&lt;/p>
&lt;p>&lt;strong>&lt;code>x-death&lt;/code> header 累積重試歷史&lt;/strong>。每次 dead-letter，RabbitMQ 在 &lt;code>x-death&lt;/code> header 追加一筆記錄（哪個隊列、什麼 reason、次數 count）。消費端讀這個 count 就能判斷「這則訊息重試幾次了」，決定要再延遲還是進 DLQ。這是實現「retry n 次後升級」的依據。&lt;/p>
&lt;h2 id="配置work--delay--dlq-三層拓樸">配置：work → delay → DLQ 三層拓樸&lt;/h2>
&lt;p>實機驗證的最小 DLX 拓樸（工作隊列的訊息 TTL 到期後 dead-letter 到 DLQ）：&lt;/p>





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





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 宣告 DLX exchange 與 DLQ</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">rabbitmqadmin <span class="nb">declare</span> exchange <span class="nv">name</span><span class="o">=</span>dlx <span class="nv">type</span><span class="o">=</span>direct
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">rabbitmqadmin <span class="nb">declare</span> queue <span class="nv">name</span><span class="o">=</span>dlq
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">rabbitmqadmin <span class="nb">declare</span> binding <span class="nv">source</span><span class="o">=</span>dlx <span class="nv">destination</span><span class="o">=</span>dlq <span class="nv">routing_key</span><span class="o">=</span>app.work
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"># 工作隊列：設 TTL + 指向 DLX（TTL 到期或 nack(requeue=false) 都會 dead-letter）</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">rabbitmqadmin <span class="nb">declare</span> queue <span class="nv">name</span><span class="o">=</span>app.work <span class="se">\
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="se"></span>  <span class="nv">arguments</span><span class="o">=</span><span class="s1">&#39;{&#34;x-message-ttl&#34;:2000,&#34;x-dead-letter-exchange&#34;:&#34;dlx&#34;,&#34;x-dead-letter-routing-key&#34;:&#34;app.work&#34;}&#39;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"># 驗證：發一則、等 2s TTL 到期、它從 app.work 搬到 dlq</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">rabbitmqadmin publish <span class="nv">routing_key</span><span class="o">=</span>app.work <span class="nv">payload</span><span class="o">=</span><span class="s2">&#34;poison-msg&#34;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"># 等 TTL（2s）過期後（實測等 4s 確保）：</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">rabbitmqctl list_queues name messages
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1"># app.work   0     ← TTL 到期被搬走</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="c1"># dlq        1     ← 落到 DLQ（訊息帶 x-death header、reason=expired）</span></span></span></code></pre></div><p>實機驗證於 rabbitmq:3-management（最後檢查日 2026-06-16）：publish 後等 TTL 過期，<code>app.work</code> 歸零、<code>dlq</code> 出現該訊息。</p>
<p>三層 escalation 的完整拓樸（對應 Indeed 模式）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">app.work（主工作隊列）
</span></span><span class="line"><span class="ln">2</span><span class="cl">  └─ consumer nack(requeue=false) 或處理失敗
</span></span><span class="line"><span class="ln">3</span><span class="cl">       ↓ dead-letter 到
</span></span><span class="line"><span class="ln">4</span><span class="cl">app.retry（延遲隊列：x-message-ttl=30s、無 consumer、DLX 指回 app.work）
</span></span><span class="line"><span class="ln">5</span><span class="cl">  └─ TTL 到期
</span></span><span class="line"><span class="ln">6</span><span class="cl">       ↓ dead-letter 回
</span></span><span class="line"><span class="ln">7</span><span class="cl">app.work（再次嘗試；消費端讀 x-death count）
</span></span><span class="line"><span class="ln">8</span><span class="cl">  └─ 重試達上限（例如 count &gt;= 3）→ 消費端主動 nack 到
</span></span><span class="line"><span class="ln">9</span><span class="cl">app.dlq（死信終點：無自動重試、人工 / 專門 consumer 處理）</span></span></code></pre></div><p>判讀：</p>
<ul>
<li>延遲時間靠 <code>app.retry</code> 的 TTL 控制；要指數退避就設多個不同 TTL 的 delay 隊列（30s / 5m / 1h）逐層升級</li>
<li>「重試幾次」由消費端讀 <code>x-death</code> 的 count 判斷、達上限才送終點 DLQ</li>
<li>DLQ 不該有自動重試的 consumer（否則又是迴圈）；它是給人看的、或給冪等的專門修復流程</li>
</ul>
<h2 id="production-故障演練">Production 故障演練</h2>
<h3 id="case-1requeue-回隊首毒訊息卡死整條隊列">Case 1：requeue 回隊首、毒訊息卡死整條隊列</h3>
<p><strong>徵兆</strong>：下游短暫故障期間，整條隊列的消費停滯、consumer CPU 衝高但吞吐歸零，恢復後發現大量正常訊息延遲。</p>
<p><strong>根因</strong>：失敗時用 <code>nack(requeue=true)</code>，訊息回到隊首被立刻重取、反覆失敗，head-of-line blocking。下游故障越久，毒訊息霸佔隊首越久。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>失敗一律 <code>nack(requeue=false)</code> 走 DLX，不要 requeue 回原隊列</li>
<li>用 delay 隊列（TTL + DLX）讓重試隔一段時間，給下游恢復時間</li>
<li>重試有上限，達上限進終點 DLQ，停止自動重試</li>
<li>這正是 <a href="/blog/backend/03-message-queue/cases/rabbitmq-indeed-delay-dlq-escalation/" data-link-title="3.C25 Indeed：Delay queue &#43; DLQ 三層 escalation" data-link-desc="Indeed 每天 35M&#43; 職缺、設計 Requeue → Delay queue → DLQ 三層 escalation 避開 head-of-line blocking。">Indeed 案例</a> 的核心教訓：retry 拓樸化，不要 requeue-to-head</li>
</ol>
<h3 id="case-2delay-隊列綁錯retry-變無限迴圈">Case 2：delay 隊列綁錯、retry 變無限迴圈</h3>
<p><strong>徵兆</strong>：某些訊息永遠在重試、<code>x-death</code> count 累積到幾百次，DLQ 卻一直是空的。</p>
<p><strong>根因</strong>：delay 隊列的 DLX 指回工作隊列，但消費端沒有檢查 <code>x-death</code> count、或上限判斷寫錯，訊息在 work ↔ retry 之間無限往返、永遠到不了終點 DLQ。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>消費端每次處理前讀 <code>x-death</code> 的 count，超過上限就主動投遞到終點 DLQ（不再走 retry）</li>
<li>上限判斷要涵蓋所有 retry 路徑，不要漏掉某條</li>
<li>監控 <code>x-death</code> count 分布，出現高 count 訊息代表升級邏輯漏了</li>
<li>終點 DLQ 絕對不要接會 nack-to-DLX 的 consumer，否則迴圈</li>
</ol>
<h3 id="case-3per-queue-ttl-的隊首阻塞陷阱">Case 3：per-queue TTL 的隊首阻塞陷阱</h3>
<p><strong>徵兆</strong>：用 <code>x-message-ttl</code> 設隊列級 TTL 做延遲，但發現訊息沒有按預期時間 dead-letter，延遲時間忽長忽短。</p>
<p><strong>根因</strong>：隊列級 TTL（<code>x-message-ttl</code>）只在訊息到達隊首時才檢查是否過期。如果用 per-message TTL 且不同訊息 TTL 不同，前面一則長 TTL 的訊息會擋住後面短 TTL 的——後者明明過期了卻因為不在隊首而沒被 dead-letter。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>delay 隊列用統一的隊列級 TTL（同一個 delay 隊列裡所有訊息延遲時間相同），不要在同隊列混用 per-message TTL</li>
<li>要多種延遲時間就開多個 delay 隊列（每個固定 TTL），不要靠 per-message TTL</li>
<li>理解 TTL 是「到隊首才檢查」的惰性求值，不是精準定時器</li>
<li>需要精準排程的延遲用專門的 delay 機制（rabbitmq-delayed-message-exchange plugin），不靠 TTL 模擬</li>
</ol>
<h3 id="case-4dlx-沒綁好死信靜默消失">Case 4：DLX 沒綁好、死信靜默消失</h3>
<p><strong>徵兆</strong>：訊息明明該 dead-letter，但 DLQ 一直收不到，訊息憑空消失。</p>
<p><strong>根因</strong>：DLX exchange 存在、隊列也設了 <code>x-dead-letter-exchange</code>，但 DLX 到 DLQ 的 binding 不存在或 routing key 對不上。死信被發布到 DLX 後沒有任何隊列接收（unroutable），直接被丟棄。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>確認 DLX → DLQ 的 binding 存在且 routing key 匹配（<code>x-dead-letter-routing-key</code> 對上 binding key）</li>
<li>沒設 <code>x-dead-letter-routing-key</code> 時死信沿用原 routing key，binding 要對應原 key</li>
<li>給 DLX 設 alternate exchange 或在 DLX 上掛一個 catch-all 隊列，避免 unroutable 死信靜默消失</li>
<li>監控 DLX 的 unroutable / drop 指標，死信消失是嚴重的資料遺失</li>
</ol>
<h3 id="case-5dlq-無上限成長變成第二個問題">Case 5：DLQ 無上限成長、變成第二個問題</h3>
<p><strong>徵兆</strong>：DLQ 累積到幾十萬則訊息、記憶體吃緊，沒人處理。</p>
<p><strong>根因</strong>：DLQ 是終點但沒有處理流程——訊息一直進、沒人消費，DLQ 變成一個越長越大的垃圾堆，最終吃光 broker 記憶體（classic queue 訊息在記憶體）。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>DLQ 要有處理流程：告警 + 人工 / 自動修復 consumer（冪等地重新投遞或記錄）</li>
<li>DLQ 設 <code>x-max-length</code> 或自己的 TTL，避免無限成長（但要先確認丟棄可接受）</li>
<li>監控 DLQ 深度與成長速率，持續成長代表上游有系統性失敗、要根治而非堆 DLQ</li>
<li>quorum queue 對 DLQ 是合理選擇（持久、不純靠記憶體），見 <a href="/blog/backend/03-message-queue/vendors/rabbitmq/queue-types-classic-quorum-stream/" data-link-title="RabbitMQ Queue Type 選型：Classic、Quorum、Stream 的責任邊界與容量取捨" data-link-desc="RabbitMQ 3.x 三種 queue type 的選型 deep article — classic queue（mirrored 已 deprecated）、quorum queue（Raft 一致性、取代 mirrored）、stream（3.9&#43; append-only log、可重複消費）。涵蓋三種模型在 throughput / retention / replay / 記憶體成本的判讀、宣告語意差異（實機驗證）、4 個 production 故障演練（mirrored 網路放大 / quorum loss / stream retention 超量 / classic→quorum in-flight message），與容量規劃。">quorum vs mirrored queue deep article</a></li>
</ol>
<h2 id="capacity--cost-邊界">Capacity / cost 邊界</h2>
<p>分層 retry 拓樸的容量判讀：</p>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>健康區間</th>
          <th>警戒與動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>主隊列消費吞吐</td>
          <td>穩定、無停滯</td>
          <td>歸零但有積壓 → 毒訊息 head-of-line blocking</td>
      </tr>
      <tr>
          <td><code>x-death</code> count 分布</td>
          <td>多數低（1-2 次成功）</td>
          <td>高 count 訊息多 → 下游系統性故障 / 升級邏輯漏</td>
      </tr>
      <tr>
          <td>DLQ 深度</td>
          <td>低且有處理流程</td>
          <td>持續成長 → 無人處理、會吃光記憶體</td>
      </tr>
      <tr>
          <td>delay 隊列堆積</td>
          <td>隨重試量波動、可消化</td>
          <td>持續堆高 → 重試量超過下游恢復速度</td>
      </tr>
      <tr>
          <td>unroutable 死信</td>
          <td>0</td>
          <td>&gt; 0 → DLX binding 錯、死信靜默遺失</td>
      </tr>
  </tbody>
</table>
<p>撞牆後的路由判斷：</p>
<ul>
<li><strong>重試量大、delay 隊列堆積</strong>：重試治標、下游系統性故障要根治；考慮 circuit breaker 在上游擋住而非無限重試。</li>
<li><strong>需要精準延遲排程</strong>：TTL 模擬的延遲不精準（惰性求值），用 rabbitmq-delayed-message-exchange plugin。</li>
<li><strong>DLQ / 隊列要持久可靠</strong>：classic queue 靠記憶體 + 鏡像，大量積壓有風險；用 <a href="/blog/backend/03-message-queue/vendors/rabbitmq/queue-types-classic-quorum-stream/" data-link-title="RabbitMQ Queue Type 選型：Classic、Quorum、Stream 的責任邊界與容量取捨" data-link-desc="RabbitMQ 3.x 三種 queue type 的選型 deep article — classic queue（mirrored 已 deprecated）、quorum queue（Raft 一致性、取代 mirrored）、stream（3.9&#43; append-only log、可重複消費）。涵蓋三種模型在 throughput / retention / replay / 記憶體成本的判讀、宣告語意差異（實機驗證）、4 個 production 故障演練（mirrored 網路放大 / quorum loss / stream retention 超量 / classic→quorum in-flight message），與容量規劃。">quorum queue</a>（Raft 持久）。</li>
<li><strong>吞吐 / 保留需求超過 RabbitMQ</strong>：retry / replay 是 log-based broker 的強項，大規模 replay 走 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka</a>（consumer 各自 offset、可重讀）。</li>
</ul>
<h2 id="整合--下一步">整合 / 下一步</h2>
<p>分層 retry 是 RabbitMQ 可靠消費的核心，它跟其他議題交織：</p>
<ul>
<li><strong>跟 <a href="/blog/backend/03-message-queue/durable-queue/" data-link-title="3.2 durable queue 與重試策略" data-link-desc="整理持久化佇列、DLQ 與重試流程">3.2 durable queue</a></strong>：DLQ 要持久才不會在 broker 重啟時丟失死信。</li>
<li><strong>跟 <a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer design</a></strong>：prefetch / ack 策略決定毒訊息影響範圍，跟 retry 拓樸一起設計。</li>
<li><strong>跟 <a href="/blog/backend/06-reliability/idempotency-replay/" data-link-title="6.12 Idempotency 與 Replay 驗證" data-link-desc="把重試、重播與冪等性從口頭約定變成可驗證屬性">6.12 idempotency / replay</a></strong>：retry 與 DLQ 重新投遞都要求消費冪等，否則重試造成重複副作用。</li>
<li><strong>跟 <a href="/blog/backend/03-message-queue/vendors/rabbitmq/queue-types-classic-quorum-stream/" data-link-title="RabbitMQ Queue Type 選型：Classic、Quorum、Stream 的責任邊界與容量取捨" data-link-desc="RabbitMQ 3.x 三種 queue type 的選型 deep article — classic queue（mirrored 已 deprecated）、quorum queue（Raft 一致性、取代 mirrored）、stream（3.9&#43; append-only log、可重複消費）。涵蓋三種模型在 throughput / retention / replay / 記憶體成本的判讀、宣告語意差異（實機驗證）、4 個 production 故障演練（mirrored 網路放大 / quorum loss / stream retention 超量 / classic→quorum in-flight message），與容量規劃。">quorum vs mirrored queue</a></strong>：DLQ 與重試隊列的持久性選 quorum queue，避開 mirrored queue 的網路成本。</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li>上游 vendor 頁：<a href="/blog/backend/03-message-queue/vendors/rabbitmq/" data-link-title="RabbitMQ" data-link-desc="Classic message broker、AMQP routing 為主">RabbitMQ</a></li>
<li>同 vendor deep article：<a href="/blog/backend/03-message-queue/vendors/rabbitmq/queue-types-classic-quorum-stream/" data-link-title="RabbitMQ Queue Type 選型：Classic、Quorum、Stream 的責任邊界與容量取捨" data-link-desc="RabbitMQ 3.x 三種 queue type 的選型 deep article — classic queue（mirrored 已 deprecated）、quorum queue（Raft 一致性、取代 mirrored）、stream（3.9&#43; append-only log、可重複消費）。涵蓋三種模型在 throughput / retention / replay / 記憶體成本的判讀、宣告語意差異（實機驗證）、4 個 production 故障演練（mirrored 網路放大 / quorum loss / stream retention 超量 / classic→quorum in-flight message），與容量規劃。">quorum vs mirrored queue</a></li>
<li>對應案例：<a href="/blog/backend/03-message-queue/cases/rabbitmq-indeed-delay-dlq-escalation/" data-link-title="3.C25 Indeed：Delay queue &#43; DLQ 三層 escalation" data-link-desc="Indeed 每天 35M&#43; 職缺、設計 Requeue → Delay queue → DLQ 三層 escalation 避開 head-of-line blocking。">3.C25 Indeed delay queue + DLQ 三層 escalation</a></li>
<li>上游概念：<a href="/blog/backend/03-message-queue/durable-queue/" data-link-title="3.2 durable queue 與重試策略" data-link-desc="整理持久化佇列、DLQ 與重試流程">3.2 durable queue</a>、<a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer design</a></li>
</ul>
]]></content:encoded></item></channel></rss>