<?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>Buffer on Tarragon</title><link>https://tarrragon.github.io/blog/tags/buffer/</link><description>Recent content in Buffer on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Sat, 20 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/buffer/index.xml" rel="self" type="application/rss+xml"/><item><title>背壓機制</title><link>https://tarrragon.github.io/blog/devops/03-traffic-management/backpressure/</link><pubDate>Sat, 20 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/devops/03-traffic-management/backpressure/</guid><description>&lt;p>背壓是一種被動的流量控制機制 — 當下游（處理端）的速度跟不上上游（請求端）時，下游透過訊號讓上游知道「慢一點」。背壓不拒絕請求，而是讓請求的發送者自己決定要等待、重試還是放棄。&lt;/p>
&lt;h2 id="背壓-vs-rate-limit">背壓 vs Rate Limit&lt;/h2>
&lt;p>背壓和 rate limit 都是流量控制，但觸發邏輯不同：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>維度&lt;/th>
 &lt;th>背壓&lt;/th>
 &lt;th>Rate Limit&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>觸發條件&lt;/td>
 &lt;td>下游實際變慢了（buffer 滿）&lt;/td>
 &lt;td>請求速率超過預設上限&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>性質&lt;/td>
 &lt;td>被動（根據實際負載）&lt;/td>
 &lt;td>主動（根據預設規則）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>訊號&lt;/td>
 &lt;td>HTTP 429 + Retry-After / TCP 窗口縮小 / channel 阻塞&lt;/td>
 &lt;td>HTTP 429 + 固定的 rate limit header&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>發送者行為&lt;/td>
 &lt;td>根據 Retry-After 動態調整&lt;/td>
 &lt;td>等待限速窗口重設&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>背壓在系統承載達到上限時才觸發，rate limit 在到達預設上限時就觸發（即使系統還有餘裕）。兩者互補：rate limit 防止單一來源打爆系統，背壓防止所有來源加起來打爆系統。&lt;/p>
&lt;h2 id="實作模式">實作模式&lt;/h2>
&lt;h3 id="有限-buffer--回壓訊號">有限 buffer + 回壓訊號&lt;/h3>
&lt;p>最常見的背壓實作是在處理管線中加一個有限容量的 buffer。Buffer 滿了代表下游處理不完，這時對新請求回傳「忙碌」訊號。&lt;/p>
&lt;p>在 Go 的 HTTP server 中，buffer 可以是一個有限容量的 channel：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">var&lt;/span> &lt;span class="nx">ingestCh&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nb">make&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">chan&lt;/span> &lt;span class="nx">Event&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">10000&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1">// 有限 buffer&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">handleIngest&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ResponseWriter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Request&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nx">event&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">parseEvent&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="k">select&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="nx">ingestCh&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">WriteHeader&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusAccepted&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1">// 202&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="k">default&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Header&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="nf">Set&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;Retry-After&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;5&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">WriteHeader&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusTooManyRequests&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1">// 429&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Buffer 容量的選擇取決於下游的處理速度和可接受的記憶體用量。每個 event 約 1KB 時，10000 容量的 buffer 佔 ~10MB — 對多數服務來說可以接受。&lt;/p>
&lt;h3 id="http-429--retry-after">HTTP 429 + Retry-After&lt;/h3>
&lt;p>HTTP 429 Too Many Requests 是標準的回壓訊號。&lt;code>Retry-After&lt;/code> header 告訴 client 多少秒後重試。&lt;/p>
&lt;p>&lt;code>Retry-After&lt;/code> 的值可以是固定的（如 5 秒），也可以根據 buffer 的填充程度動態計算 — buffer 越滿、Retry-After 越長。&lt;/p>
&lt;h3 id="tcp-層的背壓">TCP 層的背壓&lt;/h3>
&lt;p>TCP 協議本身有背壓機制 — 接收端的 receive window 縮小時，發送端自動減速。但 HTTP 層的背壓比 TCP 層更精確，因為 HTTP 可以回傳語意化的狀態碼和 header，client 可以根據語意做出更智慧的回應（如優先重試 error 事件、放棄 event 事件）。&lt;/p>
&lt;h2 id="監控系統的應用">監控系統的應用&lt;/h2>
&lt;p>監控系統的 &lt;a href="https://tarrragon.github.io/blog/monitoring/04-collector/" data-link-title="模組四：Collector 設計" data-link-desc="收 → 驗 → 存 → 查 → 觸發的完整鏈路 — Go 單一 binary、可插拔 Storage Backend、rule engine">collector&lt;/a> 是背壓的典型場景：多個 SDK 同時 flush 事件到 collector，collector 的寫入速度（SQLite / PostgreSQL）是瓶頸。&lt;/p>
&lt;p>背壓鏈路：SDK flush → collector HTTP endpoint → 寫入 channel（有限容量）→ 寫入 goroutine → storage。Channel 滿時回 429，SDK 的離線 buffer 機制接手 — 事件暫存本地，等 collector 恢復後補發。&lt;/p>
&lt;p>這個設計讓 collector 在高峰時不崩潰（有限 buffer 控制記憶體）、SDK 端不丟事件（離線 buffer 暫存）。代價是事件的到達有延遲（Retry-After 時間 + 補發時間）。&lt;/p></description><content:encoded><![CDATA[<p>背壓是一種被動的流量控制機制 — 當下游（處理端）的速度跟不上上游（請求端）時，下游透過訊號讓上游知道「慢一點」。背壓不拒絕請求，而是讓請求的發送者自己決定要等待、重試還是放棄。</p>
<h2 id="背壓-vs-rate-limit">背壓 vs Rate Limit</h2>
<p>背壓和 rate limit 都是流量控制，但觸發邏輯不同：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>背壓</th>
          <th>Rate Limit</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>觸發條件</td>
          <td>下游實際變慢了（buffer 滿）</td>
          <td>請求速率超過預設上限</td>
      </tr>
      <tr>
          <td>性質</td>
          <td>被動（根據實際負載）</td>
          <td>主動（根據預設規則）</td>
      </tr>
      <tr>
          <td>訊號</td>
          <td>HTTP 429 + Retry-After / TCP 窗口縮小 / channel 阻塞</td>
          <td>HTTP 429 + 固定的 rate limit header</td>
      </tr>
      <tr>
          <td>發送者行為</td>
          <td>根據 Retry-After 動態調整</td>
          <td>等待限速窗口重設</td>
      </tr>
  </tbody>
</table>
<p>背壓在系統承載達到上限時才觸發，rate limit 在到達預設上限時就觸發（即使系統還有餘裕）。兩者互補：rate limit 防止單一來源打爆系統，背壓防止所有來源加起來打爆系統。</p>
<h2 id="實作模式">實作模式</h2>
<h3 id="有限-buffer--回壓訊號">有限 buffer + 回壓訊號</h3>
<p>最常見的背壓實作是在處理管線中加一個有限容量的 buffer。Buffer 滿了代表下游處理不完，這時對新請求回傳「忙碌」訊號。</p>
<p>在 Go 的 HTTP server 中，buffer 可以是一個有限容量的 channel：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">var</span> <span class="nx">ingestCh</span> <span class="p">=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="nx">Event</span><span class="p">,</span> <span class="mi">10000</span><span class="p">)</span> <span class="c1">// 有限 buffer</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="kd">func</span> <span class="nf">handleIngest</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">event</span> <span class="o">:=</span> <span class="nf">parseEvent</span><span class="p">(</span><span class="nx">r</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">case</span> <span class="nx">ingestCh</span> <span class="o">&lt;-</span> <span class="nx">event</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">w</span><span class="p">.</span><span class="nf">WriteHeader</span><span class="p">(</span><span class="nx">http</span><span class="p">.</span><span class="nx">StatusAccepted</span><span class="p">)</span> <span class="c1">// 202</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">default</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nx">w</span><span class="p">.</span><span class="nf">Header</span><span class="p">().</span><span class="nf">Set</span><span class="p">(</span><span class="s">&#34;Retry-After&#34;</span><span class="p">,</span> <span class="s">&#34;5&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">w</span><span class="p">.</span><span class="nf">WriteHeader</span><span class="p">(</span><span class="nx">http</span><span class="p">.</span><span class="nx">StatusTooManyRequests</span><span class="p">)</span> <span class="c1">// 429</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Buffer 容量的選擇取決於下游的處理速度和可接受的記憶體用量。每個 event 約 1KB 時，10000 容量的 buffer 佔 ~10MB — 對多數服務來說可以接受。</p>
<h3 id="http-429--retry-after">HTTP 429 + Retry-After</h3>
<p>HTTP 429 Too Many Requests 是標準的回壓訊號。<code>Retry-After</code> header 告訴 client 多少秒後重試。</p>
<p><code>Retry-After</code> 的值可以是固定的（如 5 秒），也可以根據 buffer 的填充程度動態計算 — buffer 越滿、Retry-After 越長。</p>
<h3 id="tcp-層的背壓">TCP 層的背壓</h3>
<p>TCP 協議本身有背壓機制 — 接收端的 receive window 縮小時，發送端自動減速。但 HTTP 層的背壓比 TCP 層更精確，因為 HTTP 可以回傳語意化的狀態碼和 header，client 可以根據語意做出更智慧的回應（如優先重試 error 事件、放棄 event 事件）。</p>
<h2 id="監控系統的應用">監控系統的應用</h2>
<p>監控系統的 <a href="/blog/monitoring/04-collector/" data-link-title="模組四：Collector 設計" data-link-desc="收 → 驗 → 存 → 查 → 觸發的完整鏈路 — Go 單一 binary、可插拔 Storage Backend、rule engine">collector</a> 是背壓的典型場景：多個 SDK 同時 flush 事件到 collector，collector 的寫入速度（SQLite / PostgreSQL）是瓶頸。</p>
<p>背壓鏈路：SDK flush → collector HTTP endpoint → 寫入 channel（有限容量）→ 寫入 goroutine → storage。Channel 滿時回 429，SDK 的離線 buffer 機制接手 — 事件暫存本地，等 collector 恢復後補發。</p>
<p>這個設計讓 collector 在高峰時不崩潰（有限 buffer 控制記憶體）、SDK 端不丟事件（離線 buffer 暫存）。代價是事件的到達有延遲（Retry-After 時間 + 補發時間）。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>主動的流量限制 → <a href="/blog/devops/03-traffic-management/rate-limiting/" data-link-title="Rate Limiting" data-link-desc="主動限制每個來源的請求速率 — per-client vs global、token bucket vs sliding window、優先級豁免">Rate Limiting</a></li>
<li>依賴服務失敗時的防護 → <a href="/blog/devops/03-traffic-management/circuit-breaker/" data-link-title="熔斷器" data-link-desc="依賴服務失敗時怎麼快速失敗而非拖慢自己 — 三狀態模型（closed → open → half-open）和熔斷判斷條件">熔斷器</a></li>
<li>突發流量時的組合策略 → <a href="/blog/devops/07-burst-traffic/" data-link-title="模組七：突發流量應對" data-link-desc="行銷活動或新聞曝光帶來 10x-100x 流量時怎麼撐 — 突發分類、降級策略、queue 緩衝、規模分級應對">模組七 突發流量</a></li>
</ul>
]]></content:encoded></item><item><title>攢批送出策略</title><link>https://tarrragon.github.io/blog/monitoring/03-sdk-design/batch-flush/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/03-sdk-design/batch-flush/</guid><description>&lt;p>攢批送出策略控制事件從 SDK 內部 buffer 送到 collector 的時機。事件產生後先進入記憶體 buffer，累積到一定數量或間隔一定時間後，一次性透過 HTTP POST 送出整批事件。攢批的目的是減少網路請求次數 — 100 筆事件合併成一個 HTTP 請求，比 100 個獨立請求的網路開銷低。&lt;/p>
&lt;h2 id="三個觸發條件">三個觸發條件&lt;/h2>
&lt;h3 id="時間觸發flush-interval">時間觸發（flush interval）&lt;/h3>
&lt;p>固定間隔自動 flush。SDK 在 init 時啟動計時器，每隔 N 毫秒檢查 buffer 是否有待發事件，有則送出。&lt;/p>
&lt;p>合理的間隔範圍：10-60 秒。間隔太短（1 秒）接近逐筆送出，失去攢批的效益；間隔太長（5 分鐘）可能讓事件延遲到達 collector，影響即時監控和告警的反應速度。&lt;/p>
&lt;p>自用工具場景下 30 秒是合理的預設 — 事件量低，30 秒的延遲對 debug 分析沒有實質影響。商業產品可以降到 10 秒以獲得更接近即時的 error 告警。&lt;/p>
&lt;h3 id="數量觸發buffer-size">數量觸發（buffer size）&lt;/h3>
&lt;p>Buffer 內的事件數量達到上限時立即 flush。Buffer size 設定為一次 HTTP POST 的合理 payload 大小對應的事件數量。&lt;/p>
&lt;p>合理的數量範圍：50-200 筆。數量太少（10 筆）頻繁觸發 flush；數量太多（1000 筆）單次 HTTP POST 的 payload 過大，增加傳輸失敗的風險（超時、記憶體）。&lt;/p>
&lt;p>數量觸發和時間觸發互為備援。高頻事件場景（使用者快速操作）靠數量觸發避免 buffer 溢出；低頻事件場景（使用者長時間閒置）靠時間觸發確保事件在合理時間內送出。&lt;/p>
&lt;h3 id="關閉觸發flush-on-close">關閉觸發（flush on close）&lt;/h3>
&lt;p>SDK close 時強制 flush buffer 中所有剩餘事件。這是最後一道保障 — app 關閉後 buffer 中未送出的事件就永久遺失了。&lt;/p>
&lt;p>close flush 的挑戰是時間限制。iOS app 進入背景後約 5 秒會被系統 suspend，Android 的限制更嚴格。Close flush 必須在這個時間窗口內完成網路請求。如果 buffer 中事件太多導致 flush 超時，需要截斷 — 送出最近的 N 筆，放棄較舊的。&lt;/p>
&lt;h2 id="buffer-管理">Buffer 管理&lt;/h2>
&lt;h3 id="記憶體-buffer">記憶體 buffer&lt;/h3>
&lt;p>Buffer 在記憶體中維護一個事件陣列。新事件 append 到尾端，flush 時取出整個陣列送出並清空。&lt;/p>
&lt;p>記憶體 buffer 的上限應該設定為 buffer size 的 2-3 倍（允許 1-2 次 flush 失敗後累積的事件）。超過上限時丟棄最舊的事件（FIFO），保留最新的 — 最新的事件對 debug 和即時分析的價值更高。&lt;/p>
&lt;h3 id="離線-buffer">離線 buffer&lt;/h3>
&lt;p>網路不可用時，事件累積在記憶體 buffer 中。如果離線時間超過記憶體 buffer 容量，需要離線 persistence — 見 &lt;a href="https://tarrragon.github.io/blog/monitoring/03-sdk-design/offline-buffer/" data-link-title="離線 buffer 與重試" data-link-desc="網路不可用時的事件保存策略 — FIFO 丟棄、本地 persistence、恢復後補發的取捨">離線 buffer 與重試&lt;/a>。&lt;/p>
&lt;h2 id="flush-失敗處理">Flush 失敗處理&lt;/h2>
&lt;p>HTTP POST 失敗時（網路中斷、server 回 5xx、超時），事件保留在 buffer 中等待下一次 flush 重試。不立即重試 — 連續失敗通常代表網路問題或 server 問題，立即重試只會增加負載。&lt;/p>
&lt;p>重試次數有上限（3 次）。超過重試上限的事件被丟棄，記錄一筆 &lt;code>sdk.flush.dropped&lt;/code> metric 事件（這筆 metric 本身也進 buffer，在下次成功 flush 時送出）。&lt;/p>
&lt;h3 id="sdk-對-collector-回應的處理">SDK 對 collector 回應的處理&lt;/h3>
&lt;p>SDK 只需要判斷 HTTP status code 就知道怎麼處理 buffer，不需要解析 response body 的細節。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Status&lt;/th>
 &lt;th>SDK 行為&lt;/th>
 &lt;th>理由&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>200&lt;/td>
 &lt;td>清除已送出的 buffer&lt;/td>
 &lt;td>全部成功&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>207&lt;/td>
 &lt;td>清除 buffer + 記錄 warning log&lt;/td>
 &lt;td>合法事件已被接受；失敗事件是 schema 問題，重試也不會過&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>400&lt;/td>
 &lt;td>清除 buffer + 記錄 error log&lt;/td>
 &lt;td>Schema 問題重試也不會過，保留在 buffer 只會擋住後續事件&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>503&lt;/td>
 &lt;td>保留 buffer + 等待 &lt;code>retry_after&lt;/code> 秒&lt;/td>
 &lt;td>collector 暫時不可用，事件本身沒問題&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>其他（network error / timeout）&lt;/td>
 &lt;td>保留 buffer + 下次 flush 重試&lt;/td>
 &lt;td>暫時性問題，重試有機會成功&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>207 和 400 都清 buffer 的關鍵判斷：Schema 驗證失敗是 SDK 端產出了不合規的事件，問題在 SDK 的事件建構邏輯（程式碼 bug），不在 collector 或網路 — 重試相同事件永遠不會過。SDK 把失敗事件的 error 訊息記到 warning/error log 供開發者排查，然後放行後續事件。&lt;/p></description><content:encoded><![CDATA[<p>攢批送出策略控制事件從 SDK 內部 buffer 送到 collector 的時機。事件產生後先進入記憶體 buffer，累積到一定數量或間隔一定時間後，一次性透過 HTTP POST 送出整批事件。攢批的目的是減少網路請求次數 — 100 筆事件合併成一個 HTTP 請求，比 100 個獨立請求的網路開銷低。</p>
<h2 id="三個觸發條件">三個觸發條件</h2>
<h3 id="時間觸發flush-interval">時間觸發（flush interval）</h3>
<p>固定間隔自動 flush。SDK 在 init 時啟動計時器，每隔 N 毫秒檢查 buffer 是否有待發事件，有則送出。</p>
<p>合理的間隔範圍：10-60 秒。間隔太短（1 秒）接近逐筆送出，失去攢批的效益；間隔太長（5 分鐘）可能讓事件延遲到達 collector，影響即時監控和告警的反應速度。</p>
<p>自用工具場景下 30 秒是合理的預設 — 事件量低，30 秒的延遲對 debug 分析沒有實質影響。商業產品可以降到 10 秒以獲得更接近即時的 error 告警。</p>
<h3 id="數量觸發buffer-size">數量觸發（buffer size）</h3>
<p>Buffer 內的事件數量達到上限時立即 flush。Buffer size 設定為一次 HTTP POST 的合理 payload 大小對應的事件數量。</p>
<p>合理的數量範圍：50-200 筆。數量太少（10 筆）頻繁觸發 flush；數量太多（1000 筆）單次 HTTP POST 的 payload 過大，增加傳輸失敗的風險（超時、記憶體）。</p>
<p>數量觸發和時間觸發互為備援。高頻事件場景（使用者快速操作）靠數量觸發避免 buffer 溢出；低頻事件場景（使用者長時間閒置）靠時間觸發確保事件在合理時間內送出。</p>
<h3 id="關閉觸發flush-on-close">關閉觸發（flush on close）</h3>
<p>SDK close 時強制 flush buffer 中所有剩餘事件。這是最後一道保障 — app 關閉後 buffer 中未送出的事件就永久遺失了。</p>
<p>close flush 的挑戰是時間限制。iOS app 進入背景後約 5 秒會被系統 suspend，Android 的限制更嚴格。Close flush 必須在這個時間窗口內完成網路請求。如果 buffer 中事件太多導致 flush 超時，需要截斷 — 送出最近的 N 筆，放棄較舊的。</p>
<h2 id="buffer-管理">Buffer 管理</h2>
<h3 id="記憶體-buffer">記憶體 buffer</h3>
<p>Buffer 在記憶體中維護一個事件陣列。新事件 append 到尾端，flush 時取出整個陣列送出並清空。</p>
<p>記憶體 buffer 的上限應該設定為 buffer size 的 2-3 倍（允許 1-2 次 flush 失敗後累積的事件）。超過上限時丟棄最舊的事件（FIFO），保留最新的 — 最新的事件對 debug 和即時分析的價值更高。</p>
<h3 id="離線-buffer">離線 buffer</h3>
<p>網路不可用時，事件累積在記憶體 buffer 中。如果離線時間超過記憶體 buffer 容量，需要離線 persistence — 見 <a href="/blog/monitoring/03-sdk-design/offline-buffer/" data-link-title="離線 buffer 與重試" data-link-desc="網路不可用時的事件保存策略 — FIFO 丟棄、本地 persistence、恢復後補發的取捨">離線 buffer 與重試</a>。</p>
<h2 id="flush-失敗處理">Flush 失敗處理</h2>
<p>HTTP POST 失敗時（網路中斷、server 回 5xx、超時），事件保留在 buffer 中等待下一次 flush 重試。不立即重試 — 連續失敗通常代表網路問題或 server 問題，立即重試只會增加負載。</p>
<p>重試次數有上限（3 次）。超過重試上限的事件被丟棄，記錄一筆 <code>sdk.flush.dropped</code> metric 事件（這筆 metric 本身也進 buffer，在下次成功 flush 時送出）。</p>
<h3 id="sdk-對-collector-回應的處理">SDK 對 collector 回應的處理</h3>
<p>SDK 只需要判斷 HTTP status code 就知道怎麼處理 buffer，不需要解析 response body 的細節。</p>
<table>
  <thead>
      <tr>
          <th>Status</th>
          <th>SDK 行為</th>
          <th>理由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>200</td>
          <td>清除已送出的 buffer</td>
          <td>全部成功</td>
      </tr>
      <tr>
          <td>207</td>
          <td>清除 buffer + 記錄 warning log</td>
          <td>合法事件已被接受；失敗事件是 schema 問題，重試也不會過</td>
      </tr>
      <tr>
          <td>400</td>
          <td>清除 buffer + 記錄 error log</td>
          <td>Schema 問題重試也不會過，保留在 buffer 只會擋住後續事件</td>
      </tr>
      <tr>
          <td>503</td>
          <td>保留 buffer + 等待 <code>retry_after</code> 秒</td>
          <td>collector 暫時不可用，事件本身沒問題</td>
      </tr>
      <tr>
          <td>其他（network error / timeout）</td>
          <td>保留 buffer + 下次 flush 重試</td>
          <td>暫時性問題，重試有機會成功</td>
      </tr>
  </tbody>
</table>
<p>207 和 400 都清 buffer 的關鍵判斷：Schema 驗證失敗是 SDK 端產出了不合規的事件，問題在 SDK 的事件建構邏輯（程式碼 bug），不在 collector 或網路 — 重試相同事件永遠不會過。SDK 把失敗事件的 error 訊息記到 warning/error log 供開發者排查，然後放行後續事件。</p>
<p>503 保留 buffer 的關鍵判斷：collector 暫時不可用是基礎設施問題（SQLite busy timeout、背壓），事件本身合法，等 collector 恢復後重試會成功。<code>retry_after</code> 由 collector 在回應中指定，SDK 用這個值設定下次 flush 的最小等待時間。</p>
<h2 id="batch-格式">Batch 格式</h2>
<p>SDK 在 flush 時把 buffer 中所有事件包裝成一個 batch，帶上 <code>batch_id</code> 送出。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nt">&#34;batch_id&#34;</span><span class="p">:</span> <span class="s2">&#34;019537a0-7b2c-7def-8a2b-3c4d5e6f7890&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nt">&#34;events&#34;</span><span class="p">:</span> <span class="p">[</span> <span class="err">...</span> <span class="p">]</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>batch_id</code> 由 SDK 在 flush 時產生。使用 UUID v7（<code>uuid.uuid7()</code>，Python 3.14+ 標準庫）——時間戳前綴保證有序（debug 時按 batch_id 排序即時間順序），隨機後綴保證唯一（高負載下多個 SDK 同時 flush 不碰撞）。用途是追蹤和 debug（collector log 中標記同一批事件的來源）。Collector 不依賴 batch_id 做去重 — 同一批事件被 SDK 重試時會帶不同的 batch_id（每次 flush 重新產生），collector 按事件內容（timestamp + source + name）判斷是否重複。</p>
<p>UUID v7 而非時間戳格式的選型理由：時間戳格式（<code>b-{YYYYMMDD}-{HHMMSSfff}</code>）在同毫秒多次 flush 時會碰撞，雖然 MVP 的 debug 用途碰撞無害，但 batch_id 碰撞在後續版本的離線補發去重場景（見 <a href="/blog/monitoring/03-sdk-design/offline-buffer/" data-link-title="離線 buffer 與重試" data-link-desc="網路不可用時的事件保存策略 — FIFO 丟棄、本地 persistence、恢復後補發的取捨">離線 buffer 與重試</a>）會造成歧義。UUID v7 兼顧有序和唯一，一次到位。</p>
<h2 id="heartbeat-和-flush-的整合">Heartbeat 和 flush 的整合</h2>
<p>DevOps dashboard 需要 <code>sdk.heartbeat</code> 事件判斷 SDK 是否存活。Heartbeat 不需要獨立的 timer — 整合在 flush timer 中：</p>
<p>flush timer 觸發時，如果 buffer 為空且距上次 heartbeat 超過設定間隔（預設 5 分鐘），自動注入一筆 <code>sdk.heartbeat</code> lifecycle 事件後送出。App idle 時仍有心跳但不多一個 timer；app 活躍時 heartbeat 被正常事件的 flush 取代（buffer 不會為空）。</p>
<p>Heartbeat 間隔由 SDK init config 的 <code>heartbeatInterval</code> 設定。設為 0 停用 heartbeat。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>離線場景的處理 → <a href="/blog/monitoring/03-sdk-design/offline-buffer/" data-link-title="離線 buffer 與重試" data-link-desc="網路不可用時的事件保存策略 — FIFO 丟棄、本地 persistence、恢復後補發的取捨">離線 buffer 與重試</a></li>
<li>SDK 公開 API → <a href="/blog/monitoring/03-sdk-design/public-api/" data-link-title="SDK 公開 API 設計" data-link-desc="init / event / error / metric / flush / close 六個方法構成 SDK 的完整生命週期 — 跨平台共用相同 API 介面">SDK 公開 API 設計</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>
</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></channel></rss>