<?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>Scaling on Tarragon</title><link>https://tarrragon.github.io/blog/tags/scaling/</link><description>Recent content in Scaling on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Mon, 22 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/scaling/index.xml" rel="self" type="application/rss+xml"/><item><title>規模分級應對表</title><link>https://tarrragon.github.io/blog/devops/07-burst-traffic/scale-tier-response/</link><pubDate>Sat, 20 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/devops/07-burst-traffic/scale-tier-response/</guid><description>&lt;p>突發流量的應對方案隨服務規模分成四級。每一級在前一級的基礎上增加元件，複雜度和成本同步上升。選擇哪一級取決於「預期的峰值流量」和「可接受的降級程度」。&lt;/p>
&lt;h2 id="四級分級">四級分級&lt;/h2>
&lt;h3 id="tier-1自用級-100-eventssec">Tier 1：自用級（&amp;lt; 100 events/sec）&lt;/h3>





&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">SDK ──→ Collector (單 binary + SQLite)&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>維度&lt;/th>
 &lt;th>設定&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>架構&lt;/td>
 &lt;td>單 Go binary、SQLite embedded&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>流量控制&lt;/td>
 &lt;td>背壓（channel buffer 10000 + 429）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>突發應對&lt;/td>
 &lt;td>SDK 離線 buffer 吸收短暫 burst&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>降級&lt;/td>
 &lt;td>無（流量不會到需要降級的程度）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>成本&lt;/td>
 &lt;td>零（自有主機、零外部依賴）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>適用&lt;/td>
 &lt;td>自用工具、開發期測試、小型團隊&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Tier 1 的假設是峰值流量不超過 SQLite WAL mode 的寫入能力（每秒數千筆）。自用場景下這個假設幾乎永遠成立。&lt;/p>
&lt;h3 id="tier-2中型100-10000-eventssec">Tier 2：中型（100-10000 events/sec）&lt;/h3>





&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"> ┌─ Collector A ──→ PostgreSQL
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">SDK ──→ LB ─┤
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> └─ Collector B ──→ PostgreSQL&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>維度&lt;/th>
 &lt;th>設定&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>架構&lt;/td>
 &lt;td>多 collector + load balancer + PostgreSQL&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>流量控制&lt;/td>
 &lt;td>背壓 + per-SDK rate limit&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>突發應對&lt;/td>
 &lt;td>LB 分散流量 + collector 水平擴展&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>降級&lt;/td>
 &lt;td>動態取樣（超載時 SDK 降到 10%）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>成本&lt;/td>
 &lt;td>PostgreSQL + LB 的維護（可用 managed service 降低維護成本）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>適用&lt;/td>
 &lt;td>使用者數百到數千、有付費能力&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Tier 1 → Tier 2 的觸發：SQLite 的 &lt;code>database is locked&lt;/code> 頻繁出現，或 dashboard 的聚合查詢需要 PostgreSQL 的能力。&lt;/p>
&lt;h3 id="tier-3大型10000-100000-eventssec">Tier 3：大型（10000-100000 events/sec）&lt;/h3>





&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"> ┌─ Collector A ─┐
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">SDK ──→ LB ─┤ ├─→ Queue ──→ Worker 群 ──→ PostgreSQL
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> └─ Collector B ─┘&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>維度&lt;/th>
 &lt;th>設定&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>架構&lt;/td>
 &lt;td>Collector 群 + queue（NATS / Kafka）+ worker 群 + PostgreSQL&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>流量控制&lt;/td>
 &lt;td>背壓 + rate limit + bulkhead&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>突發應對&lt;/td>
 &lt;td>Queue 做時間緩衝（積壓 → 追趕）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>降級&lt;/td>
 &lt;td>動態取樣 + 事件優先級 + 功能降級&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>成本&lt;/td>
 &lt;td>Queue + worker 的基礎設施（顯著上升）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>適用&lt;/td>
 &lt;td>中大型 SaaS、使用者數萬&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Tier 2 → Tier 3 的觸發：直接寫 PostgreSQL 的背壓頻繁觸發（即使有多個 collector 寫入）。&lt;/p>
&lt;h3 id="tier-4商業網站級-100000-eventssec">Tier 4：商業網站級（&amp;gt; 100000 events/sec）&lt;/h3>





&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">SDK ──→ CDN/Edge ──→ LB ──→ Collector 群 ──→ Kafka ──→ Worker 群 ──→ 分層 DB
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> ├─ 即時查詢 DB（ClickHouse / TimescaleDB）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> └─ 歸檔 DB（S3 + Athena）&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>維度&lt;/th>
 &lt;th>設定&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>架構&lt;/td>
 &lt;td>CDN edge 收集 + Kafka + 分層存儲&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>流量控制&lt;/td>
 &lt;td>CDN rate limit + 全鏈路背壓&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>突發應對&lt;/td>
 &lt;td>Kafka partition 水平擴展 + auto-scaling worker&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>降級&lt;/td>
 &lt;td>全套（動態取樣 + 優先級 + 聚合前移 + 功能降級）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>成本&lt;/td>
 &lt;td>基礎設施團隊級別的投入&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>適用&lt;/td>
 &lt;td>大型 SaaS、電商、社群平台&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Tier 3 → Tier 4 的觸發：Kafka 單 cluster 的吞吐不夠、或查詢需要跨日誌級的時間序列分析。&lt;/p></description><content:encoded><![CDATA[<p>突發流量的應對方案隨服務規模分成四級。每一級在前一級的基礎上增加元件，複雜度和成本同步上升。選擇哪一級取決於「預期的峰值流量」和「可接受的降級程度」。</p>
<h2 id="四級分級">四級分級</h2>
<h3 id="tier-1自用級-100-eventssec">Tier 1：自用級（&lt; 100 events/sec）</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">SDK ──→ Collector (單 binary + SQLite)</span></span></code></pre></div><table>
  <thead>
      <tr>
          <th>維度</th>
          <th>設定</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>架構</td>
          <td>單 Go binary、SQLite embedded</td>
      </tr>
      <tr>
          <td>流量控制</td>
          <td>背壓（channel buffer 10000 + 429）</td>
      </tr>
      <tr>
          <td>突發應對</td>
          <td>SDK 離線 buffer 吸收短暫 burst</td>
      </tr>
      <tr>
          <td>降級</td>
          <td>無（流量不會到需要降級的程度）</td>
      </tr>
      <tr>
          <td>成本</td>
          <td>零（自有主機、零外部依賴）</td>
      </tr>
      <tr>
          <td>適用</td>
          <td>自用工具、開發期測試、小型團隊</td>
      </tr>
  </tbody>
</table>
<p>Tier 1 的假設是峰值流量不超過 SQLite WAL mode 的寫入能力（每秒數千筆）。自用場景下這個假設幾乎永遠成立。</p>
<h3 id="tier-2中型100-10000-eventssec">Tier 2：中型（100-10000 events/sec）</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">         ┌─ Collector A ──→ PostgreSQL
</span></span><span class="line"><span class="ln">2</span><span class="cl">SDK ──→ LB ─┤
</span></span><span class="line"><span class="ln">3</span><span class="cl">         └─ Collector B ──→ PostgreSQL</span></span></code></pre></div><table>
  <thead>
      <tr>
          <th>維度</th>
          <th>設定</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>架構</td>
          <td>多 collector + load balancer + PostgreSQL</td>
      </tr>
      <tr>
          <td>流量控制</td>
          <td>背壓 + per-SDK rate limit</td>
      </tr>
      <tr>
          <td>突發應對</td>
          <td>LB 分散流量 + collector 水平擴展</td>
      </tr>
      <tr>
          <td>降級</td>
          <td>動態取樣（超載時 SDK 降到 10%）</td>
      </tr>
      <tr>
          <td>成本</td>
          <td>PostgreSQL + LB 的維護（可用 managed service 降低維護成本）</td>
      </tr>
      <tr>
          <td>適用</td>
          <td>使用者數百到數千、有付費能力</td>
      </tr>
  </tbody>
</table>
<p>Tier 1 → Tier 2 的觸發：SQLite 的 <code>database is locked</code> 頻繁出現，或 dashboard 的聚合查詢需要 PostgreSQL 的能力。</p>
<h3 id="tier-3大型10000-100000-eventssec">Tier 3：大型（10000-100000 events/sec）</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">         ┌─ Collector A ─┐
</span></span><span class="line"><span class="ln">2</span><span class="cl">SDK ──→ LB ─┤               ├─→ Queue ──→ Worker 群 ──→ PostgreSQL
</span></span><span class="line"><span class="ln">3</span><span class="cl">         └─ Collector B ─┘</span></span></code></pre></div><table>
  <thead>
      <tr>
          <th>維度</th>
          <th>設定</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>架構</td>
          <td>Collector 群 + queue（NATS / Kafka）+ worker 群 + PostgreSQL</td>
      </tr>
      <tr>
          <td>流量控制</td>
          <td>背壓 + rate limit + bulkhead</td>
      </tr>
      <tr>
          <td>突發應對</td>
          <td>Queue 做時間緩衝（積壓 → 追趕）</td>
      </tr>
      <tr>
          <td>降級</td>
          <td>動態取樣 + 事件優先級 + 功能降級</td>
      </tr>
      <tr>
          <td>成本</td>
          <td>Queue + worker 的基礎設施（顯著上升）</td>
      </tr>
      <tr>
          <td>適用</td>
          <td>中大型 SaaS、使用者數萬</td>
      </tr>
  </tbody>
</table>
<p>Tier 2 → Tier 3 的觸發：直接寫 PostgreSQL 的背壓頻繁觸發（即使有多個 collector 寫入）。</p>
<h3 id="tier-4商業網站級-100000-eventssec">Tier 4：商業網站級（&gt; 100000 events/sec）</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">SDK ──→ CDN/Edge ──→ LB ──→ Collector 群 ──→ Kafka ──→ Worker 群 ──→ 分層 DB
</span></span><span class="line"><span class="ln">2</span><span class="cl">                                                                      ├─ 即時查詢 DB（ClickHouse / TimescaleDB）
</span></span><span class="line"><span class="ln">3</span><span class="cl">                                                                      └─ 歸檔 DB（S3 + Athena）</span></span></code></pre></div><table>
  <thead>
      <tr>
          <th>維度</th>
          <th>設定</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>架構</td>
          <td>CDN edge 收集 + Kafka + 分層存儲</td>
      </tr>
      <tr>
          <td>流量控制</td>
          <td>CDN rate limit + 全鏈路背壓</td>
      </tr>
      <tr>
          <td>突發應對</td>
          <td>Kafka partition 水平擴展 + auto-scaling worker</td>
      </tr>
      <tr>
          <td>降級</td>
          <td>全套（動態取樣 + 優先級 + 聚合前移 + 功能降級）</td>
      </tr>
      <tr>
          <td>成本</td>
          <td>基礎設施團隊級別的投入</td>
      </tr>
      <tr>
          <td>適用</td>
          <td>大型 SaaS、電商、社群平台</td>
      </tr>
  </tbody>
</table>
<p>Tier 3 → Tier 4 的觸發：Kafka 單 cluster 的吞吐不夠、或查詢需要跨日誌級的時間序列分析。</p>
<p>多數自架開源工具不需要超過 Tier 2。Tier 3 和 Tier 4 是商業 SaaS 的領域。</p>
<h2 id="規模遷移路徑">規模遷移路徑</h2>
<table>
  <thead>
      <tr>
          <th>遷移</th>
          <th>改什麼</th>
          <th>停機</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Tier 1 → 2</td>
          <td>Storage backend 切 PostgreSQL + 加 LB + 加 collector</td>
          <td>config change + 資料遷移（分鐘級停機）</td>
      </tr>
      <tr>
          <td>Tier 2 → 3</td>
          <td>加 queue + 改 collector 為 ingestion-only + 加 worker</td>
          <td>架構重構（需要開發時間）</td>
      </tr>
      <tr>
          <td>Tier 3 → 4</td>
          <td>加 CDN edge + 分層 DB + auto-scaling</td>
          <td>基礎設施工程（需要專職團隊）</td>
      </tr>
  </tbody>
</table>
<p>每一級的遷移成本遞增。Tier 1 → 2 是 config change 級、Tier 2 → 3 是架構重構級、Tier 3 → 4 是團隊級。選擇起始 tier 時選最低的足夠 tier — 過早引入高 tier 的複雜度是浪費。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>流量管控的四種機制 → <a href="/blog/devops/03-traffic-management/" data-link-title="模組三：流量管控" data-link-desc="收到的流量超過處理能力時怎麼辦 — 背壓、rate limit、熔斷、bulkhead 四種防護機制">模組三 流量管控</a></li>
<li>容量預備和壓力測試 → <a href="/blog/devops/05-capacity-planning/" data-link-title="模組五：容量規劃與壓力測試" data-link-desc="要準備多少資源才夠 — 壓力測試方法、峰值估算、成本模型、規模拐點的判斷">模組五 容量規劃</a></li>
<li>Collector 的可插拔 storage 架構 → <a href="/blog/monitoring/04-collector/scaling-evolution/" data-link-title="規模演進" data-link-desc="可插拔 Storage Backend 架構 — SQLite 預設、PostgreSQL 觸發切換、時間序列 DB 長期演進">monitoring 模組四 規模演進</a></li>
<li>Queue 的選型 → <a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">backend 非同步佇列</a></li>
</ul>
]]></content:encoded></item><item><title>規模演進</title><link>https://tarrragon.github.io/blog/monitoring/04-collector/scaling-evolution/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/04-collector/scaling-evolution/</guid><description>&lt;p>Collector 的儲存方案是可插拔 storage backend — 同一個 binary 透過啟動參數選擇不同的 storage implementation。Go 的 interface composition 讓 storage 分成 BasicStorage（所有 backend 共用）和 AnalyticsStorage（PostgreSQL 層新增），內部實作（SQLite / PostgreSQL / 時間序列 DB）分離，切換是 config change 而非重寫程式碼。&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">type&lt;/span> &lt;span class="nx">BasicStorage&lt;/span> &lt;span class="kd">interface&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nf">Store&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">event&lt;/span> &lt;span class="nx">Event&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">error&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nf">Query&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">filter&lt;/span> &lt;span class="nx">QueryFilter&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">([]&lt;/span>&lt;span class="nx">Event&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">error&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="nf">Close&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="kt">error&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nf">Downsample&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="kt">error&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nf">Purge&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="kt">error&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">AnalyticsStorage&lt;/span> &lt;span class="kd">interface&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">BasicStorage&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="nf">Aggregate&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">spec&lt;/span> &lt;span class="nx">AggregateSpec&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">AggregateResult&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">error&lt;/span>&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="nf">Funnel&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">steps&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">timeWindow&lt;/span> &lt;span class="nx">Duration&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">FunnelResult&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">error&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="nf">Cohort&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">groupBy&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">metric&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">CohortResult&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">error&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>SQLite implementation 只實作 &lt;code>BasicStorage&lt;/code>。PostgreSQL implementation 實作 &lt;code>AnalyticsStorage&lt;/code>。Dashboard 用 Go 的 type assertion（&lt;code>if as, ok := storage.(AnalyticsStorage); ok { ... }&lt;/code>）判斷能力 — funnel/cohort 視圖在 SQLite 模式下不顯示入口，而非顯示後報錯。&lt;/p>
&lt;p>選擇哪個 backend 取決於部署場景和查詢需求：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>場景&lt;/th>
 &lt;th>Backend&lt;/th>
 &lt;th>啟動參數&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>自架簡單版（零依賴）&lt;/td>
 &lt;td>SQLite&lt;/td>
 &lt;td>&lt;code>--storage=sqlite&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>需要聚合分析的自用版&lt;/td>
 &lt;td>PostgreSQL&lt;/td>
 &lt;td>&lt;code>--storage=postgres --dsn=...&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>高併發 + 長期保留&lt;/td>
 &lt;td>時間序列 DB&lt;/td>
 &lt;td>&lt;code>--storage=timescale --dsn=...&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="sqlite-backendday-one-預設">SQLite Backend（day-one 預設）&lt;/h2>
&lt;p>SQLite 是嵌入式資料庫，編譯進 collector binary 中，不需要額外 server。Go 用 &lt;code>modernc.org/sqlite&lt;/code>（pure Go、無 CGO 依賴、效能約為 CGO driver mattn/go-sqlite3 的 60-80%，自用規模下足夠），開源使用者 &lt;code>go build &amp;amp;&amp;amp; ./collector&lt;/code> 就能跑，部署步驟為零。WAL mode 允許讀寫並行 — dashboard 的 SELECT 查詢不會被 ingestion 的 INSERT 阻塞，反之亦然。寫入之間的競爭由 busy_timeout 處理。&lt;/p>
&lt;h3 id="能力範圍">能力範圍&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>索引查詢&lt;/strong>：按 type、name、timestamp 建索引，查詢從全表掃描變成索引查找&lt;/li>
&lt;li>&lt;strong>SQL 聚合&lt;/strong>：&lt;code>SELECT name, COUNT(*) FROM events WHERE type='error' GROUP BY name&lt;/code> — 一行 SQL 完成分群計數&lt;/li>
&lt;li>&lt;strong>跨欄位過濾&lt;/strong>：&lt;code>WHERE type='error' AND name LIKE 'terminal.%' AND ts &amp;gt; '2026-06-18'&lt;/code>&lt;/li>
&lt;li>&lt;strong>寫入&lt;/strong>：WAL mode 下每秒數千筆 append 寫入&lt;/li>
&lt;/ul>
&lt;h3 id="events-主表-ddl">Events 主表 DDL&lt;/h3>
&lt;p>Events 表的欄位從 &lt;a href="https://tarrragon.github.io/blog/monitoring/02-log-schema/event-schema-fields/" data-link-title="event.schema.json 完整欄位解說" data-link-desc="監控事件的 JSON Schema 定義 — 每個欄位的語意、必填/選填、資料型別和設計理由">event.schema.json&lt;/a> 的 JSON 結構推導。Source 的 nested object 攤平成獨立 column — 方便 SQL 查詢和索引，不需要每次從 JSON 裡 extract。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">events&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INTEGER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">PRIMARY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">KEY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">AUTOINCREMENT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">v&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INTEGER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DEFAULT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">type&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">name&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">ts&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">source_sdk&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">source_app&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">source_version&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">source_platform&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">source_os&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">session_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">session_started&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">level&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">data&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">error_message&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">error_stack&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">error_type&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">receive_ts&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>source_sdk&lt;/code> 獨立成 column 讓「按 SDK 來源篩選」（&lt;code>WHERE source_sdk = 'python'&lt;/code>）不需要從 JSON extract。&lt;code>data&lt;/code> 用 TEXT 存 JSON。SQLite 沒有原生 JSON 型別，但 3.38+ 支援 &lt;code>json_extract()&lt;/code> 函式做查詢（&lt;code>WHERE json_extract(data, '$.duration_ms') &amp;gt; 1000&lt;/code>）。&lt;code>session_id&lt;/code> 獨立成 column 讓 session 回放的 JOIN 不需要 JSON extract。&lt;code>error_stack&lt;/code> 獨立成 column 讓 error 調查時全文搜尋 stack trace 不需要 JSON extract。&lt;code>receive_ts&lt;/code> 是 collector 收到事件的時間，和 SDK 端的 &lt;code>ts&lt;/code> 對照可估算 clock drift。&lt;/p></description><content:encoded><![CDATA[<p>Collector 的儲存方案是可插拔 storage backend — 同一個 binary 透過啟動參數選擇不同的 storage implementation。Go 的 interface composition 讓 storage 分成 BasicStorage（所有 backend 共用）和 AnalyticsStorage（PostgreSQL 層新增），內部實作（SQLite / PostgreSQL / 時間序列 DB）分離，切換是 config change 而非重寫程式碼。</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">type</span> <span class="nx">BasicStorage</span> <span class="kd">interface</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nf">Store</span><span class="p">(</span><span class="nx">event</span> <span class="nx">Event</span><span class="p">)</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nf">Query</span><span class="p">(</span><span class="nx">filter</span> <span class="nx">QueryFilter</span><span class="p">)</span> <span class="p">([]</span><span class="nx">Event</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nf">Close</span><span class="p">()</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nf">Downsample</span><span class="p">()</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nf">Purge</span><span class="p">()</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="kd">type</span> <span class="nx">AnalyticsStorage</span> <span class="kd">interface</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nx">BasicStorage</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nf">Aggregate</span><span class="p">(</span><span class="nx">spec</span> <span class="nx">AggregateSpec</span><span class="p">)</span> <span class="p">(</span><span class="nx">AggregateResult</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nf">Funnel</span><span class="p">(</span><span class="nx">steps</span> <span class="p">[]</span><span class="kt">string</span><span class="p">,</span> <span class="nx">timeWindow</span> <span class="nx">Duration</span><span class="p">)</span> <span class="p">(</span><span class="nx">FunnelResult</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nf">Cohort</span><span class="p">(</span><span class="nx">groupBy</span> <span class="kt">string</span><span class="p">,</span> <span class="nx">metric</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">CohortResult</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>SQLite implementation 只實作 <code>BasicStorage</code>。PostgreSQL implementation 實作 <code>AnalyticsStorage</code>。Dashboard 用 Go 的 type assertion（<code>if as, ok := storage.(AnalyticsStorage); ok { ... }</code>）判斷能力 — funnel/cohort 視圖在 SQLite 模式下不顯示入口，而非顯示後報錯。</p>
<p>選擇哪個 backend 取決於部署場景和查詢需求：</p>
<table>
  <thead>
      <tr>
          <th>場景</th>
          <th>Backend</th>
          <th>啟動參數</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>自架簡單版（零依賴）</td>
          <td>SQLite</td>
          <td><code>--storage=sqlite</code></td>
      </tr>
      <tr>
          <td>需要聚合分析的自用版</td>
          <td>PostgreSQL</td>
          <td><code>--storage=postgres --dsn=...</code></td>
      </tr>
      <tr>
          <td>高併發 + 長期保留</td>
          <td>時間序列 DB</td>
          <td><code>--storage=timescale --dsn=...</code></td>
      </tr>
  </tbody>
</table>
<h2 id="sqlite-backendday-one-預設">SQLite Backend（day-one 預設）</h2>
<p>SQLite 是嵌入式資料庫，編譯進 collector binary 中，不需要額外 server。Go 用 <code>modernc.org/sqlite</code>（pure Go、無 CGO 依賴、效能約為 CGO driver mattn/go-sqlite3 的 60-80%，自用規模下足夠），開源使用者 <code>go build &amp;&amp; ./collector</code> 就能跑，部署步驟為零。WAL mode 允許讀寫並行 — dashboard 的 SELECT 查詢不會被 ingestion 的 INSERT 阻塞，反之亦然。寫入之間的競爭由 busy_timeout 處理。</p>
<h3 id="能力範圍">能力範圍</h3>
<ul>
<li><strong>索引查詢</strong>：按 type、name、timestamp 建索引，查詢從全表掃描變成索引查找</li>
<li><strong>SQL 聚合</strong>：<code>SELECT name, COUNT(*) FROM events WHERE type='error' GROUP BY name</code> — 一行 SQL 完成分群計數</li>
<li><strong>跨欄位過濾</strong>：<code>WHERE type='error' AND name LIKE 'terminal.%' AND ts &gt; '2026-06-18'</code></li>
<li><strong>寫入</strong>：WAL mode 下每秒數千筆 append 寫入</li>
</ul>
<h3 id="events-主表-ddl">Events 主表 DDL</h3>
<p>Events 表的欄位從 <a href="/blog/monitoring/02-log-schema/event-schema-fields/" data-link-title="event.schema.json 完整欄位解說" data-link-desc="監控事件的 JSON Schema 定義 — 每個欄位的語意、必填/選填、資料型別和設計理由">event.schema.json</a> 的 JSON 結構推導。Source 的 nested object 攤平成獨立 column — 方便 SQL 查詢和索引，不需要每次從 JSON 裡 extract。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">events</span><span class="w"> </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="n">id</span><span class="w"> </span><span class="nb">INTEGER</span><span class="w"> </span><span class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</span><span class="w"> </span><span class="n">AUTOINCREMENT</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">    </span><span class="n">v</span><span class="w"> </span><span class="nb">INTEGER</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="w"> </span><span class="k">DEFAULT</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">    </span><span class="k">type</span><span class="w"> </span><span class="nb">TEXT</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">    </span><span class="n">name</span><span class="w"> </span><span class="nb">TEXT</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</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="n">ts</span><span class="w"> </span><span class="nb">TEXT</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">    </span><span class="n">source_sdk</span><span class="w"> </span><span class="nb">TEXT</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">    </span><span class="n">source_app</span><span class="w"> </span><span class="nb">TEXT</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">    </span><span class="n">source_version</span><span class="w"> </span><span class="nb">TEXT</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">    </span><span class="n">source_platform</span><span class="w"> </span><span class="nb">TEXT</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">    </span><span class="n">source_os</span><span class="w"> </span><span class="nb">TEXT</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">    </span><span class="n">session_id</span><span class="w"> </span><span class="nb">TEXT</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">    </span><span class="n">session_started</span><span class="w"> </span><span class="nb">TEXT</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w">    </span><span class="k">level</span><span class="w"> </span><span class="nb">TEXT</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w">    </span><span class="k">data</span><span class="w"> </span><span class="nb">TEXT</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w">    </span><span class="n">error_message</span><span class="w"> </span><span class="nb">TEXT</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="w">    </span><span class="n">error_stack</span><span class="w"> </span><span class="nb">TEXT</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="w">    </span><span class="n">error_type</span><span class="w"> </span><span class="nb">TEXT</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="w">    </span><span class="n">receive_ts</span><span class="w"> </span><span class="nb">TEXT</span><span class="w">
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="w"></span><span class="p">);</span></span></span></code></pre></div><p><code>source_sdk</code> 獨立成 column 讓「按 SDK 來源篩選」（<code>WHERE source_sdk = 'python'</code>）不需要從 JSON extract。<code>data</code> 用 TEXT 存 JSON。SQLite 沒有原生 JSON 型別，但 3.38+ 支援 <code>json_extract()</code> 函式做查詢（<code>WHERE json_extract(data, '$.duration_ms') &gt; 1000</code>）。<code>session_id</code> 獨立成 column 讓 session 回放的 JOIN 不需要 JSON extract。<code>error_stack</code> 獨立成 column 讓 error 調查時全文搜尋 stack trace 不需要 JSON extract。<code>receive_ts</code> 是 collector 收到事件的時間，和 SDK 端的 <code>ts</code> 對照可估算 clock drift。</p>
<p>PostgreSQL 版本的差異：<code>data</code> 改成 <code>JSONB</code> 型別（原生索引和查詢）、<code>source_*</code> 可保持為 nested JSON（PostgreSQL 的 JSONB 查詢效能足夠）或維持攤平（和 SQLite 版本保持一致）。</p>
<h3 id="建議索引">建議索引</h3>
<p>建表時一起建索引，覆蓋 dashboard 的核心查詢模式：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="k">INDEX</span><span class="w"> </span><span class="n">idx_type_ts</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">events</span><span class="p">(</span><span class="k">type</span><span class="p">,</span><span class="w"> </span><span class="n">ts</span><span class="p">);</span><span class="w">    </span><span class="c1">-- 按 type + 時間過濾（error 列表、趨勢圖）
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">INDEX</span><span class="w"> </span><span class="n">idx_session</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">events</span><span class="p">(</span><span class="n">session_id</span><span class="p">);</span><span class="w">   </span><span class="c1">-- 按 session 回放
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">INDEX</span><span class="w"> </span><span class="n">idx_name</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">events</span><span class="p">(</span><span class="n">name</span><span class="p">);</span><span class="w">            </span><span class="c1">-- 按 name 分群計數（功能使用排行）</span></span></span></code></pre></div><p>Day-one 建表時就建，不是效能出問題後才加。</p>
<h3 id="適用規模">適用規模</h3>
<p>單日事件量在十萬筆以下、SQLite 資料庫在 1GB 以下。索引查詢在毫秒級完成。自用工具和小型團隊的日常使用通常在這個範圍。</p>
<h3 id="分層保留與降採樣">分層保留與降採樣</h3>
<p>保留策略從查詢需求反推，每一種查詢需要的資料粒度和回溯深度不同。回溯越深的查詢需要的粒度越粗 — debug 需要最近幾天的逐筆事件，cohort 留存需要一整年的資料但每週一筆聚合數字就夠。</p>
<table>
  <thead>
      <tr>
          <th>查詢用途</th>
          <th>需要的粒度</th>
          <th>回溯深度</th>
          <th>對應表</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Debug 定位</td>
          <td>逐筆原始</td>
          <td>天</td>
          <td>events</td>
      </tr>
      <tr>
          <td>Funnel</td>
          <td>逐筆 event</td>
          <td>週～月</td>
          <td>events</td>
      </tr>
      <tr>
          <td>Error 趨勢</td>
          <td>每小時計數</td>
          <td>月～季</td>
          <td>hourly_summary</td>
      </tr>
      <tr>
          <td>Cohort</td>
          <td>每天計數</td>
          <td>季～年</td>
          <td>daily_summary</td>
      </tr>
      <tr>
          <td>RFM 分群</td>
          <td>每月聚合</td>
          <td>年</td>
          <td>monthly_summary</td>
      </tr>
  </tbody>
</table>
<p>SQLite 中的實作是三張摘要表加定期 job：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">-- 摘要表
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">hourly_summary</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">    </span><span class="n">hour</span><span class="w"> </span><span class="nb">TEXT</span><span class="p">,</span><span class="w"> </span><span class="k">type</span><span class="w"> </span><span class="nb">TEXT</span><span class="p">,</span><span class="w"> </span><span class="n">name</span><span class="w"> </span><span class="nb">TEXT</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">    </span><span class="k">count</span><span class="w"> </span><span class="nb">INTEGER</span><span class="p">,</span><span class="w"> </span><span class="n">error_count</span><span class="w"> </span><span class="nb">INTEGER</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">    </span><span class="k">UNIQUE</span><span class="p">(</span><span class="n">hour</span><span class="p">,</span><span class="w"> </span><span class="k">type</span><span class="p">,</span><span class="w"> </span><span class="n">name</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="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">daily_summary</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">    </span><span class="nb">date</span><span class="w"> </span><span class="nb">TEXT</span><span class="p">,</span><span class="w"> </span><span class="k">type</span><span class="w"> </span><span class="nb">TEXT</span><span class="p">,</span><span class="w"> </span><span class="n">name</span><span class="w"> </span><span class="nb">TEXT</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">    </span><span class="k">count</span><span class="w"> </span><span class="nb">INTEGER</span><span class="p">,</span><span class="w"> </span><span class="n">unique_sessions</span><span class="w"> </span><span class="nb">INTEGER</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">    </span><span class="k">UNIQUE</span><span class="p">(</span><span class="nb">date</span><span class="p">,</span><span class="w"> </span><span class="k">type</span><span class="p">,</span><span class="w"> </span><span class="n">name</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w"></span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w"></span><span class="c1">-- 降採樣（Downsample，每小時跑一次，幂等 — 重跑只更新不重複）
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">OR</span><span class="w"> </span><span class="k">REPLACE</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="n">hourly_summary</span><span class="w"> </span><span class="p">(</span><span class="n">hour</span><span class="p">,</span><span class="w"> </span><span class="k">type</span><span class="p">,</span><span class="w"> </span><span class="n">name</span><span class="p">,</span><span class="w"> </span><span class="k">count</span><span class="p">,</span><span class="w"> </span><span class="n">error_count</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">strftime</span><span class="p">(</span><span class="s1">&#39;%Y-%m-%dT%H:00:00&#39;</span><span class="p">,</span><span class="w"> </span><span class="n">ts</span><span class="p">),</span><span class="w"> </span><span class="k">type</span><span class="p">,</span><span class="w"> </span><span class="n">name</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w">       </span><span class="k">COUNT</span><span class="p">(</span><span class="o">*</span><span class="p">),</span><span class="w"> </span><span class="k">SUM</span><span class="p">(</span><span class="k">CASE</span><span class="w"> </span><span class="k">WHEN</span><span class="w"> </span><span class="k">type</span><span class="o">=</span><span class="s1">&#39;error&#39;</span><span class="w"> </span><span class="k">THEN</span><span class="w"> </span><span class="mi">1</span><span class="w"> </span><span class="k">ELSE</span><span class="w"> </span><span class="mi">0</span><span class="w"> </span><span class="k">END</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="n">events</span><span class="w">
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="n">ts</span><span class="w"> </span><span class="o">&gt;=</span><span class="w"> </span><span class="n">datetime</span><span class="p">(</span><span class="s1">&#39;now&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;-1 hour&#39;</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="w"></span><span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w"> </span><span class="mi">2</span><span class="p">,</span><span class="w"> </span><span class="mi">3</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="w"></span><span class="c1">-- 清理（Purge，每天跑一次，分批刪除避免長時間鎖定）
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="c1"></span><span class="k">DELETE</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">events</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">rowid</span><span class="w"> </span><span class="k">IN</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="w">  </span><span class="k">SELECT</span><span class="w"> </span><span class="n">rowid</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">events</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">ts</span><span class="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="n">datetime</span><span class="p">(</span><span class="s1">&#39;now&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;-7 days&#39;</span><span class="p">)</span><span class="w"> </span><span class="k">LIMIT</span><span class="w"> </span><span class="mi">10000</span><span class="w">
</span></span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="w"></span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="w"></span><span class="c1">-- 重複執行直到影響行數為 0
</span></span></span><span class="line"><span class="ln">26</span><span class="cl"><span class="c1"></span><span class="k">DELETE</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">hourly_summary</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">hour</span><span class="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="n">datetime</span><span class="p">(</span><span class="s1">&#39;now&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;-90 days&#39;</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">27</span><span class="cl"><span class="w"></span><span class="k">DELETE</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">daily_summary</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="nb">date</span><span class="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="n">datetime</span><span class="p">(</span><span class="s1">&#39;now&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;-365 days&#39;</span><span class="p">);</span></span></span></code></pre></div><p>保留期限由 collector config 設定，數字的來源是「哪些查詢需要回溯多遠」：</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">retention</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">raw_events</span><span class="p">:</span><span class="w"> </span><span class="l">7d</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">hourly_summary</span><span class="p">:</span><span class="w"> </span><span class="l">90d</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">daily_summary</span><span class="p">:</span><span class="w"> </span><span class="l">365d</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">monthly_summary</span><span class="p">:</span><span class="w"> </span><span class="l">forever</span></span></span></code></pre></div><p>Storage interface 的 <code>Downsample()</code> 和 <code>Purge()</code> 由 collector 的定時排程觸發（Go 的 <code>time.Ticker</code>）。每個 storage backend 各自實作 — SQLite 用上述 SQL、PostgreSQL 用相同邏輯但可以加 partial index 加速、時間序列 DB 的 continuous aggregate 和 retention policy 原生支援。</p>
<h3 id="為什麼是聚合而非抽樣">為什麼是聚合而非抽樣</h3>
<p>原始事件的保留期到期後，需要決定如何保留歷史統計。降採樣有兩種思路。<strong>抽樣保留</strong>是同事件名稱（<code>name</code> 欄位）同小時保留一筆原始事件、刪除其餘，保留了逐筆查詢能力但喪失準確計數。<strong>聚合摘要</strong>是把一小時內的事件壓成一筆計數記錄，喪失逐筆細節但保留準確統計。</p>
<p>Collector 選擇聚合摘要——捨棄逐筆細節，換取準確計數。降採樣後的資料用途是趨勢圖和長期統計，這些查詢需要「過去 30 天每小時的 error 總數」而非「某一筆原始 error 的 stack trace」。</p>
<p>這意味著原始事件 purge（定期清理過期事件）後，超過保留期的逐筆查詢會回傳空結果。Dashboard 在回溯超過原始事件保留期的時間範圍時，應切換到上方的摘要表（<code>hourly_summary</code>/<code>daily_summary</code>）查詢——顯示趨勢圖而非事件列表。設計方向是查詢 API 的 <code>from</code> 參數超過 <code>retention.raw_events</code> 時自動降級到摘要表，或回傳提示告知 client 該時間範圍只有聚合資料（初版 collector 尚未實作此降級邏輯）。</p>
<h3 id="觸發切換到-postgresql-的訊號">觸發切換到 PostgreSQL 的訊號</h3>
<p><strong>寫入爭搶</strong>：SQLite 是單寫者模型。高併發寫入（多個 SDK 同時 flush、每秒數百筆以上持續發生）會出現 <code>database is locked</code> 錯誤。WAL mode 能緩解但不能根治。</p>
<p><strong>聚合查詢效能不足</strong>：Dashboard 需要的聚合查詢（「過去 30 天每小時的 error 數量趨勢」「funnel 的每步轉換率」）在資料量成長後變慢。SQLite 沒有 parallel query 和 partial index 等進階 OLAP 能力。</p>
<p><strong>跨實例需求</strong>：需要多個 collector 實例共用同一個資料庫時，SQLite 的單檔案模型無法跨主機存取。</p>
<h2 id="postgresql-backend分析觸發">PostgreSQL Backend（分析觸發）</h2>
<p>PostgreSQL 是獨立的資料庫 server，提供多連線並行寫入、進階索引（GIN for JSONB、partial index）和完整的 SQL 分析能力。切換到 PostgreSQL 意味著 collector 從「零依賴單一 binary」變成「binary + 外部 DB」，運維複雜度上升。</p>
<h3 id="觸發條件">觸發條件</h3>
<p>SQLite 的寫入爭搶或聚合效能成為瓶頸時切換。具體訊號：<code>database is locked</code> 錯誤頻率超過每分鐘一次、或 dashboard 的聚合查詢超過 3 秒。</p>
<h3 id="切換方式">切換方式</h3>
<p>切換是 config change：把 <code>--storage=sqlite</code> 改成 <code>--storage=postgres --dsn=postgres://...</code>。資料遷移用匯出 + 匯入完成：</p>
<ol>
<li>從 SQLite 匯出事件為 JSONL（<code>monitor export --format=jsonl</code>）</li>
<li>在 PostgreSQL 建立 events 表（schema 和 SQLite 相同，data 欄位改用 JSONB）</li>
<li>匯入 JSONL 到 PostgreSQL（<code>monitor import --storage=postgres --file=events.jsonl</code>）</li>
<li>切換啟動參數、確認查詢正常後停用 SQLite 檔案</li>
</ol>
<p>Storage interface 保證 collector 的 ingestion、query、rule engine 邏輯不需要改動 — 只有 storage implementation 層切換。</p>
<h3 id="能力增量">能力增量</h3>
<ul>
<li><strong>並行寫入</strong>：多個 SDK 同時 flush 不會 lock</li>
<li><strong>JSONB 索引</strong>：對 data 欄位的特定 key 建索引（<code>CREATE INDEX ON events ((data-&gt;&gt;'name'))</code>）</li>
<li><strong>Window function</strong>：funnel 和 cohort 分析的 SQL 基礎</li>
<li><strong>Read replica</strong>：寫入和查詢分離，dashboard 的查詢不影響 ingestion 效能</li>
</ul>
<h2 id="時間序列-db-backend長期演進">時間序列 DB Backend（長期演進）</h2>
<p>時間序列資料庫（TimescaleDB、InfluxDB、VictoriaMetrics）專門為高頻 append 寫入和時間分桶聚合設計。TimescaleDB 基於 PostgreSQL 擴展，Storage interface 的 PostgreSQL implementation 可以直接複用、加上 hypertable 和 continuous aggregate。</p>
<h3 id="觸發條件-1">觸發條件</h3>
<p>每秒數萬筆以上的持續寫入、或需要自動 downsampling（每分鐘的原始資料保留 7 天、每小時的聚合保留 90 天、每天的聚合永久保留）。多數自用工具和小型團隊不會到達這個規模。</p>
<h3 id="能力增量-1">能力增量</h3>
<ul>
<li><strong>時間分桶原生操作</strong>：<code>time_bucket('1 hour', ts)</code> 替代手動 DATE_TRUNC</li>
<li><strong>Continuous aggregate</strong>：預計算的聚合結果自動更新</li>
<li><strong>壓縮</strong>：歷史資料自動壓縮，TB 級資料可查詢</li>
<li><strong>Retention policy</strong>：按時間自動清理舊資料</li>
</ul>
<h2 id="jsonl-匯出debug-用途">JSONL 匯出（debug 用途）</h2>
<p>JSONL 不作為主要 storage backend，而是作為匯出格式保留人類可讀性和 grep 友好性。<code>monitor export --format=jsonl</code> 把 storage 中的事件匯出為每行一個 JSON 物件的檔案，讓開發者可以用 grep / jq 做臨時查詢或把資料搬到其他工具。</p>
<p>JSONL 匯出也是備份和遷移的中介格式 — SQLite 損壞時從 JSONL 重建、切換到 PostgreSQL 時從 JSONL 匯入。</p>
<p>匯出使用 streaming — 從 storage 逐筆讀取、逐行寫出檔案，記憶體使用和事件總量無關。300 萬筆事件（約 900MB JSONL）的匯出不需要載入全部資料到記憶體。匯出的 JSONL 檔案包含事件明文（已 redaction 的欄位除外），匯出後不受 collector 的存取控制保護，應注意存放位置和存取權限。</p>
<h2 id="演進原則">演進原則</h2>
<p><strong>按觀察到的瓶頸切換</strong>。<code>database is locked</code> 錯誤頻率、聚合查詢延遲、磁碟使用量 — 這些是可觀察的訊號。「未來可能有百萬筆事件」是預測。按訊號行動，不按預測行動。</p>
<p><strong>切換是 config change</strong>。Storage interface 確保切換 backend 時 collector 的其他邏輯（ingestion、query API、rule engine、dashboard）不需要改動。切換的成本是資料遷移，不是程式碼重寫。</p>
<p><strong>SQLite 是安全的起點</strong>。多數開源使用者會停留在 SQLite backend — 單日萬筆以下、索引查詢毫秒級、零依賴部署。只有明確的效能瓶頸才值得引入外部 DB 的運維成本。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>Collector 的整體架構 → <a href="/blog/monitoring/04-collector/architecture/" data-link-title="Collector 架構" data-link-desc="HTTP endpoint → JSON Schema 驗證 → 儲存 → 查詢 → rule engine 的五段式處理鏈路">Collector 架構</a></li>
<li>查詢 API 的設計（跨 backend 統一） → <a href="/blog/monitoring/04-collector/query-api/" data-link-title="查詢 API 設計" data-link-desc="CLI grep 友好的 JSONL 結構 &#43; HTTP 查詢 endpoint — 兩種查詢介面各自的適用場景和設計要點">查詢 API 設計</a></li>
<li>資料庫選型的通用指南 → <a href="/blog/backend/01-database/" data-link-title="模組一：資料庫與持久化" data-link-desc="整理 SQL、transaction、migration 與 repository adapter 的後端實務">backend 01 資料庫</a></li>
<li>效能瓶頸的判讀方法 → <a href="/blog/backend/09-performance-capacity/" data-link-title="模組九：效能工程與容量規劃" data-link-desc="把『目前配置能撐多少、要加多少機器』變成可量化、可驗證、可改進的工程流程">backend 09 效能容量</a></li>
<li>水平擴展的基礎概念 → <a href="/blog/devops/02-horizontal-scaling/" data-link-title="模組二：水平擴展" data-link-desc="一個實例不夠時怎麼加第二個 — stateless 設計、shared storage、session 處理的工程約束">DevOps 水平擴展</a></li>
<li>Error fingerprint 的 DDL 擴充 → <a href="/blog/monitoring/04-collector/error-fingerprint/" data-link-title="Error Fingerprint 與去重分群" data-link-desc="把大量 error 事件歸組成可管理的 issue 列表 — fingerprint 演算法、message normalization、error_groups 表設計、自架方案的務實邊界">Error Fingerprint 與去重分群</a></li>
</ul>
]]></content:encoded></item><item><title>模組七：突發流量應對</title><link>https://tarrragon.github.io/blog/devops/07-burst-traffic/</link><pubDate>Sat, 20 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/devops/07-burst-traffic/</guid><description>&lt;p>回答「流量突然暴增時怎麼不掛」。突發流量和穩定高流量的處理策略不同 — 突發有時間限制，撐過去就恢復正常。&lt;/p>
&lt;h2 id="待寫章節">待寫章節&lt;/h2>
&lt;ul>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 突發流量的分類（可預期 vs 不可預期、持續時間和倍率）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 降級策略（動態取樣、事件優先級、功能降級、聚合前移）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Queue 緩衝（Kafka / NATS / Redis Streams 做 burst buffer）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 規模分級應對表（自用 → 中型 → 大型 → 商業網站）&lt;/li>
&lt;/ul>
&lt;h2 id="跨分類引用">跨分類引用&lt;/h2>
&lt;ul>
&lt;li>← &lt;a href="https://tarrragon.github.io/blog/devops/03-traffic-management/" data-link-title="模組三：流量管控" data-link-desc="收到的流量超過處理能力時怎麼辦 — 背壓、rate limit、熔斷、bulkhead 四種防護機制">devops 模組三 流量管控&lt;/a>：背壓和 rate limit 是突發應對的基礎元件&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/monitoring/04-collector/" data-link-title="模組四：Collector 設計" data-link-desc="收 → 驗 → 存 → 查 → 觸發的完整鏈路 — Go 單一 binary、可插拔 Storage Backend、rule engine">monitoring 模組四 Collector&lt;/a>：Collector 的 ingestion scaling 是本模組的應用場景&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">backend 非同步佇列&lt;/a>：Queue 的選型和操作實務&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/devops/05-capacity-planning/" data-link-title="模組五：容量規劃與壓力測試" data-link-desc="要準備多少資源才夠 — 壓力測試方法、峰值估算、成本模型、規模拐點的判斷">devops 模組五 容量規劃&lt;/a>：預期突發的容量預備&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/monitoring/04-collector/data-integrity/" data-link-title="端到端資料完整性" data-link-desc="從 SDK 到 storage 的資料損失地圖 — 每個環節的損失類型、控制策略、完整性指標、被自己 SDK DDoS 的防護">端到端資料完整性&lt;/a>：被自己 SDK DDoS 的三種場景&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>回答「流量突然暴增時怎麼不掛」。突發流量和穩定高流量的處理策略不同 — 突發有時間限制，撐過去就恢復正常。</p>
<h2 id="待寫章節">待寫章節</h2>
<ul>
<li><input checked="" disabled="" type="checkbox"> 突發流量的分類（可預期 vs 不可預期、持續時間和倍率）</li>
<li><input checked="" disabled="" type="checkbox"> 降級策略（動態取樣、事件優先級、功能降級、聚合前移）</li>
<li><input checked="" disabled="" type="checkbox"> Queue 緩衝（Kafka / NATS / Redis Streams 做 burst buffer）</li>
<li><input checked="" disabled="" type="checkbox"> 規模分級應對表（自用 → 中型 → 大型 → 商業網站）</li>
</ul>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>← <a href="/blog/devops/03-traffic-management/" data-link-title="模組三：流量管控" data-link-desc="收到的流量超過處理能力時怎麼辦 — 背壓、rate limit、熔斷、bulkhead 四種防護機制">devops 模組三 流量管控</a>：背壓和 rate limit 是突發應對的基礎元件</li>
<li>→ <a href="/blog/monitoring/04-collector/" data-link-title="模組四：Collector 設計" data-link-desc="收 → 驗 → 存 → 查 → 觸發的完整鏈路 — Go 單一 binary、可插拔 Storage Backend、rule engine">monitoring 模組四 Collector</a>：Collector 的 ingestion scaling 是本模組的應用場景</li>
<li>→ <a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">backend 非同步佇列</a>：Queue 的選型和操作實務</li>
<li>→ <a href="/blog/devops/05-capacity-planning/" data-link-title="模組五：容量規劃與壓力測試" data-link-desc="要準備多少資源才夠 — 壓力測試方法、峰值估算、成本模型、規模拐點的判斷">devops 模組五 容量規劃</a>：預期突發的容量預備</li>
<li>→ <a href="/blog/monitoring/04-collector/data-integrity/" data-link-title="端到端資料完整性" data-link-desc="從 SDK 到 storage 的資料損失地圖 — 每個環節的損失類型、控制策略、完整性指標、被自己 SDK DDoS 的防護">端到端資料完整性</a>：被自己 SDK DDoS 的三種場景</li>
</ul>
]]></content:encoded></item><item><title>Ingestion Scaling</title><link>https://tarrragon.github.io/blog/monitoring/04-collector/ingestion-scaling/</link><pubDate>Sat, 20 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/04-collector/ingestion-scaling/</guid><description>&lt;p>Ingestion scaling 處理的是「大量事件同時湧入 collector 時怎麼辦」。這和 storage scaling（&lt;a href="https://tarrragon.github.io/blog/monitoring/04-collector/scaling-evolution/" data-link-title="規模演進" data-link-desc="可插拔 Storage Backend 架構 — SQLite 預設、PostgreSQL 觸發切換、時間序列 DB 長期演進">SQLite → PostgreSQL 的可插拔 backend&lt;/a>）是兩個獨立的擴展軸 — storage scaling 解決「查得動嗎」，ingestion scaling 解決「收得下嗎」。一個 collector 可能 storage 用 PostgreSQL（查詢能力足夠）但 ingestion 撐不住（HTTP 請求太多），反之亦然。&lt;/p>
&lt;h2 id="四層防線">四層防線&lt;/h2>
&lt;p>每一層在不同規模觸發，由近到遠依序啟用。前一層能擋住的流量不需要啟用後一層。本章的四層按防線位置劃分（SDK / Collector / 基礎設施兩層）。DevOps 的&lt;a href="https://tarrragon.github.io/blog/devops/07-burst-traffic/scale-tier-response/" data-link-title="規模分級應對表" data-link-desc="自用級 → 中型 → 大型 → 商業網站級的四級應對方案 — 每級的觸發條件、架構組成和成本">規模分級應對表&lt;/a>按 events/sec 量級劃分（Tier 1-4），兩者視角不同但覆蓋相同的擴展路徑。&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;th>適用規模&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>一&lt;/td>
 &lt;td>SDK 端取樣 + 聚合前移&lt;/td>
 &lt;td>SDK&lt;/td>
 &lt;td>高頻事件超過合理粒度&lt;/td>
 &lt;td>所有規模&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>二&lt;/td>
 &lt;td>Collector 單機背壓 + rate limit&lt;/td>
 &lt;td>Collector&lt;/td>
 &lt;td>寫入 channel 接近滿載&lt;/td>
 &lt;td>自用 ~ 小型&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>三&lt;/td>
 &lt;td>水平擴展（多 collector + LB）&lt;/td>
 &lt;td>基礎設施&lt;/td>
 &lt;td>單機 CPU / 連線數飽和&lt;/td>
 &lt;td>中型 ~ 大型&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>四&lt;/td>
 &lt;td>Queue 解耦（Kafka / NATS）&lt;/td>
 &lt;td>基礎設施&lt;/td>
 &lt;td>突發流量超過 collector 群的即時處理能力&lt;/td>
 &lt;td>商業網站級&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="第一層sdk-端的流量控制">第一層：SDK 端的流量控制&lt;/h2>
&lt;p>流量控制的最有效位置是事件產生的源頭。SDK 端減少的事件量，後面每一層都不需要處理。&lt;/p>
&lt;h3 id="動態取樣">動態取樣&lt;/h3>
&lt;p>SDK 在收到 collector 的 HTTP 429（Too Many Requests）回應時，自動降低取樣率。恢復正常後逐步回升。&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">正常 → sampling 1.0
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">收到 429 → sampling 降到 0.5
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">持續 429 → sampling 降到 0.1
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">連續 10 次成功 → sampling 回升到 0.5
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">連續 30 次成功 → sampling 回到 1.0&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>動態取樣的控制邏輯在 SDK 端實作，不需要 collector 端額外支援 — 429 回應碼就是觸發訊號。和&lt;a href="https://tarrragon.github.io/blog/monitoring/03-sdk-design/sensor-lifecycle-management/" data-link-title="感測器生命週期管理" data-link-desc="產品生命週期的五個階段各啟用什麼感測器 — feature flag 整合、取樣率動態調整、感測器開關的可觀察性">感測器生命週期管理&lt;/a>的靜態取樣率互補 — 靜態取樣在 config 中設定、動態取樣在執行期自動調整。&lt;/p>
&lt;h3 id="聚合前移">聚合前移&lt;/h3>
&lt;p>SDK 端累積一段時間的同名事件，送出摘要而非逐筆。適合 metric 類的高頻取樣。&lt;/p>
&lt;p>例：原本每 100ms 送一筆 &lt;code>render.frame_drop&lt;/code>，改成每 5 秒送一筆 &lt;code>render.frame_drop_summary&lt;/code>（帶 count + min + max + avg）。事件數從 50 筆/5s 降到 1 筆/5s。&lt;/p>
&lt;p>聚合前移犧牲事件粒度換取吞吐量。只適合「趨勢比每筆細節重要」的 metric 類事件。Error 和 lifecycle 事件不做聚合 — 每筆的 stack trace 和狀態轉換都有 debug 價值。&lt;/p></description><content:encoded><![CDATA[<p>Ingestion scaling 處理的是「大量事件同時湧入 collector 時怎麼辦」。這和 storage scaling（<a href="/blog/monitoring/04-collector/scaling-evolution/" data-link-title="規模演進" data-link-desc="可插拔 Storage Backend 架構 — SQLite 預設、PostgreSQL 觸發切換、時間序列 DB 長期演進">SQLite → PostgreSQL 的可插拔 backend</a>）是兩個獨立的擴展軸 — storage scaling 解決「查得動嗎」，ingestion scaling 解決「收得下嗎」。一個 collector 可能 storage 用 PostgreSQL（查詢能力足夠）但 ingestion 撐不住（HTTP 請求太多），反之亦然。</p>
<h2 id="四層防線">四層防線</h2>
<p>每一層在不同規模觸發，由近到遠依序啟用。前一層能擋住的流量不需要啟用後一層。本章的四層按防線位置劃分（SDK / Collector / 基礎設施兩層）。DevOps 的<a href="/blog/devops/07-burst-traffic/scale-tier-response/" data-link-title="規模分級應對表" data-link-desc="自用級 → 中型 → 大型 → 商業網站級的四級應對方案 — 每級的觸發條件、架構組成和成本">規模分級應對表</a>按 events/sec 量級劃分（Tier 1-4），兩者視角不同但覆蓋相同的擴展路徑。</p>
<table>
  <thead>
      <tr>
          <th>層</th>
          <th>機制</th>
          <th>在哪裡做</th>
          <th>觸發條件</th>
          <th>適用規模</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>一</td>
          <td>SDK 端取樣 + 聚合前移</td>
          <td>SDK</td>
          <td>高頻事件超過合理粒度</td>
          <td>所有規模</td>
      </tr>
      <tr>
          <td>二</td>
          <td>Collector 單機背壓 + rate limit</td>
          <td>Collector</td>
          <td>寫入 channel 接近滿載</td>
          <td>自用 ~ 小型</td>
      </tr>
      <tr>
          <td>三</td>
          <td>水平擴展（多 collector + LB）</td>
          <td>基礎設施</td>
          <td>單機 CPU / 連線數飽和</td>
          <td>中型 ~ 大型</td>
      </tr>
      <tr>
          <td>四</td>
          <td>Queue 解耦（Kafka / NATS）</td>
          <td>基礎設施</td>
          <td>突發流量超過 collector 群的即時處理能力</td>
          <td>商業網站級</td>
      </tr>
  </tbody>
</table>
<h2 id="第一層sdk-端的流量控制">第一層：SDK 端的流量控制</h2>
<p>流量控制的最有效位置是事件產生的源頭。SDK 端減少的事件量，後面每一層都不需要處理。</p>
<h3 id="動態取樣">動態取樣</h3>
<p>SDK 在收到 collector 的 HTTP 429（Too Many Requests）回應時，自動降低取樣率。恢復正常後逐步回升。</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">正常 → sampling 1.0
</span></span><span class="line"><span class="ln">2</span><span class="cl">收到 429 → sampling 降到 0.5
</span></span><span class="line"><span class="ln">3</span><span class="cl">持續 429 → sampling 降到 0.1
</span></span><span class="line"><span class="ln">4</span><span class="cl">連續 10 次成功 → sampling 回升到 0.5
</span></span><span class="line"><span class="ln">5</span><span class="cl">連續 30 次成功 → sampling 回到 1.0</span></span></code></pre></div><p>動態取樣的控制邏輯在 SDK 端實作，不需要 collector 端額外支援 — 429 回應碼就是觸發訊號。和<a href="/blog/monitoring/03-sdk-design/sensor-lifecycle-management/" data-link-title="感測器生命週期管理" data-link-desc="產品生命週期的五個階段各啟用什麼感測器 — feature flag 整合、取樣率動態調整、感測器開關的可觀察性">感測器生命週期管理</a>的靜態取樣率互補 — 靜態取樣在 config 中設定、動態取樣在執行期自動調整。</p>
<h3 id="聚合前移">聚合前移</h3>
<p>SDK 端累積一段時間的同名事件，送出摘要而非逐筆。適合 metric 類的高頻取樣。</p>
<p>例：原本每 100ms 送一筆 <code>render.frame_drop</code>，改成每 5 秒送一筆 <code>render.frame_drop_summary</code>（帶 count + min + max + avg）。事件數從 50 筆/5s 降到 1 筆/5s。</p>
<p>聚合前移犧牲事件粒度換取吞吐量。只適合「趨勢比每筆細節重要」的 metric 類事件。Error 和 lifecycle 事件不做聚合 — 每筆的 stack trace 和狀態轉換都有 debug 價值。</p>
<h3 id="優先級丟棄">優先級丟棄</h3>
<p>SDK 的離線 buffer 滿時，按優先級丟棄。Error 的 debug 價值最高，最後丟。</p>
<table>
  <thead>
      <tr>
          <th>優先級</th>
          <th>事件類型</th>
          <th>理由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>高</td>
          <td>error</td>
          <td>每筆都可能是需要修的 bug</td>
      </tr>
      <tr>
          <td>高</td>
          <td>lifecycle</td>
          <td>session 邊界和狀態轉換、影響 debug 和 cohort</td>
      </tr>
      <tr>
          <td>中</td>
          <td>metric</td>
          <td>丟幾筆不影響趨勢（聚合摘要仍然有效）</td>
      </tr>
      <tr>
          <td>低</td>
          <td>event</td>
          <td>行為事件在取樣後丟幾筆對 funnel 影響有限</td>
      </tr>
  </tbody>
</table>
<h2 id="第二層collector-單機的防護">第二層：Collector 單機的防護</h2>
<p>Collector 在自身能力範圍內保護自己不被壓垮。和 <a href="/blog/monitoring/04-collector/architecture/" data-link-title="Collector 架構" data-link-desc="HTTP endpoint → JSON Schema 驗證 → 儲存 → 查詢 → rule engine 的五段式處理鏈路">architecture.md 的並發寫入策略</a>直接相關 — 寫入 channel 是背壓的實作基礎。背壓和流量管控的通用概念見 <a href="/blog/devops/03-traffic-management/" data-link-title="模組三：流量管控" data-link-desc="收到的流量超過處理能力時怎麼辦 — 背壓、rate limit、熔斷、bulkhead 四種防護機制">DevOps 流量管控</a>。</p>
<h3 id="寫入-channel-容量--背壓">寫入 channel 容量 + 背壓</h3>
<p>Single-writer goroutine pattern 的 Go channel 有固定容量（如 10,000）。Channel 滿時 HTTP handler 無法送入事件，此時回 429：</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="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">case</span> <span class="nx">writeCh</span> <span class="o">&lt;-</span> <span class="nx">event</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">3</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">4</span><span class="cl"><span class="k">default</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">5</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">6</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">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Channel 容量的設定依據：容量 × 每筆事件的記憶體大小 = 背壓 buffer 的記憶體上限。10,000 筆 × 每筆 ~1KB = ~10MB，對多數機器微不足道。</p>
<h3 id="per-sdk-rate-limiting">Per-SDK rate limiting</h3>
<p>按 source.app（或 API key，啟用認證後）限制每個 SDK 實例的請求速率。防止單一 SDK 的 bug（無限迴圈送事件）打爆 collector。</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="c1">// 每個 source.app 一個 rate limiter</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">limiter</span> <span class="o">:=</span> <span class="nx">rateLimiters</span><span class="p">.</span><span class="nf">GetOrCreate</span><span class="p">(</span><span class="nx">sourceApp</span><span class="p">,</span> <span class="nx">rate</span><span class="p">.</span><span class="nf">Limit</span><span class="p">(</span><span class="mi">100</span><span class="p">))</span> <span class="c1">// 100 events/sec</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="k">if</span> <span class="p">!</span><span class="nx">limiter</span><span class="p">.</span><span class="nf">Allow</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">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></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="k">return</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><h3 id="error-快通道">Error 快通道</h3>
<p>Error 事件不經 rate limit — 它們的 debug 價值最高，且在正常情況下數量遠少於其他類型。Error storm（app 出 bug 導致大量 error）時，error 的量可能暴增，但這正是最需要記錄的時刻。</p>
<p>Error 快通道用獨立的 channel 或跳過 rate limiter 的 check。如果 error 量也超出承載，用第一層的 SDK 端優先級丟棄處理。</p>
<h2 id="第三層水平擴展">第三層：水平擴展</h2>
<p>單機的 CPU、記憶體或網路頻寬飽和時，水平擴展 — 多個 collector 實例分攤流量。水平擴展的通用模式見 <a href="/blog/devops/02-horizontal-scaling/" data-link-title="模組二：水平擴展" data-link-desc="一個實例不夠時怎麼加第二個 — stateless 設計、shared storage、session 處理的工程約束">DevOps 水平擴展</a>。</p>
<h3 id="前提已切換到-postgresql">前提：已切換到 PostgreSQL</h3>
<p>SQLite backend 不支援水平擴展。每個 collector 實例有各自的 SQLite 檔案，無法合併查詢。水平擴展的前提是所有 collector 寫入同一個 PostgreSQL。</p>
<h3 id="架構">架構</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">SDK ──→ Load Balancer (nginx / HAProxy)
</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></span><span class="line"><span class="ln"> 4</span><span class="cl">        ▼         ▼
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">   Collector A  Collector B
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        │         │
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        └────┬────┘
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">             ▼
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        PostgreSQL
</span></span><span class="line"><span class="ln">10</span><span class="cl">             │
</span></span><span class="line"><span class="ln">11</span><span class="cl">             ▼
</span></span><span class="line"><span class="ln">12</span><span class="cl">         Dashboard</span></span></code></pre></div><p>Collector 實例是 stateless 的 — 不在記憶體保存查詢狀態，所有持久化資料在 PostgreSQL。任何 collector 接收的事件都能被任何 dashboard 查到。</p>
<p>Load balancer 用 round-robin 或 least-connections 分配。不需要 sticky session — collector 不保存 session 狀態。</p>
<h3 id="多機的-downsample-和-purge">多機的 Downsample 和 Purge</h3>
<p>Downsample 和 Purge job 只能由一個 collector 實例執行（避免重複處理）。用 PostgreSQL 的 advisory lock 或外部的 distributed lock 確保單一執行者。</p>
<h2 id="第四層queue-解耦">第四層：Queue 解耦</h2>
<p>突發流量超過 collector 群的即時處理能力時，在 collector 和 storage 之間插入 message queue 做緩衝。Queue 緩衝的通用概念見 <a href="/blog/devops/07-burst-traffic/" data-link-title="模組七：突發流量應對" data-link-desc="行銷活動或新聞曝光帶來 10x-100x 流量時怎麼撐 — 突發分類、降級策略、queue 緩衝、規模分級應對">DevOps 突發流量應對</a>，message queue 的選型見 <a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">Backend 模組三 非同步與訊息佇列</a>。</p>
<h3 id="架構-1">架構</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">SDK ──→ Collector (ingestion only)
</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></span><span class="line"><span class="ln"> 4</span><span class="cl">        Queue (Kafka / NATS / Redis Streams)
</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></span><span class="line"><span class="ln"> 7</span><span class="cl">        ▼         ▼
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    Worker A   Worker B
</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></span><span class="line"><span class="ln">11</span><span class="cl">             ▼
</span></span><span class="line"><span class="ln">12</span><span class="cl">        PostgreSQL</span></span></code></pre></div><p>Collector 的職責簡化為「接收 → 驗證 → 寫入 queue → 回 202」。寫入 queue 比寫入 DB 快得多（append-only、不需要索引更新），collector 的吞吐上限大幅提升。</p>
<p>Worker 從 queue 消費、寫入 PostgreSQL。Worker 按自己的速度處理 — 高峰時 queue 積壓，高峰過後 worker 消化積壓。Queue 的持久化保證事件不遺失。</p>
<h3 id="queue-的選擇">Queue 的選擇</h3>
<table>
  <thead>
      <tr>
          <th>Queue</th>
          <th>適合場景</th>
          <th>代價</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Kafka</td>
          <td>高吞吐（百萬 events/sec）、需要 replay</td>
          <td>運維重（ZooKeeper / KRaft）</td>
      </tr>
      <tr>
          <td>NATS JetStream</td>
          <td>輕量、Go 原生、足夠的持久化</td>
          <td>生態較小</td>
      </tr>
      <tr>
          <td>Redis Streams</td>
          <td>簡單、如果已有 Redis</td>
          <td>不是專門的 queue、持久化設定需注意</td>
      </tr>
  </tbody>
</table>
<p>自架監控工具的 queue 層級推薦 NATS JetStream — Go 原生 client、單 binary 部署、JetStream 提供持久化和 replay。</p>
<h3 id="觸發條件">觸發條件</h3>
<p>Queue 解耦的引入時機是「collector 群已水平擴展但仍無法處理突發流量」。如果日常流量 collector 群能處理，只有行銷活動 / 新聞曝光的短暫高峰需要 queue 緩衝，queue 的維護成本可能高於收益 — 考慮用第一層的動態取樣在源頭降量。</p>
<h2 id="功能分層整合">功能分層整合</h2>
<p>擴展 <a href="/blog/monitoring/04-collector/feature-tier-boundary/" data-link-title="功能分層與 Backend 選擇" data-link-desc="SQLite 層和 PostgreSQL 層各自承載哪些功能 — 分界線是查詢模式而非資料量、觸發升級的是功能需求而非規模成長">功能分層與 Backend 選擇</a> 的分層表，加入 ingestion 維度：</p>
<table>
  <thead>
      <tr>
          <th>功能層級</th>
          <th>Storage</th>
          <th>Ingestion</th>
          <th>適用規模</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>SQLite 層</td>
          <td>SQLite embedded</td>
          <td>單 collector + 背壓</td>
          <td>自用 ~ 小型團隊</td>
      </tr>
      <tr>
          <td>PostgreSQL 層</td>
          <td>PostgreSQL</td>
          <td>多 collector + LB</td>
          <td>中型 ~ 大型</td>
      </tr>
      <tr>
          <td>Queue 層</td>
          <td>PostgreSQL</td>
          <td>Collector + Queue + Worker</td>
          <td>商業網站級</td>
      </tr>
  </tbody>
</table>
<p>每一層是前一層的超集 — Queue 層包含 PostgreSQL 層的所有查詢能力，加上 ingestion 的 queue 緩衝。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>Collector 的並發寫入策略 → <a href="/blog/monitoring/04-collector/architecture/" data-link-title="Collector 架構" data-link-desc="HTTP endpoint → JSON Schema 驗證 → 儲存 → 查詢 → rule engine 的五段式處理鏈路">Collector 架構</a></li>
<li>Storage 端的擴展設計 → <a href="/blog/monitoring/04-collector/scaling-evolution/" data-link-title="規模演進" data-link-desc="可插拔 Storage Backend 架構 — SQLite 預設、PostgreSQL 觸發切換、時間序列 DB 長期演進">規模演進</a></li>
<li>功能分層的定義 → <a href="/blog/monitoring/04-collector/feature-tier-boundary/" data-link-title="功能分層與 Backend 選擇" data-link-desc="SQLite 層和 PostgreSQL 層各自承載哪些功能 — 分界線是查詢模式而非資料量、觸發升級的是功能需求而非規模成長">功能分層與 Backend 選擇</a></li>
<li>背壓和流量管控的通用概念 → <a href="/blog/devops/03-traffic-management/" data-link-title="模組三：流量管控" data-link-desc="收到的流量超過處理能力時怎麼辦 — 背壓、rate limit、熔斷、bulkhead 四種防護機制">DevOps 流量管控</a></li>
<li>水平擴展的通用模式 → <a href="/blog/devops/02-horizontal-scaling/" data-link-title="模組二：水平擴展" data-link-desc="一個實例不夠時怎麼加第二個 — stateless 設計、shared storage、session 處理的工程約束">DevOps 水平擴展</a></li>
<li>突發流量應對 → <a href="/blog/devops/07-burst-traffic/" data-link-title="模組七：突發流量應對" data-link-desc="行銷活動或新聞曝光帶來 10x-100x 流量時怎麼撐 — 突發分類、降級策略、queue 緩衝、規模分級應對">DevOps 突發流量</a></li>
<li>Message queue 選型 → <a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">Backend 模組三 非同步與訊息佇列</a></li>
<li>端到端資料完整性（資料損失地圖、完整性指標）→ <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>讀寫分離與查詢擴展</title><link>https://tarrragon.github.io/blog/monitoring/04-collector/read-write-separation/</link><pubDate>Mon, 22 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/04-collector/read-write-separation/</guid><description>&lt;p>Monitor 的寫入路徑（SDK flush → HTTP endpoint → Storage）和讀取路徑（Dashboard 刷新、Debug 即席查詢、聚合趨勢、Rule engine 評估）在 SQLite 階段不太會互相干擾 — 事件量小、查詢簡單、WAL mode 讓讀寫各自進行。進入 PostgreSQL 層之後，兩條路徑的負載都會成長，而且成長方向不同。本章處理的是讀寫開始互相干擾時的辨識訊號和應對策略。&lt;/p>
&lt;h2 id="讀寫競爭的具體場景">讀寫競爭的具體場景&lt;/h2>
&lt;p>Monitor 的 PostgreSQL 層同時承擔三種負載，各自的資源消耗特性不同。&lt;/p>
&lt;h3 id="寫入負載">寫入負載&lt;/h3>
&lt;p>SDK flush 是 Monitor 的主要寫入來源。多個 SDK 同時 flush 時，collector 透過連線池並行寫入 PostgreSQL。每筆 INSERT 涉及主表寫入 + 索引更新（&lt;code>idx_type_ts&lt;/code>、&lt;code>idx_session&lt;/code>、&lt;code>idx_name&lt;/code>）。寫入量隨 SDK 數量和 flush 頻率線性成長。&lt;/p>
&lt;p>Downsample job 是另一種寫入：定期把原始事件聚合到 &lt;code>hourly_summary&lt;/code> / &lt;code>daily_summary&lt;/code>。Downsample 執行時同時做大量 SELECT（讀原始事件）和 INSERT（寫摘要），佔用連線和 I/O。&lt;/p>
&lt;h3 id="dashboard-讀取負載">Dashboard 讀取負載&lt;/h3>
&lt;p>Dashboard 是穩定的高頻背景負載。總覽頁每 30 秒刷新、Error 列表每分鐘刷新、趨勢圖每分鐘重算。每次刷新執行一到多個聚合查詢（&lt;code>GROUP BY name&lt;/code>、&lt;code>COUNT(*)&lt;/code>、時間分桶）。&lt;/p>
&lt;p>Dashboard 查詢的掃描量隨資料累積成長。「過去 7 天每小時的 error 數量」在第一週掃描幾千筆，三個月後掃描幾十萬筆。如果沒有用 &lt;code>hourly_summary&lt;/code> 摘要表、而是直接查原始 events 表，查詢時間會隨資料量線性增加。&lt;/p>
&lt;h3 id="debug-即席讀取負載">Debug 即席讀取負載&lt;/h3>
&lt;p>Debug 查詢是偶發的突增負載。開發者在排查問題時，可能用 session_id 拉出整條事件鏈、用 error name 掃描最近 N 筆 stack trace、或用 &lt;code>data-&amp;gt;&amp;gt;'duration_ms'&lt;/code> 做 ad-hoc 效能分析。這些查詢的特徵是不可預測、偶發但延遲敏感 — 開發者在等結果。&lt;/p>
&lt;h3 id="競爭發生在哪">競爭發生在哪&lt;/h3>
&lt;p>三種負載打同一個 PostgreSQL 時，競爭集中在兩個資源：&lt;/p>
&lt;p>&lt;strong>連線池&lt;/strong>：collector 的 &lt;code>SetMaxOpenConns&lt;/code> 是固定值（例如 20）。如果 ingestion 佔用 15 條連線做批次 INSERT、dashboard 需要 3 條做聚合查詢、debug 需要 2 條做 ad-hoc 查詢 — 剛好佔滿。這時 downsample job 啟動需要連線，會排隊等待。&lt;/p>
&lt;p>&lt;strong>I/O 頻寬&lt;/strong>：聚合查詢需要掃描大量資料（sequential scan 或 index scan + heap access），跟 INSERT 的隨機寫入搶磁碟 I/O。在 HDD 或低階 SSD 上，一個 heavy 聚合查詢可以讓同時進行的 INSERT latency 從毫秒跳到十毫秒。&lt;/p>
&lt;p>&lt;strong>鎖競爭&lt;/strong>：PostgreSQL 的 MVCC 讓 SELECT 跟 INSERT 不互相阻塞（reader 不等 writer），但 Downsample 的 INSERT OR REPLACE 跟 ingestion 的 INSERT 可能在同一張表上競爭 row-level lock。長時間的 aggregation query 也可能觸發 &lt;code>idle in transaction&lt;/code> 問題，佔住連線不釋放。&lt;/p>
&lt;h2 id="辨識訊號">辨識訊號&lt;/h2>
&lt;p>讀寫競爭的辨識訊號是「寫入跟讀取的效能同時退化，而且退化是交互的」：&lt;/p>
&lt;ul>
&lt;li>Ingestion 的 INSERT latency 在 dashboard 刷新時段（每 30 秒）出現週期性尖峰&lt;/li>
&lt;li>Dashboard 的聚合查詢在 SDK 高峰 flush 時段（例：每整點、app 啟動潮）變慢&lt;/li>
&lt;li>Debug 即席查詢在 downsample job 執行期間 timeout&lt;/li>
&lt;li>PostgreSQL 的 &lt;code>pg_stat_activity&lt;/code> 顯示多個 &lt;code>idle in transaction&lt;/code> 或 &lt;code>waiting&lt;/code> 狀態&lt;/li>
&lt;li>連線池使用率持續高於 80%，偶發 &lt;code>too many connections&lt;/code> 或連線等待&lt;/li>
&lt;/ul>
&lt;p>單純的寫入慢（沒有讀取影響）或單純的查詢慢（沒有寫入影響）不是讀寫競爭，可能是索引缺失或查詢效率問題。讀寫競爭的特徵是「兩邊同時退化、一邊忙的時候另一邊也變慢」。&lt;/p></description><content:encoded><![CDATA[<p>Monitor 的寫入路徑（SDK flush → HTTP endpoint → Storage）和讀取路徑（Dashboard 刷新、Debug 即席查詢、聚合趨勢、Rule engine 評估）在 SQLite 階段不太會互相干擾 — 事件量小、查詢簡單、WAL mode 讓讀寫各自進行。進入 PostgreSQL 層之後，兩條路徑的負載都會成長，而且成長方向不同。本章處理的是讀寫開始互相干擾時的辨識訊號和應對策略。</p>
<h2 id="讀寫競爭的具體場景">讀寫競爭的具體場景</h2>
<p>Monitor 的 PostgreSQL 層同時承擔三種負載，各自的資源消耗特性不同。</p>
<h3 id="寫入負載">寫入負載</h3>
<p>SDK flush 是 Monitor 的主要寫入來源。多個 SDK 同時 flush 時，collector 透過連線池並行寫入 PostgreSQL。每筆 INSERT 涉及主表寫入 + 索引更新（<code>idx_type_ts</code>、<code>idx_session</code>、<code>idx_name</code>）。寫入量隨 SDK 數量和 flush 頻率線性成長。</p>
<p>Downsample job 是另一種寫入：定期把原始事件聚合到 <code>hourly_summary</code> / <code>daily_summary</code>。Downsample 執行時同時做大量 SELECT（讀原始事件）和 INSERT（寫摘要），佔用連線和 I/O。</p>
<h3 id="dashboard-讀取負載">Dashboard 讀取負載</h3>
<p>Dashboard 是穩定的高頻背景負載。總覽頁每 30 秒刷新、Error 列表每分鐘刷新、趨勢圖每分鐘重算。每次刷新執行一到多個聚合查詢（<code>GROUP BY name</code>、<code>COUNT(*)</code>、時間分桶）。</p>
<p>Dashboard 查詢的掃描量隨資料累積成長。「過去 7 天每小時的 error 數量」在第一週掃描幾千筆，三個月後掃描幾十萬筆。如果沒有用 <code>hourly_summary</code> 摘要表、而是直接查原始 events 表，查詢時間會隨資料量線性增加。</p>
<h3 id="debug-即席讀取負載">Debug 即席讀取負載</h3>
<p>Debug 查詢是偶發的突增負載。開發者在排查問題時，可能用 session_id 拉出整條事件鏈、用 error name 掃描最近 N 筆 stack trace、或用 <code>data-&gt;&gt;'duration_ms'</code> 做 ad-hoc 效能分析。這些查詢的特徵是不可預測、偶發但延遲敏感 — 開發者在等結果。</p>
<h3 id="競爭發生在哪">競爭發生在哪</h3>
<p>三種負載打同一個 PostgreSQL 時，競爭集中在兩個資源：</p>
<p><strong>連線池</strong>：collector 的 <code>SetMaxOpenConns</code> 是固定值（例如 20）。如果 ingestion 佔用 15 條連線做批次 INSERT、dashboard 需要 3 條做聚合查詢、debug 需要 2 條做 ad-hoc 查詢 — 剛好佔滿。這時 downsample job 啟動需要連線，會排隊等待。</p>
<p><strong>I/O 頻寬</strong>：聚合查詢需要掃描大量資料（sequential scan 或 index scan + heap access），跟 INSERT 的隨機寫入搶磁碟 I/O。在 HDD 或低階 SSD 上，一個 heavy 聚合查詢可以讓同時進行的 INSERT latency 從毫秒跳到十毫秒。</p>
<p><strong>鎖競爭</strong>：PostgreSQL 的 MVCC 讓 SELECT 跟 INSERT 不互相阻塞（reader 不等 writer），但 Downsample 的 INSERT OR REPLACE 跟 ingestion 的 INSERT 可能在同一張表上競爭 row-level lock。長時間的 aggregation query 也可能觸發 <code>idle in transaction</code> 問題，佔住連線不釋放。</p>
<h2 id="辨識訊號">辨識訊號</h2>
<p>讀寫競爭的辨識訊號是「寫入跟讀取的效能同時退化，而且退化是交互的」：</p>
<ul>
<li>Ingestion 的 INSERT latency 在 dashboard 刷新時段（每 30 秒）出現週期性尖峰</li>
<li>Dashboard 的聚合查詢在 SDK 高峰 flush 時段（例：每整點、app 啟動潮）變慢</li>
<li>Debug 即席查詢在 downsample job 執行期間 timeout</li>
<li>PostgreSQL 的 <code>pg_stat_activity</code> 顯示多個 <code>idle in transaction</code> 或 <code>waiting</code> 狀態</li>
<li>連線池使用率持續高於 80%，偶發 <code>too many connections</code> 或連線等待</li>
</ul>
<p>單純的寫入慢（沒有讀取影響）或單純的查詢慢（沒有寫入影響）不是讀寫競爭，可能是索引缺失或查詢效率問題。讀寫競爭的特徵是「兩邊同時退化、一邊忙的時候另一邊也變慢」。</p>
<h2 id="read-replica-分離">Read Replica 分離</h2>
<p>Read replica 是 Monitor 在 PostgreSQL 層後的第一步讀寫分離。概念簡單：寫入走 primary、讀取走 replica，兩者物理隔離。</p>
<h3 id="架構">架構</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">SDK ──→ Collector
</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></span><span class="line"><span class="ln"> 4</span><span class="cl">        ▼                ▼
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">   Primary (write)   Replica (read)
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        │                │
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        │  replication →  │
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        │                │
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        ▼                ▼
</span></span><span class="line"><span class="ln">10</span><span class="cl">   Ingestion        Dashboard + Debug
</span></span><span class="line"><span class="ln">11</span><span class="cl">   Downsample       聚合查詢</span></span></code></pre></div><p>Collector 持有兩個連線池 — 一個連 primary（用於 <code>Store()</code>、<code>Downsample()</code>、<code>Purge()</code>），一個連 replica（用於 <code>Query()</code>、<code>Aggregate()</code>、Dashboard 的所有讀取）。</p>
<h3 id="storage-interface-的調整">Storage interface 的調整</h3>
<p>現有的 <code>BasicStorage</code> interface 不需要改動。實作層在初始化時接收兩個 DSN（primary + replica），內部根據操作類型選擇連線池：</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">type</span> <span class="nx">PostgresStorage</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">primary</span> <span class="o">*</span><span class="nx">sql</span><span class="p">.</span><span class="nx">DB</span>  <span class="c1">// write operations</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">replica</span> <span class="o">*</span><span class="nx">sql</span><span class="p">.</span><span class="nx">DB</span>  <span class="c1">// read operations (nil = use primary)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>當 replica DSN 未設定時，所有操作走 primary — 行為跟目前一樣，不破壞 single-instance 部署。</p>
<h3 id="replica-lag-對各查詢場景的影響">Replica lag 對各查詢場景的影響</h3>
<p>PostgreSQL streaming replication 的 lag 在同 AZ 通常 &lt; 100ms，跨 AZ 可能到秒級。各查詢場景對 lag 的容忍度不同：</p>
<table>
  <thead>
      <tr>
          <th>查詢場景</th>
          <th>Lag 容忍度</th>
          <th>走哪裡</th>
          <th>理由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Dashboard 總覽</td>
          <td>秒級可接受</td>
          <td>Replica</td>
          <td>30 秒刷新一次，lag &lt; 1 秒不影響判讀</td>
      </tr>
      <tr>
          <td>Error 列表</td>
          <td>秒級可接受</td>
          <td>Replica</td>
          <td>新 error 晚一秒出現在列表上不影響 debug</td>
      </tr>
      <tr>
          <td>聚合趨勢圖</td>
          <td>分鐘級可接受</td>
          <td>Replica</td>
          <td>趨勢圖本身就是歷史資料的聚合</td>
      </tr>
      <tr>
          <td>Funnel / Cohort</td>
          <td>分鐘級可接受</td>
          <td>Replica</td>
          <td>分析查詢看的是天級或週級的資料</td>
      </tr>
      <tr>
          <td>Debug 即席查詢</td>
          <td>數秒可能不接受</td>
          <td>Primary</td>
          <td>開發者剛送一筆 test event 想立刻查到</td>
      </tr>
      <tr>
          <td>Rule engine 查歷史</td>
          <td>秒級可接受</td>
          <td>Replica</td>
          <td>Rule 的閾值判斷容忍短暫延遲</td>
      </tr>
  </tbody>
</table>
<p>Debug 即席查詢的 lag 問題是 read-after-write 一致性 — 開發者從 SDK 送出 test event 後立刻查詢，如果查 replica 可能還沒同步到。解法是讓 debug query API 提供 <code>consistency=strong</code> 參數，強制走 primary。預設走 replica（大部分 debug 查的是歷史資料），只有需要 read-after-write 時切 primary。</p>
<h3 id="引入時機">引入時機</h3>
<p>Read replica 的引入時機是「辨識訊號」段列出的讀寫競爭訊號持續出現，而且已經做過基本最佳化（索引補齊、dashboard 改讀 summary 表、downsample job 調整執行時段避開高峰）仍然不夠。</p>
<p>引入 read replica 的成本是多一台 PostgreSQL 實例（或 managed service 的 read replica 選項）和 replication 設定。Monitor 的 PostgreSQL 層已經承擔外部 DB 的運維成本，加 replica 是增量而非從零開始。</p>
<h2 id="預聚合作為讀取面的第一道防線">預聚合作為讀取面的第一道防線</h2>
<p>在引入 read replica 之前，預聚合是降低讀取負載最有效的方式 — 不改架構、不加機器、只改查詢的資料來源。</p>
<p>Monitor 已經有 <code>hourly_summary</code> 跟 <code>daily_summary</code> 兩張摘要表（見 <a href="/blog/monitoring/04-collector/scaling-evolution/" data-link-title="規模演進" data-link-desc="可插拔 Storage Backend 架構 — SQLite 預設、PostgreSQL 觸發切換、時間序列 DB 長期演進">規模演進</a>）。Dashboard 的趨勢圖跟 Error 計數應該讀摘要表而非原始 events 表。</p>
<p>預聚合沒處理到的讀取負載是「需要原始事件的查詢」— Debug 即席查詢（看 stack trace）、Session 回放（看事件序列）、Funnel 分析（跨 session JOIN）。這些查詢必須掃描原始資料，預聚合無法取代。當這類查詢的負載開始擠壓寫入時，才是引入 read replica 的時機。</p>
<p>概念上，預聚合就是 <a href="/blog/backend/knowledge-cards/recording-rule/" data-link-title="Recording Rule" data-link-desc="說明把 query-time 聚合計算推到寫入時的 pre-aggregation 機制">recording rule</a> 在關聯式資料庫的實作。Downsample job 定期執行 aggregation query、把結果寫入 summary 表，dashboard 讀 summary 表而非重算 raw data。Monitor 的 <code>hourly_summary</code> 等同於 Prometheus 的 recording rule output、PostgreSQL 的 <a href="/blog/backend/knowledge-cards/materialized-view/" data-link-title="Materialized View" data-link-desc="說明預先計算並儲存查詢結果以加速讀取的資料結構">materialized view</a> 等同於 TSDB 的 continuous aggregate。</p>
<h2 id="cqrs-的判讀訊號">CQRS 的判讀訊號</h2>
<p>Read replica 解決的是「讀寫搶同一台機器的 I/O 跟連線」。當問題不只是資源競爭、而是讀寫的資料形狀根本不同時，read replica 不夠 — 需要獨立的 <a href="/blog/backend/knowledge-cards/read-model/" data-link-title="Read Model" data-link-desc="說明為查詢場景建立的讀取模型，與正式狀態的責任分離">read model</a>。</p>
<p><a href="/blog/backend/knowledge-cards/cqrs/" data-link-title="CQRS" data-link-desc="說明讀寫不對稱時為何需要分離查詢與寫入責任、分離的判準與代價">CQRS</a> 的完整概念見知識卡。以下是 Monitor 情境下，什麼訊號出現時該考慮從 read replica 往 CQRS 方向演進。</p>
<h3 id="訊號一讀取需要的資料形狀跟-events-表差異太大">訊號一：讀取需要的資料形狀跟 events 表差異太大</h3>
<p>Monitor 的 events 表是 append-only 的正規化結構（一筆事件一個 row）。如果讀取面需要的是：</p>
<ul>
<li>每個 user 的行為摘要（最近登入、最常用功能、累計 error 數）— 需要跨所有事件聚合成 per-user profile</li>
<li>即時的 error fingerprint 索引（相同 stack trace 的 error 自動分群、計數、追蹤首次出現時間）— 需要維護一張反正規化的 error group 表</li>
<li>跨 session 的 funnel conversion 快照 — 需要維護一張 pre-computed funnel 表</li>
</ul>
<p>這些讀取形狀無法用 <code>SELECT FROM events</code> + 索引高效產生，需要獨立的 read model 持續從 events 推算。</p>
<h3 id="訊號二預聚合的種類和刷新頻率失控">訊號二：預聚合的種類和刷新頻率失控</h3>
<p>Summary 表從 2 張（hourly + daily）增長到 5 張、10 張，每張的刷新頻率從每小時變成每分鐘。Downsample job 的執行時間從秒級增長到分鐘級，開始擠壓 ingestion。</p>
<p>這時候 summary 表已經不只是「摘要」，而是事實上的 read model — 專門為讀取需求設計的獨立資料結構。承認這個事實、把 summary 表的維護從 Downsample job 拆出來成為獨立的 projection consumer，就是進入 <a href="/blog/backend/knowledge-cards/cqrs/" data-link-title="CQRS" data-link-desc="說明讀寫不對稱時為何需要分離查詢與寫入責任、分離的判準與代價">CQRS</a> 的起點。</p>
<h3 id="訊號三讀取跟寫入需要獨立擴展">訊號三：讀取跟寫入需要獨立擴展</h3>
<p>寫入量穩定（SDK 數量不變），但讀取面因為新增 dashboard、新增分析維度、新增使用者而持續成長。Read replica 可以加多台分攤讀取，但每台 replica 仍然存的是跟 primary 一樣的 events 表結構 — 讀取查詢的複雜度不變，只是分攤到更多機器。</p>
<p>獨立的 read model 可以用完全不同的 schema（反正規化、pre-joined、pre-aggregated），讓讀取查詢從 O(N) 的聚合變成 O(1) 的 lookup。這是 CQRS 的核心價值 — 讀取面的效能不再受限於寫入面的資料結構。</p>
<h3 id="monitor-目前的位置">Monitor 目前的位置</h3>
<p>Monitor 目前在「SQLite → PostgreSQL → Read Replica」這條路徑的前半段。MVP 用 SQLite、功能需求觸發 PostgreSQL、讀寫競爭觸發 Read Replica。CQRS 是更遠的演進方向，只有上述三個訊號明確出現時才值得引入。</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">SQLite（零依賴）
</span></span><span class="line"><span class="ln">2</span><span class="cl">  → PostgreSQL（聚合分析觸發）
</span></span><span class="line"><span class="ln">3</span><span class="cl">    → 預聚合 summary 表（讀取負載觸發）
</span></span><span class="line"><span class="ln">4</span><span class="cl">      → Read Replica（讀寫競爭觸發）
</span></span><span class="line"><span class="ln">5</span><span class="cl">        → 獨立 read model / CQRS（資料形狀不對稱觸發）</span></span></code></pre></div><p>每一步都是被具體的效能訊號或功能需求推動的，跟 Monitor 整體的「按觀察到的瓶頸切換」原則一致。教學的價值在於讓讀者在每一步都知道「下一步是什麼、什麼訊號出現時該走」— 而不是在 SQLite 階段就預先設計 CQRS。</p>
<h2 id="跟-backend-的概念對照">跟 Backend 的概念對照</h2>
<p>Monitor 的讀寫分離路徑跟 backend 教材的概念有直接對應：</p>
<table>
  <thead>
      <tr>
          <th>Monitor 演進階段</th>
          <th>Backend 對應概念</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>SQLite WAL（讀寫各自進行）</td>
          <td><a href="/blog/backend/knowledge-cards/write-ahead-log/" data-link-title="Write-Ahead Log" data-link-desc="說明資料庫如何先寫入 log 再合併回主資料，以提供持久性與崩潰復原">WAL mode</a> 的 reader-writer 並行</td>
      </tr>
      <tr>
          <td>PostgreSQL summary 表</td>
          <td><a href="/blog/backend/knowledge-cards/materialized-view/" data-link-title="Materialized View" data-link-desc="說明預先計算並儲存查詢結果以加速讀取的資料結構">Materialized view</a> 的最簡實作</td>
      </tr>
      <tr>
          <td>Read replica</td>
          <td><a href="/blog/backend/01-database/state-ownership-query-boundary/" data-link-title="1.8 State Ownership 與 Query Boundary" data-link-desc="正式狀態 vs 派生狀態的責任分層、CQRS / event sourcing / materialized view、四種 query 邊界">1.8 Query Boundary</a> 的讀寫分流</td>
      </tr>
      <tr>
          <td>獨立 read model</td>
          <td><a href="/blog/backend/knowledge-cards/cqrs/" data-link-title="CQRS" data-link-desc="說明讀寫不對稱時為何需要分離查詢與寫入責任、分離的判準與代價">CQRS</a> + <a href="/blog/backend/knowledge-cards/projection/" data-link-title="Projection" data-link-desc="說明從事件流或資料變更推算出查詢用讀取視圖的轉換機制">Projection</a></td>
      </tr>
      <tr>
          <td>Downsample job → 獨立 worker</td>
          <td><a href="/blog/backend/knowledge-cards/event-sourcing/" data-link-title="Event Sourcing" data-link-desc="說明用 append-only 事件流取代 mutable state 作為正式紀錄的設計模式、需求判準與代價">Event sourcing</a> 架構中 projection consumer 的起點</td>
      </tr>
  </tbody>
</table>
<p>Monitor 的規模演進路徑是 backend 概念的具體實例 — 從自用工具到小型服務、從單機到讀寫分離、從 summary 表到可能的 CQRS，每一步都能回到 backend 教材找到概念基礎。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>Storage backend 的可插拔架構 → <a href="/blog/monitoring/04-collector/scaling-evolution/" data-link-title="規模演進" data-link-desc="可插拔 Storage Backend 架構 — SQLite 預設、PostgreSQL 觸發切換、時間序列 DB 長期演進">規模演進</a></li>
<li>功能分層的定義 → <a href="/blog/monitoring/04-collector/feature-tier-boundary/" data-link-title="功能分層與 Backend 選擇" data-link-desc="SQLite 層和 PostgreSQL 層各自承載哪些功能 — 分界線是查詢模式而非資料量、觸發升級的是功能需求而非規模成長">功能分層與 Backend 選擇</a></li>
<li>Ingestion 端的流量防線 → <a href="/blog/monitoring/04-collector/ingestion-scaling/" data-link-title="Ingestion Scaling" data-link-desc="四層防線應對 ingestion 端的流量擴展 — SDK 取樣、Collector 背壓、水平擴展、Queue 解耦">Ingestion Scaling</a></li>
<li>讀寫分離的通用概念 → <a href="/blog/backend/knowledge-cards/cqrs/" data-link-title="CQRS" data-link-desc="說明讀寫不對稱時為何需要分離查詢與寫入責任、分離的判準與代價">CQRS 知識卡</a></li>
<li>資料庫層的讀寫分離設計 → <a href="/blog/backend/01-database/state-ownership-query-boundary/" data-link-title="1.8 State Ownership 與 Query Boundary" data-link-desc="正式狀態 vs 派生狀態的責任分層、CQRS / event sourcing / materialized view、四種 query 邊界">1.8 State Ownership 與 Query Boundary</a></li>
<li>觀測領域的讀取路徑設計 → <a href="/blog/backend/04-observability/observability-query-design/" data-link-title="4.23 觀測查詢設計" data-link-desc="把觀測資料的讀取路徑當系統設計問題處理：三種查詢模式、storage tiering、pre-aggregation 與資源治理">4.23 觀測查詢設計</a></li>
</ul>
]]></content:encoded></item><item><title>9.13 擴展軸與 Stateless 前提</title><link>https://tarrragon.github.io/blog/backend/09-performance-capacity/scaling-axes/</link><pubDate>Wed, 27 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/09-performance-capacity/scaling-axes/</guid><description>&lt;p>「要換更大的機器、還是要加更多臺機器？」這個問題在規模成長過程中會反覆出現。垂直擴展（scale-up）與水平擴展（scale-out）對應不同壓力來源、各自承擔不同代價：垂直擴展用「換更大的機器」換取簡單、水平擴展用「加更多機器」換取彈性。規劃容量時先判讀自己的壓力屬於哪一種、再選對應的擴展軸 — 選錯軸的代價會在事故時放大。&lt;/p>
&lt;h2 id="兩個軸的責任差異">兩個軸的責任差異&lt;/h2>
&lt;p>垂直擴展指把單一機器換成更高規格（更多 CPU / 記憶體 / IOPS），水平擴展指增加機器數量。同樣是「加資源」，兩者面對的工程問題完全不同。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>維度&lt;/th>
 &lt;th>垂直擴展（scale-up）&lt;/th>
 &lt;th>水平擴展（scale-out）&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>操作單位&lt;/td>
 &lt;td>換一臺機器&lt;/td>
 &lt;td>加 N 臺機器&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>程式假設&lt;/td>
 &lt;td>不需要改&lt;/td>
 &lt;td>必須是 stateless 或有狀態同步機制&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>容量上限&lt;/td>
 &lt;td>單機物理規格上限&lt;/td>
 &lt;td>理論上線性擴展，實際受協調成本限制&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>成本曲線&lt;/td>
 &lt;td>規格升級非線性（高階機器溢價）&lt;/td>
 &lt;td>線性，但每臺要付 baseline 成本&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>變更要停機或 failover、頻率低&lt;/td>
 &lt;td>隨時可加減、頻率高&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>適合場景&lt;/td>
 &lt;td>資料庫主節點、stateful 服務、單點計算&lt;/td>
 &lt;td>API、worker、無狀態服務&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>讀者要從「程式假設」這欄反推自己的選項。如果服務本身是 stateful（資料庫、cache、session store），水平擴展需要設計 partitioning 或 replication；如果是 stateless API server，水平擴展幾乎可以無腦複製。把這個前提搞錯，就會用水平擴展的策略去動 stateful 服務、然後撞牆。&lt;/p>
&lt;h3 id="第三軸拆功能--拆-partitionakf-scale-cube-y--z-軸">第三軸：拆功能 / 拆 partition（AKF Scale Cube Y / Z 軸）&lt;/h3>
&lt;p>兩個軸的對比把擴展簡化成 capacity scaling 的雙軸、但 AKF Scale Cube 模型提了第三軸：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>X 軸（複製 / 水平擴展）&lt;/strong>：本表 scale-out 即此軸、適合 stateless 服務&lt;/li>
&lt;li>&lt;strong>Y 軸（functional decomposition）&lt;/strong>：沿業務邊界拆服務、跟 &lt;a href="https://tarrragon.github.io/blog/backend/10-system-evolution/service-decomposition-boundaries/" data-link-title="10.1 服務拆分與邊界判讀" data-link-desc="整理 monolith vs microservice 取捨、服務邊界判讀訊號、拆分時機與回退路徑">10.1 服務拆分&lt;/a> 對應、適合處理「不同功能的擴展需求差距大」&lt;/li>
&lt;li>&lt;strong>Z 軸（data partition / sharding）&lt;/strong>：沿資料拆 partition、適合處理「stateful 服務超出單機容量」&lt;/li>
&lt;/ul>
&lt;p>實務系統常同時動兩到三軸：API 走 X 軸水平、按業務拆 Y 軸（user service / order service / payment service）、user service 內部再用 user ID hash 做 Z 軸 sharding。本章焦點在 X 軸、但讀者規劃容量時要記住 Y / Z 軸是同時可用的工具。&lt;/p>
&lt;h2 id="stateless-是水平擴展的前提">Stateless 是水平擴展的前提&lt;/h2>
&lt;p>Stateless 的核心定義是「處理一個請求不依賴前一個請求留下的本機狀態」。Session、本機快取、檔案系統暫存都會破壞 stateless 假設。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>狀態類型&lt;/th>
 &lt;th>是否破壞 stateless&lt;/th>
 &lt;th>緩解方向&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Session 存本機&lt;/td>
 &lt;td>破壞&lt;/td>
 &lt;td>把 session 搬到外部 store（Redis、DB），改用 token 認證&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>上傳檔案存本機&lt;/td>
 &lt;td>破壞&lt;/td>
 &lt;td>改用物件儲存（S3、GCS）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>本機快取&lt;/td>
 &lt;td>視情境&lt;/td>
 &lt;td>共用快取可接受（每臺 cache 各自 build）；強一致快取要外接&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>WebSocket 長連線&lt;/td>
 &lt;td>破壞&lt;/td>
 &lt;td>用 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/sticky-session/" data-link-title="Sticky Session" data-link-desc="說明同一 client 如何在一段時間內持續命中同一個後端實例">sticky session&lt;/a> 或外部 broker（Pub/Sub、Redis）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>本機 cron / 排程&lt;/td>
 &lt;td>破壞&lt;/td>
 &lt;td>改用分散式排程（&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/leader-election/" data-link-title="Leader Election" data-link-desc="從一群對等節點中選出單一主節點負責獨佔工作、leader 失效時自動選新 leader">leader election&lt;/a> 或外部排程服務）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>跨請求的記憶體狀態&lt;/td>
 &lt;td>破壞&lt;/td>
 &lt;td>移到外部 state store&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>很多人以為自己的服務是 stateless、但一上水平擴展就出事，原因常常在這張表的某一行。判讀方式：把單一機器停掉、重新分配流量到其他機器，使用者體驗是否完全無感？如果有任何「重新登入」「上傳消失」「資料看不到」的情境，就有 stateful 殘留。&lt;/p>
&lt;p>這張表覆蓋顯式狀態。&lt;strong>隱式狀態&lt;/strong>（implicit state）是另一類常被忽略的破壞 stateless 因素：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>In-flight request state&lt;/strong>：HTTP/2 stream、gRPC bidirectional stream — 跨多個請求保持的連線級狀態&lt;/li>
&lt;li>&lt;strong>TLS session resumption&lt;/strong>：session ticket 跟 session ID cache 跨連線、若不集中存會降低重連性能&lt;/li>
&lt;li>&lt;strong>Rate limiter state&lt;/strong>：per-user token bucket、滑動視窗 — 看似無狀態的 middleware 其實在記每個 user 的計數&lt;/li>
&lt;li>&lt;strong>連線預熱（connection warm-up）&lt;/strong>：HTTP/2 / gRPC 連線建立成本高、機器接到流量後需要時間熱起來&lt;/li>
&lt;/ul>
&lt;p>這類「看似 stateless 但有 implicit state」是水平擴展撞牆的常見主因。處理方式是把隱式狀態抽到外部 store（rate limit 用 Redis、TLS session 用共用 cache）或設計連線級 sticky。&lt;/p></description><content:encoded><![CDATA[<p>「要換更大的機器、還是要加更多臺機器？」這個問題在規模成長過程中會反覆出現。垂直擴展（scale-up）與水平擴展（scale-out）對應不同壓力來源、各自承擔不同代價：垂直擴展用「換更大的機器」換取簡單、水平擴展用「加更多機器」換取彈性。規劃容量時先判讀自己的壓力屬於哪一種、再選對應的擴展軸 — 選錯軸的代價會在事故時放大。</p>
<h2 id="兩個軸的責任差異">兩個軸的責任差異</h2>
<p>垂直擴展指把單一機器換成更高規格（更多 CPU / 記憶體 / IOPS），水平擴展指增加機器數量。同樣是「加資源」，兩者面對的工程問題完全不同。</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>垂直擴展（scale-up）</th>
          <th>水平擴展（scale-out）</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>操作單位</td>
          <td>換一臺機器</td>
          <td>加 N 臺機器</td>
      </tr>
      <tr>
          <td>程式假設</td>
          <td>不需要改</td>
          <td>必須是 stateless 或有狀態同步機制</td>
      </tr>
      <tr>
          <td>容量上限</td>
          <td>單機物理規格上限</td>
          <td>理論上線性擴展，實際受協調成本限制</td>
      </tr>
      <tr>
          <td>成本曲線</td>
          <td>規格升級非線性（高階機器溢價）</td>
          <td>線性，但每臺要付 baseline 成本</td>
      </tr>
      <tr>
          <td>故障代價</td>
          <td>單點失敗影響整個服務</td>
          <td>一臺壞了還有其他臺、可分流</td>
      </tr>
      <tr>
          <td>變更節奏</td>
          <td>變更要停機或 failover、頻率低</td>
          <td>隨時可加減、頻率高</td>
      </tr>
      <tr>
          <td>適合場景</td>
          <td>資料庫主節點、stateful 服務、單點計算</td>
          <td>API、worker、無狀態服務</td>
      </tr>
  </tbody>
</table>
<p>讀者要從「程式假設」這欄反推自己的選項。如果服務本身是 stateful（資料庫、cache、session store），水平擴展需要設計 partitioning 或 replication；如果是 stateless API server，水平擴展幾乎可以無腦複製。把這個前提搞錯，就會用水平擴展的策略去動 stateful 服務、然後撞牆。</p>
<h3 id="第三軸拆功能--拆-partitionakf-scale-cube-y--z-軸">第三軸：拆功能 / 拆 partition（AKF Scale Cube Y / Z 軸）</h3>
<p>兩個軸的對比把擴展簡化成 capacity scaling 的雙軸、但 AKF Scale Cube 模型提了第三軸：</p>
<ul>
<li><strong>X 軸（複製 / 水平擴展）</strong>：本表 scale-out 即此軸、適合 stateless 服務</li>
<li><strong>Y 軸（functional decomposition）</strong>：沿業務邊界拆服務、跟 <a href="/blog/backend/10-system-evolution/service-decomposition-boundaries/" data-link-title="10.1 服務拆分與邊界判讀" data-link-desc="整理 monolith vs microservice 取捨、服務邊界判讀訊號、拆分時機與回退路徑">10.1 服務拆分</a> 對應、適合處理「不同功能的擴展需求差距大」</li>
<li><strong>Z 軸（data partition / sharding）</strong>：沿資料拆 partition、適合處理「stateful 服務超出單機容量」</li>
</ul>
<p>實務系統常同時動兩到三軸：API 走 X 軸水平、按業務拆 Y 軸（user service / order service / payment service）、user service 內部再用 user ID hash 做 Z 軸 sharding。本章焦點在 X 軸、但讀者規劃容量時要記住 Y / Z 軸是同時可用的工具。</p>
<h2 id="stateless-是水平擴展的前提">Stateless 是水平擴展的前提</h2>
<p>Stateless 的核心定義是「處理一個請求不依賴前一個請求留下的本機狀態」。Session、本機快取、檔案系統暫存都會破壞 stateless 假設。</p>
<table>
  <thead>
      <tr>
          <th>狀態類型</th>
          <th>是否破壞 stateless</th>
          <th>緩解方向</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Session 存本機</td>
          <td>破壞</td>
          <td>把 session 搬到外部 store（Redis、DB），改用 token 認證</td>
      </tr>
      <tr>
          <td>上傳檔案存本機</td>
          <td>破壞</td>
          <td>改用物件儲存（S3、GCS）</td>
      </tr>
      <tr>
          <td>本機快取</td>
          <td>視情境</td>
          <td>共用快取可接受（每臺 cache 各自 build）；強一致快取要外接</td>
      </tr>
      <tr>
          <td>WebSocket 長連線</td>
          <td>破壞</td>
          <td>用 <a href="/blog/backend/knowledge-cards/sticky-session/" data-link-title="Sticky Session" data-link-desc="說明同一 client 如何在一段時間內持續命中同一個後端實例">sticky session</a> 或外部 broker（Pub/Sub、Redis）</td>
      </tr>
      <tr>
          <td>本機 cron / 排程</td>
          <td>破壞</td>
          <td>改用分散式排程（<a href="/blog/backend/knowledge-cards/leader-election/" data-link-title="Leader Election" data-link-desc="從一群對等節點中選出單一主節點負責獨佔工作、leader 失效時自動選新 leader">leader election</a> 或外部排程服務）</td>
      </tr>
      <tr>
          <td>跨請求的記憶體狀態</td>
          <td>破壞</td>
          <td>移到外部 state store</td>
      </tr>
  </tbody>
</table>
<p>很多人以為自己的服務是 stateless、但一上水平擴展就出事，原因常常在這張表的某一行。判讀方式：把單一機器停掉、重新分配流量到其他機器，使用者體驗是否完全無感？如果有任何「重新登入」「上傳消失」「資料看不到」的情境，就有 stateful 殘留。</p>
<p>這張表覆蓋顯式狀態。<strong>隱式狀態</strong>（implicit state）是另一類常被忽略的破壞 stateless 因素：</p>
<ul>
<li><strong>In-flight request state</strong>：HTTP/2 stream、gRPC bidirectional stream — 跨多個請求保持的連線級狀態</li>
<li><strong>TLS session resumption</strong>：session ticket 跟 session ID cache 跨連線、若不集中存會降低重連性能</li>
<li><strong>Rate limiter state</strong>：per-user token bucket、滑動視窗 — 看似無狀態的 middleware 其實在記每個 user 的計數</li>
<li><strong>連線預熱（connection warm-up）</strong>：HTTP/2 / gRPC 連線建立成本高、機器接到流量後需要時間熱起來</li>
</ul>
<p>這類「看似 stateless 但有 implicit state」是水平擴展撞牆的常見主因。處理方式是把隱式狀態抽到外部 store（rate limit 用 Redis、TLS session 用共用 cache）或設計連線級 sticky。</p>
<h2 id="auto-scaling-的操作模型">Auto Scaling 的操作模型</h2>
<p>水平擴展通常搭配 <a href="/blog/backend/knowledge-cards/autoscaling/" data-link-title="Autoscaling" data-link-desc="說明系統如何依負載自動調整服務實例數量">auto scaling</a> — 根據訊號自動加減機器數量。常見的擴展訊號跟對應的判讀重點：</p>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>反應速度</th>
          <th>判讀重點</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>CPU 使用率</td>
          <td>中</td>
          <td>通用、但對 I/O bound 服務失準</td>
      </tr>
      <tr>
          <td>記憶體使用率</td>
          <td>慢</td>
          <td>適合判 leak、不適合判尖峰流量</td>
      </tr>
      <tr>
          <td>Request rate (RPS)</td>
          <td>快</td>
          <td>適合 API 服務、需要設定 cool-down 避免抖動</td>
      </tr>
      <tr>
          <td>Queue depth</td>
          <td>快</td>
          <td>適合 worker 服務、queue 是天然 buffer</td>
      </tr>
      <tr>
          <td>Latency P95</td>
          <td>中</td>
          <td>用戶體驗訊號、但已經出現延遲才擴展可能來不及</td>
      </tr>
      <tr>
          <td>自訂業務訊號</td>
          <td>視訊號</td>
          <td>訂單數、活動人數，貼近業務但要自己維護 metric pipeline</td>
      </tr>
  </tbody>
</table>
<p>設定 auto scaling 的判讀順序：先選訊號（CPU vs RPS vs queue depth），再設閾值（避免過早觸發或過晚觸發），最後加 cool-down（避免反覆擴縮造成抖動）。三步驟有一步沒做好就會撞牆。</p>
<p>Auto scaling 不是萬靈丹。三類問題它無法解決：擴展速度跟不上（冷啟動時間視 stack 範圍 5-300 秒、流量尖峰若集中在秒級就來不及）、預測式流量（黑五、新片上線、活動）、stateful 服務（資料庫不能用 auto scaling 加 primary）。這三類要分別用 <a href="/blog/backend/knowledge-cards/predictive-scaling/" data-link-title="Predictive Scaling" data-link-desc="說明用歷史模式或 ML 模型預測流量、提前擴容的 autoscaler 模式">predictive scaling</a>、<a href="/blog/backend/knowledge-cards/scheduled-scaling/" data-link-title="Scheduled Scaling" data-link-desc="說明按已知時間表預先擴容的 autoscaler 模式">scheduled scaling</a> 跟 partitioning 處理。</p>
<h2 id="垂直擴展的天花板">垂直擴展的天花板</h2>
<p>垂直擴展看起來簡單但有兩道牆。</p>
<p>第一道是物理上限。雲端機型的最大規格是有限的：以 2025 年公開資料為例、AWS 的 u 系列 instance（如 <code>u7i-12tb</code>、<code>u-24tb1.metal</code>）可達 24 TiB 記憶體級別、vCPU 數量視 SKU 而異；GCP / Azure 也有對應的 memory-optimized 系列、但具體上限隨年份更新。要查最新規格走 vendor 官方文件、不要拿這裡數字當決策依據。對 stateful workload（例如 OLTP 主節點）真實天花板通常出現在 32-64 vCPU 級別、是 lock contention / context switch / memory bandwidth 等架構因素而非規格上限。</p>
<p>第二道是成本曲線。雲端機型的價格不是線性的、越高階的機型每單位資源越貴。以 AWS general-purpose 機型（m 系列）為例、4 vCPU → 8 vCPU 約 ×1.8、8 → 16 約 ×1.9（接近線性）、但到 48 vCPU 以上會明顯偏離線性外推、特別是 memory-optimized（r 系列）跟 high-memory（x 系列）的高階規格溢價更陡。具體曲線依機型 family 跟雲廠商而異 — 走 vendor calculator 算實際 workload 的成本曲線比抓單一倍數可靠。垂直擴展到一定規模、就算物理上撐得住、財務上也會比水平擴展貴。</p>
<p>對 stateful 服務（特別是主資料庫），垂直擴展常常是第一選擇，因為水平擴展需要重新設計 partitioning。但要清楚兩道牆會在什麼時候撞上：基於目前流量增長率，預估垂直擴展能撐多久？多久之後必須改成水平擴展？這個答案要在「還沒撞牆時」就準備好，不是等到下一次撞牆才開始討論。</p>
<h2 id="水平擴展的隱性成本">水平擴展的隱性成本</h2>
<p>水平擴展看起來彈性、但有它自己的代價。</p>
<p><strong>協調成本</strong>：多臺機器要處理「誰是 leader、誰來執行排程、誰來處理同一筆訂單」這類問題。<a href="/blog/backend/knowledge-cards/consensus-protocol/" data-link-title="Consensus Protocol" data-link-desc="讓多個獨立節點在訊息可能延遲、丟失、亂序的網路下對單一決策達成一致的演算法">consensus protocol</a> 跟 <a href="/blog/backend/knowledge-cards/distributed-lock/" data-link-title="Distributed Lock" data-link-desc="跨機器跨 process 的互斥鎖、用 lease 機制處理 holder 失效">distributed lock</a>（含 leader election、Raft / Paxos 演算法）都會引入新的故障模式跟 latency 代價。</p>
<p><strong>連線池放大</strong>：100 臺機器、每臺對資料庫開 10 個連線，等於對 DB 開 1000 個連線。DB 連線是有限資源，水平擴展應用層的同時要評估資料層連線壓力。常見緩解：connection pooler（PgBouncer）、serverless DB（DynamoDB）、讀寫分離。</p>
<p><strong>狀態同步成本</strong>：cache、session、配置這些「跨機器需要一致」的狀態，要靠外部 store 或 broadcast 機制同步。同步延遲跟頻率會反過來影響服務行為。</p>
<p><strong>Cold start</strong>：新機器啟動到接流量需要時間（image pull、init container、warm-up）。auto scaling 觸發跟流量到達之間的延遲就是這段。冷啟動長的服務（JVM、需要載入大量資料的服務）要預留更多 buffer。</p>
<p><strong>Debug 變難</strong>：請求散落在多臺機器，排查問題需要 log 聚合、trace context。沒有這些基礎設施，水平擴展只會把「一臺機器壞」的問題變成「不知道哪一臺機器壞」的問題。</p>
<h2 id="混合策略">混合策略</h2>
<p>純垂直或純水平在實際系統中都罕見。常見的混合模式：</p>
<ul>
<li><strong>小規模垂直、大規模水平</strong>：早期單機就能撐，先用較大規格降低運維複雜度；流量上來後再轉水平，把每臺機器規格降回中等。</li>
<li><strong>stateless 水平、stateful 垂直</strong>：API server 水平擴展、資料庫主節點垂直擴展、加 read replica 做讀路徑水平擴展。</li>
<li><strong>熱資料水平 sharding、冷資料保持單庫</strong>：把熱表用 partition key 拆到多個 shard，冷表保留在主庫不動。</li>
<li><strong>核心服務垂直保底、邊緣服務水平彈性</strong>：核心交易服務用更大規格降低事故風險，前端、推薦等服務走 auto scaling。</li>
</ul>
<p>選混合策略時，要明確標記每個服務在哪個軸上、極限在哪、下一步轉換點在什麼條件下觸發。沒有這張對照表，混合策略容易變成「每個服務都是特例」、最後沒人記得當初為什麼這樣設計。</p>
<h2 id="判讀訊號">判讀訊號</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>判讀重點</th>
          <th>對應動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>加機器後 QPS 沒提升</td>
          <td>stateful 殘留（本機快取 / session / 鎖）</td>
          <td>找出 stateful 點、移到外部 store，或改回垂直擴展</td>
      </tr>
      <tr>
          <td>加機器後 DB 連線爆掉</td>
          <td>連線池放大、DB 是瓶頸</td>
          <td>加 connection pooler、評估讀寫分離、考慮資料層擴展</td>
      </tr>
      <tr>
          <td>Auto scaling 反覆擴縮</td>
          <td>cool-down 太短或訊號抖動</td>
          <td>加 cool-down、改用更穩定訊號（移動平均、business metric）</td>
      </tr>
      <tr>
          <td>流量尖峰時新機器來不及啟動</td>
          <td>cold start 太長 / 預測訊號不夠早</td>
          <td>改 <a href="/blog/backend/knowledge-cards/scheduled-scaling/" data-link-title="Scheduled Scaling" data-link-desc="說明按已知時間表預先擴容的 autoscaler 模式">scheduled scaling</a> 或 <a href="/blog/backend/knowledge-cards/predictive-scaling/" data-link-title="Predictive Scaling" data-link-desc="說明用歷史模式或 ML 模型預測流量、提前擴容的 autoscaler 模式">predictive scaling</a>、warm pool</td>
      </tr>
      <tr>
          <td>垂直擴展後成本曲線陡升</td>
          <td>撞到高階機型溢價</td>
          <td>評估水平擴展轉型 / 重構 stateful 部分</td>
      </tr>
      <tr>
          <td>水平擴展後事故 MTTR 拉長</td>
          <td>觀測能力跟不上</td>
          <td>補 trace context、結構化 log、service topology</td>
      </tr>
  </tbody>
</table>
<h2 id="常見誤區">常見誤區</h2>
<p>把「加機器」當作所有效能問題的萬靈丹。如果瓶頸在演算法、SQL query、序列化、locks，加機器只會讓問題變得更貴。先用 <a href="/blog/backend/09-performance-capacity/bottleneck-localization/" data-link-title="9.5 瓶頸定位流程" data-link-desc="從 app 到 DB / cache / broker / 第三方 quota 的逐層瓶頸定位">9.5 瓶頸定位流程</a> 確定瓶頸位置，再決定擴展軸。</p>
<p>把 auto scaling 當成「設定完就不用管」。auto scaling 是 reactive 策略，它無法處理可預期的尖峰（活動、新片上線、節日）。預期型流量要用 scheduled / predictive scaling 提前準備。</p>
<p>把 stateless 當成「沒有狀態就好」。WebSocket、long-polling、上傳、檔案處理這類服務天然 stateful、強行水平擴展會出事。要分辨「業務本質 stateful」跟「實作偷懶 stateful」，前者用 partitioning 處理、後者用重構移除。</p>
<h2 id="定位邊界">定位邊界</h2>
<p>本章專注「擴展軸的選擇與前提」。當問題進入具體量化（要加多少臺機器？headroom 多少？），交給 <a href="/blog/backend/09-performance-capacity/capacity-planning/" data-link-title="9.6 容量規劃模型" data-link-desc="peak forecast、headroom budget、growth curve、autoscaling sizing">9.6 容量規劃模型</a>；進入瓶頸定位（瓶頸在哪一層？），交給 <a href="/blog/backend/09-performance-capacity/bottleneck-localization/" data-link-title="9.5 瓶頸定位流程" data-link-desc="從 app 到 DB / cache / broker / 第三方 quota 的逐層瓶頸定位">9.5 瓶頸定位流程</a>；進入服務拆分（要不要先把 stateful 部分拆出來再水平擴展？），交給 <a href="/blog/backend/10-system-evolution/service-decomposition-boundaries/" data-link-title="10.1 服務拆分與邊界判讀" data-link-desc="整理 monolith vs microservice 取捨、服務邊界判讀訊號、拆分時機與回退路徑">10.1 服務拆分與邊界判讀</a>。</p>
<h2 id="案例回寫">案例回寫</h2>
<p>擴展軸選擇可用以下案例回寫。每個案例對應的軸不同，引用時要先辨識案例的主要壓力來源，再對照本章相應段落。</p>
<ul>
<li><a href="/blog/backend/09-performance-capacity/cases/zoom-covid-surge-dynamodb/" data-link-title="9.C18 Zoom：COVID 期間從 1000 萬到 3 億 DAU 的 30 倍突發" data-link-desc="Zoom 在 2020 年 COVID 爆發時、日活從 1000 萬衝到 3 億、用 DynamoDB 撐住會議後端">9.C18 Zoom：COVID 30 倍突發</a> — 案例主軸是「stateless API 層水平擴展、stateful 資料層改用 DynamoDB 移除單點」，直接對應本章「stateless 是水平擴展的前提」段。是本批最貼近 scaling axis 主題的案例。</li>
<li><a href="/blog/backend/09-performance-capacity/cases/riot-games-eks-multi-cluster/" data-link-title="9.C12 Riot Games：246 個 EKS cluster 的多遊戲多地區治理" data-link-desc="Riot Games 從 Mesos 遷移到 EKS、用 246 個 cluster 跨遊戲跨地區治理、年省 1000 萬美金">9.C12 Riot Games：246 個 EKS cluster 的多遊戲多地區治理</a> — 案例展示水平擴展到極端規模後，協調成本（cluster 治理、版本一致性）變成新的瓶頸；對照本章「水平擴展的隱性成本 / 協調成本」段。</li>
<li><a href="/blog/backend/09-performance-capacity/cases/capcom-gaming-dynamodb-eks/" data-link-title="9.C19 Capcom：Resident Evil / Monster Hunter 在 DynamoDB &#43; EKS 上的遊戲後端" data-link-desc="Capcom 把 Resident Evil、Street Fighter、Monster Hunter 遊戲後端跑在 DynamoDB &#43; EKS、單一秒位數延遲、營運成本降 30%">9.C19 Capcom：DynamoDB + EKS 上的遊戲後端</a> — 案例主軸是 KV 業務語意、不是 scaling axis 取捨；但可反向追問「stateful 玩家狀態為何適合 KV vs RDB」、對照本章「stateless 是水平擴展的前提」段中的「狀態類型 vs 緩解方向」表。</li>
<li><a href="/blog/backend/09-performance-capacity/cases/netflix-aurora-consolidation/" data-link-title="9.C23 Netflix：把關聯式 DB 統一到 Aurora、效能 &#43;75%、成本 -28%" data-link-desc="Netflix 把多套關聯式 DB 統一到 Aurora、效能提升 75%、成本下降 28%、串流數十億小時">9.C23 Netflix：把關聯式 DB 統一到 Aurora</a> — 案例主軸是「DB 種類整併」、不直接對應 scale-up vs scale-out；但 Aurora 在 single-primary 規格選擇上隱含了「先垂直、再考慮分散」的策略，可作為「垂直擴展天花板」段的對照組。</li>
</ul>
<p>Zomato 跟 Netflix 不在這份案例清單裡的原因要先講清楚：擴展軸的真實示範案例在後端教材中相對稀缺、09 模組多數案例的主軸落在 vendor 或容量規劃。Zoom 是這四個案例中最貼近教科書 — stateless API 水平 + stateful 改用 DynamoDB 的組合直接示範本章核心。Riot Games 揭示水平到極端規模後協調成本翻轉成新瓶頸。Capcom 跟 Netflix Aurora 不直接示範擴展軸取捨、但用反向追問「為什麼選 KV / 為什麼 single-primary 仍是 default」能把它們的決策放回擴展軸框架。</p>
<h2 id="跨模組路由">跨模組路由</h2>
<ol>
<li>與 <a href="/blog/backend/09-performance-capacity/performance-theory/" data-link-title="9.1 壓測理論與系統行為" data-link-desc="Little&#39;s Law、queueing theory、USL、saturation curve 在容量規劃中的角色">9.1 壓測理論與系統行為</a> 的交接：USL 跟 Little&rsquo;s Law 在理論上推導水平擴展的曲線、本章解釋這道牆在運維現場長什麼樣。</li>
<li>與 <a href="/blog/backend/09-performance-capacity/capacity-planning/" data-link-title="9.6 容量規劃模型" data-link-desc="peak forecast、headroom budget、growth curve、autoscaling sizing">9.6 容量規劃</a> 的交接：擴展軸選定後，容量規劃決定具體數字。</li>
<li>與 <a href="/blog/backend/10-system-evolution/service-decomposition-boundaries/" data-link-title="10.1 服務拆分與邊界判讀" data-link-desc="整理 monolith vs microservice 取捨、服務邊界判讀訊號、拆分時機與回退路徑">10.1 服務拆分</a> 的交接：水平擴展常常是服務拆分的觸發點，反之亦然。</li>
<li>與 <a href="/blog/backend/01-database/high-concurrency-access/" data-link-title="1.1 高併發下的 SQL 讀寫邊界" data-link-desc="說明高併發服務如何共用資料庫 client、控制 transaction、管理 connection pool、避免資料庫成為瓶頸">01 database high-concurrency-access</a> 的交接：資料層水平擴展（sharding、replica）的具體機制。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<p><strong>規模成長路線下一站 → <a href="/blog/backend/01-database/query-anti-patterns/" data-link-title="1.13 應用層查詢反模式與 Query 預算" data-link-desc="整理 N&#43;1、select *、缺索引、ORM lazy load、long transaction 等查詢反模式與每請求的 query 預算判讀">1.13 應用層查詢反模式與 Query 預算</a></strong>：選定擴展軸後、在加機器前先用反模式清單收回單機可撐住的容量。</p>
<p>其他延伸方向：</p>
<ul>
<li>容量計算與 headroom 模型 → <a href="/blog/backend/09-performance-capacity/capacity-planning/" data-link-title="9.6 容量規劃模型" data-link-desc="peak forecast、headroom budget、growth curve、autoscaling sizing">9.6 容量規劃模型</a></li>
<li>擴展前的瓶頸定位 → <a href="/blog/backend/09-performance-capacity/bottleneck-localization/" data-link-title="9.5 瓶頸定位流程" data-link-desc="從 app 到 DB / cache / broker / 第三方 quota 的逐層瓶頸定位">9.5 瓶頸定位流程</a></li>
<li>服務拆分如何配合水平擴展 → <a href="/blog/backend/10-system-evolution/service-decomposition-boundaries/" data-link-title="10.1 服務拆分與邊界判讀" data-link-desc="整理 monolith vs microservice 取捨、服務邊界判讀訊號、拆分時機與回退路徑">10.1 服務拆分與邊界判讀</a></li>
</ul>
]]></content:encoded></item><item><title>9.14 連線池放大解法（PgBouncer / RDS Proxy / ProxySQL）</title><link>https://tarrragon.github.io/blog/backend/09-performance-capacity/connection-pool-amplification/</link><pubDate>Wed, 27 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/09-performance-capacity/connection-pool-amplification/</guid><description>&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/scaling-axes/" data-link-title="9.13 擴展軸與 Stateless 前提" data-link-desc="整理垂直 / 水平擴展取捨、stateless vs stateful 前提、auto scaling 操作模型與兩種擴展的 hidden cost">9.13 擴展軸與 Stateless 前提&lt;/a> 指出了水平擴展應用層時的隱性成本之一：連線池放大 — 100 臺機器 × 每臺 10 個連線 = 對 DB 開 1000 個連線、超過 PostgreSQL &lt;code>max_connections&lt;/code> default（100）十倍。本章把這條撞牆訊號的具體解法說清楚 — connection pooler 是什麼、PgBouncer / RDS Proxy / ProxySQL 怎麼選、不同場景的取捨。&lt;/p>
&lt;h2 id="連線池放大的物理本質">連線池放大的物理本質&lt;/h2>
&lt;p>PostgreSQL / MySQL 每個連線都會在 DB server 端配一個 backend process / thread。Backend 佔 5-15 MB 記憶體、context switch 也有成本。當應用層連線數超過 DB 機器能負擔的數量，會出現三類問題：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>記憶體吃光&lt;/strong>：500 個 backend × 10 MB = 5 GB、再加 shared buffer、可能直接 OOM&lt;/li>
&lt;li>&lt;strong>Context switch 抖動&lt;/strong>：上百個 backend 競爭 CPU、上下文切換 overhead 變成主要消耗&lt;/li>
&lt;li>&lt;strong>連線建立失敗&lt;/strong>：超過 &lt;code>max_connections&lt;/code> 後、新請求拿不到連線、即使現有連線多數 idle&lt;/li>
&lt;/ul>
&lt;p>問題的根因不是「連線多」、是「連線&lt;strong>生命週期跟使用率不對齊&lt;/strong>」。應用層 connection pool 通常維持「每臺機器 N 個常駐連線、避免每個 request 重新建連」、但 100 臺機器各自 keep 10 個常駐就是 1000 個 idle 連線。&lt;/p>
&lt;p>解法的方向不是「砍應用層連線數」（會讓 connection acquisition 變慢、影響 latency）、是「在 DB 跟應用層之間放一層 multiplexer」— 把多個應用層連線複用到少數 DB 連線上。這層中介就是 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/connection-pooler/" data-link-title="Connection Pooler" data-link-desc="應用層跟資料庫之間的連線複用中介層、解水平擴展時的連線數放大問題">connection pooler&lt;/a>。&lt;/p>
&lt;h2 id="connection-pooler-三大選項">Connection Pooler 三大選項&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>工具&lt;/th>
 &lt;th>部署模式&lt;/th>
 &lt;th>主要適用 DB&lt;/th>
 &lt;th>主要特點&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>PgBouncer&lt;/td>
 &lt;td>Self-managed / sidecar&lt;/td>
 &lt;td>PostgreSQL only&lt;/td>
 &lt;td>輕量（C 寫的 single process）、三種 pooling 模式可選&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>AWS RDS Proxy&lt;/td>
 &lt;td>Managed&lt;/td>
 &lt;td>RDS / Aurora (PG / MySQL)&lt;/td>
 &lt;td>整合 IAM auth、自動 failover、計價 per vCPU&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>ProxySQL&lt;/td>
 &lt;td>Self-managed&lt;/td>
 &lt;td>MySQL&lt;/td>
 &lt;td>規則型 routing、可做 query rewriting、自動 failover&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="pgbouncer--三種-pooling-模式決定一切">PgBouncer — 三種 pooling 模式決定一切&lt;/h3>
&lt;p>PgBouncer 的核心參數是 &lt;code>pool_mode&lt;/code>：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Session mode&lt;/strong>：應用層 client 拿到的連線、跟 DB backend 1:1 綁定、整個 session 結束才釋放。其實沒做 multiplexing、只是 connection caching。&lt;/li>
&lt;li>&lt;strong>Transaction mode&lt;/strong>：每個 transaction 結束、應用層 client 的連線釋放回 pool、下個 transaction 再分配 DB backend。multiplexing 比較強、但&lt;strong>不支援 transaction-scoped state&lt;/strong>（如 &lt;code>SET LOCAL&lt;/code>、prepared statement、temporary table）。&lt;/li>
&lt;li>&lt;strong>Statement mode&lt;/strong>：每個 statement 結束就釋放、最強 multiplexing 但&lt;strong>不支援 transaction&lt;/strong>。極少用、只在純 stateless query workload 適用。&lt;/li>
&lt;/ul>
&lt;p>Transaction mode 是多數場景的 default。但要注意：應用層的 ORM / driver 可能默認用 prepared statement、跟 transaction mode 衝突。PostgreSQL 14+ 的 protocol-level prepared statement 才相容、JDBC / asyncpg 等需要特別配置。&lt;/p></description><content:encoded><![CDATA[<p><a href="/blog/backend/09-performance-capacity/scaling-axes/" data-link-title="9.13 擴展軸與 Stateless 前提" data-link-desc="整理垂直 / 水平擴展取捨、stateless vs stateful 前提、auto scaling 操作模型與兩種擴展的 hidden cost">9.13 擴展軸與 Stateless 前提</a> 指出了水平擴展應用層時的隱性成本之一：連線池放大 — 100 臺機器 × 每臺 10 個連線 = 對 DB 開 1000 個連線、超過 PostgreSQL <code>max_connections</code> default（100）十倍。本章把這條撞牆訊號的具體解法說清楚 — connection pooler 是什麼、PgBouncer / RDS Proxy / ProxySQL 怎麼選、不同場景的取捨。</p>
<h2 id="連線池放大的物理本質">連線池放大的物理本質</h2>
<p>PostgreSQL / MySQL 每個連線都會在 DB server 端配一個 backend process / thread。Backend 佔 5-15 MB 記憶體、context switch 也有成本。當應用層連線數超過 DB 機器能負擔的數量，會出現三類問題：</p>
<ul>
<li><strong>記憶體吃光</strong>：500 個 backend × 10 MB = 5 GB、再加 shared buffer、可能直接 OOM</li>
<li><strong>Context switch 抖動</strong>：上百個 backend 競爭 CPU、上下文切換 overhead 變成主要消耗</li>
<li><strong>連線建立失敗</strong>：超過 <code>max_connections</code> 後、新請求拿不到連線、即使現有連線多數 idle</li>
</ul>
<p>問題的根因不是「連線多」、是「連線<strong>生命週期跟使用率不對齊</strong>」。應用層 connection pool 通常維持「每臺機器 N 個常駐連線、避免每個 request 重新建連」、但 100 臺機器各自 keep 10 個常駐就是 1000 個 idle 連線。</p>
<p>解法的方向不是「砍應用層連線數」（會讓 connection acquisition 變慢、影響 latency）、是「在 DB 跟應用層之間放一層 multiplexer」— 把多個應用層連線複用到少數 DB 連線上。這層中介就是 <a href="/blog/backend/knowledge-cards/connection-pooler/" data-link-title="Connection Pooler" data-link-desc="應用層跟資料庫之間的連線複用中介層、解水平擴展時的連線數放大問題">connection pooler</a>。</p>
<h2 id="connection-pooler-三大選項">Connection Pooler 三大選項</h2>
<table>
  <thead>
      <tr>
          <th>工具</th>
          <th>部署模式</th>
          <th>主要適用 DB</th>
          <th>主要特點</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>PgBouncer</td>
          <td>Self-managed / sidecar</td>
          <td>PostgreSQL only</td>
          <td>輕量（C 寫的 single process）、三種 pooling 模式可選</td>
      </tr>
      <tr>
          <td>AWS RDS Proxy</td>
          <td>Managed</td>
          <td>RDS / Aurora (PG / MySQL)</td>
          <td>整合 IAM auth、自動 failover、計價 per vCPU</td>
      </tr>
      <tr>
          <td>ProxySQL</td>
          <td>Self-managed</td>
          <td>MySQL</td>
          <td>規則型 routing、可做 query rewriting、自動 failover</td>
      </tr>
  </tbody>
</table>
<h3 id="pgbouncer--三種-pooling-模式決定一切">PgBouncer — 三種 pooling 模式決定一切</h3>
<p>PgBouncer 的核心參數是 <code>pool_mode</code>：</p>
<ul>
<li><strong>Session mode</strong>：應用層 client 拿到的連線、跟 DB backend 1:1 綁定、整個 session 結束才釋放。其實沒做 multiplexing、只是 connection caching。</li>
<li><strong>Transaction mode</strong>：每個 transaction 結束、應用層 client 的連線釋放回 pool、下個 transaction 再分配 DB backend。multiplexing 比較強、但<strong>不支援 transaction-scoped state</strong>（如 <code>SET LOCAL</code>、prepared statement、temporary table）。</li>
<li><strong>Statement mode</strong>：每個 statement 結束就釋放、最強 multiplexing 但<strong>不支援 transaction</strong>。極少用、只在純 stateless query workload 適用。</li>
</ul>
<p>Transaction mode 是多數場景的 default。但要注意：應用層的 ORM / driver 可能默認用 prepared statement、跟 transaction mode 衝突。PostgreSQL 14+ 的 protocol-level prepared statement 才相容、JDBC / asyncpg 等需要特別配置。</p>
<h3 id="aws-rds-proxy--managed-換掉運維">AWS RDS Proxy — managed 換掉運維</h3>
<p>RDS Proxy 是 PgBouncer / ProxySQL 同類功能的 managed 版本：AWS 負責部署、HA、failover、IAM 整合。應用層連到 RDS Proxy endpoint、Proxy 在背後維持跟 RDS / Aurora 的連線池。</p>
<p>特點：</p>
<ul>
<li><strong>連線 share 模式類似 transaction mode</strong>：自動 detect 連線是否在 transaction、空閒時釋放</li>
<li><strong>IAM auth 整合</strong>：應用層用 IAM token、不用維護 DB password</li>
<li><strong>Failover 加速</strong>：DB failover 時 Proxy 維持應用層連線不斷、background 重連 new primary。Failover 期間應用層感受最小化。</li>
<li><strong>計價</strong>：per vCPU-hour、Aurora 約 $0.015/vCPU-hr、RDS 約 $0.02/vCPU-hr — 加在 RDS 計價上面</li>
</ul>
<p>不適用場景：很多 read-only / analytics workload 不需要 connection pooler、純讀 replica 直接連通常更便宜。RDS Proxy 是給「寫入混合」「連線抖動嚴重」這類場景。</p>
<h3 id="proxysql--mysql-規則型-routing">ProxySQL — MySQL 規則型 routing</h3>
<p>ProxySQL 是 MySQL 生態的 connection pooler、但比 PgBouncer 更全功能：</p>
<ul>
<li><strong>Query routing rules</strong>：可以按 query pattern 把 query 導去不同 backend（讀路徑去 replica、寫路徑去 primary、特定 query 強制 cache）</li>
<li><strong>Connection multiplexing</strong>：類似 PgBouncer transaction mode</li>
<li><strong>Query rewriting</strong>：可以攔截 query 改寫（debug / 漸進遷移 schema）</li>
<li><strong>Auto failover</strong>：監控 backend 健康、自動切流</li>
</ul>
<p>ProxySQL 的代價是學習曲線跟運維成本 — 規則設計需要對 query pattern 跟 DB topology 有掌控、設錯規則會把 query 導去錯誤 backend、debug 困難。</p>
<h2 id="選型對照">選型對照</h2>
<p>實務選型的關鍵變數是「DB 廠商 / managed 程度 / 規模 / 預算」：</p>
<table>
  <thead>
      <tr>
          <th>場景</th>
          <th>推薦</th>
          <th>理由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>AWS RDS / Aurora、團隊不想自管</td>
          <td>RDS Proxy</td>
          <td>Managed、整合度高、failover 加速是 free value</td>
      </tr>
      <tr>
          <td>AWS RDS / Aurora、需要極致省成本</td>
          <td>PgBouncer（PG）/ ProxySQL（MySQL）on EC2</td>
          <td>比 RDS Proxy 便宜、但要自管 HA</td>
      </tr>
      <tr>
          <td>GCP Cloud SQL / 自管 PostgreSQL</td>
          <td>PgBouncer</td>
          <td>PG 生態事實標準、配置文件多</td>
      </tr>
      <tr>
          <td>Azure Database for PostgreSQL</td>
          <td>PgBouncer 或 Azure 內建 connection pooling</td>
          <td>Azure 部分 SKU 內建類似功能、檢查 vendor 文件</td>
      </tr>
      <tr>
          <td>MySQL 需要讀寫分離 + query routing</td>
          <td>ProxySQL</td>
          <td>規則型 routing 是 ProxySQL 強項</td>
      </tr>
      <tr>
          <td>不確定要不要 connection pooler</td>
          <td>先用 vendor 內建（RDS Proxy / PG managed pooler）跑一段、再評估自管</td>
          <td>降低初期決策成本</td>
      </tr>
  </tbody>
</table>
<h2 id="不裝-pooler-的判讀">不裝 pooler 的判讀</h2>
<p>Connection pooler 不是必要 — 在以下情境可以暫時不裝：</p>
<ul>
<li><strong>應用層機器數 &lt; 10</strong>：對 DB 連線總數壓力小、deferred 安裝 pooler 沒問題</li>
<li><strong>每臺機器連線數 &lt; 5</strong>：應用層 connection pool 已經很省、再加 pooler 改善有限</li>
<li><strong>DB 機器規格大、<code>max_connections</code> 充裕</strong>：高階 RDS instance 可開到 5000-10000 連線、有 buffer 之前不必加 pooler</li>
<li><strong>Workload 全是長 transaction</strong>：transaction mode pooler 在這種 workload 跟 session mode 沒差、收益低</li>
</ul>
<p>該裝 pooler 的訊號是相反：應用層機器數 ≥ 20、每臺連線數 ≥ 10、<code>max_connections</code> 使用率 ≥ 70%、或 P99 connection wait time 升高。</p>
<h2 id="判讀訊號">判讀訊號</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>判讀重點</th>
          <th>對應動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>DB <code>pg_stat_activity</code> 顯示大量 idle 連線</td>
          <td>應用層 keep-alive 連線、實際使用率低</td>
          <td>加 connection pooler 把 idle 釋放回 DB</td>
      </tr>
      <tr>
          <td>應用層 connection acquisition 等待時間升高</td>
          <td>應用層 pool 太小、或 DB 連線數已撞 <code>max_connections</code></td>
          <td>加 pooler 把連線總數壓低、應用層 pool size 維持原樣</td>
      </tr>
      <tr>
          <td>DB failover 後應用層 5-10 分鐘錯誤率高</td>
          <td>應用層 connection pool 沒 detect 到 backend 切換</td>
          <td>RDS Proxy 的 failover 加速、或應用層 connection validation 加強</td>
      </tr>
      <tr>
          <td>Pooler 上線後出現「unexpected error」</td>
          <td>transaction mode 跟 prepared statement / SET LOCAL 衝突</td>
          <td>改 ORM 配置、用 protocol-level prepared statement 或避開 SET LOCAL</td>
      </tr>
      <tr>
          <td>應用層 N+1 query 仍然存在</td>
          <td>Pooler 沒解 N+1、它只解連線數放大</td>
          <td>回 <a href="/blog/backend/01-database/query-anti-patterns/" data-link-title="1.13 應用層查詢反模式與 Query 預算" data-link-desc="整理 N&#43;1、select *、缺索引、ORM lazy load、long transaction 等查詢反模式與每請求的 query 預算判讀">1.13 query 反模式</a> 修反模式</td>
      </tr>
  </tbody>
</table>
<h2 id="常見誤區">常見誤區</h2>
<p>把 connection pooler 當「N+1 解藥」。Pooler 解的是「連線數放大」、不是「query 數量過多」。N+1 query 在裝完 pooler 後仍然慢、只是 DB 不會因為連線爆掉而當機。兩個是正交問題、各自要解。</p>
<p>把 RDS Proxy 當「免費功能」。Proxy 的計價跟 RDS / Aurora 本體疊加、高 connection volume 場景 Proxy 成本可能可觀。要算實際的 cost-per-request、不是預設「managed 一定值得」。</p>
<p>把 transaction mode 配置當「裝完就好」。Prepared statement / SET LOCAL / temporary table 都會跟 transaction mode 衝突、ORM 預設行為要 audit 過、不然會在 production 出現難 debug 的「query 隨機失敗」。</p>
<h2 id="定位邊界">定位邊界</h2>
<p>本章專注「連線池放大的解法」。當問題進入擴展軸選擇（要垂直 vs 水平？stateful 前提？）、回 <a href="/blog/backend/09-performance-capacity/scaling-axes/" data-link-title="9.13 擴展軸與 Stateless 前提" data-link-desc="整理垂直 / 水平擴展取捨、stateless vs stateful 前提、auto scaling 操作模型與兩種擴展的 hidden cost">9.13 擴展軸</a>；進入 DB 本身的容量規劃（要多大規格 instance？要不要 read replica？）、進 <a href="/blog/backend/09-performance-capacity/capacity-planning/" data-link-title="9.6 容量規劃模型" data-link-desc="peak forecast、headroom budget、growth curve、autoscaling sizing">9.6 容量規劃</a>；進入 application-level connection 設計（per-request pool / persistent pool）、進 <a href="/blog/backend/01-database/high-concurrency-access/" data-link-title="1.1 高併發下的 SQL 讀寫邊界" data-link-desc="說明高併發服務如何共用資料庫 client、控制 transaction、管理 connection pool、避免資料庫成為瓶頸">1.1 高併發 SQL</a>。</p>
<h2 id="案例回寫">案例回寫</h2>
<p>09 案例庫多數案例規模到 connection pool 已是 secondary concern、但兩個案例有對應參考：</p>
<ul>
<li><a href="/blog/backend/09-performance-capacity/cases/zoom-covid-surge-dynamodb/" data-link-title="9.C18 Zoom：COVID 期間從 1000 萬到 3 億 DAU 的 30 倍突發" data-link-desc="Zoom 在 2020 年 COVID 爆發時、日活從 1000 萬衝到 3 億、用 DynamoDB 撐住會議後端">9.C18 Zoom：COVID 30 倍突發</a> — Zoom 把 stateful 資料層改用 DynamoDB、繞過 SQL connection pool 問題（KV 沒有 backend process 概念）。對照本章可問：若 Zoom 保留 SQL、connection pool 怎麼設計才撐得住 30 倍突發？</li>
<li><a href="/blog/backend/09-performance-capacity/cases/doordash-cockroachdb-orders-platform/" data-link-title="9.C39 DoorDash：Aurora Postgres 寫入瓶頸 → CockroachDB 多主寫入" data-link-desc="DoorDash 從 Aurora Postgres 遷到 CockroachDB、解 1.6 M QPS 單主寫入瓶頸、外送平台爆量壓力下重做 OLTP 拓樸">9.C39 DoorDash：CockroachDB 多主寫入</a> — DoorDash 從 Aurora single-primary 換成 CockroachDB 多主、connection pool 設計從「集中在 primary」變成「分散在多 node」。對照本章可問：CockroachDB 是否仍需要 connection pooler？</li>
</ul>
<h2 id="跨模組路由">跨模組路由</h2>
<ol>
<li>與 <a href="/blog/backend/09-performance-capacity/scaling-axes/" data-link-title="9.13 擴展軸與 Stateless 前提" data-link-desc="整理垂直 / 水平擴展取捨、stateless vs stateful 前提、auto scaling 操作模型與兩種擴展的 hidden cost">9.13 擴展軸</a> 的交接：9.13 提出隱性成本、本章給具體解法。</li>
<li>與 <a href="/blog/backend/01-database/high-concurrency-access/" data-link-title="1.1 高併發下的 SQL 讀寫邊界" data-link-desc="說明高併發服務如何共用資料庫 client、控制 transaction、管理 connection pool、避免資料庫成為瓶頸">1.1 高併發 SQL 讀寫邊界</a> 的交接：1.1 講應用層 connection pool 設計、本章補 DB 端 pooler 中介層。</li>
<li>與 <a href="/blog/backend/01-database/vendors/" data-link-title="資料庫 Vendor 清單" data-link-desc="規劃 SQL、managed SQL、document、KV 與 distributed SQL 的服務頁撰寫順序與教學大綱">01 vendors</a> 的交接：各 DB vendor 的內建 pooler 能力詳見 vendor deep article。</li>
<li>與 <a href="/blog/backend/09-performance-capacity/capacity-planning/" data-link-title="9.6 容量規劃模型" data-link-desc="peak forecast、headroom budget、growth curve、autoscaling sizing">9.6 容量規劃</a> 的交接：pooler 加上後、DB 容量規劃的單位從「連線數」變成「DB backend 數 + Pooler vCPU」。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<p>要看擴展軸選擇的完整 framing、回 <a href="/blog/backend/09-performance-capacity/scaling-axes/" data-link-title="9.13 擴展軸與 Stateless 前提" data-link-desc="整理垂直 / 水平擴展取捨、stateless vs stateful 前提、auto scaling 操作模型與兩種擴展的 hidden cost">9.13 擴展軸與 Stateless 前提</a>。要看 DB-side 高併發處理、進 <a href="/blog/backend/01-database/high-concurrency-access/" data-link-title="1.1 高併發下的 SQL 讀寫邊界" data-link-desc="說明高併發服務如何共用資料庫 client、控制 transaction、管理 connection pool、避免資料庫成為瓶頸">1.1 高併發 SQL 讀寫邊界</a>。要看具體 vendor 的 pooler 文件、進對應 <a href="/blog/backend/01-database/vendors/" data-link-title="資料庫 Vendor 清單" data-link-desc="規劃 SQL、managed SQL、document、KV 與 distributed SQL 的服務頁撰寫順序與教學大綱">vendor deep article</a>。</p>
]]></content:encoded></item><item><title>PostgreSQL Connection Scaling：process-per-connection model 跟為什麼 pooler 是必裝</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/connection-scaling/</link><pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/connection-scaling/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/" data-link-title="PostgreSQL" data-link-desc="多用途 OLTP 主流關聯式資料庫、MVCC、豐富 SQL 特性、是 Aurora / Cosmos DB / Spanner / CockroachDB / Aurora DSQL 的相容目標">PostgreSQL&lt;/a> overview 的 implementation-layer deep article。Overview 已說明 PG 在 OLTP 譜系的定位、本文聚焦 &lt;em>connection scaling 的根因&lt;/em> — 為什麼 PG 比多數 DB 更需要 pooler、跟 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/pgbouncer-config/" data-link-title="PostgreSQL pgBouncer 配置 &amp;#43; 連線池治理" data-link-desc="pgBouncer transaction pooling 配置、跟 application connection pool 的分層、production 故障演練（pool exhaustion / stale connection / DNS failover）跟容量規劃">pgbouncer-config&lt;/a> 是 &lt;em>根因 vs 配置&lt;/em> 的關係。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="connection-per-process-model-是-pg-的結構性選擇">Connection-per-Process Model 是 PG 的結構性選擇&lt;/h2>
&lt;p>PG 接受 client connection 時的行為跟多數現代 DB 不同：每個 connection 由 postmaster &lt;code>fork()&lt;/code> 一個獨立的 OS process（backend）來服務。這個 process 在 connection lifetime 內專屬該 client、不跟其他 client 共享。&lt;/p>
&lt;p>對比常見 DB 的 connection model：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Vendor&lt;/th>
 &lt;th>Connection model&lt;/th>
 &lt;th>每 connection 資源&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>PostgreSQL&lt;/td>
 &lt;td>Process-per-connection（fork）&lt;/td>
 &lt;td>5-15MB RAM、獨立 PID&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>MySQL&lt;/td>
 &lt;td>Thread-per-connection&lt;/td>
 &lt;td>256KB-2MB RAM、共享 process&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Oracle&lt;/td>
 &lt;td>Shared server / dedicated 可選&lt;/td>
 &lt;td>配置決定&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>SQL Server&lt;/td>
 &lt;td>Thread-per-connection（pooled）&lt;/td>
 &lt;td>~512KB&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>MongoDB&lt;/td>
 &lt;td>Thread-per-connection&lt;/td>
 &lt;td>~1MB&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>PG 選 process 不選 thread 是 1990s 設計決定 — 當時 thread library 在多 UNIX 平台不穩定、process 隔離性更好（一個 backend crash 不會帶倒整個 DB）。這個 trade-off 一路保留到今天、是 PG 在 high-connection-count workload 的 &lt;em>結構性負擔&lt;/em>。&lt;/p>
&lt;h2 id="量化connection-數量對-ram-跟-cpu-的壓力">量化：connection 數量對 RAM 跟 CPU 的壓力&lt;/h2>
&lt;p>一個 PG backend process 的 RAM footprint 由三部分組成：&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">backend_rss ≈ shared_buffers_attach + process_private + work_mem 高水位&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>shared_buffers&lt;/code> 是所有 backend 共享的、不重複計、但 &lt;code>process_private&lt;/code>（catalog cache / plan cache / temp buffer）跟 &lt;code>work_mem&lt;/code> 是 per-backend：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Workload 類型&lt;/th>
 &lt;th>process_private&lt;/th>
 &lt;th>work_mem 高水位&lt;/th>
 &lt;th>單 backend RAM&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Idle / 簡單 OLTP&lt;/td>
 &lt;td>3-5MB&lt;/td>
 &lt;td>4MB&lt;/td>
 &lt;td>7-9MB&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>中等 query（join / sort）&lt;/td>
 &lt;td>5-8MB&lt;/td>
 &lt;td>16-64MB&lt;/td>
 &lt;td>21-72MB&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Heavy analytical（CTE / window）&lt;/td>
 &lt;td>8-15MB&lt;/td>
 &lt;td>256MB+&lt;/td>
 &lt;td>264MB+&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>500 個 connection、平均 30MB 各 ≈ 15GB RAM 給 backend processes（還沒算 shared_buffers）。這是 PG 在 cloud instance 上很快撞到 RAM ceiling 的根因。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/postgresql/" data-link-title="PostgreSQL" data-link-desc="多用途 OLTP 主流關聯式資料庫、MVCC、豐富 SQL 特性、是 Aurora / Cosmos DB / Spanner / CockroachDB / Aurora DSQL 的相容目標">PostgreSQL</a> overview 的 implementation-layer deep article。Overview 已說明 PG 在 OLTP 譜系的定位、本文聚焦 <em>connection scaling 的根因</em> — 為什麼 PG 比多數 DB 更需要 pooler、跟 <a href="/blog/backend/01-database/vendors/postgresql/pgbouncer-config/" data-link-title="PostgreSQL pgBouncer 配置 &#43; 連線池治理" data-link-desc="pgBouncer transaction pooling 配置、跟 application connection pool 的分層、production 故障演練（pool exhaustion / stale connection / DNS failover）跟容量規劃">pgbouncer-config</a> 是 <em>根因 vs 配置</em> 的關係。</p></blockquote>
<hr>
<h2 id="connection-per-process-model-是-pg-的結構性選擇">Connection-per-Process Model 是 PG 的結構性選擇</h2>
<p>PG 接受 client connection 時的行為跟多數現代 DB 不同：每個 connection 由 postmaster <code>fork()</code> 一個獨立的 OS process（backend）來服務。這個 process 在 connection lifetime 內專屬該 client、不跟其他 client 共享。</p>
<p>對比常見 DB 的 connection model：</p>
<table>
  <thead>
      <tr>
          <th>Vendor</th>
          <th>Connection model</th>
          <th>每 connection 資源</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>PostgreSQL</td>
          <td>Process-per-connection（fork）</td>
          <td>5-15MB RAM、獨立 PID</td>
      </tr>
      <tr>
          <td>MySQL</td>
          <td>Thread-per-connection</td>
          <td>256KB-2MB RAM、共享 process</td>
      </tr>
      <tr>
          <td>Oracle</td>
          <td>Shared server / dedicated 可選</td>
          <td>配置決定</td>
      </tr>
      <tr>
          <td>SQL Server</td>
          <td>Thread-per-connection（pooled）</td>
          <td>~512KB</td>
      </tr>
      <tr>
          <td>MongoDB</td>
          <td>Thread-per-connection</td>
          <td>~1MB</td>
      </tr>
  </tbody>
</table>
<p>PG 選 process 不選 thread 是 1990s 設計決定 — 當時 thread library 在多 UNIX 平台不穩定、process 隔離性更好（一個 backend crash 不會帶倒整個 DB）。這個 trade-off 一路保留到今天、是 PG 在 high-connection-count workload 的 <em>結構性負擔</em>。</p>
<h2 id="量化connection-數量對-ram-跟-cpu-的壓力">量化：connection 數量對 RAM 跟 CPU 的壓力</h2>
<p>一個 PG backend process 的 RAM footprint 由三部分組成：</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">backend_rss ≈ shared_buffers_attach + process_private + work_mem 高水位</span></span></code></pre></div><p><code>shared_buffers</code> 是所有 backend 共享的、不重複計、但 <code>process_private</code>（catalog cache / plan cache / temp buffer）跟 <code>work_mem</code> 是 per-backend：</p>
<table>
  <thead>
      <tr>
          <th>Workload 類型</th>
          <th>process_private</th>
          <th>work_mem 高水位</th>
          <th>單 backend RAM</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Idle / 簡單 OLTP</td>
          <td>3-5MB</td>
          <td>4MB</td>
          <td>7-9MB</td>
      </tr>
      <tr>
          <td>中等 query（join / sort）</td>
          <td>5-8MB</td>
          <td>16-64MB</td>
          <td>21-72MB</td>
      </tr>
      <tr>
          <td>Heavy analytical（CTE / window）</td>
          <td>8-15MB</td>
          <td>256MB+</td>
          <td>264MB+</td>
      </tr>
  </tbody>
</table>
<p>500 個 connection、平均 30MB 各 ≈ 15GB RAM 給 backend processes（還沒算 shared_buffers）。這是 PG 在 cloud instance 上很快撞到 RAM ceiling 的根因。</p>
<p>CPU 層面、<code>fork()</code> 系統呼叫在 Linux 通常 1-3ms、context switch ~3-5μs。100 connection burst 在 1 秒內進來、accumulated fork cost 100-300ms、加 query 本身的 CPU 跟 scheduler latency、平均 query 延遲會跳 2-5x。</p>
<h2 id="三個-guc-互動max_connections--shared_buffers--work_mem">三個 GUC 互動：max_connections / shared_buffers / work_mem</h2>
<p>PG 的 memory 規劃由這三個 GUC 互動決定、不能獨立調：</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">total_RAM ≈ shared_buffers + (max_connections × work_mem 高水位) + OS overhead</span></span></code></pre></div><p>實務 sizing 規則（16GB instance、OLTP workload）：</p>
<table>
  <thead>
      <tr>
          <th>GUC</th>
          <th>建議值</th>
          <th>理由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>shared_buffers</code></td>
          <td>25% RAM（4GB）</td>
          <td>太大 OS file cache 收益遞減、&lt; 25% wastes RAM</td>
      </tr>
      <tr>
          <td><code>work_mem</code></td>
          <td>8-32MB</td>
          <td>每 query operation 用一份、不是每 connection 一份</td>
      </tr>
      <tr>
          <td><code>max_connections</code></td>
          <td>100-200</td>
          <td>超過 200 需 pooler、不是調更大</td>
      </tr>
      <tr>
          <td><code>effective_cache_size</code></td>
          <td>50-75% RAM</td>
          <td>planner 估 cost 用、不是實際配置</td>
      </tr>
      <tr>
          <td><code>maintenance_work_mem</code></td>
          <td>64-512MB</td>
          <td>VACUUM / CREATE INDEX 用</td>
      </tr>
  </tbody>
</table>
<p><code>max_connections = 1000</code> 是常見 anti-pattern — 真實 active query 可能只 50-100、剩下都 idle、但每個還是吃 RAM 跟 process slot、context switch overhead 還在。</p>
<h2 id="pooler-為什麼是-production-prerequisite">Pooler 為什麼是 <em>production prerequisite</em></h2>
<blockquote>
<p>本段是「為什麼必裝」、實際 PgBouncer 配置看 <a href="/blog/backend/01-database/vendors/postgresql/pgbouncer-config/" data-link-title="PostgreSQL pgBouncer 配置 &#43; 連線池治理" data-link-desc="pgBouncer transaction pooling 配置、跟 application connection pool 的分層、production 故障演練（pool exhaustion / stale connection / DNS failover）跟容量規劃">pgbouncer-config</a>。</p></blockquote>
<p>Pooler 的核心責任是 <em>把 N 個 application connection multiplex 成 M 個 PG backend（M ≪ N）</em>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">Application (3000 connection)
</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">Pooler（PgBouncer / PgCat）
</span></span><span class="line"><span class="ln">4</span><span class="cl">   ↓
</span></span><span class="line"><span class="ln">5</span><span class="cl">PostgreSQL (50 backend process)</span></span></code></pre></div><p>Application 看到的是 <em>無限 connection 池</em>、PG 看到的是 <em>穩定 50 個 backend</em>。三個層次的效益：</p>
<ol>
<li><strong>RAM 節省</strong>：3000 connection × 30MB = 90GB → 50 backend × 30MB = 1.5GB</li>
<li><strong>Fork() cost 攤平</strong>：backend 重用、不是每個 client 都 fork</li>
<li><strong>Connection storm 緩衝</strong>：application 重啟 / scaling event 不會直接打到 PG</li>
</ol>
<p>Pooler 有三種 pool mode、各有 application 層相容性 trade-off：</p>
<table>
  <thead>
      <tr>
          <th>Pool mode</th>
          <th>Session 隔離</th>
          <th>適用 application</th>
          <th>PG feature 限制</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Session</td>
          <td>每 client 獨佔 1 backend</td>
          <td>用 prepared statement、SET、temp table</td>
          <td>等同沒 pool、僅救 fork cost</td>
      </tr>
      <tr>
          <td>Transaction</td>
          <td>每 transaction 換 backend</td>
          <td>多數 stateless API（最常用）</td>
          <td>不能用 session-level state</td>
      </tr>
      <tr>
          <td>Statement</td>
          <td>每 statement 換 backend</td>
          <td>Read-only / analytical</td>
          <td>不能用 transaction</td>
      </tr>
  </tbody>
</table>
<p>Production 多數選 transaction pool — 救 RAM 又保留 transaction semantics、代價是 application 不能用 session-level <code>SET</code>、<code>LISTEN/NOTIFY</code>、prepared statement（部分 pooler 已支援）。</p>
<h2 id="application-side-pool-vs-middleware-pool-vs-rds-proxy">Application-side Pool vs Middleware Pool vs RDS Proxy</h2>
<p>三層 pool 都能解 connection 問題、但解的問題不同：</p>
<table>
  <thead>
      <tr>
          <th>層級</th>
          <th>代表</th>
          <th>解的問題</th>
          <th>限制</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Application-side（driver）</td>
          <td>HikariCP（Java）/ pgx pool（Go）/ asyncpg / Sequelize</td>
          <td>Connection 重用 + lifecycle 管理</td>
          <td>仍每 app instance 開 N 個到 PG、總量沒收斂</td>
      </tr>
      <tr>
          <td>Middleware pooler</td>
          <td>PgBouncer / PgCat</td>
          <td>Multiplex 所有 application instance 到少數 backend</td>
          <td>多一跳 latency 0.1-1ms、需自管 HA</td>
      </tr>
      <tr>
          <td>Cloud-managed proxy</td>
          <td>RDS Proxy / Cloud SQL Proxy</td>
          <td>Multiplex + IAM auth + Secrets Manager integration</td>
          <td>Latency 1-3ms、cost premium、PG feature 受限</td>
      </tr>
  </tbody>
</table>
<p><strong>典型 production 拓撲</strong>：</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">Application (HikariCP pool 10/instance × 50 instance = 500)
</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">PgBouncer transaction pool（50 backend）
</span></span><span class="line"><span class="ln">4</span><span class="cl">   ↓
</span></span><span class="line"><span class="ln">5</span><span class="cl">PostgreSQL primary</span></span></code></pre></div><p>Application pool 救 fork cost、PgBouncer 救 backend 總量、兩層各做各的事不衝突。</p>
<p><strong>雙層 pool 配置容易出錯</strong>：application pool size 5 + PgBouncer default_pool_size 50 + 100 個 app instance、application 願意開 500 connection、PgBouncer 只給 50 個 backend — 多 450 個 application connection wait、看起來像「DB 慢」但實際是 pool 不足。</p>
<h2 id="5-個-production-踩雷">5 個 Production 踩雷</h2>
<h3 id="case-1connection-storm重啟--autoscale-同時打進來">Case 1：Connection storm（重啟 / autoscale 同時打進來）</h3>
<p><strong>情境</strong>：Kubernetes rolling restart、200 個 pod 同時重連、每 pod 開 20 個 connection、瞬間 4000 個 connection 嘗試打到 PG。</p>
<p>PG <code>max_connections = 500</code> 直接拒絕 3500 個、application 看到 <code>FATAL: sorry, too many clients already</code>、retry storm 雪上加霜。</p>
<p>修法：</p>
<ul>
<li>PgBouncer 在前面、application 連 PgBouncer 不直連 PG</li>
<li><code>reserve_pool_size = 5</code> 給管理流量留 buffer</li>
<li>Application 端加 jittered exponential backoff、避免 retry 同步</li>
</ul>
<h3 id="case-2fork-cost-在-burst-流量">Case 2：fork() cost 在 burst 流量</h3>
<p><strong>情境</strong>：Cron job 每分鐘整點觸發、500 個 worker 同時開 short-lived connection 跑 30ms query、結束關閉。</p>
<p>每分鐘 500 次 <code>fork()</code> + 500 次 <code>exit()</code>、fork cost 500-1500ms、CPU spike、其他 OLTP query 延遲飆。</p>
<p>修法：</p>
<ul>
<li>Worker 改 connect 到 PgBouncer transaction pool、backend 重用、fork 只在 PgBouncer 首次拓展時</li>
<li>或 worker 改成 long-lived process + 內部 task queue、避免每分鐘重 fork</li>
</ul>
<h3 id="case-3shared_buffers-跟-max_connections-互相壓縮">Case 3：shared_buffers 跟 max_connections 互相壓縮</h3>
<p><strong>情境</strong>：16GB instance、<code>shared_buffers = 8GB</code>（50%）、<code>max_connections = 800</code>、<code>work_mem = 16MB</code>。</p>
<p>預估 RAM：8GB + 800 × ~30MB = 32GB ≫ 16GB instance、OOM kill 來訪。</p>
<p>修法（重新分配）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="ln">1</span><span class="cl"><span class="na">shared_buffers</span> <span class="o">=</span> <span class="s">4GB           # 25%</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="na">max_connections</span> <span class="o">=</span> <span class="s">200          # 透過 PgBouncer multiplex</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="na">work_mem</span> <span class="o">=</span> <span class="s">16MB</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="na">effective_cache_size</span> <span class="o">=</span> <span class="s">12GB</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="na">maintenance_work_mem</span> <span class="o">=</span> <span class="s">512MB</span></span></span></code></pre></div><p>關鍵：<code>max_connections</code> 不是調更大救 connection 不足、是調 <em>PgBouncer pool size</em> 拓展 application 容量。</p>
<h3 id="case-4double-pool-配置失敗">Case 4：Double-pool 配置失敗</h3>
<p><strong>情境</strong>：Application HikariCP pool size = 50、50 個 instance、PgBouncer <code>default_pool_size = 20</code>、PG <code>max_connections = 100</code>。</p>
<p>Application 願意開 2500 個 connection、PgBouncer 只給 20 個 backend、application thread 大量 block 在 PgBouncer 等 backend 釋出。</p>
<p>修法：</p>
<ul>
<li>計算 <em>application 願意的並發</em> vs <em>PgBouncer 允許的 backend</em> vs <em>PG max_connections</em> 三層匹配</li>
<li>通常 <code>application_total_connection ≪ pgbouncer_max_client_conn</code> + <code>pgbouncer_default_pool_size + reserve ≪ pg_max_connections</code></li>
<li>Monitor PgBouncer <code>SHOW POOLS</code> 的 <code>cl_waiting</code>、長期 &gt; 0 表示 pool 不足</li>
</ul>
<h3 id="case-5max_connections-設太大反而慢">Case 5：max_connections 設太大反而慢</h3>
<p><strong>情境</strong>：team 看到 <code>connection refused</code>、把 <code>max_connections</code> 從 200 調到 2000、想說「給更多 connection 應該更好」。</p>
<p>調完 throughput 反而降 30% — context switch overhead、planner cache 競爭、lock manager 競爭都跟 connection 數線性放大。</p>
<p>修法：</p>
<ul>
<li><code>max_connections</code> 上限通常 200-500、超過要靠 pooler multiplex</li>
<li>用 <code>pg_stat_activity</code> 看真實 active connection（state != &lsquo;idle&rsquo;）、通常 &lt; 100</li>
<li>真實上限 = active 高水位 × 安全係數 1.5、不是「未來可能會用到的數量」</li>
</ul>
<h2 id="跟-mysql-connection-model-對比">跟 MySQL connection model 對比</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>PostgreSQL</th>
          <th>MySQL</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Connection 模型</td>
          <td>Process-per-connection（fork）</td>
          <td>Thread-per-connection</td>
      </tr>
      <tr>
          <td>單 connection RAM</td>
          <td>5-15MB（idle）/ 30-200MB（heavy）</td>
          <td>256KB-2MB</td>
      </tr>
      <tr>
          <td>Fork / spawn cost</td>
          <td>1-3ms</td>
          <td>&lt; 100μs</td>
      </tr>
      <tr>
          <td>Pooler 必要性</td>
          <td><strong>強烈必要</strong>（300+ connection 必裝）</td>
          <td>中等（ProxySQL 對特定 case 有用）</td>
      </tr>
      <tr>
          <td>主流 pooler</td>
          <td>PgBouncer / PgCat</td>
          <td>ProxySQL / MySQL Router</td>
      </tr>
  </tbody>
</table>
<p>MySQL thread-per-connection model 讓它在 high-connection-count workload 上 <em>看起來</em> 更省 — 但 PG 透過 PgBouncer 達到的 application 看到的容量跟 MySQL 直連是一樣的、只是多一層 indirection。</p>
<p>實務影響：</p>
<ul>
<li>MySQL 直連 1000 connection 還 OK、PG 直連 1000 connection 通常 OOM</li>
<li>PG + PgBouncer 1000 application connection、後端 50 backend、表現跟 MySQL 1000 直連相當</li>
<li>沒有 <em>PG 更耗 RAM</em> 的本質結論、是 <em>PG 預設不 multiplex、需要外掛 multiplex 層</em></li>
</ul>
<h2 id="pg-17-的-connection-進展">PG 17+ 的 connection 進展</h2>
<p>PG 17（2024）對 connection 仍維持 process-per-connection、但有幾個減壓改進：</p>
<ul>
<li><strong>Per-process memory 降低</strong>：catalog cache 改 generational allocator、idle backend RAM 降 ~20%</li>
<li><strong>Subscriber-side parallel apply</strong>：logical replication 減少 connection 開銷</li>
<li><strong><code>io_combine_limit</code></strong>：buffered read 合併、降 syscall overhead</li>
</ul>
<p>但 <em>process-per-connection model 本身</em> 沒換 — 短期內 PG 仍需 pooler。長期方向（PG 18+ 討論）可能引入 thread-based backend、但目前是 experimental patch。</p>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/postgresql/pgbouncer-config/" data-link-title="PostgreSQL pgBouncer 配置 &#43; 連線池治理" data-link-desc="pgBouncer transaction pooling 配置、跟 application connection pool 的分層、production 故障演練（pool exhaustion / stale connection / DNS failover）跟容量規劃">pgbouncer-config</a>：PgBouncer 操作配置 + 5 case</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/replication-topology/" data-link-title="PostgreSQL Replication Topology：async / sync / quorum 三模式跟 LSN &#43; replication slot 的三軸組合" data-link-desc="PostgreSQL streaming replication 不是「sync 或 async」、是 *durability / latency / consistency* 三軸組合 &#43; LSN-based 進度追蹤 &#43; replication slot 治理。本文走 3 軸取捨模型、async / sync / quorum-based sync 行為對比、LSN &#43; replication slot 機制、配置 step-by-step、5 production 踩雷（standby lag 暴衝 / sync standby 退回 async / orphan replication slot / cascading replication 雪崩 / failover 後 timeline 分歧）、跟 Patroni HA &#43; logical replication 整合">replication-topology</a>：Read replica + connection 分流</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/query-optimization/" data-link-title="PostgreSQL Query Optimization：EXPLAIN ANALYZE / pg_hint_plan / auto_explain 三層工具跟 4 個 case" data-link-desc="PG query 慢的根因常是 *planner 選錯 plan 或 statistics 過時*。本文從 4 個 production case 開場（seq scan vs index / hash vs nested loop / 多 column 統計缺 / parallel query 沒觸發）、走 EXPLAIN / EXPLAIN ANALYZE / auto_explain 三層工具、pg_hint_plan extension 跟 planner GUC 取捨、5 production 踩雷（ANALYZE 過時 / multi-column statistics / cost-base setting 不對齊硬體 / random_page_cost SSD 沒調 / parallel query 配置）、跟 MySQL query-optimization sibling 對比">query-optimization</a>：<code>work_mem</code> 影響 plan</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/mvcc-lock-model/" data-link-title="PostgreSQL MVCC &#43; Lock Model：為什麼 PG 比 MySQL 少 deadlock、但 vacuum 是別的代價" data-link-desc="PG 用 *MVCC-heavy &#43; 少 explicit lock* 的並行控制、跟 MySQL InnoDB 的 *lock-based*（record / gap / next-key）相反。本文走 MVCC 機制（tuple version &#43; xmin/xmax &#43; visibility）、PG 4 種 lock（row-level / table-level / advisory / predicate）、預測 SERIALIZABLE 行為、5 production 踩雷（idle transaction 卡 vacuum / SELECT FOR UPDATE 跨 transaction / advisory lock 沒釋放 / bloat 不是 vacuum 問題 / predicate lock 在 SSI 下 rollback）、跟 MySQL lock-contention sibling 對比">mvcc-lock-model</a>：connection idle in transaction 卡 vacuum</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/autovacuum-tuning/" data-link-title="PostgreSQL autovacuum tuning：為什麼你的 autovacuum 永遠追不上 bloat" data-link-desc="MVCC 怎麼產生 dead tuple、autovacuum cost-based throttle 為什麼預設保守、per-table tuning 怎麼設、5 個 production 踩雷（cost_limit 太低 / 長 transaction blocks vacuum / anti-wraparound 在 peak / partition vacuum 滿 worker / index bloat 沒處理）、跟 partitioning &#43; monitoring 整合">autovacuum-tuning</a>：autovacuum 也吃 connection slot</li>
</ul>
<h2 id="下一步">下一步</h2>
<ul>
<li>連到 <a href="/blog/backend/01-database/vendors/postgresql/pgbouncer-config/" data-link-title="PostgreSQL pgBouncer 配置 &#43; 連線池治理" data-link-desc="pgBouncer transaction pooling 配置、跟 application connection pool 的分層、production 故障演練（pool exhaustion / stale connection / DNS failover）跟容量規劃">pgbouncer-config</a> 學配置細節</li>
<li>看 <a href="/blog/backend/01-database/vendors/postgresql/" data-link-title="PostgreSQL" data-link-desc="多用途 OLTP 主流關聯式資料庫、MVCC、豐富 SQL 特性、是 Aurora / Cosmos DB / Spanner / CockroachDB / Aurora DSQL 的相容目標">PostgreSQL overview</a> 回到全圖</li>
</ul>
]]></content:encoded></item></channel></rss>