<?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>Deep-Article on Tarragon</title><link>https://tarrragon.github.io/blog/tags/deep-article/</link><description>Recent content in Deep-Article on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Tue, 16 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/deep-article/index.xml" rel="self" type="application/rss+xml"/><item><title>DB3 Vendor Selection：document / KV / multi-model 三方選型 + workload shape 前置判讀</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/db3-vendor-selection/</link><pubDate>Wed, 27 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/db3-vendor-selection/</guid><description>&lt;p>DB3 vendor selection 的核心責任是把讀者從「我該選 MongoDB / DynamoDB / Cosmos DB 哪一家」這個問題、推到「我的 workload 是 document / KV / multi-model 哪一類」這個更前置的問題。三家文件都標榜 scalable schema-less、但實際取捨在 &lt;em>資料形狀、access pattern 穩定度、consistency 可接受度&lt;/em> 三軸決定 — 不識別 workload shape 直接比 vendor 是源頭錯誤。本文是 DB3 reader 進來的第一站：先做 workload shape 三軸前置判讀、再過 migration path 三型 + federated DB 視角、最後落到三 vendor 對比 10 軸。&lt;/p>
&lt;p>本文 &lt;em>不&lt;/em> 展開 vendor 機制細節（partition key 設計 / consistency level / RU sizing / connection management 等）— 那些屬 per-vendor deep article 的責任、本文在每個軸後 cross-link 過去。本文也 &lt;em>不&lt;/em> 比較三家「誰比較強」— 三 vendor 在 workload-by-workload 適配光譜上各有位置、寫成優劣比較會誤導讀者把選型壓成單軸。&lt;/p>
&lt;h2 id="問題情境讀者進來時的真實壓力">問題情境：讀者進來時的真實壓力&lt;/h2>
&lt;p>典型啟動壓力分兩類：&lt;/p>
&lt;p>第一類、團隊評估 document / KV / multi-model NoSQL 三家、文件都說「scalable schema-less」、看不出實際取捨。讀者徵兆是「我的資料是 document-shaped 還是 KV-shaped？」「partition key 該怎麼選？」「Atlas 跟 Cosmos DB MongoDB API 不一樣的點在哪？」「Cosmos DB multi-model 是真用得到還是行銷話術？」「on-demand vs provisioned 怎麼選？」&lt;/p>
&lt;p>第二類、既有 PostgreSQL / MySQL workload 撞 connection limit（surge 下 1K-5K pool 是隱性天花板、F1.7）、想換 KV 但不知道是否適合。讀者徵兆是「我已經有 Memcached、還要再加 MongoDB cache 層嗎？」「DynamoDB 適合當 OLTP 嗎？」「換 NoSQL 是不是解 connection 問題的銀彈？」&lt;/p>
&lt;p>這兩類讀者進來時的 &lt;em>真實問題&lt;/em> 不在 vendor 之間、在 &lt;em>workload 自己屬哪一型&lt;/em>。Case anchor 覆蓋六個 unique 角度：&lt;/p>
&lt;ul>
&lt;li>多型 document workload — &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/toyota-connected-mongodb-telematics-iot/" data-link-title="9.C38 Toyota Connected：MongoDB Atlas 撐 900 萬車輛 telematics、月 180 億 transaction" data-link-desc="Toyota Connected 用 MongoDB Atlas 撐 Safety Connect 900 萬車、月 180 億 transaction、緊急訊號 3 秒內到 agent">9.C38 Toyota Connected&lt;/a>（車載 sensor schema 隨車型演進、20 個 Atlas DB blast radius 切分）&lt;/li>
&lt;li>Document 跨雲 hedging — &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/forbes-mongodb-atlas-multi-cloud-migration/" data-link-title="9.C37 Forbes：自管 MongoDB → Atlas on GCP、build 時間 25 → 9 分鐘" data-link-desc="Forbes 把自管 MongoDB 遷到 Atlas on Google Cloud、6 個月完成、build 25 → 9 分鐘、120M 不重複訪客單月承接">9.C37 Forbes&lt;/a>（自管 → Atlas、6 個月遷移、跨雲彈性）&lt;/li>
&lt;li>同 model 換 vendor 的 dogfood signal — &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/microsoft-365-cosmos-db-analytics/" data-link-title="9.C30 Microsoft 365：從 MongoDB 遷移到 Cosmos DB 的分析平台" data-link-desc="Microsoft 365 把使用分析平台從 MongoDB 遷移到 Cosmos DB、planet-scale 全球分散式分析">9.C30 Microsoft 365&lt;/a>（MongoDB → Cosmos DB MongoDB API、保留 driver、wire compat 限制）&lt;/li>
&lt;li>KV-as-buffer 正向用例 — &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/tixcraft-ticketing-flash-sale-spike/" data-link-title="9.C15 拓元 Tixcraft：售票搶購的瞬間爆量架構" data-link-desc="拓元用 DynamoDB 當寫入緩衝 &amp;#43; 傳統伺服器當慢速消費者、承受 100K&amp;#43; 同時選位 &amp;#43; 30 秒從 6 台擴到 800 台">9.C15 Tixcraft&lt;/a>（DynamoDB 寫入緩衝、6750x 彈性、後端慢消費）&lt;/li>
&lt;li>PK 天然均勻典範 — &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/amazon-ads-dynamodb-extreme-kv/" data-link-title="9.C5 Amazon Ads：DynamoDB 9000 萬 reads/sec 的廣告事件量測" data-link-desc="Amazon Ads 在 DynamoDB 上跑 9000 萬 reads/sec &amp;#43; 500 萬 writes/sec、99.999% 可用性的廣告事件量測">9.C5 Amazon Ads&lt;/a>（90M reads/sec 年度峰值、KV pattern 純粹）&lt;/li>
&lt;li>Federated DB 真實系統 — &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/coinbase-mongodb-document-platform/" data-link-title="9.C36 Coinbase：MongoDB 撐 Ruby 單體 &amp;#43; 1.5M reads/sec identity 服務" data-link-desc="Coinbase 以 MongoDB 為主資料層、自建 mongobetween connection proxy、users 服務在加密貨幣 surge 時撐 1.5M reads/sec">9.C36 Coinbase&lt;/a>（MongoDB + DynamoDB + Memcached + mongobetween + freshness token）&lt;/li>
&lt;/ul>
&lt;h2 id="workload-shape--access-pattern--consistency-三軸前置判讀">Workload shape × access pattern × consistency 三軸前置判讀&lt;/h2>
&lt;p>進三家 vendor 對比前先回答：你的 workload 屬哪一型？三軸的組合決定 vendor 候選清單、軸不識別清楚直接比 vendor 是把選型壓成「品牌偏好」、不是工程決策。&lt;/p></description><content:encoded><![CDATA[<p>DB3 vendor selection 的核心責任是把讀者從「我該選 MongoDB / DynamoDB / Cosmos DB 哪一家」這個問題、推到「我的 workload 是 document / KV / multi-model 哪一類」這個更前置的問題。三家文件都標榜 scalable schema-less、但實際取捨在 <em>資料形狀、access pattern 穩定度、consistency 可接受度</em> 三軸決定 — 不識別 workload shape 直接比 vendor 是源頭錯誤。本文是 DB3 reader 進來的第一站：先做 workload shape 三軸前置判讀、再過 migration path 三型 + federated DB 視角、最後落到三 vendor 對比 10 軸。</p>
<p>本文 <em>不</em> 展開 vendor 機制細節（partition key 設計 / consistency level / RU sizing / connection management 等）— 那些屬 per-vendor deep article 的責任、本文在每個軸後 cross-link 過去。本文也 <em>不</em> 比較三家「誰比較強」— 三 vendor 在 workload-by-workload 適配光譜上各有位置、寫成優劣比較會誤導讀者把選型壓成單軸。</p>
<h2 id="問題情境讀者進來時的真實壓力">問題情境：讀者進來時的真實壓力</h2>
<p>典型啟動壓力分兩類：</p>
<p>第一類、團隊評估 document / KV / multi-model NoSQL 三家、文件都說「scalable schema-less」、看不出實際取捨。讀者徵兆是「我的資料是 document-shaped 還是 KV-shaped？」「partition key 該怎麼選？」「Atlas 跟 Cosmos DB MongoDB API 不一樣的點在哪？」「Cosmos DB multi-model 是真用得到還是行銷話術？」「on-demand vs provisioned 怎麼選？」</p>
<p>第二類、既有 PostgreSQL / MySQL workload 撞 connection limit（surge 下 1K-5K pool 是隱性天花板、F1.7）、想換 KV 但不知道是否適合。讀者徵兆是「我已經有 Memcached、還要再加 MongoDB cache 層嗎？」「DynamoDB 適合當 OLTP 嗎？」「換 NoSQL 是不是解 connection 問題的銀彈？」</p>
<p>這兩類讀者進來時的 <em>真實問題</em> 不在 vendor 之間、在 <em>workload 自己屬哪一型</em>。Case anchor 覆蓋六個 unique 角度：</p>
<ul>
<li>多型 document workload — <a href="/blog/backend/09-performance-capacity/cases/toyota-connected-mongodb-telematics-iot/" data-link-title="9.C38 Toyota Connected：MongoDB Atlas 撐 900 萬車輛 telematics、月 180 億 transaction" data-link-desc="Toyota Connected 用 MongoDB Atlas 撐 Safety Connect 900 萬車、月 180 億 transaction、緊急訊號 3 秒內到 agent">9.C38 Toyota Connected</a>（車載 sensor schema 隨車型演進、20 個 Atlas DB blast radius 切分）</li>
<li>Document 跨雲 hedging — <a href="/blog/backend/09-performance-capacity/cases/forbes-mongodb-atlas-multi-cloud-migration/" data-link-title="9.C37 Forbes：自管 MongoDB → Atlas on GCP、build 時間 25 → 9 分鐘" data-link-desc="Forbes 把自管 MongoDB 遷到 Atlas on Google Cloud、6 個月完成、build 25 → 9 分鐘、120M 不重複訪客單月承接">9.C37 Forbes</a>（自管 → Atlas、6 個月遷移、跨雲彈性）</li>
<li>同 model 換 vendor 的 dogfood signal — <a href="/blog/backend/09-performance-capacity/cases/microsoft-365-cosmos-db-analytics/" data-link-title="9.C30 Microsoft 365：從 MongoDB 遷移到 Cosmos DB 的分析平台" data-link-desc="Microsoft 365 把使用分析平台從 MongoDB 遷移到 Cosmos DB、planet-scale 全球分散式分析">9.C30 Microsoft 365</a>（MongoDB → Cosmos DB MongoDB API、保留 driver、wire compat 限制）</li>
<li>KV-as-buffer 正向用例 — <a href="/blog/backend/09-performance-capacity/cases/tixcraft-ticketing-flash-sale-spike/" data-link-title="9.C15 拓元 Tixcraft：售票搶購的瞬間爆量架構" data-link-desc="拓元用 DynamoDB 當寫入緩衝 &#43; 傳統伺服器當慢速消費者、承受 100K&#43; 同時選位 &#43; 30 秒從 6 台擴到 800 台">9.C15 Tixcraft</a>（DynamoDB 寫入緩衝、6750x 彈性、後端慢消費）</li>
<li>PK 天然均勻典範 — <a href="/blog/backend/09-performance-capacity/cases/amazon-ads-dynamodb-extreme-kv/" data-link-title="9.C5 Amazon Ads：DynamoDB 9000 萬 reads/sec 的廣告事件量測" data-link-desc="Amazon Ads 在 DynamoDB 上跑 9000 萬 reads/sec &#43; 500 萬 writes/sec、99.999% 可用性的廣告事件量測">9.C5 Amazon Ads</a>（90M reads/sec 年度峰值、KV pattern 純粹）</li>
<li>Federated DB 真實系統 — <a href="/blog/backend/09-performance-capacity/cases/coinbase-mongodb-document-platform/" data-link-title="9.C36 Coinbase：MongoDB 撐 Ruby 單體 &#43; 1.5M reads/sec identity 服務" data-link-desc="Coinbase 以 MongoDB 為主資料層、自建 mongobetween connection proxy、users 服務在加密貨幣 surge 時撐 1.5M reads/sec">9.C36 Coinbase</a>（MongoDB + DynamoDB + Memcached + mongobetween + freshness token）</li>
</ul>
<h2 id="workload-shape--access-pattern--consistency-三軸前置判讀">Workload shape × access pattern × consistency 三軸前置判讀</h2>
<p>進三家 vendor 對比前先回答：你的 workload 屬哪一型？三軸的組合決定 vendor 候選清單、軸不識別清楚直接比 vendor 是把選型壓成「品牌偏好」、不是工程決策。</p>
<h3 id="軸-1--資料形狀document--kv--不清楚">軸 1 — 資料形狀：document / KV / 不清楚</h3>
<p>資料形狀的核心判讀是 <em>aggregate root 邊界是否明確</em> 跟 <em>schema 是否會隨產品演進新增欄位</em>。document 適合的場景是資料天然多型、單筆記錄欄位差異大、應用層用 aggregate root 模式存取；KV 適合的場景是資料形狀固定、access pattern 數量少（&lt; 5 種）、固定 lookup by key。</p>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>適配資料模型</th>
          <th>對應 case</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>資料天然多型（不同記錄欄位不同）、隨產品演進 schema 增刪欄位、aggregate root 邊界明確</td>
          <td>Document（MongoDB / Cosmos DB SQL API / MongoDB API）</td>
          <td>Toyota sensor schema 隨車型演進、Forbes CMS article 欄位多型</td>
      </tr>
      <tr>
          <td>資料形狀固定、access pattern &lt; 5 種、固定 lookup by key（meeting_id / message_id / user_id）</td>
          <td>KV（DynamoDB / Cosmos DB Table API / Redis 持久化變體）</td>
          <td>Amazon Ads 用 ad_id 查、Disney+ 用 user_id 查 watchlist、PayPay 用 message_id 查通知</td>
      </tr>
      <tr>
          <td>資料形狀還在探索、access pattern 變動頻繁、未來 6 個月會加 5+ 種新 query</td>
          <td>暫緩 NoSQL 選型、用 PostgreSQL + JSONB 過渡</td>
          <td>屬讀者誤判常見模式、case 沒揭露但 F1.3 / F1.6 推論：NoSQL 假設 access pattern 穩定、未穩定就上 NoSQL 會撞 single-table 設計天花板</td>
      </tr>
  </tbody>
</table>
<p>第三列的「暫緩 NoSQL」是反指標。NoSQL（特別是 DynamoDB single-table design）的核心假設是「access pattern 在設計時已知、後續變動有限」。資料模型還在探索、access pattern 半年內會大幅增減的場景、PostgreSQL + JSONB 給的彈性遠高於 NoSQL — JSONB 欄位可以演進、ad-hoc query 可以用 SQL 跑、未來釐清穩定 access pattern 後再選 NoSQL 不遲。</p>
<h3 id="軸-2--access-pattern-穩定度kv-適用度前置判讀">軸 2 — Access pattern 穩定度（KV 適用度前置判讀）</h3>
<p>KV 適用度的核心判讀是 <em>partition key 天然均勻度</em>。partition key 不均勻會讓 vendor 廣告的「scale infinitely」變成「scale 到 hot partition 為止」、單一 logical key 流量超過該 partition 上限就 throttle 或 latency spike（F1.1）。</p>
<ul>
<li><strong>天然均勻 PK + 穩定 access pattern</strong>（meeting_id / player_id / message_id / user_id）→ DynamoDB / Cosmos DB Table API 適用、PK 不需 composite key 修補。Amazon Ads 用 ad_id 撐 90M reads/sec、Zoom 用 meeting_id、Capcom 用 player_id、PayPay 用 message_id、Disney+ 用 user_id — 五個 case 都揭露同一 frame：<em>業務天然存在均勻 key 時 KV 是最自然的選擇</em>。</li>
<li><strong>天然不均勻 PK</strong>（event_id 一場演唱會集中 / date 時間序集中）→ 需 composite key 或 write sharding 修補。Tixcraft（9.C15）用 <code>event_id + user_id_hash</code> composite key 把單一熱門演唱會的 6750x spike 攤平到 partition 上 — 不是 DynamoDB 自身彈性、是 partition key 均勻分散的結果（F1.2）。</li>
<li><strong>Access pattern 變動頻繁</strong>（探索期、&lt; 5 種 query 還會增加）→ 不適合 DynamoDB single-table design、回 RDB。Single-table 把 access pattern 編進 PK / SK 結構、增加新 query 等於改 schema、改 schema 等於重新 load 資料、成本不對。</li>
</ul>
<p>KV 適用度判讀的延伸細節（hot partition 反模式 / composite key 設計 / adaptive capacity）見 <a href="/blog/backend/01-database/vendors/dynamodb/partition-key-antipatterns/" data-link-title="DynamoDB Partition Key 反模式與 Write Sharding：composite key 修復跟 mode × partition 交叉判讀" data-link-desc="DynamoDB partition 上限 1000 WCU 是 hot partition 的根因；composite key（event_id &#43; shard suffix）跟 calculated shard（hash % N）兩種修法、mode × partition 在 provisioned / on-demand 不同表現，以及 9.C15 Tixcraft 6750x 擴展的工程細節">DynamoDB partition key antipatterns</a>。</p>
<h3 id="軸-3--consistency-需求是否可接受-eventual">軸 3 — Consistency 需求是否可接受 eventual</h3>
<p>Consistency 需求的核心判讀是 <em>跨 partition / 跨 region transaction 是否為產品契約</em>。三家 vendor 都支援單 partition / 單 region 強一致、但 cross-partition / cross-region transaction 的機制跟限制差異大。</p>
<ul>
<li><strong>可接受 eventual / session consistency</strong>：DynamoDB（default eventually consistent reads、可選 strong）、Cosmos DB（5 個 consistency level、default session）、MongoDB（read concern 多級）— 三家都可以、選擇看其他軸。多數 KV / document workload 屬此類（social timeline、watchlist、message queue、analytics aggregation）。</li>
<li><strong>需要強一致 cross-partition transaction</strong>：DynamoDB 跨 partition transaction 限制（單一 transaction 最多 100 個 action、跨 region 不支援）、MongoDB 4.0+ 支援 multi-document transaction 但 sharded cluster 仍有 limitation、Cosmos DB 跨 logical partition transaction 受限 — 都不如 SQL／distributed SQL 自然、應回 DB4 entry point 評估 <a href="/blog/backend/01-database/vendors/cockroachdb/aurora-dsql-spanner-decision-tree/" data-link-title="CockroachDB vs Aurora DSQL vs Spanner：撞牆訊號分型 &#43; 七問題決策樹" data-link-desc="Distributed SQL 三選一決策樹。先用撞牆訊號分型識別 driver path（DoorDash 單主寫入撞牆 / Netflix Cassandra 缺口 / Hard Rock 合規驅動）、再走七問題（跨雲 / 雲商生態 / 風險預算 / PG 相容 / 管理負擔 / team size / vendor sizing barrier）。PostgreSQL 相容性 audit checklist 4 項、Spanner 100 pu sizing barrier、Hard Rock 「省 10-20 工程師」機會成本警示、Netflix Database Platform Team 規模">Aurora DSQL / Spanner / CockroachDB</a>。</li>
<li><strong>跨 region active-active write</strong>：三家機制完全不同 — Cosmos DB multi-region write 跟 Strong consistency 是 <em>互斥</em> 設定（CAP 取捨硬約束、見 <a href="/blog/backend/01-database/vendors/cosmosdb/multi-region-write-conflict/" data-link-title="Cosmos DB Multi-Region Write：active-active、LWW、custom merge、Strong &#43; multi-region 互斥的 AP 取捨" data-link-desc="Multi-region active-active write 的 conflict resolution（LWW / custom merge / conflict feed）、Strong 跟 multi-region write 為什麼互斥、廣告 SLA vs 實測可用性鏈路拆解 — 從 Minecraft Earth &#43; Toyota Connected 切入">Cosmos DB multi-region write conflict</a> SSoT 主寫位置）；DynamoDB Global Tables 走 LWW（last-writer-wins）conflict resolution；MongoDB Atlas 跨 region 需手動 conflict 處理。三家不在同一光譜、選擇前必看各 vendor outline 的機制段。</li>
</ul>
<h2 id="migration-path-三型跨-case-合成-frame">Migration path 三型（跨 case 合成 frame）</h2>
<blockquote>
<p>本段是 <em>跨 case 合成 frame</em>、不是單一 case 揭露 — 從 Coinbase（9.C36）/ Forbes（9.C37）/ Microsoft 365（9.C30）三 case 萃取的共通結構（F2.1）。</p></blockquote>
<p>讀者進來時通常不是綠地、是 <em>既有系統演進</em>。三型遷移路徑的風險、ROI、適用條件完全不同、選錯路徑會推到錯的 vendor。</p>
<h3 id="第一型保留原-db--補周邊工具">第一型：保留原 DB + 補周邊工具</h3>
<p>不換 vendor、加 connection proxy（mongobetween / pgbouncer 類）、加 cache（Memcached + freshness token）、加 predictive scaling — 主資料層不動、應用層跟 ops 層補強。</p>
<ul>
<li><strong>代表 case</strong>：Coinbase（9.C36）保留 MongoDB Atlas、自建 mongobetween 把 60K connections/min 降到 ~2K（一個量級）、用 Memcached + freshness token 撐 1.5M reads/sec、用 ML predictive scaling 把擴容時間從 70 → 25 分鐘提前 60 分鐘</li>
<li><strong>路徑成本</strong>：中（自建工具、需要工程資源 build &amp; operate proxy / cache layer / ML model）</li>
<li><strong>風險</strong>：低（主資料層不動、回滾代價小）</li>
<li><strong>ROI</strong>：保留主資料 schema + access pattern、解 driver / 部署模型 / cache 一致性瓶頸</li>
<li><strong>適合</strong>：MongoDB（或主 DB）資料層撐得住、但應用層 connection storm / cache miss / 擴容慢卡瓶頸；團隊有工程能力 build 跟 maintain 周邊工具</li>
</ul>
<p>延伸實作細節見 MongoDB connection management（per-vendor article、cross-link 待寫稿）。</p>
<h3 id="第二型同-db-換託管">第二型：同 DB 換託管</h3>
<p>自管 → managed（Atlas / Cosmos DB / DocumentDB）、保留 schema 跟 access pattern、遷移期 6 個月量級。</p>
<ul>
<li><strong>代表 case</strong>：Forbes（9.C37）自管 MongoDB → MongoDB Atlas、保留 CMS schema、6 個月遷移、揭露「TCO 改善 25%」</li>
<li><strong>路徑成本</strong>：中（dual-write + shadow read 驗證、driver 行為差異、operation runbook 重寫）</li>
<li><strong>風險</strong>：中（dual-write 期間雙寫一致性、cutover 時點選擇）</li>
<li><strong>ROI</strong>：operation transfer（DBA bandwidth 釋放給 schema design / query tuning）+ TCO 改善</li>
<li><strong>適合</strong>：自管 ops burden 大（DBA bandwidth 被 backup / patching / replica lag 吃光）、不想換 model</li>
</ul>
<p><strong>Scope warning（Forbes 25% TCO）</strong>：「25% TCO 改善」是 Forbes 特定流量規模（120M MAU、70+ Atlas region）下的數字、<em>不普適</em>。引用要帶條件 — 不要寫成「Atlas 比自管便宜 25%」這種 vendor-neutral 結論。實際省多少要看自管當下的 license / hardware / ops 工時分配、跟 Atlas 在你流量規模下的 pricing tier。</p>
<h3 id="第三型換-vendor-保留-model">第三型：換 vendor 保留 model</h3>
<p>MongoDB → Cosmos DB MongoDB API、或 MongoDB → DocumentDB — wire protocol + driver 不變、底層架構整個換、ops 模型整個換。</p>
<ul>
<li><strong>代表 case</strong>：Microsoft 365（9.C30）MongoDB → Cosmos DB MongoDB API、保留 MongoDB driver</li>
<li><strong>路徑成本</strong>：高（dual-write per query pattern 驗證、wire compat ≠ 100% 行為相同、aggregation pipeline 跟 transaction 行為要逐項驗證）</li>
<li><strong>風險</strong>：高（每個 query pattern 都可能踩到不相容 edge case、cutover 點選擇難）</li>
<li><strong>ROI</strong>：跨 vendor 換（Azure 生態 / multi-model API / global distribution）+ 保留應用層 driver code</li>
</ul>
<p><strong>Scope warning（Microsoft 365 dogfood）</strong>：Microsoft 365 是 Microsoft 自家 dogfood、case 沒揭露具體 throughput / latency / cost 數字（F2.17）。dogfood 是 <em>高權重 selection signal</em>（雲商賭自家旗艦產品）、但 <em>不是 production benchmark</em>（沒公開數字可比對）。引用要明示「dogfood signal」而非「production proof」。</p>
<p><strong>Scope warning（100% wire compat）</strong>：Cosmos DB MongoDB API 廣告「100% wire compatibility」是 <em>vendor 行銷話術</em>、實際是「在某些 query pattern 下相容」（F2.9）。遷移時必須 <em>dual-write per query pattern</em> 驗證 — 不是看 vendor 文件 spec list、是用 production query corpus 跑一遍實測行為。Phase 0 audit checklist 應列出 unsupported aggregation stage、transaction edge case、index behavior 差異、change stream 跟 Change Feed 對應關係。</p>
<p>延伸 Cosmos DB MongoDB API vs SQL API 選型見 <a href="/blog/backend/01-database/vendors/cosmosdb/mongodb-api-vs-sql-api/" data-link-title="Cosmos DB MongoDB API vs SQL API：遷移路徑、dogfood signal、multi-model、跨雲 hedging" data-link-desc="從『MongoDB API 跟 SQL API 哪個快』推進到 vendor selection 的四層問題：三型遷移路徑、dogfood signal 怎麼讀、multi-model 差異化、跨雲 hedging — 從 Microsoft 365 dogfood 案例切入">Cosmos DB MongoDB API vs SQL API</a>。</p>
<h3 id="第四型不在-db3-範圍paradigm-shift-換引擎">第四型不在 DB3 範圍：paradigm shift 換引擎</h3>
<p>KV → SQL 或 SQL → distributed SQL 屬 paradigm shift、應進 <a href="/blog/backend/01-database/vendors/cockroachdb/aurora-dsql-spanner-decision-tree/" data-link-title="CockroachDB vs Aurora DSQL vs Spanner：撞牆訊號分型 &#43; 七問題決策樹" data-link-desc="Distributed SQL 三選一決策樹。先用撞牆訊號分型識別 driver path（DoorDash 單主寫入撞牆 / Netflix Cassandra 缺口 / Hard Rock 合規驅動）、再走七問題（跨雲 / 雲商生態 / 風險預算 / PG 相容 / 管理負擔 / team size / vendor sizing barrier）。PostgreSQL 相容性 audit checklist 4 項、Spanner 100 pu sizing barrier、Hard Rock 「省 10-20 工程師」機會成本警示、Netflix Database Platform Team 規模">DB4 entry point: Aurora DSQL / Spanner / CockroachDB decision tree</a>。本文範圍是 DB3 三家內部選型、不展開 paradigm shift。</p>
<h2 id="從-rdb-撞牆來的快速路徑">從 RDB 撞牆來的快速路徑</h2>
<p>讀者若從 PostgreSQL / Aurora connection limit 撞牆過來、想評估 KV 替代、依撞牆訊號直接 route 到對應 article、不必先跑完三軸前置判讀：</p>
<ul>
<li><strong>撞 connection limit</strong>（surge 下 pool 1K-5K 隱性天花板、long-lived TCP 占滿）→ HTTP API 模型（no long-lived connection）的 KV 直接接寫入緩衝、進 <a href="/blog/backend/01-database/vendors/dynamodb/single-table-design-pattern/" data-link-title="DynamoDB Single-Table Design：從適用度前置判讀到 access pattern 反推 PK/SK" data-link-desc="DynamoDB single-table 設計不是「資料表越少越好」，而是 access pattern 反推 PK/SK 跟 GSI；本文先做 DynamoDB 適用度 4 軸前置判讀（PK 天然均勻 / control plane vs data plane / consistency / access pattern 穩定），再展開設計流程、failure modes 與 durable queue 正向用例">dynamodb/single-table-design-pattern</a> 的「durable queue / write buffer」段（Tixcraft 9.C15 路徑：DynamoDB 接訂單、傳統 server 慢消費）、或評估 <a href="/blog/backend/01-database/vendors/cosmosdb/mongodb-api-vs-sql-api/" data-link-title="Cosmos DB MongoDB API vs SQL API：遷移路徑、dogfood signal、multi-model、跨雲 hedging" data-link-desc="從『MongoDB API 跟 SQL API 哪個快』推進到 vendor selection 的四層問題：三型遷移路徑、dogfood signal 怎麼讀、multi-model 差異化、跨雲 hedging — 從 Microsoft 365 dogfood 案例切入">Cosmos DB Table API</a></li>
<li><strong>撞單 primary 寫入上限</strong>（單 leader 寫吞吐天花板、read replica 無法分擔寫）→ multi-primary distributed SQL 路徑、進 <a href="/blog/backend/01-database/vendors/cockroachdb/aurora-dsql-spanner-decision-tree/" data-link-title="CockroachDB vs Aurora DSQL vs Spanner：撞牆訊號分型 &#43; 七問題決策樹" data-link-desc="Distributed SQL 三選一決策樹。先用撞牆訊號分型識別 driver path（DoorDash 單主寫入撞牆 / Netflix Cassandra 缺口 / Hard Rock 合規驅動）、再走七問題（跨雲 / 雲商生態 / 風險預算 / PG 相容 / 管理負擔 / team size / vendor sizing barrier）。PostgreSQL 相容性 audit checklist 4 項、Spanner 100 pu sizing barrier、Hard Rock 「省 10-20 工程師」機會成本警示、Netflix Database Platform Team 規模">DB4 entry point: Aurora DSQL / Spanner / CockroachDB decision tree</a> 的 Path A（DoorDash 1.636 M QPS 單主寫入撞牆）</li>
<li><strong>撞單一 DB 撐不下 + 多 workload 形狀並存</strong>（read-heavy / write-heavy / analytics 混在一個 DB）→ federated DB 模式、看 <a href="/blog/backend/09-performance-capacity/cases/coinbase-mongodb-document-platform/" data-link-title="9.C36 Coinbase：MongoDB 撐 Ruby 單體 &#43; 1.5M reads/sec identity 服務" data-link-desc="Coinbase 以 MongoDB 為主資料層、自建 mongobetween connection proxy、users 服務在加密貨幣 surge 時撐 1.5M reads/sec">9.C36 Coinbase</a>（MongoDB + DynamoDB + Memcached + mongobetween）+ <a href="/blog/backend/09-performance-capacity/cases/ntt-docomo-lemino-japanese-streaming/" data-link-title="9.C29 NTT DOCOMO Lemino：3 個月達 500 萬 MAU 的串流後端" data-link-desc="Lemino 用 DynamoDB &#43; AWS Media Services 撐 30 channels live &#43; 5M MAU、工程工時下降 90%">9.C29 Lemino</a>（PostgreSQL → DynamoDB 揭露 RDB connection limit 隱性 bottleneck）</li>
</ul>
<p>進 <a href="/blog/backend/01-database/vendors/dynamodb/single-table-design-pattern/" data-link-title="DynamoDB Single-Table Design：從適用度前置判讀到 access pattern 反推 PK/SK" data-link-desc="DynamoDB single-table 設計不是「資料表越少越好」，而是 access pattern 反推 PK/SK 跟 GSI；本文先做 DynamoDB 適用度 4 軸前置判讀（PK 天然均勻 / control plane vs data plane / consistency / access pattern 穩定），再展開設計流程、failure modes 與 durable queue 正向用例">dynamodb/single-table-design-pattern</a> 前先確認軸 1 / 軸 2 的 access pattern 穩定度跟 PK 天然均勻度 — connection limit 訊號 <em>必要但不充分</em>、KV 適用度 4 軸還是要走完、避免「為了解 connection 把不穩定 access pattern 硬塞 single-table」反模式。</p>
<h2 id="federated-db--system-role-視角跨-case-合成-frame">Federated DB + system role 視角（跨 case 合成 frame）</h2>
<blockquote>
<p>本段也是 <em>跨 case 合成 frame</em>（F2.18 + F1.6）— 三個 rich case（Coinbase / Toyota / Forbes）都揭露 production 系統是 <em>DB + 周邊工具</em> 組合、不是單一 DB monolithic 撐起來。</p></blockquote>
<p>讀者常誤以為「全用 X」是正解 — 全用 MongoDB、或全遷 DynamoDB、或全換 Cosmos DB。真實 production case 揭露兩個更前置的事實：(a) production 系統是 <em>federated</em>（多 DB 按 workload 分流）、不是 monolithic；(b) 每個 vendor 在系統中扮演 <em>特定角色</em>（control plane vs data plane vs cache）、不是 all-purpose store。</p>
<h3 id="federated-db-by-workload">Federated DB by workload</h3>
<p>Coinbase（9.C36）production 配置：MongoDB Atlas（document 主資料、identity service）+ DynamoDB（部分固定 KV workload）+ Memcached（read cache）+ mongobetween（connection proxy）+ Kinesis（event stream）。不是「全用 MongoDB」也不是「全遷 DynamoDB」、是按 workload shape 分流。</p>
<p>Toyota Connected（9.C38）：MongoDB Atlas 20 個 DB（microservice 拆 blast radius）+ Lambda + Kinesis + Redis + Kubernetes。20 個 DB 不是吞吐撐不住（18B txn/月 ≈ 7K txn/sec、單一 cluster 撐得下）、是 <em>microservice ownership</em> + <em>blast radius</em> 切分（F2.6）。</p>
<p>Forbes（9.C37）：MongoDB Atlas + 中介 abstraction layer + 50+ microservice。abstraction layer 隔離 schema 變動、避免 50 個服務都依賴 DB schema 細節（F2.3）。</p>
<p>三 case 揭露的共同 frame 是：<strong>寫 production 系統時假設「DB 一個服務搞定」、忽略 cache / queue / proxy / abstraction layer 跨層責任、會撞 connection limit / cache miss / cross-region replication 等隱性瓶頸</strong>。</p>
<h3 id="system-rolecontrol-plane-vs-data-plane">System role：control plane vs data plane</h3>
<p>DynamoDB 在 surge 場景能撐 nearly infinitely 不是 DynamoDB 自己神奇、是 <em>系統架構解耦</em> 的結果（F1.6）：</p>
<ul>
<li><strong>Control plane（metadata、state、user record）</strong>：DynamoDB / MongoDB / Cosmos DB 適合 — 流量是 small payload + high QPS pattern</li>
<li><strong>Data plane（影音、大型 BLOB、media stream）</strong>：CDN / S3 / object storage、<em>不在 DB3 範圍</em> — 流量是 large payload + bandwidth-bound</li>
<li><strong>Cache layer</strong>：Redis / Memcached / DAX（DynamoDB 補位）— 跟主 DB 形成跨層架構、處理讀峰值 + read-your-own-write 一致性</li>
</ul>
<p>三個 case 揭露同一 frame：Zoom 視訊 metadata 走 DynamoDB、影音走 WebRTC / edge servers；Disney+ watchlist 走 DynamoDB、影片串流走 CDN + S3；Capcom game state 走 DynamoDB + DAX、game server 走 EKS。<strong>把影音串流塞 DynamoDB 是違反 control plane vs data plane 分離、容量規劃會錯</strong>（每筆 1KB 的 KV vs 每筆 100MB 的 media chunk 是不同 workload）。</p>
<h2 id="三-vendor-對比-10-軸">三 vendor 對比 10 軸</h2>
<p>下表是三 vendor 在 selection 階段的 10 軸對比。每個軸後續都有 per-vendor deep article 展開機制、本文不重複展開。</p>
<table>
  <thead>
      <tr>
          <th>軸</th>
          <th>MongoDB</th>
          <th>DynamoDB</th>
          <th>Cosmos DB</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>資料模型核心</strong></td>
          <td>Document（aggregate root）+ aggregation pipeline</td>
          <td>KV with optional document fields + GSI / LSI</td>
          <td>Multi-model（SQL / MongoDB / Cassandra / Gremlin / Table API）</td>
      </tr>
      <tr>
          <td><strong>部署 topology</strong></td>
          <td>跨雲（Atlas AWS / GCP / Azure）+ self-hosted</td>
          <td>AWS-only managed</td>
          <td>Azure-only managed</td>
      </tr>
      <tr>
          <td><strong>跨雲 hedging</strong></td>
          <td>高（Atlas 跨雲、Forbes case）</td>
          <td>無（AWS lock-in）</td>
          <td>無（Azure lock-in）</td>
      </tr>
      <tr>
          <td><strong>Capacity 抽象</strong></td>
          <td>CPU + IOPS + working set RAM 三軸</td>
          <td>WCU/RCU + on-demand/provisioned + adaptive capacity</td>
          <td>RU（Request Unit）+ 5 consistency level</td>
      </tr>
      <tr>
          <td><strong>Contract layer</strong></td>
          <td>DB 層 <code>$jsonSchema</code> validator / app 層 abstraction / 混合</td>
          <td>DynamoDB Stream + app 層 validator</td>
          <td>DB 層 stored procedure + app 層 validator</td>
      </tr>
      <tr>
          <td><strong>Partition / shard key 可逆性</strong></td>
          <td><code>reshardCollection</code> 4.4+ 可改、成本高</td>
          <td>可改用 backfill</td>
          <td>不可改、必 export-recreate</td>
      </tr>
      <tr>
          <td><strong>Consistency model</strong></td>
          <td>Read concern（local / majority / linearizable）+ causal consistency session</td>
          <td>Eventually / strongly consistent reads</td>
          <td>5 level spectrum（Strong / Bounded staleness / Session / Consistent prefix / Eventual）</td>
      </tr>
      <tr>
          <td><strong>Multi-region write</strong></td>
          <td>Atlas 跨 region 手動 conflict 處理</td>
          <td>Global Tables LWW</td>
          <td>Multi-region write（Strong 互斥、見 cosmosdb/multi-region-write-conflict SSoT）</td>
      </tr>
      <tr>
          <td><strong>Dogfood signal</strong></td>
          <td>無（MongoDB 是獨立公司、不適用）</td>
          <td>Amazon 自家高頻使用（9.C5 Amazon Ads / 9.C27 Disney+ etc）</td>
          <td>Microsoft 365 dogfood（9.C30、<strong>Scope warning</strong>：dogfood 數字不公開、是 selection signal 不是 benchmark）</td>
      </tr>
      <tr>
          <td><strong>Multi-model 差異化</strong></td>
          <td>單一 document model</td>
          <td>單一 KV-with-document model</td>
          <td>唯一單服務支援 5 API（差異化價值、F2.16）</td>
      </tr>
  </tbody>
</table>
<h3 id="軸的延伸子段">軸的延伸子段</h3>
<p><strong>部署 topology / 跨雲 hedging</strong>：三家 topology 是 <em>vendor lock-in</em> 跟 <em>跨雲彈性</em> 的硬取捨。Forbes 選 Atlas 不是當下省錢（自管 MongoDB 也可以、TCO 改善是副作用）、是 <em>未來雲商策略尚未底定</em> 的 hedging — Atlas 提供 AWS / GCP / Azure 三家部署、未來換雲不用換 DB（F2.10）。對照 DynamoDB / Cosmos DB / Spanner / Aurora 都是單雲鎖定 — 選了就跟著該雲商生態走。團隊雲商策略已底定（深度用 AWS / Azure / GCP 其一）時、單雲 vendor 通常較划算（更好的 IAM 整合、更深的 ops 工具、單一 support 通道）。跨雲價值真正成立是 <em>策略不確定</em> 或 <em>合規要求多雲</em> 場景。</p>
<p><strong>Capacity 抽象</strong>：三家 capacity 抽象的 <em>思維遷移成本</em> 可能高過 vendor 廣告的價差（F2.12）。MongoDB 用 CPU + IOPS + working set RAM 三軸思維、跟自管 PostgreSQL / MySQL 類似、團隊轉換成本低。DynamoDB 用 WCU/RCU 抽象、要學「估每個操作消耗多少 unit」、加上 on-demand / provisioned / adaptive capacity 三模式選擇。Cosmos DB 用 <a href="/blog/backend/knowledge-cards/request-unit/" data-link-title="Request Unit" data-link-desc="Cosmos DB 的容量抽象單位、1 RU = 1KB document strong-consistent read 的 CPU &#43; memory &#43; IOPS 綜合 cost、寫 ~5 RU、複雜 query 數百 RU">Request Unit</a>（RU）抽象、1 RU ≈ 1 KB document 的 strong read 成本、寫 ~5 RU、複雜 query 數百 RU — 工程師要學會用 RU 思考、不是用 CPU 思考、團隊知識遷移成本可能高。容量規劃延伸見對應 vendor 的 sizing article。</p>
<p><strong>Partition / shard key 可逆性</strong>：三家 <em>不在同一光譜</em>、是選 vendor 前必做的 access pattern audit 重點（F2.15）。MongoDB <code>reshardCollection</code>（4.4+）可改、但成本高、需要 cluster downtime 或長時間 background migration。DynamoDB partition key 技術上可改、實作上用 backfill（建新 table、新 PK、雙寫舊新、cutover）— ops 工作量大但可逆。Cosmos DB partition key <em>不可改</em>、改 partition key 等於 export-recreate-import — 對 1TB+ 資料是大型 migration 工程。三家不可逆性遞增、選 Cosmos DB 前必須前期完整 access pattern audit、不能「先上 production 之後再調」。</p>
<p><strong>Consistency model</strong>：三家機制設計哲學不同。MongoDB read concern 是 <em>per-operation</em> 選擇（同一 client connection 可以混用）；DynamoDB strong vs eventual 是 <em>per-read</em> 選項（write 端統一強一致）；Cosmos DB 5 個 level 是 <em>account-level default + per-request override</em>、且 Strong 跟 multi-region write 互斥（CAP 硬約束）。設計上 MongoDB 最 flexible、Cosmos DB 最 explicit、DynamoDB 介於中間。延伸機制細節見 <a href="/blog/backend/01-database/vendors/cosmosdb/consistency-levels-engineering/" data-link-title="Cosmos DB 5 Consistency Levels：Session 預設、Bounded staleness、Strong 邊界跟跨 collection 分流策略" data-link-desc="Cosmos DB 5 個 consistency level 的工程選擇邏輯、Session 為何是 production 預設、per-request override 跟跨 collection 分流的進階策略、Strong &#43; multi-region 互斥的 cross-link — 從 Minecraft Earth &#43; ASOS 切入">Cosmos DB consistency levels engineering</a>、<a href="/blog/backend/01-database/vendors/cosmosdb/multi-region-write-conflict/" data-link-title="Cosmos DB Multi-Region Write：active-active、LWW、custom merge、Strong &#43; multi-region 互斥的 AP 取捨" data-link-desc="Multi-region active-active write 的 conflict resolution（LWW / custom merge / conflict feed）、Strong 跟 multi-region write 為什麼互斥、廣告 SLA vs 實測可用性鏈路拆解 — 從 Minecraft Earth &#43; Toyota Connected 切入">Cosmos DB multi-region write conflict</a>（SSoT 主寫位置）。</p>
<p><strong>Multi-model 差異化</strong>：Cosmos DB 是 <em>唯一單一服務支援 5 API</em> 的雲商 DB（SQL / MongoDB / Cassandra / Gremlin / Table）— 對照 AWS 走多產品覆蓋（DynamoDB KV + DocumentDB MongoDB-compat + Neptune graph + Keyspaces Cassandra-compat）、GCP 走多產品覆蓋（Firestore + Spanner + Bigtable）。multi-model 的差異化價值是 <em>減少多 DB 並存運維</em> — 一個產品團隊只養一個 service、一套 IAM、一套 backup / DR、一套 monitoring。但 <em>是否真用上 multi-model</em> 要看團隊實際 workload — 多數團隊只用 1-2 個 API、單一 model 的競品（DynamoDB / MongoDB）可能更專注（F2.16）。</p>
<h2 id="失敗模式cross-vendor-反模式">失敗模式（cross-vendor 反模式）</h2>
<p>下列七條是三 vendor 都會踩、跨 case 共通的反模式。Per-vendor 特定反模式（例如 DynamoDB on-demand 隱性 hot partition、MongoDB schema 三代並存）在 per-vendor deep article。</p>
<h3 id="反模式-1把-dynamodb-當-oltp">反模式 1：把 DynamoDB 當 OLTP</h3>
<p>訊號：access pattern 還在探索期、5+ 種 query 還會增加、強一致 cross-partition transaction 是產品契約。應回 PostgreSQL / Aurora、不是繼續加碼 DynamoDB single-table design。</p>
<p>DynamoDB 的 <em>正確</em> 用法包含 control plane KV（Zoom / Disney+ / Capcom）跟 durable queue / write buffer（Tixcraft 9.C15 揭露的非 OLTP 正向用例、F1.3）— DynamoDB 接「訂單」寫入、不是即時生效、是讓 traditional server（金流 / 票庫）用自己能承受的速度消費。這層解耦讓「前端可以擴 130 倍、後端不用同步擴」。</p>
<h3 id="反模式-2把-mongodb-當-kv">反模式 2：把 MongoDB 當 KV</h3>
<p>訊號：access pattern 固定、PK 天然均勻、不需要 aggregation pipeline、document 內部從不展開（只查 root 欄位）。</p>
<p>應改 DynamoDB / Cosmos DB Table API。MongoDB 在這場景的 overhead（document overhead / connection model / aggregation engine 未用上）不划算 — KV vendor 的單筆讀寫成本更低、scaling 模型更簡單。</p>
<h3 id="反模式-3把-cosmos-db-當跨雲服務">反模式 3：把 Cosmos DB 當跨雲服務</h3>
<p>訊號：團隊評估 multi-cloud DR / 跨雲 portability、看到 Cosmos DB 文件強調「global distribution」就以為支援跨雲。</p>
<p>Cosmos DB 是 <em>Azure-only</em>、global distribution 指 Azure 內跨 region。想跨雲應改 MongoDB Atlas。multi-model 差異化是 <em>Azure 生態內</em> 的價值（F2.16）— 一旦離開 Azure、Cosmos DB 的所有獨特優勢都不存在。</p>
<h3 id="反模式-4federated-db-假設全用-x">反模式 4：federated DB 假設「全用 X」</h3>
<p>訊號：寫架構設計時假設「DB 一個服務搞定」、不規劃 cache / queue / proxy / abstraction layer。</p>
<p>Production 真實系統都是 federated（Coinbase / Toyota / Forbes 都是）。寫架構時假設一個 DB 搞定會撞 connection limit（surge 下 RDB 第一個爆點、F1.7）/ cache miss（單靠 DB 撐不住讀峰值）/ cross-region replication（跨 region 一致性處理錯）等隱性瓶頸。預先設計 federated topology + 跨層責任分配、不是事後補。</p>
<h3 id="反模式-5誤判-dogfood-case-數字">反模式 5：誤判 dogfood case 數字</h3>
<p>訊號：引用 Microsoft 365 / Amazon Prime Day 等 dogfood case 時、把它當 production benchmark、抄具體數字當 sizing 依據。</p>
<p>Dogfood case 數字常 <em>不公開</em> 或 <em>不適用 customer-facing</em>（F2.17 + F1.10）— Amazon Prime Day 「90M reads/sec」是年度峰值最高一秒不是平均、Microsoft 365 直接沒給數字、Google Spanner「10 億 req/sec」是 Google 全使用者加總不是單客戶配額。寫架構時引用要明示 selection signal（雲商賭身家、值得當高權重 vendor 訊號）vs production benchmark（具體 sizing 數字）— 兩者不可混為一談。</p>
<h3 id="反模式-6partition-key-一上-production-才發現不可逆">反模式 6：partition key 一上 production 才發現不可逆</h3>
<p>訊號：選 Cosmos DB / DynamoDB 時、partition key 設計沒做完整 access pattern audit、上 production 一段時間後發現 hot partition、想改 PK。</p>
<p>三家不在同一光譜（見前段對比表）— MongoDB shard key 4.4+ 可改但成本高、DynamoDB 可 backfill 改、Cosmos DB <em>不可改</em> 必 export-recreate。選 Cosmos DB 前要前期完整 access pattern audit、列所有預期 query 跟對應 PK 訪問頻率、確認最熱 PK 流量在單一 partition 容量上限內（F2.15）。</p>
<h3 id="反模式-7wire-compatibility-當-100-行為相同">反模式 7：wire compatibility 當 100% 行為相同</h3>
<p>訊號：選 Cosmos DB MongoDB API 或 DocumentDB、看到「MongoDB compatible」就假設 MongoDB driver 跑得起來就是相容、跳過 query pattern 驗證。</p>
<p>Wire compat ≠ 行為 100% 相同（F2.9）。Cosmos DB MongoDB API 廣告「100% wire compatibility」是行銷話術、實際是「在某些 query pattern 下相容」— aggregation pipeline 某些 stage 不支援、transaction edge case 行為差異、index 行為差異都會踩到。遷移必須 dual-write per query pattern 驗證、不是看 vendor spec list。</p>
<h2 id="不該選-db3-的訊號升-sql--升-distributed-sql-路徑">不該選 DB3 的訊號（升 SQL / 升 distributed SQL 路徑）</h2>
<p>下列四條訊號出現時、選擇應跳出 DB3 範圍。</p>
<ul>
<li><strong>JOIN-heavy + 強 normalize workload</strong>：應留 PostgreSQL（包括 PostgreSQL + JSONB 混合方案）、不該塞 NoSQL 再 <code>$lookup</code>。aggregation pipeline 的 <code>$lookup</code> 性能遠不如 SQL JOIN、在 sharded cluster 還有限制。</li>
<li><strong>強一致 cross-region transaction 是產品契約</strong>：應進 <a href="/blog/backend/01-database/vendors/cockroachdb/aurora-dsql-spanner-decision-tree/" data-link-title="CockroachDB vs Aurora DSQL vs Spanner：撞牆訊號分型 &#43; 七問題決策樹" data-link-desc="Distributed SQL 三選一決策樹。先用撞牆訊號分型識別 driver path（DoorDash 單主寫入撞牆 / Netflix Cassandra 缺口 / Hard Rock 合規驅動）、再走七問題（跨雲 / 雲商生態 / 風險預算 / PG 相容 / 管理負擔 / team size / vendor sizing barrier）。PostgreSQL 相容性 audit checklist 4 項、Spanner 100 pu sizing barrier、Hard Rock 「省 10-20 工程師」機會成本警示、Netflix Database Platform Team 規模">DB4 entry point</a> 評估 distributed SQL（CockroachDB / Spanner / Aurora DSQL）。三家 NoSQL 的 cross-region transaction 都有 limitation、不該當主路徑。</li>
<li><strong>大流量 + 跨業務 fleet 治理</strong>：Aurora 200 cluster 模式（9.C4 DraftKings 揭露的 business sharding fleet）可能更合適、進 Aurora fleet 治理。NoSQL 的 fleet 治理工具鏈（cluster lifecycle / cross-cluster query / unified IAM）通常不如 managed SQL 成熟。</li>
<li><strong>資料模型還在探索 + access pattern 變動快</strong>：暫緩 NoSQL 選型、用 PostgreSQL + JSONB 過渡。JSONB 給 document-like flexibility、SQL 給 ad-hoc query power、未來釐清穩定 access pattern 後再選 NoSQL 不遲。</li>
</ul>
<h2 id="下一步路由per-vendor-outline-子組">下一步路由（per-vendor outline 子組）</h2>
<p>讀者識別 workload type（軸 1-3）+ migration path（三型）+ system role（federated / control plane）後、進對應 per-vendor 子組繼續深化。</p>
<h3 id="mongodb-子組">MongoDB 子組</h3>
<ul>
<li>入門：<a href="/blog/backend/01-database/vendors/mongodb/schema-design-pattern/" data-link-title="MongoDB Schema Design Pattern：contract layer 在哪 vs embedded / reference" data-link-desc="MongoDB document schema 真正的 production 議題不是 embedded vs reference 二選一、是 schema contract 該放 DB 層 validator 還是 app 層 abstraction；含 Toyota polymorphic governance、Forbes abstraction layer、time-series collection 邊界">schema design pattern</a>（contract layer 三選一：DB 層 validator / app 層 abstraction / 混合）</li>
<li>容量：<a href="/blog/backend/01-database/vendors/mongodb/shard-key-selection/" data-link-title="MongoDB Shard Key Selection：hashed vs ranged、單 cluster 切 shard vs 多 cluster 切 blast radius" data-link-desc="MongoDB sharded cluster shard key 選型（hashed / ranged / compound）、單 cluster 分 shard vs 多 cluster 分 blast radius 對照、跟 DynamoDB / Cosmos DB partition key 可逆性的跨 vendor 紀律">shard key selection</a>（單 cluster vs 多 cluster blast radius、Toyota 20 DB 模式）</li>
<li>Migration：<a href="/blog/backend/01-database/vendors/mongodb/migrate-to-atlas/" data-link-title="MongoDB → Atlas：Atlas 不是 MongoDB &#43; managed、是另一個 product" data-link-desc="Atlas 號稱「MongoDB managed」但 operational model 完全不同（auto-scaling / VPC peering / IAM-driven access / 內建 backup / billing 模型）；本文採用 Type C operational redesign hybrid 結構、4-phase operational migration &#43; drop-in cutover、5 個 production 踩雷（連線數限制 / IP whitelist / backup retention / IAM token 過期 / billing 暴漲）">migrate to Atlas</a>（同 DB 換託管型）</li>
</ul>
<h3 id="dynamodb-子組">DynamoDB 子組</h3>
<ul>
<li>入門：<a href="/blog/backend/01-database/vendors/dynamodb/single-table-design-pattern/" data-link-title="DynamoDB Single-Table Design：從適用度前置判讀到 access pattern 反推 PK/SK" data-link-desc="DynamoDB single-table 設計不是「資料表越少越好」，而是 access pattern 反推 PK/SK 跟 GSI；本文先做 DynamoDB 適用度 4 軸前置判讀（PK 天然均勻 / control plane vs data plane / consistency / access pattern 穩定），再展開設計流程、failure modes 與 durable queue 正向用例">single-table design pattern</a>（access pattern 設計 + 適用度前置判讀）</li>
<li>機制：<a href="/blog/backend/01-database/vendors/dynamodb/consistency-model-optimization/" data-link-title="DynamoDB Strongly Consistent → Eventually Consistent：same protocol, different contract" data-link-desc="DynamoDB consistency model 從 strongly consistent read 改 eventually consistent read 是 50% cost 優化但風險集中在 application contract — 同 vendor / 同 protocol / 同 table / 不同 read consistency；驗證 [#128](/report/data-topology-as-audit-dimension/) self-aware limitation 提出的 consistency axis 候選；涵蓋 read pattern audit / 5 個 production 踩雷">consistency model optimization</a>（strong vs eventually consistent 取捨）</li>
</ul>
<h3 id="cosmos-db-子組">Cosmos DB 子組</h3>
<ul>
<li>入門：<a href="/blog/backend/01-database/vendors/cosmosdb/mongodb-api-vs-sql-api/" data-link-title="Cosmos DB MongoDB API vs SQL API：遷移路徑、dogfood signal、multi-model、跨雲 hedging" data-link-desc="從『MongoDB API 跟 SQL API 哪個快』推進到 vendor selection 的四層問題：三型遷移路徑、dogfood signal 怎麼讀、multi-model 差異化、跨雲 hedging — 從 Microsoft 365 dogfood 案例切入">MongoDB API vs SQL API</a>（API model 選型、四層 framing）</li>
</ul>
<h3 id="跨層架構federated-db--cache--proxy">跨層架構（federated DB / cache / proxy）</h3>
<p>跨層架構的延伸內容見對應 per-vendor connection management / cache layer article（後續會寫）— 本文只在軸 2 / federated frame 點到、不展開機制。</p>
<h3 id="進-db4-evaluation">進 DB4 evaluation</h3>
<p>若需要強一致 cross-region SQL / paradigm shift（KV → distributed SQL 或 SQL → distributed SQL）、進 <a href="/blog/backend/01-database/vendors/cockroachdb/aurora-dsql-spanner-decision-tree/" data-link-title="CockroachDB vs Aurora DSQL vs Spanner：撞牆訊號分型 &#43; 七問題決策樹" data-link-desc="Distributed SQL 三選一決策樹。先用撞牆訊號分型識別 driver path（DoorDash 單主寫入撞牆 / Netflix Cassandra 缺口 / Hard Rock 合規驅動）、再走七問題（跨雲 / 雲商生態 / 風險預算 / PG 相容 / 管理負擔 / team size / vendor sizing barrier）。PostgreSQL 相容性 audit checklist 4 項、Spanner 100 pu sizing barrier、Hard Rock 「省 10-20 工程師」機會成本警示、Netflix Database Platform Team 規模">DB4 entry point: Aurora DSQL / Spanner / CockroachDB decision tree</a>。</p>
<h2 id="knowledge-card-路由">Knowledge card 路由</h2>
<p>本文涉及的 knowledge card：</p>
<ul>
<li><a href="/blog/backend/knowledge-cards/document-store/" data-link-title="Document Store" data-link-desc="說明以 JSON 文件與彈性 schema 提供資料存取的模式，以及它仍需的治理邊界">document-store</a> — document model 的核心概念跟 aggregate root 邊界</li>
<li><a href="/blog/backend/knowledge-cards/hot-partition/" data-link-title="Hot Partition" data-link-desc="說明分散式 KV / OLTP 中、單一 partition 流量遠超其他的容量問題">hot-partition</a> — KV vendor 的 partition 容量上限機制</li>
<li><a href="/blog/backend/knowledge-cards/database-sharding/" data-link-title="Database Sharding" data-link-desc="說明資料庫如何依 shard key 分散資料、路由請求與承擔跨 shard 查詢成本">database-sharding</a> — shard key 跟 partition key 設計</li>
<li><a href="/blog/backend/knowledge-cards/consistency-level/" data-link-title="Consistency Level" data-link-desc="資料系統對讀寫一致性語意的可選擇層級">consistency-level</a> — strong / eventual / session 三類取捨</li>
<li><a href="/blog/backend/knowledge-cards/vendor-lock-in/" data-link-title="Vendor Lock-In" data-link-desc="說明採用供應商產品後，其 API 與格式滲入程式碼造成的退出成本">vendor-lock-in</a> — 單雲 vs 跨雲的 hedging 取捨</li>
<li><a href="/blog/backend/knowledge-cards/distributed-sql/" data-link-title="Distributed SQL" data-link-desc="把 SQL 與交易語意延伸到多節點與多區域的資料庫形態">distributed-sql</a> — 跳出 DB3 進 DB4 的概念入口</li>
</ul>
]]></content:encoded></item><item><title>Cloudflare Page Shield：用 CSP + SRI + script monitoring 防 client-side supply chain</title><link>https://tarrragon.github.io/blog/backend/07-security-data-protection/vendors/cloudflare-waf/page-shield-csp-sri/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/07-security-data-protection/vendors/cloudflare-waf/page-shield-csp-sri/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/07-security-data-protection/vendors/cloudflare-waf/" data-link-title="Cloudflare WAF" data-link-desc="Edge WAF &amp;#43; DDoS &amp;#43; Bot management 整合套件、global anycast 網路、控制面信任邊界跟客戶側補強的對照">Cloudflare WAF&lt;/a> overview 的 implementation-layer deep article。Overview 已說明 Cloudflare WAF 在入口治理譜系的定位、本文聚焦 &lt;em>Page Shield&lt;/em> 這個 client-side（browser）supply chain attack 防禦工具 — 跟 WAF 攔 server-side request 是不同層。&lt;/p>&lt;/blockquote>
&lt;h2 id="attack-pattern--defense-mechanism-對照">Attack pattern × Defense mechanism 對照&lt;/h2>
&lt;p>Client-side supply chain attack 不會被 WAF 看到 — 攻擊發生在 browser 渲染 page 時、不在 origin server 跟 client 之間的網路層。Page Shield 是 &lt;em>browser-side script execution&lt;/em> 的監測 + 防禦層、跟 WAF 處理 &lt;em>server-side request inspection&lt;/em> 互補不重疊。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Attack pattern&lt;/th>
 &lt;th>表現&lt;/th>
 &lt;th>Page Shield 對應防禦&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Magecart 信用卡 skimmer&lt;/td>
 &lt;td>第三方 JS 被注入惡意 form listener、信用卡資訊送外部 endpoint&lt;/td>
 &lt;td>CSP &lt;code>connect-src&lt;/code> + script alert&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>第三方 SDK 被 compromise&lt;/td>
 &lt;td>廠商 CDN 被攻擊、SDK 改版內含 malicious payload&lt;/td>
 &lt;td>SRI hash mismatch + script alert&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Formjacking&lt;/td>
 &lt;td>結帳頁 form action 被改、submit 送外部 server&lt;/td>
 &lt;td>CSP &lt;code>form-action&lt;/code> directive&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Inline script injection&lt;/td>
 &lt;td>XSS / DOM-based injection 插入 &lt;code>&amp;lt;script&amp;gt;&lt;/code> 跑外部 source&lt;/td>
 &lt;td>CSP &lt;code>script-src&lt;/code> + nonce&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Storage abuse&lt;/td>
 &lt;td>malicious JS 讀 localStorage / cookies 送外部&lt;/td>
 &lt;td>CSP &lt;code>connect-src&lt;/code> + CSP report&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>三層防禦對應不同 attack 階段：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>CSP（Content Security Policy）&lt;/strong>：browser-enforced policy、preventive、阻止違反 policy 的 script load / network request&lt;/li>
&lt;li>&lt;strong>SRI（Subresource Integrity）&lt;/strong>：load 階段 hash 驗證、detective + preventive、廠商 CDN 上 script 被改就 browser 拒載&lt;/li>
&lt;li>&lt;strong>Script monitoring&lt;/strong>：runtime 觀測、detective only、記錄頁面 load 哪些 third-party script、變動時 alert&lt;/li>
&lt;/ol>
&lt;p>三層各有 ceiling — &lt;em>CSP 擋 inline / unauthorized source 但擋不到 allowed source 被 compromise&lt;/em>；&lt;em>SRI 擋已知 vendor 改 hash 但擋不到動態 loader&lt;/em>；&lt;em>monitoring 看得到但攔不到&lt;/em>。Production 三層疊用、不要單一 layer。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/07-security-data-protection/vendors/cloudflare-waf/" data-link-title="Cloudflare WAF" data-link-desc="Edge WAF &#43; DDoS &#43; Bot management 整合套件、global anycast 網路、控制面信任邊界跟客戶側補強的對照">Cloudflare WAF</a> overview 的 implementation-layer deep article。Overview 已說明 Cloudflare WAF 在入口治理譜系的定位、本文聚焦 <em>Page Shield</em> 這個 client-side（browser）supply chain attack 防禦工具 — 跟 WAF 攔 server-side request 是不同層。</p></blockquote>
<h2 id="attack-pattern--defense-mechanism-對照">Attack pattern × Defense mechanism 對照</h2>
<p>Client-side supply chain attack 不會被 WAF 看到 — 攻擊發生在 browser 渲染 page 時、不在 origin server 跟 client 之間的網路層。Page Shield 是 <em>browser-side script execution</em> 的監測 + 防禦層、跟 WAF 處理 <em>server-side request inspection</em> 互補不重疊。</p>
<table>
  <thead>
      <tr>
          <th>Attack pattern</th>
          <th>表現</th>
          <th>Page Shield 對應防禦</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Magecart 信用卡 skimmer</td>
          <td>第三方 JS 被注入惡意 form listener、信用卡資訊送外部 endpoint</td>
          <td>CSP <code>connect-src</code> + script alert</td>
      </tr>
      <tr>
          <td>第三方 SDK 被 compromise</td>
          <td>廠商 CDN 被攻擊、SDK 改版內含 malicious payload</td>
          <td>SRI hash mismatch + script alert</td>
      </tr>
      <tr>
          <td>Formjacking</td>
          <td>結帳頁 form action 被改、submit 送外部 server</td>
          <td>CSP <code>form-action</code> directive</td>
      </tr>
      <tr>
          <td>Inline script injection</td>
          <td>XSS / DOM-based injection 插入 <code>&lt;script&gt;</code> 跑外部 source</td>
          <td>CSP <code>script-src</code> + nonce</td>
      </tr>
      <tr>
          <td>Storage abuse</td>
          <td>malicious JS 讀 localStorage / cookies 送外部</td>
          <td>CSP <code>connect-src</code> + CSP report</td>
      </tr>
  </tbody>
</table>
<p>三層防禦對應不同 attack 階段：</p>
<ol>
<li><strong>CSP（Content Security Policy）</strong>：browser-enforced policy、preventive、阻止違反 policy 的 script load / network request</li>
<li><strong>SRI（Subresource Integrity）</strong>：load 階段 hash 驗證、detective + preventive、廠商 CDN 上 script 被改就 browser 拒載</li>
<li><strong>Script monitoring</strong>：runtime 觀測、detective only、記錄頁面 load 哪些 third-party script、變動時 alert</li>
</ol>
<p>三層各有 ceiling — <em>CSP 擋 inline / unauthorized source 但擋不到 allowed source 被 compromise</em>；<em>SRI 擋已知 vendor 改 hash 但擋不到動態 loader</em>；<em>monitoring 看得到但攔不到</em>。Production 三層疊用、不要單一 layer。</p>
<h2 id="csp-配置-step-by-step">CSP 配置 step-by-step</h2>
<h3 id="從-cloudflare-dashboard-啟用--寫-policy">從 Cloudflare dashboard 啟用 + 寫 policy</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"># Dashboard: Security → Page Shield → CSP
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"># 模式: Report-only（第一週）→ Enforced（驗證後）
</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"># 範例 policy
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">default-src &#39;self&#39;;
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">script-src &#39;self&#39; &#39;nonce-{NONCE}&#39; https://cdn.trusted.com https://www.googletagmanager.com;
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">style-src &#39;self&#39; &#39;unsafe-inline&#39; https://fonts.googleapis.com;
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">img-src &#39;self&#39; data: https:;
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">connect-src &#39;self&#39; https://api.myapp.com https://*.sentry.io;
</span></span><span class="line"><span class="ln">10</span><span class="cl">form-action &#39;self&#39;;
</span></span><span class="line"><span class="ln">11</span><span class="cl">frame-ancestors &#39;none&#39;;
</span></span><span class="line"><span class="ln">12</span><span class="cl">report-uri https://csp-report.cloudflare.com/cdn-cgi/script_monitor/report;
</span></span><span class="line"><span class="ln">13</span><span class="cl">report-to default;</span></span></code></pre></div><p>關鍵直覺：</p>
<ul>
<li><strong><code>'nonce-{NONCE}'</code></strong>：origin server 每 request 生成 random nonce、注入 <code>&lt;script nonce=&quot;...&quot;&gt;</code> 跟 CSP header；script tag 沒對應 nonce 就被 browser 拒跑、擋 XSS</li>
<li><strong><code>connect-src</code> 精準寫</strong>：第三方 API endpoint 全列出；不寫 <code>*</code> 或 <code>https:</code> 是擋 exfiltration 的關鍵（Magecart 把信用卡送外部 endpoint 就是用 <code>connect-src</code> 攔）</li>
<li><strong><code>form-action</code></strong>：擋 form 被改 action attribute 送外部、formjacking 第一道防線</li>
<li><strong><code>report-uri</code> + <code>report-to</code></strong>：违反 policy 的 event 送 Cloudflare、Page Shield dashboard 看 violation report</li>
</ul>
<h3 id="report-only-mode-第一週">Report-only mode 第一週</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">Content-Security-Policy-Report-Only: &lt;policy&gt;
</span></span><span class="line"><span class="ln">2</span><span class="cl">Content-Security-Policy:             default-src &#39;self&#39;;   # 鬆 policy 仍 enforce</span></span></code></pre></div><p>Report-only 期間 browser <em>report 違反但不擋</em>、production traffic 不受影響；SOC 看 report 找：</p>
<ul>
<li>漏列的 legitimate third-party（marketing / analytics SDK 沒寫進 policy）</li>
<li>意外 inline script（dev 留下的 debug snippet）</li>
<li>跨 domain 的合法 connect（CRM / chat widget）</li>
</ul>
<p>第一週後 dashboard 看 violation 數量趨穩 + 主要違規都已 whitelist、切 Enforced。</p>
<h3 id="enforced-mode-切換--canary">Enforced mode 切換 + canary</h3>
<p>不要直接全站 enforced — 用 Cloudflare Page Rule 對 10% traffic enforced、90% report-only：</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">URL pattern: example.com/*
</span></span><span class="line"><span class="ln">2</span><span class="cl">Page Rule: Add CSP header (enforced)
</span></span><span class="line"><span class="ln">3</span><span class="cl">Bypass: 90% by Cookie / IP hash</span></span></code></pre></div><p>10% traffic 跑 24-48h、確認 zero legitimate violation、再擴大到 50% → 100%。canary 期間 monitor <code>error-rate</code> metric、不只是 violation report。</p>
<h2 id="sri-配置">SRI 配置</h2>
<p>Subresource Integrity 用 hash 驗證 CDN-hosted script 沒被改：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">&lt;</span><span class="nt">script</span> <span class="na">src</span><span class="o">=</span><span class="s">&#34;https://cdn.example.com/widget.v1.2.3.js&#34;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">        <span class="na">integrity</span><span class="o">=</span><span class="s">&#34;sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">        <span class="na">crossorigin</span><span class="o">=</span><span class="s">&#34;anonymous&#34;</span><span class="p">&gt;&lt;/</span><span class="nt">script</span><span class="p">&gt;</span></span></span></code></pre></div><p>Browser load 時算 hash、跟 <code>integrity</code> 不符就拒跑。關鍵：</p>
<ul>
<li><strong>Hash 一定要 version-pinned</strong>：用 <code>widget.v1.2.3.js</code>、不能用 <code>widget.latest.js</code>；廠商更新 latest 時 hash 變 → SRI 拒載 → 服務中斷</li>
<li><strong>多 hash</strong>：寫 <code>integrity=&quot;sha384-... sha512-...&quot;</code> 至少一個 match 就過、可在 vendor rotate hash 時平滑遷移</li>
<li><strong><code>crossorigin=&quot;anonymous&quot;</code></strong> 必加：跨 origin script 預設 browser 不暴露 hash 失敗細節、<code>anonymous</code> 才允許 CORS-based hash check</li>
</ul>
<h3 id="page-shield-自動產-sri-提示">Page Shield 自動產 SRI 提示</h3>
<p>Dashboard → Page Shield → Scripts 列出所有偵測到的 script、含 <em>建議 SRI hash</em>；可以 export 整合進 build pipeline、自動把所有 vendor script 加 SRI。</p>
<h2 id="故障演練">故障演練</h2>
<h3 id="case-1csp-report-floodsoc-noise">Case 1：CSP report flood，SOC noise</h3>
<p><strong>徵兆</strong>：切 Enforced 後、CSP violation report 從每天 ~500 漲到每分鐘 ~50K、Page Shield dashboard 變紅、SOC 收 alert 收到 silent。</p>
<p><strong>根因</strong>：browser extension（廣告攔截 / spell checker / password manager）注入 inline script 跟 connect、被 CSP block 同時觸發 report；不是真實 attack、是 user 端 extension 行為。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>CSP <code>report-sample</code> directive 限 sampling（只 report 10%）— spec 部分支援、不是所有 browser 都認</li>
<li>Page Shield 規則：filter out extension protocol（<code>chrome-extension://</code>、<code>moz-extension://</code>、<code>safari-extension://</code>）後再 alert</li>
<li>Report endpoint 自管 + aggregation：不直接接 SIEM、先 batch + dedupe、再送 SIEM</li>
<li>接受 report flood 是 normal、focus 監測 <em>unique violation pattern</em> 不是 <em>total volume</em></li>
</ol>
<h3 id="case-2inline-script-漏舊頁面突然壞">Case 2：Inline script 漏，舊頁面突然壞</h3>
<p><strong>徵兆</strong>：切 Enforced 後 X 個舊頁面壞、user feedback 提交 form 失敗、debugger 看到 console <code>Refused to execute inline script because it violates...</code>。</p>
<p><strong>根因</strong>：legacy page 有 inline <code>&lt;script&gt;</code> 沒 nonce、CSP enforced 後 browser 拒跑；報表/管理後台/舊 admin page 常見。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>Audit 所有 inline <code>&lt;script&gt;</code>、加 nonce attribute（server-side render 時注入）</li>
<li>短期：對舊頁面用 <code>unsafe-inline</code> 寫進 CSP（接受降級）、page-specific CSP override</li>
<li>長期：legacy page 改 build-time bundle、消除 inline script</li>
</ol>
<h3 id="case-3dynamic-script-loader-繞過-sri">Case 3：Dynamic script loader 繞過 SRI</h3>
<p><strong>徵兆</strong>：vendor script load 成功、但 Page Shield monitoring 看到該 vendor script <em>load 後又動態 load 多個額外 script</em>；額外 script 沒 SRI 保護、廠商側 compromise 直接過。</p>
<p><strong>根因</strong>：第三方 SDK 用 <code>document.createElement('script')</code> + <code>script.src = '...'</code> runtime 動態 load；CSP <code>script-src</code> 可能允許這個來源、但 SRI 沒法在 runtime 注入。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>CSP <code>script-src</code> 精準到 <em>只允許特定 path</em>、不是整個 domain（例：<code>https://cdn.vendor.com/sdk/v3/</code> 而不是 <code>https://cdn.vendor.com</code>）</li>
<li>評估 vendor 是否有 <em>static-only</em> 替代（多數 marketing / analytics SDK 不需要 dynamic loader、是 legacy 設計）</li>
<li>不能消除 dynamic loader 時、Page Shield monitoring 設 <em>new script alert</em>、廠商加 sub-script 即刻通知</li>
</ol>
<h3 id="case-4sri-hash-mismatchvendor-偷偷更新">Case 4：SRI hash mismatch，vendor 偷偷更新</h3>
<p><strong>徵兆</strong>：第三方 widget 突然不顯示、Page Shield 顯示 SRI mismatch、廠商 status page 沒事故公告。</p>
<p><strong>根因</strong>：廠商在 same URL（不是 versioned）下偷偷 push minor patch、hash 變了 → SRI 拒載；不是 attack、是 vendor 不遵守 immutable URL 慣例。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>強制要求廠商提供 versioned URL（<code>widget.v1.2.3.js</code>）、不收 <code>widget.latest.js</code></li>
<li>廠商不配合時、build pipeline 加 <em>daily hash check</em>、廠商偷改 SRI hash 自動更新 + Slack alert</li>
<li>評估換 vendor — 不遵守 immutable URL 的廠商 supply chain integrity 信用低</li>
</ol>
<h2 id="容量--cost">容量 + cost</h2>
<p>Page Shield 是 <em>Enterprise plan + Page Shield add-on</em>、cost 維度：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>影響</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>CSP report 量</td>
          <td>Cloudflare 端聚合、不另外計費；report endpoint 自管要 sizing</td>
      </tr>
      <tr>
          <td>Script monitoring</td>
          <td>不影響 page load latency（async detection）</td>
      </tr>
      <tr>
          <td>Per-zone pricing</td>
          <td>跨子域 + apex domain 多 zone 各算一份</td>
      </tr>
      <tr>
          <td>SOC operation</td>
          <td>第一週 report 量大、需要 1-2 analyst FTE 跑 tuning；穩定後低人力</td>
      </tr>
  </tbody>
</table>
<p>Page load 影響：</p>
<ul>
<li>CSP header ~1-2KB（policy 寫越精準越長、不是越短越好）</li>
<li>SRI 比對 ~5-10ms / script、現代 browser cache decoded hash、不重複算</li>
<li>Script monitoring beacon ~100 byte / script load、async 不阻塞 page render</li>
</ul>
<p>實務 default：</p>
<ul>
<li>Critical e-commerce / fintech：CSP enforced + SRI 全 vendor + monitoring all、SOC review weekly</li>
<li>一般 SaaS：CSP report-only ongoing + SRI critical vendor only + monitoring 主域</li>
<li>Marketing / blog：CSP <code>default-src 'self'</code> minimum + monitoring only</li>
</ul>
<h2 id="整合--下一步">整合 / 下一步</h2>
<h3 id="跟-dev-workflow-整合">跟 dev workflow 整合</h3>
<p>CSP 寫進 <em>deploy pipeline</em>、不是 dashboard 手動配：</p>
<ol>
<li>Repo 內 <code>csp-policy.yml</code>、跟 code 同 lifecycle</li>
<li>CI 跑 <em>CSP linter</em>（如 <code>csp-evaluator</code>）、檢查 policy 弱點</li>
<li>Deploy 時 push 到 Cloudflare API、自動 versioning + rollback</li>
</ol>
<h3 id="跟-waf-互補">跟 WAF 互補</h3>
<p>Page Shield 跟 <a href="/blog/backend/07-security-data-protection/vendors/cloudflare-waf/" data-link-title="Cloudflare WAF" data-link-desc="Edge WAF &#43; DDoS &#43; Bot management 整合套件、global anycast 網路、控制面信任邊界跟客戶側補強的對照">Cloudflare WAF</a> 不重疊但互補：</p>
<ul>
<li>WAF 攔 <em>server-side</em> request injection（SQL / command / path traversal）</li>
<li>Page Shield 攔 <em>client-side</em> script execution（XSS / supply chain）</li>
<li>共同 dashboard + alert routing、不要分開 SOC team 看</li>
</ul>
<h3 id="跟-supply-chain-sbom">跟 supply chain SBOM</h3>
<p>Page Shield 偵測的 <em>client-side dependency</em> 可進 SBOM、跟 <a href="/blog/backend/07-security-data-protection/vendors/snyk/" data-link-title="Snyk" data-link-desc="跨 SCM 多模組 application security platform：Open Source (SCA) &#43; Code (SAST) &#43; Container &#43; IaC &#43; Cloud (CSPM)、Reachability analysis">Snyk</a> / <a href="/blog/backend/07-security-data-protection/vendors/dependabot/" data-link-title="Dependabot" data-link-desc="GitHub 原生依賴更新自動化、Version Update &#43; Security Update &#43; Alerts、Grouped Updates 減 PR noise、Auto-merge 配 branch protection">Dependabot</a> 的 server-side SBOM 合併、得到完整 dependency graph。</p>
<h3 id="下一步議題">下一步議題</h3>
<ul>
<li><strong>Trusted Types</strong>：browser-side template injection 的下一代防禦、Chrome 已支援、Firefox / Safari 進度不一</li>
<li><strong>CSP Level 3 + strict-dynamic</strong>：減少 maintenance burden、用 nonce 動態信任 nested script</li>
<li><strong>Reporting API v1</strong>：standard report endpoint + <code>Reporting-Endpoints</code> header 取代 <code>report-uri</code></li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li>上游 vendor 頁：<a href="/blog/backend/07-security-data-protection/vendors/cloudflare-waf/" data-link-title="Cloudflare WAF" data-link-desc="Edge WAF &#43; DDoS &#43; Bot management 整合套件、global anycast 網路、控制面信任邊界跟客戶側補強的對照">Cloudflare WAF</a></li>
<li>上游 chapter：<a href="/blog/backend/07-security-data-protection/entrypoint-and-server-protection/" data-link-title="7.3 入口治理與伺服器防護" data-link-desc="以問題驅動方式整理對外入口、管理平面與伺服器邊界">7.3 入口治理與伺服器防護</a>、<a href="/blog/backend/07-security-data-protection/supply-chain-integrity-and-artifact-trust/" data-link-title="7.12 供應鏈完整性與 Artifact 信任" data-link-desc="定義 build provenance、artifact 信任與交付鏈風險問題">7.12 供應鏈完整性</a></li>
<li>對照案例：British Airways 2018 Magecart / Macy&rsquo;s 2019 skimmer（公開 supply chain 案例）</li>
<li>平行 vendor：<a href="/blog/backend/07-security-data-protection/vendors/aws-waf/" data-link-title="AWS WAF" data-link-desc="AWS-internal WAF、跟 ALB / CloudFront / API Gateway 直接整合、Web ACL &#43; Managed Rule Group &#43; Rate-based Rule、Shield Standard 內含">AWS WAF</a> / <a href="/blog/backend/07-security-data-protection/vendors/fastly-ngwaf/" data-link-title="Fastly Next-Gen WAF" data-link-desc="Behavioral / 語意分析 WAF（前 Signal Sciences）、低 false positive、Edge / Agent / Cloud 三種部署模型、API &#43; ATO &#43; Bot 一體">Fastly Next-Gen WAF</a></li>
<li>平行 deep article：<a href="/blog/backend/07-security-data-protection/vendors/hashicorp-vault/dynamic-credential/" data-link-title="HashiCorp Vault Dynamic Credential：lease 治理跟 application 整合的實作層" data-link-desc="Vault database secrets engine 怎麼配、application 怎麼 renew lease、production 五大踩雷（lease 過期 race、DB max_connections 撞牆、Vault sealed、token expire、scope 過寬）、容量規劃跟 vault-agent injector 整合">Vault Dynamic Credential</a> / <a href="/blog/backend/07-security-data-protection/vendors/splunk/risk-based-alerting/" data-link-title="Splunk Risk-Based Alerting：從 alert per rule 到 score-aggregated notable" data-link-desc="Splunk Enterprise Security 的 RBA 方法論：risk score / modifier / notable 三層 model、ES 配置 step-by-step、tuning playbook（false positive / score inflation / threshold drift / decay）、capacity 成本、跟 SOAR &#43; case management 整合">Splunk RBA</a></li>
<li>Methodology：<a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章的寫作方法論</a></li>
</ul>
]]></content:encoded></item><item><title>HashiCorp Vault Dynamic Credential：lease 治理跟 application 整合的實作層</title><link>https://tarrragon.github.io/blog/backend/07-security-data-protection/vendors/hashicorp-vault/dynamic-credential/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/07-security-data-protection/vendors/hashicorp-vault/dynamic-credential/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/07-security-data-protection/vendors/hashicorp-vault/" data-link-title="HashiCorp Vault" data-link-desc="Self-hosted secret management 與 dynamic credential / encryption-as-a-service / PKI engine、跨雲跨環境的 secret 控制面">HashiCorp Vault&lt;/a> overview 的 implementation-layer deep article。Overview 已說明 Vault 在 secrets / credentials 治理譜系的定位（跟 cloud-native secrets manager / cert-manager 的取捨）、本文聚焦 &lt;em>dynamic credential engine&lt;/em> 的實作層：怎麼配 database engine、application 怎麼 renew lease、production 踩過哪些坑、跟 cloud-native vault 跟 vault-agent injector 怎麼整合。&lt;/p>&lt;/blockquote>
&lt;h2 id="問題情境">問題情境&lt;/h2>
&lt;p>Long-lived database credential 寫進 application config 是 production 環境最常見的 secret hygiene 失敗：credential 一旦外洩、輪替成本是 &lt;em>跨團隊協調 + 多服務同步重啟&lt;/em>、實務上半年才換一次、credential 在 git history / log / dump file 留下軌跡。動態憑證（dynamic credential）的核心承諾是 &lt;em>credential 生命週期跟 application session 對齊&lt;/em>、用完就 revoke、外洩窗口從幾個月縮到幾分鐘。&lt;/p>
&lt;p>但 dynamic credential 不是「換個 SDK 就好」、它把 &lt;em>credential 治理&lt;/em> 從 secret rotation 問題轉成 &lt;em>lease lifecycle&lt;/em> 問題。lease TTL 設多久、renewal 怎麼跑、DB 端 user 創建會不會撞 max_connections、Vault sealed 時 application 怎麼降級 — 每個都是 production-grade 議題、無法靠 vendor doc 預設值直接上線。&lt;/p>
&lt;h2 id="核心概念lease-lifecycle-跟-secrets-engine-模型">核心概念：lease lifecycle 跟 secrets engine 模型&lt;/h2>
&lt;p>Vault dynamic credential 由三個元件協作：&lt;/p>
&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;strong>Secrets engine&lt;/strong>&lt;/td>
 &lt;td>後端執行 credential 創建跟 revoke、每個 engine 對應一個 datastore（database / aws / ssh）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Role&lt;/strong>&lt;/td>
 &lt;td>創建 credential 的範本：DB 連線 + creation SQL + default / max TTL + allowed_roles&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Lease&lt;/strong>&lt;/td>
 &lt;td>每次 credential 發放都對應一個 lease ID、由 Vault 管 TTL / renew / revoke&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>跟 static secret（K/V store）對照、dynamic credential 的關鍵差異是 &lt;em>credential 在 read 時才產生&lt;/em>、且 Vault 追蹤每個 outstanding lease；application 必須 &lt;em>主動 renew&lt;/em> 或接受 credential 失效。&lt;/p>
&lt;p>Lease 的兩個 TTL：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>default_ttl&lt;/strong>：credential 初始有效期、application 不 renew 就到期&lt;/li>
&lt;li>&lt;strong>max_ttl&lt;/strong>：credential 最長有效期、不管 renew 幾次都不能超過&lt;/li>
&lt;/ul>
&lt;p>實務 default 配置：&lt;code>default_ttl: 1h&lt;/code> + &lt;code>max_ttl: 24h&lt;/code>、application 每 30-45 分鐘 renew 一次、credential 最多活 24 小時必換新的。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/07-security-data-protection/vendors/hashicorp-vault/" data-link-title="HashiCorp Vault" data-link-desc="Self-hosted secret management 與 dynamic credential / encryption-as-a-service / PKI engine、跨雲跨環境的 secret 控制面">HashiCorp Vault</a> overview 的 implementation-layer deep article。Overview 已說明 Vault 在 secrets / credentials 治理譜系的定位（跟 cloud-native secrets manager / cert-manager 的取捨）、本文聚焦 <em>dynamic credential engine</em> 的實作層：怎麼配 database engine、application 怎麼 renew lease、production 踩過哪些坑、跟 cloud-native vault 跟 vault-agent injector 怎麼整合。</p></blockquote>
<h2 id="問題情境">問題情境</h2>
<p>Long-lived database credential 寫進 application config 是 production 環境最常見的 secret hygiene 失敗：credential 一旦外洩、輪替成本是 <em>跨團隊協調 + 多服務同步重啟</em>、實務上半年才換一次、credential 在 git history / log / dump file 留下軌跡。動態憑證（dynamic credential）的核心承諾是 <em>credential 生命週期跟 application session 對齊</em>、用完就 revoke、外洩窗口從幾個月縮到幾分鐘。</p>
<p>但 dynamic credential 不是「換個 SDK 就好」、它把 <em>credential 治理</em> 從 secret rotation 問題轉成 <em>lease lifecycle</em> 問題。lease TTL 設多久、renewal 怎麼跑、DB 端 user 創建會不會撞 max_connections、Vault sealed 時 application 怎麼降級 — 每個都是 production-grade 議題、無法靠 vendor doc 預設值直接上線。</p>
<h2 id="核心概念lease-lifecycle-跟-secrets-engine-模型">核心概念：lease lifecycle 跟 secrets engine 模型</h2>
<p>Vault dynamic credential 由三個元件協作：</p>
<table>
  <thead>
      <tr>
          <th>元件</th>
          <th>責任</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>Secrets engine</strong></td>
          <td>後端執行 credential 創建跟 revoke、每個 engine 對應一個 datastore（database / aws / ssh）</td>
      </tr>
      <tr>
          <td><strong>Role</strong></td>
          <td>創建 credential 的範本：DB 連線 + creation SQL + default / max TTL + allowed_roles</td>
      </tr>
      <tr>
          <td><strong>Lease</strong></td>
          <td>每次 credential 發放都對應一個 lease ID、由 Vault 管 TTL / renew / revoke</td>
      </tr>
  </tbody>
</table>
<p>跟 static secret（K/V store）對照、dynamic credential 的關鍵差異是 <em>credential 在 read 時才產生</em>、且 Vault 追蹤每個 outstanding lease；application 必須 <em>主動 renew</em> 或接受 credential 失效。</p>
<p>Lease 的兩個 TTL：</p>
<ul>
<li><strong>default_ttl</strong>：credential 初始有效期、application 不 renew 就到期</li>
<li><strong>max_ttl</strong>：credential 最長有效期、不管 renew 幾次都不能超過</li>
</ul>
<p>實務 default 配置：<code>default_ttl: 1h</code> + <code>max_ttl: 24h</code>、application 每 30-45 分鐘 renew 一次、credential 最多活 24 小時必換新的。</p>
<h2 id="step-by-step-配置">Step-by-step 配置</h2>
<h3 id="vault-server-啟用-database-secrets-engine">Vault server 啟用 database secrets engine</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 1. enable secrets engine</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">vault secrets <span class="nb">enable</span> -path<span class="o">=</span>database database
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"># 2. 配置 PostgreSQL connection</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">vault write database/config/myapp-prod <span class="se">\
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="se"></span>  <span class="nv">plugin_name</span><span class="o">=</span>postgresql-database-plugin <span class="se">\
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="se"></span>  <span class="nv">allowed_roles</span><span class="o">=</span><span class="s2">&#34;myapp-reader,myapp-writer&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="se"></span>  <span class="nv">connection_url</span><span class="o">=</span><span class="s2">&#34;postgresql://{{username}}:{{password}}@db.internal:5432/myapp?sslmode=require&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="se"></span>  <span class="nv">username</span><span class="o">=</span><span class="s2">&#34;vault_root&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="se"></span>  <span class="nv">password</span><span class="o">=</span><span class="s2">&#34;&lt;vault_root_pw&gt;&#34;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"># 3. 創建 role</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">vault write database/roles/myapp-reader <span class="se">\
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="se"></span>  <span class="nv">db_name</span><span class="o">=</span>myapp-prod <span class="se">\
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="se"></span>  <span class="nv">creation_statements</span><span class="o">=</span><span class="s2">&#34;CREATE ROLE \&#34;{{name}}\&#34; WITH LOGIN PASSWORD &#39;{{password}}&#39; VALID UNTIL &#39;{{expiration}}&#39;; \
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="s2">                       GRANT SELECT ON ALL TABLES IN SCHEMA public TO \&#34;{{name}}\&#34;;&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="se"></span>  <span class="nv">default_ttl</span><span class="o">=</span><span class="s2">&#34;1h&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="se"></span>  <span class="nv">max_ttl</span><span class="o">=</span><span class="s2">&#34;24h&#34;</span></span></span></code></pre></div><p>關鍵：<code>vault_root</code> 是 Vault 用來創建其他 user 的 <em>bootstrapping account</em>、權限要含 <code>CREATEROLE</code>、但不需要 SUPERUSER；creation_statements 必須含 <code>VALID UNTIL '{{expiration}}'</code>、否則 DB 端 user 不會自動過期、Vault revoke 失敗時會留 zombie account。</p>
<h3 id="application-取得-credential">Application 取得 credential</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># Read 動態 credential（每次 read 都產生新 user）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">vault <span class="nb">read</span> database/creds/myapp-reader
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"># Key                Value</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># lease_id           database/creds/myapp-reader/abc123</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># lease_duration     1h</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"># username           v-myapp-reader-x7y8z9-1747512345</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"># password           A1b2C3d4E5f6...</span></span></span></code></pre></div><p>Application 從 response 拿三個值：<code>lease_id</code>（用來 renew / revoke）、<code>username</code> + <code>password</code>（DB 連線）、<code>lease_duration</code>（決定何時 renew）。</p>
<h3 id="renew-lease">Renew lease</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 在 lease 到期前 renew（推薦在 50-70% TTL 跑）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">vault lease renew database/creds/myapp-reader/abc123
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"># Key                Value</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># lease_id           database/creds/myapp-reader/abc123</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># lease_duration     1h    # renew 後重置回 default_ttl</span></span></span></code></pre></div><p><code>lease_duration</code> 在 renew 後 <em>重置回 default_ttl</em>、但 <em>不會超過 max_ttl</em>。例：default 1h / max 24h、application 連 renew 23 小時後、第 24 次 renew Vault 拒絕、application 必須拿新 credential。</p>
<h3 id="revoke-leaseapplication-shutdown-時">Revoke lease（application shutdown 時）</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># Graceful shutdown 時主動 revoke</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">vault lease revoke database/creds/myapp-reader/abc123</span></span></code></pre></div><p>Application 結束時 revoke 是 <em>credential hygiene 的最後一道閘門</em> — 即使 lease 還有時間、主動 revoke 讓 DB 端 user 立刻消失、避免 credential 在 application crash dump / log 內被翻出時還能用。</p>
<h2 id="故障演練--邊界-case">故障演練 / 邊界 case</h2>
<h3 id="case-1lease-renewal-racecredential-中途失效">Case 1：Lease renewal race，credential 中途失效</h3>
<p><strong>徵兆</strong>：application log 突然出現 <code>FATAL: role &quot;v-myapp-reader-x7y8z9-...&quot; does not exist</code>、且時間點接近某個整點 / 半點。</p>
<p><strong>根因</strong>：application 用 lease_duration 推算 renew 時機、但用了 <em>系統時間</em> 而非 <em>lease 簽發時間</em>；application 啟動晚於 lease 簽發 30 秒、renew 跑在 lease 過期後 5 秒、Vault 已 revoke credential、DB 端 user 已刪除。</p>
<p><strong>修法</strong>：用 <em>server 回傳的 lease_duration</em> 反推 renew 時機、留 <em>20-30% buffer</em>。例：lease_duration 3600 秒、application 在 2400-2520 秒（66-70%）開始 renew、不要拖到 3500 秒。Vault SDK 多數有 LifetimeWatcher（Go SDK）或 Renewer（Python hvac）這類 helper、優先用 SDK 不要自管 ticker。</p>
<h3 id="case-2db-max_connections-撞牆">Case 2：DB max_connections 撞牆</h3>
<p><strong>徵兆</strong>：application 在流量高峰開始大量 <code>FATAL: too many connections for role</code>、Vault audit log 顯示新 credential 還在發、PostgreSQL <code>pg_stat_activity</code> 看到上百個 <code>v-myapp-...</code> user 同時連著。</p>
<p><strong>根因</strong>：每個 application instance / pod 在啟動時 read 一次 credential、credential lease 1h、但 <em>application 跑 30 分鐘就重啟</em>（K8s rolling update / OOM）；舊 user 還在 PostgreSQL 端連著（connection pool 沒釋放）、新 user 又被創建、累積到 max_connections。</p>
<p><strong>修法</strong>：兩層</p>
<ol>
<li>Application graceful shutdown 時 <code>vault lease revoke</code> + connection pool drain</li>
<li>PostgreSQL connection pool 加 <code>pool_lifetime_max</code> 跟 application instance lifetime 對齊、避免 connection leak 到 lease 失效後仍 holding</li>
</ol>
<h3 id="case-3vault-sealed-中existing-lease-仍可用但新-lease-拿不到">Case 3：Vault sealed 中、existing lease 仍可用但新 lease 拿不到</h3>
<p><strong>徵兆</strong>：deploy 新 version 時、新 pod 起不來、<code>vault read database/creds/...</code> 卡住或回 <code>Vault is sealed</code>；但 <em>舊 pod 持續運作正常</em>（因為已持有 lease）。</p>
<p><strong>根因</strong>：Vault sealed（master key 被 wrap、需要 unseal key 解封）時、existing lease 因為 <em>credential 已在 DB 端創建</em>、application 連線不需要 Vault 介入；但 <em>新 lease 創建需要 Vault</em> / <em>renew 也需要 Vault</em>。Sealed 期間 application 還能用、但無法擴容、無法 renew。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>Vault HA cluster + auto-unseal（KMS / HSM auto-unseal）避免人工 unseal 鏈</li>
<li>Application 加 retry-with-backoff、Vault 短暫 unavailable 時不要立刻 crash</li>
<li>Lease 設長一點（default 4h、max 48h）給 unseal 流程留時間</li>
</ol>
<h3 id="case-4application-vault-token-expirelease-orphan">Case 4：Application Vault token expire、lease orphan</h3>
<p><strong>徵兆</strong>：application 在連續跑 1-2 週後突然開始 <code>Permission denied</code> on <code>vault lease renew</code>、credential 在 max_ttl 後失效但 application 不知道。</p>
<p><strong>根因</strong>：application 的 Vault token（不是 DB credential 的 lease）也有 TTL；token 過期後 application 無法 renew lease、但 application 可能還沒到 <em>自己拿新 token</em> 的循環。Lease 變 orphan（沒人能 renew）、TTL 到就被 revoke。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>Application 用 vault-agent injector / sidecar pattern、由 sidecar 維護 token + lease；application 只讀 file</li>
<li>不用 sidecar 時、application token 用 <em>renewable token</em> + 跟 lease 同 lifecycle 管</li>
<li>AppRole auth method 的 secret_id 跟 token TTL 都要納入 application reload 流程</li>
</ol>
<h3 id="case-5circleci-2023-incident-對照--secret_id-scope-過寬">Case 5：<a href="/blog/backend/07-security-data-protection/cases/" data-link-title="模組七案例正文" data-link-desc="資安控制面與控制平面轉換案例入口。">CircleCI 2023 incident</a> 對照 — secret_id scope 過寬</h3>
<p><strong>徵兆</strong>：CircleCI 2023 1 月事件、攻擊者拿到開發者 endpoint session token、進而拿到 Vault AppRole 的 secret_id；secret_id 對應的 policy 含 <em>跨環境跨資料庫 read</em>、攻擊者用 secret_id 拿到大量動態 credential。</p>
<p><strong>根因</strong>：AppRole secret_id 的 policy scope 設成 <em>single AppRole 服務所有環境</em>、而不是 <em>per-environment AppRole</em>；secret_id 外洩等於拿到全公司 dynamic credential 發放權。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>Per-environment AppRole：dev / staging / prod 各有獨立 AppRole + secret_id、policy 只允許該環境的 database engine path</li>
<li>Secret_id TTL 短化（&lt; 24h）、用 <em>response wrapping</em> 傳遞、拿到後立刻 unwrap、減少 secret_id 在 build pipeline log 留軌跡</li>
<li>Vault audit log 接 SIEM、<code>approle/login</code> 異常 location / IP 即刻 alert</li>
</ol>
<h2 id="容量規劃">容量規劃</h2>
<p>Dynamic credential 的容量設計圍繞 <em>lease churn rate</em> — 每秒多少新 lease 創建、多少 renew、多少 revoke。</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>估算方式</th>
          <th>警戒值</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>新 lease / s</td>
          <td><code>應用 instance 數 × (1 / lease_duration)</code></td>
          <td>單 Vault node ~50/s、HA cluster ~200/s</td>
      </tr>
      <tr>
          <td>Renew / s</td>
          <td><code>outstanding lease × renew_freq</code></td>
          <td>renew 跟 read 同 cost</td>
      </tr>
      <tr>
          <td>DB 端 user 數</td>
          <td><code>peak outstanding lease</code></td>
          <td>不能超過 DB max_roles 限制</td>
      </tr>
      <tr>
          <td>DB connection 數</td>
          <td><code>peak outstanding lease × avg connection per credential</code></td>
          <td>不能超過 DB max_connections</td>
      </tr>
      <tr>
          <td>Vault audit log size</td>
          <td>每 lease 操作 ~500 byte、<code>(新+renew+revoke) × 500B</code></td>
          <td>100 lease/s → 50MB/s audit、SIEM 端要 sizing</td>
      </tr>
  </tbody>
</table>
<p>實務 sizing 範例：100 個 application pod、lease_duration 1h、renew at 50% TTL：</p>
<ul>
<li>新 lease：100 / 3600 ≈ 0.03/s（pod 重啟才有）</li>
<li>Renew：100 / 1800 ≈ 0.06/s</li>
<li>Outstanding lease：~100 個（每 pod 一個）</li>
<li>DB user 數：~100 個（peak ~150 含 grace period）</li>
<li>DB connection：100 × 5（pool size）= 500、需要 PostgreSQL <code>max_connections &gt;= 600</code></li>
</ul>
<p>超出單 Vault node 容量（~50 ops/s）時、走 Vault HA cluster + auto-unseal、或拆 namespace。</p>
<h2 id="整合--下一步">整合 / 下一步</h2>
<h3 id="vault-agent-injectork8s-環境推薦">vault-agent injector（K8s 環境推薦）</h3>





<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="c"># pod annotation</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">metadata</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="nt">annotations</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="nt">vault.hashicorp.com/agent-inject</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;true&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">    </span><span class="nt">vault.hashicorp.com/role</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;myapp-reader&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">    </span><span class="nt">vault.hashicorp.com/agent-inject-secret-db-creds</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;database/creds/myapp-reader&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">    </span><span class="nt">vault.hashicorp.com/agent-inject-template-db-creds</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="sd">      {{- with secret &#34;database/creds/myapp-reader&#34; -}}
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="sd">      DB_USER={{ .Data.username }}
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="sd">      DB_PASSWORD={{ .Data.password }}
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="sd">      {{- end }}</span></span></span></code></pre></div><p>Sidecar 自動 renew lease、credential 寫進 pod shared volume、application 讀 file。Application code 不需要 Vault SDK、降低 dependency。</p>
<h3 id="sdk-pattern非-k8s-環境">SDK pattern（非 K8s 環境）</h3>
<p>Go：<code>hashicorp/vault/api</code> + <code>LifetimeWatcher</code>、Java：spring-cloud-vault、Python：hvac + Renewer。SDK 已處理 renew timing / retry / token rotation、不要自寫 ticker。</p>
<h3 id="跟-cloud-native-secret-manager-的混搭">跟 cloud-native secret manager 的混搭</h3>
<p><a href="/blog/backend/07-security-data-protection/vendors/aws-secrets-manager/" data-link-title="AWS Secrets Manager" data-link-desc="AWS 原生 secret store &#43; 內建 RDS / Redshift rotation Lambda、Resource Policy 跨帳號共享、KMS 加密">AWS Secrets Manager</a> / <a href="/blog/backend/07-security-data-protection/vendors/google-secret-manager/" data-link-title="Google Secret Manager" data-link-desc="GCP 原生 secret store、CMEK &#43; Workload Identity Federation 整合、rotation 走自寫 Cloud Function 而非 built-in Lambda">Google Secret Manager</a> 也有 dynamic credential rotation（每 30 天輪替）、但 <em>cadence 是按時間</em>、不是 <em>按 application session</em>。混搭 pattern：</p>
<ul>
<li>Cloud-native：infrastructure-level credential（RDS master / k8s service account）、long TTL（30-90 天）</li>
<li>Vault dynamic：application-level credential、short TTL（1-24 小時）</li>
<li>Vault root credential 存 cloud-native secret manager、Vault auto-unseal 也用 cloud KMS</li>
</ul>
<h3 id="下一步議題">下一步議題</h3>
<ul>
<li><strong>Database snapshot 跟 dynamic credential 衝突</strong>：PostgreSQL <code>pg_dump</code> 用 long-lived credential、不適用 dynamic；snapshot user 用 static + scoped policy、跟 application user 分離</li>
<li><strong>Connection pool 端的 dynamic credential 支援</strong>：<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</a> 不支援 per-connection credential rotation、需要 connection 整個 lifecycle 跟 lease 對齊</li>
<li><strong>多 region Vault replication</strong>：performance replication 跟 disaster recovery replication 對 lease 的處理不同、跨 region application 要 sticky 同一 region 的 Vault primary</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li>上游 vendor 頁：<a href="/blog/backend/07-security-data-protection/vendors/hashicorp-vault/" data-link-title="HashiCorp Vault" data-link-desc="Self-hosted secret management 與 dynamic credential / encryption-as-a-service / PKI engine、跨雲跨環境的 secret 控制面">HashiCorp Vault</a></li>
<li>對照案例：<a href="/blog/backend/07-security-data-protection/cases/failure-credential-rotation-without-scope/" data-link-title="7.C9 反例：憑證輪替未分 Scope" data-link-desc="憑證輪替若未分域分批，容易造成跨系統連鎖中斷。">Failure: Credential Rotation Without Scope</a></li>
<li>對照案例：<a href="/blog/backend/07-security-data-protection/cases/" data-link-title="模組七案例正文" data-link-desc="資安控制面與控制平面轉換案例入口。">CircleCI 2023 AppRole 事件</a> — Cross-vendor mapping</li>
<li>上游 chapter：<a href="/blog/backend/07-security-data-protection/secrets-and-machine-credential-governance/" data-link-title="7.6 秘密管理與機器憑證治理" data-link-desc="以問題驅動方式整理 secret、token、key 與機器身份治理">7.6 秘密管理與機器憑證治理</a></li>
<li>平行 deep article：<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 配置</a></li>
<li>Methodology：<a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章的寫作方法論</a></li>
</ul>
]]></content:encoded></item><item><title>Kubernetes Graceful Shutdown：termination 序列跟你以為的不一樣</title><link>https://tarrragon.github.io/blog/backend/05-deployment-platform/vendors/kubernetes/graceful-shutdown/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/05-deployment-platform/vendors/kubernetes/graceful-shutdown/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/05-deployment-platform/vendors/kubernetes/" data-link-title="Kubernetes" data-link-desc="Container orchestration 主流、GKE / EKS / AKS / 自管">Kubernetes&lt;/a> overview 的 implementation-layer deep article。Overview 已說明 K8s 在 deployment platform 譜系的定位、本文聚焦 &lt;em>pod termination&lt;/em> 這個 production 最常踩、被誤解最深的議題：序列、配置、五個 case、跟 service mesh 整合。&lt;/p>&lt;/blockquote>
&lt;h2 id="graceful-shutdown-沒做對500-期間每次-deploy-都吃-502">Graceful shutdown 沒做對、500 期間每次 deploy 都吃 502&lt;/h2>
&lt;p>最常見的觸發場景：deploy 新 image、prometheus alert 在 5 分鐘內收到一波 502 / 503、SRE 翻 application log 看到「正在處理 request」「connection closed」交替出現。Application 本身沒 bug、但 K8s 在 pod terminate 時跟 traffic 來源 &lt;em>沒對齊步調&lt;/em>、舊 pod 還在處理請求時就被 SIGKILL、新 request 還在打到準備關閉的 pod 上。&lt;/p>
&lt;p>很多團隊修法是 &lt;em>把 terminationGracePeriodSeconds 從 30 拉到 120&lt;/em>、暫時掩蓋問題；但症狀會在下次 rolling update / HPA scale-down / node drain 時換個形式回來。根因在 &lt;em>termination 序列&lt;/em> — pod 不是收到 SIGTERM 就 graceful、序列裡每一步出錯都有不同 fail mode。&lt;/p>
&lt;h2 id="termination-序列五步每步都能爆">Termination 序列：五步、每步都能爆&lt;/h2>
&lt;p>K8s 收到 delete pod 請求後、發生的事 &lt;em>按時間&lt;/em> 是：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>時序&lt;/th>
 &lt;th>事件&lt;/th>
 &lt;th>動作來源&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>t=0&lt;/td>
 &lt;td>API server 標 pod 為 Terminating&lt;/td>
 &lt;td>kubelet 收到 delete&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>t=0&lt;/td>
 &lt;td>Pod 從 Service Endpoints 移除（&lt;strong>async&lt;/strong>）&lt;/td>
 &lt;td>endpoint controller&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>t=0&lt;/td>
 &lt;td>kubelet 跑 preStop hook（若有定義）&lt;/td>
 &lt;td>container runtime&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>t=preStop 結束&lt;/td>
 &lt;td>container 收到 SIGTERM&lt;/td>
 &lt;td>container runtime&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>t=SIGTERM + terminationGracePeriodSeconds&lt;/td>
 &lt;td>container 收到 SIGKILL&lt;/td>
 &lt;td>container runtime&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>關鍵誤解：&lt;/p>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>「pod 從 Service 移除」跟「container 收到 SIGTERM」是 &lt;em>平行&lt;/em>、不是序列&lt;/strong>。Endpoint controller 更新 Endpoints object → kube-proxy 重新寫 iptables → 各 node 的 traffic 才真正停 — 這條鏈通常需要 &lt;em>1-5 秒&lt;/em>；同時間 SIGTERM 已經發給 application。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>preStop hook 是「container 還在跑、SIGTERM 還沒發」期間執行&lt;/strong>。pre-Stop 設 &lt;code>sleep 10&lt;/code> 是 production 標準作法 — 用 sleep 讓 endpoint controller 有時間把 pod 從 Service 移除、避免 SIGTERM 期間還有新 request 進來。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/05-deployment-platform/vendors/kubernetes/" data-link-title="Kubernetes" data-link-desc="Container orchestration 主流、GKE / EKS / AKS / 自管">Kubernetes</a> overview 的 implementation-layer deep article。Overview 已說明 K8s 在 deployment platform 譜系的定位、本文聚焦 <em>pod termination</em> 這個 production 最常踩、被誤解最深的議題：序列、配置、五個 case、跟 service mesh 整合。</p></blockquote>
<h2 id="graceful-shutdown-沒做對500-期間每次-deploy-都吃-502">Graceful shutdown 沒做對、500 期間每次 deploy 都吃 502</h2>
<p>最常見的觸發場景：deploy 新 image、prometheus alert 在 5 分鐘內收到一波 502 / 503、SRE 翻 application log 看到「正在處理 request」「connection closed」交替出現。Application 本身沒 bug、但 K8s 在 pod terminate 時跟 traffic 來源 <em>沒對齊步調</em>、舊 pod 還在處理請求時就被 SIGKILL、新 request 還在打到準備關閉的 pod 上。</p>
<p>很多團隊修法是 <em>把 terminationGracePeriodSeconds 從 30 拉到 120</em>、暫時掩蓋問題；但症狀會在下次 rolling update / HPA scale-down / node drain 時換個形式回來。根因在 <em>termination 序列</em> — pod 不是收到 SIGTERM 就 graceful、序列裡每一步出錯都有不同 fail mode。</p>
<h2 id="termination-序列五步每步都能爆">Termination 序列：五步、每步都能爆</h2>
<p>K8s 收到 delete pod 請求後、發生的事 <em>按時間</em> 是：</p>
<table>
  <thead>
      <tr>
          <th>時序</th>
          <th>事件</th>
          <th>動作來源</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>t=0</td>
          <td>API server 標 pod 為 Terminating</td>
          <td>kubelet 收到 delete</td>
      </tr>
      <tr>
          <td>t=0</td>
          <td>Pod 從 Service Endpoints 移除（<strong>async</strong>）</td>
          <td>endpoint controller</td>
      </tr>
      <tr>
          <td>t=0</td>
          <td>kubelet 跑 preStop hook（若有定義）</td>
          <td>container runtime</td>
      </tr>
      <tr>
          <td>t=preStop 結束</td>
          <td>container 收到 SIGTERM</td>
          <td>container runtime</td>
      </tr>
      <tr>
          <td>t=SIGTERM + terminationGracePeriodSeconds</td>
          <td>container 收到 SIGKILL</td>
          <td>container runtime</td>
      </tr>
  </tbody>
</table>
<p>關鍵誤解：</p>
<ol>
<li>
<p><strong>「pod 從 Service 移除」跟「container 收到 SIGTERM」是 <em>平行</em>、不是序列</strong>。Endpoint controller 更新 Endpoints object → kube-proxy 重新寫 iptables → 各 node 的 traffic 才真正停 — 這條鏈通常需要 <em>1-5 秒</em>；同時間 SIGTERM 已經發給 application。</p>
</li>
<li>
<p><strong>preStop hook 是「container 還在跑、SIGTERM 還沒發」期間執行</strong>。pre-Stop 設 <code>sleep 10</code> 是 production 標準作法 — 用 sleep 讓 endpoint controller 有時間把 pod 從 Service 移除、避免 SIGTERM 期間還有新 request 進來。</p>
</li>
<li>
<p><strong>terminationGracePeriodSeconds 是 <em>從 preStop 開始</em> 計時、不是從 SIGTERM</strong>。preStop sleep 10s + application 30s graceful = 至少要設 40s。</p>
</li>
<li>
<p><strong>graceful 不是 framework 自動的</strong>。Application 必須 <em>主動處理 SIGTERM</em>：拒絕新 request、等 in-flight 完成、close DB connection、flush log。沒處理 SIGTERM、container 會在 grace period 後被強殺。</p>
</li>
<li>
<p><strong>readiness probe 在 Terminating 期間 <em>仍會被執行</em>、但結果不影響 traffic</strong>（已經從 Endpoints 移除）。但若 application 沒主動讓 readiness fail、service mesh / external LB 可能仍在送 request（依不同 mesh 行為）。</p>
</li>
</ol>
<h2 id="配置全圖">配置全圖</h2>
<h3 id="deployment-spec">Deployment spec</h3>





<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">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">apps/v1</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">kind</span><span class="p">:</span><span class="w"> </span><span class="l">Deployment</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">spec</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="nt">template</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="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">      </span><span class="nt">terminationGracePeriodSeconds</span><span class="p">:</span><span class="w"> </span><span class="m">60</span><span class="w">          </span><span class="c"># SIGTERM 後 60s 才 SIGKILL</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">      </span><span class="nt">containers</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="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">app</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">          </span><span class="nt">lifecycle</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="nt">preStop</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="nt">exec</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="nt">command</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;/bin/sh&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;-c&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;sleep 10&#34;</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="nt">readinessProbe</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="nt">httpGet</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="nt">path</span><span class="p">:</span><span class="w"> </span><span class="l">/healthz/ready</span><span class="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w">              </span><span class="nt">port</span><span class="p">:</span><span class="w"> </span><span class="m">8080</span><span class="w">
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="w">            </span><span class="nt">periodSeconds</span><span class="p">:</span><span class="w"> </span><span class="m">5</span><span class="w">
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="w">            </span><span class="nt">failureThreshold</span><span class="p">:</span><span class="w"> </span><span class="m">2</span></span></span></code></pre></div><p>時序：t=0 preStop 開始 sleep 10s → t=10s container SIGTERM → t=70s SIGKILL（不是 t=60s、是 60s after SIGTERM）。</p>
<h3 id="application-處理-sigtermgo-範例">Application 處理 SIGTERM（Go 範例）</h3>





<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="nx">sigs</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="nx">os</span><span class="p">.</span><span class="nx">Signal</span><span class="p">,</span> <span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="nx">signal</span><span class="p">.</span><span class="nf">Notify</span><span class="p">(</span><span class="nx">sigs</span><span class="p">,</span> <span class="nx">syscall</span><span class="p">.</span><span class="nx">SIGTERM</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="nx">server</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">http</span><span class="p">.</span><span class="nx">Server</span><span class="p">{</span><span class="nx">Addr</span><span class="p">:</span> <span class="s">&#34;:8080&#34;</span><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="k">go</span> <span class="nx">server</span><span class="p">.</span><span class="nf">ListenAndServe</span><span class="p">()</span>
</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 class="o">&lt;-</span><span class="nx">sigs</span>                                              <span class="c1">// 等 SIGTERM</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="nx">log</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="s">&#34;SIGTERM received, draining...&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1">// 1. readiness fail（讓 mesh-aware 流量停）</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="nx">ready</span><span class="p">.</span><span class="nf">Store</span><span class="p">(</span><span class="kc">false</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1">// 2. wait 5s 讓 readiness probe failureThreshold 觸發</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="nx">time</span><span class="p">.</span><span class="nf">Sleep</span><span class="p">(</span><span class="mi">5</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Second</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="c1">// 3. graceful shutdown server（拒新請求、等 in-flight）</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="nx">ctx</span><span class="p">,</span> <span class="nx">cancel</span> <span class="o">:=</span> <span class="nx">context</span><span class="p">.</span><span class="nf">WithTimeout</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nf">Background</span><span class="p">(),</span> <span class="mi">45</span><span class="o">*</span><span class="nx">time</span><span class="p">.</span><span class="nx">Second</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="k">defer</span> <span class="nf">cancel</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="nx">server</span><span class="p">.</span><span class="nf">Shutdown</span><span class="p">(</span><span class="nx">ctx</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">
</span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="c1">// 4. close DB / cache / message consumer</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="nx">db</span><span class="p">.</span><span class="nf">Close</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="nx">consumer</span><span class="p">.</span><span class="nf">Stop</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">
</span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="c1">// 5. flush log + exit</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl"><span class="nx">logger</span><span class="p">.</span><span class="nf">Sync</span><span class="p">()</span></span></span></code></pre></div><p>關鍵：<code>server.Shutdown(ctx)</code> 是 <em>拒新請求、等 in-flight</em>、ctx timeout 設 <em>grace period 減去 preStop sleep 跟 readiness fail 等待時間</em>（60s - 10s - 5s = 45s）。</p>
<h2 id="production-故障演練">Production 故障演練</h2>
<h3 id="case-1rolling-update-期間-502--503">Case 1：Rolling update 期間 502 / 503</h3>
<p><strong>徵兆</strong>：每次 deploy 後 5 分鐘內 LB / ingress log 一波 502 / 503、application log 顯示「context canceled」「connection closed by peer」、新 pod 已 ready 但舊 pod 在 grace period 內仍收 request。</p>
<p><strong>根因</strong>：沒設 preStop sleep、container 收到 SIGTERM 後立刻 <code>server.Shutdown()</code>、但 kube-proxy 還沒把舊 pod 從 iptables 移除、新 request 持續送到舊 pod、舊 pod 已拒收。</p>
<p><strong>修法</strong>：preStop <code>sleep 10</code>、讓 endpoint propagation 完成再進入 SIGTERM 流程。</p>
<h3 id="case-2connection-drain-racelong-running-request-被中斷">Case 2：Connection drain race，long-running request 被中斷</h3>
<p><strong>徵兆</strong>：deploy 後 application log 有大量 <code>context canceled</code> 對應到 long-running endpoint（例：報表生成、檔案上傳）、user 端看到 transaction 失敗、但短 request 沒事。</p>
<p><strong>根因</strong>：long-running endpoint 處理時間 &gt; terminationGracePeriodSeconds、<code>server.Shutdown(ctx)</code> ctx timeout 設太短、in-flight 強制中斷。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>把 long-running endpoint 改 async（背景 job + status endpoint）、HTTP request 立刻 return job ID</li>
<li>短期：terminationGracePeriodSeconds 拉到 long-running 99 percentile + buffer</li>
<li>application 側 ctx timeout = grace period - preStop - readiness fail wait</li>
</ol>
<h3 id="case-3init-container-在-grace-period-期間重啟sigterm-沒到-main">Case 3：Init container 在 grace period 期間重啟、SIGTERM 沒到 main</h3>
<p><strong>徵兆</strong>：pod 顯示 Terminating 但 phase 一直在 Running、main container restart count + 1、application log 沒看到「SIGTERM received」。</p>
<p><strong>根因</strong>：init container 用 <code>restartPolicy: Always</code>（K8s 1.28+ sidecar 模式）、或 main container 在 SIGTERM 前先 crash 觸發 restart、kubelet 在 restart 後 <em>不重發 SIGTERM</em>、main container 跑到 grace period 結束直接 SIGKILL。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>Sidecar container（restartPolicy: Always）的 preStop 也要設 <code>sleep</code>、跟 main 同 lifecycle</li>
<li>main container readinessProbe 失敗時 <em>別自動 restart</em>（restartPolicy: OnFailure + crashLoopBackOff 觀察）</li>
<li>觀察 <code>kubectl describe pod</code> 的 events、SIGTERM 沒發出來會有 <code>Killing container</code> event 缺失</li>
</ol>
<h3 id="case-4statefulset-串行終止總時間--pod-數--grace-period">Case 4：StatefulSet 串行終止、總時間 = pod 數 × grace period</h3>
<p><strong>徵兆</strong>：StatefulSet rolling update / scale-down 比 Deployment 慢 N 倍（N = replica 數）、deploy 一個 5 replica 的 statefulset 要 5 分鐘以上。</p>
<p><strong>根因</strong>：StatefulSet 預設 <code>podManagementPolicy: OrderedReady</code> — pod 串行終止 + 串行創建、每個 pod 至少要 grace period 完成才動下一個。Deployment 用 <code>RollingUpdate</code> 預設 maxUnavailable=25% 平行終止。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>StatefulSet 改 <code>podManagementPolicy: Parallel</code>（若 application 不要求嚴格順序）</li>
<li>嚴格順序情境（Cassandra / Kafka / etcd）保留 OrderedReady、但 grace period 設 <em>單 pod 必要時間</em>、不要設 <em>總時間能承受</em></li>
<li>接受序列化代價、把 deploy 排在低流量時段</li>
</ol>
<h3 id="case-5job--cronjob-不-gracefulsigterm-直接-sigkill">Case 5：Job / CronJob 不 graceful、SIGTERM 直接 SIGKILL</h3>
<p><strong>徵兆</strong>：CronJob 在 Job timeout / pod eviction 時不 graceful、寫一半的 file 留在 PVC、下次跑時 corrupt；application log 沒「SIGTERM received」、直接斷。</p>
<p><strong>根因</strong>：Job 的 <code>activeDeadlineSeconds</code> 到期 / node eviction 觸發時、K8s 對 Job pod <em>仍會發 SIGTERM</em>、但 <em>很多 batch framework（Spring Batch / Argo Workflow worker）沒處理 SIGTERM</em>、application 沒主動 checkpoint。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>Batch application 處理 SIGTERM、checkpoint 進度寫 storage、下次跑時 resume</li>
<li>不適合 checkpoint 的 batch、保證 <em>idempotent re-run</em>、SIGKILL 後重跑不會 corrupt</li>
<li>Job spec 加 <code>terminationGracePeriodSeconds</code>（預設 30、batch 通常要 60-300）</li>
</ol>
<h2 id="規模影響">規模影響</h2>
<p>Graceful shutdown 的成本主要在 <em>deploy 時間</em> 跟 <em>capacity buffer</em>：</p>
<table>
  <thead>
      <tr>
          <th>規模因素</th>
          <th>影響</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>terminationGracePeriod 60s</td>
          <td>單 pod deploy ~70-80s（含 preStop + grace + new pod startup）</td>
      </tr>
      <tr>
          <td>Deployment 100 replica + maxSurge 25%</td>
          <td>全 deploy ~5-10 分鐘、需要 <em>25% extra capacity</em>（25 replica buffer）</td>
      </tr>
      <tr>
          <td>StatefulSet 串行 + 60s grace</td>
          <td>10 replica 約 10-12 分鐘、deploy window 要在低流量時段</td>
      </tr>
      <tr>
          <td>HPA scale-down 跟 graceful 一起跑</td>
          <td>scale-down 觸發 → preStop + grace + new metric → 下次 scale 判斷、avg 反應週期 ≈ 3-5 分鐘</td>
      </tr>
  </tbody>
</table>
<p>實務 default：</p>
<ul>
<li>Web service：<code>terminationGracePeriodSeconds: 60</code>、preStop sleep 10、application graceful 45s</li>
<li>Backend worker（消費 queue）：<code>terminationGracePeriodSeconds: 120</code>、preStop 不 sleep（用 readiness 控）、application 處理當前 message + commit offset</li>
<li>Batch job：<code>terminationGracePeriodSeconds: 300</code>、checkpoint pattern</li>
<li>StatefulSet（DB / queue）：grace period 對齊 vendor 建議（Kafka 90s、PostgreSQL 60s）</li>
</ul>
<h2 id="跟其他元件整合">跟其他元件整合</h2>
<h3 id="service-meshistio--linkerd">Service mesh（Istio / Linkerd）</h3>
<p>Service mesh sidecar（envoy / linkerd-proxy）也有自己的 termination — 通常比 main container 晚一點關。配置原則：</p>
<ol>
<li>mesh sidecar 設 <code>terminationGracePeriodSeconds</code> 比 main 多 5-10s、main 處理完才換 sidecar</li>
<li>Istio 1.12+ 的 <code>proxy.istio.io/config.holdApplicationUntilProxyStarts</code> 控啟動順序、shutdown 也要對應</li>
<li>mTLS 環境 graceful 多一道：在 SIGTERM 後等 mesh 主動 close cert rotation、不要硬斷</li>
</ol>
<h3 id="readiness-probe-跟-mesh-aware-traffic">Readiness probe 跟 mesh-aware traffic</h3>
<p>純 K8s Service（kube-proxy iptables）：endpoint 移除後 <em>已建立 connection 仍會跑完</em>、新 connection 不來。Mesh-aware traffic（service mesh / external LB with health check）：要 readiness fail 才會停送。</p>
<p>修法：application graceful 第一步是 <code>ready.Store(false)</code> + 等 readiness probe 至少 fail 一次（5-10s）、才開始 server.Shutdown。</p>
<h3 id="跟-pod-disruption-budgetpdb的衝突">跟 Pod Disruption Budget（PDB）的衝突</h3>
<p>Node drain 時 PDB 限制可同時 unavailable 的 pod 數、graceful shutdown 拖長會讓 drain 卡住。對策：</p>
<ol>
<li>緊急 drain（node 硬體故障）：<code>kubectl drain --grace-period=30 --force</code>、接受短時間 502</li>
<li>正常 drain（升級 / 維運）：PDB 設 <code>minAvailable: &lt;replicas-1&gt;</code>、容許單 pod 慢慢 graceful</li>
<li>不要設 <code>maxUnavailable: 0</code>、會讓 drain 卡死</li>
</ol>
<h2 id="下一步">下一步</h2>
<ul>
<li><strong>Application graceful 寫法</strong>：<a href="https://12factor.net/disposability">12-factor app</a> disposability 章節給 framework-agnostic 模板、各語言 SDK 寫法見對應 framework</li>
<li><strong>Queue consumer 的 graceful</strong>：訊息 ack / offset commit 必須在 SIGTERM 內完成、否則 duplicate message — 對應 <a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">03 message queue</a> 模組的 consumer-design 段</li>
<li><strong>跨 region / 多 cluster 的 graceful</strong>：multi-cluster service mesh（Istio multicluster / Linkerd multicluster）的 traffic shift 期間 graceful 行為跟單 cluster 不同、需要對齊 mesh 配置</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li>上游 vendor 頁：<a href="/blog/backend/05-deployment-platform/vendors/kubernetes/" data-link-title="Kubernetes" data-link-desc="Container orchestration 主流、GKE / EKS / AKS / 自管">Kubernetes</a></li>
<li>上游 chapter：<a href="/blog/backend/05-deployment-platform/deployment-rollout-drain-rollback/" data-link-title="5.8 Deployment Rollout with Drain and Rollback（實作示範）" data-link-desc="以 checkout service 示範部署切換如何交付 canary evidence、drain signal、release gate 與 incident decision log。">5.X deployment-rollout-drain-rollback</a></li>
<li>對照案例：rolling update 期間 502 多見於 stage-3 mesh adoption case 庫</li>
<li>平行 deep article：<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 配置</a> / <a href="/blog/backend/07-security-data-protection/vendors/hashicorp-vault/dynamic-credential/" data-link-title="HashiCorp Vault Dynamic Credential：lease 治理跟 application 整合的實作層" data-link-desc="Vault database secrets engine 怎麼配、application 怎麼 renew lease、production 五大踩雷（lease 過期 race、DB max_connections 撞牆、Vault sealed、token expire、scope 過寬）、容量規劃跟 vault-agent injector 整合">Vault Dynamic Credential</a></li>
<li>Methodology：<a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章的寫作方法論</a></li>
</ul>
]]></content:encoded></item><item><title>Splunk Risk-Based Alerting：從 alert per rule 到 score-aggregated notable</title><link>https://tarrragon.github.io/blog/backend/07-security-data-protection/vendors/splunk/risk-based-alerting/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/07-security-data-protection/vendors/splunk/risk-based-alerting/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/07-security-data-protection/vendors/splunk/" data-link-title="Splunk" data-link-desc="業界 SIEM 標準、forwarder &amp;#43; indexer &amp;#43; search head 架構、SPL 為核心查詢語言、ingestion-based 計費跟偵測覆蓋率的 trade-off">Splunk&lt;/a> overview 的 implementation-layer deep article。Overview 已說明 Splunk Enterprise Security 在 SIEM / Detection 譜系的定位、本文聚焦 &lt;em>Risk-Based Alerting (RBA)&lt;/em> 的實作層 — 從「per-rule alert」轉到「score 累積 + threshold 觸發 notable」的方法論轉變、跟 tuning / scaling / 整合的具體做法。&lt;/p>&lt;/blockquote>
&lt;h2 id="為什麼-rbaalert-fatigue-是-detection-engineering-的天花板">為什麼 RBA：alert fatigue 是 detection engineering 的天花板&lt;/h2>
&lt;p>Detection engineering 的成熟度上限不是「能寫多少 correlation rule」、是「SOC analyst 能處理多少 alert / day 而不會麻木」。多數 SOC 在 200-500 alert/day 區間就到處理上限、再加 rule 只會推升 false positive、analyst 開始 silent ignore 中低嚴重度 alert。&lt;/p>
&lt;p>RBA 的核心轉折是 &lt;em>把 alert 邏輯從「rule 觸發」拆成「score 累積」&lt;/em>：每個 detection rule 不直接產 alert、而是給 &lt;em>user / asset / process&lt;/em> 加 risk score；多個低嚴重訊號累積到 threshold 才產 notable（高優先 case）。SOC 看的不是「rule X 觸發了」、是「user Y 今天累積 70 分、上週 12 分」。&lt;/p>
&lt;p>RBA 不是 &lt;em>寫 detection rule 的替代&lt;/em>、是 &lt;em>aggregation 跟 prioritization 的新層&lt;/em>。原本 100 條 rule 各自產 alert 變成 100 條 rule 共同貢獻 score、score → notable 是新的 alert 邊界。&lt;/p>
&lt;h2 id="rba-三層-modelmodifierscorenotable">RBA 三層 model：modifier、score、notable&lt;/h2>
&lt;p>Risk 流程的三個 first-class object：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Object&lt;/th>
 &lt;th>責任&lt;/th>
 &lt;th>例&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;strong>Risk modifier&lt;/strong>&lt;/td>
 &lt;td>一條 detection rule 產出、提供「給誰加多少分、為什麼、什麼類別」&lt;/td>
 &lt;td>user &lt;code>alice@corp&lt;/code> +25 分、reason &lt;code>unusual_login_geo&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Risk index&lt;/strong>&lt;/td>
 &lt;td>累積所有 modifier、依時間衰減；query 出「user / asset 當前 score」&lt;/td>
 &lt;td>&lt;code>index=risk earliest=-7d&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Risk notable&lt;/strong>&lt;/td>
 &lt;td>當 score 累積超過 threshold 觸發、進 SOC case management&lt;/td>
 &lt;td>user 累積 50 分 → 開 incident&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>關鍵設計選擇都在 modifier 層：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>加分維度&lt;/strong>：per user / per asset / per process tree / per IP — 維度越細粒度、score 越能對應「個體」、但 query 成本越高&lt;/li>
&lt;li>&lt;strong>加分 weight&lt;/strong>：簡單做法 severity 直接對應（low=5 / med=15 / high=30 / critical=60）；細做要考慮 &lt;em>signal precision&lt;/em>（rule 的歷史 FP rate）&lt;/li>
&lt;li>&lt;strong>MITRE ATT&amp;amp;CK 對應&lt;/strong>：每個 modifier 標 tactic / technique、跟 ATT&amp;amp;CK 對應、用來判斷 &lt;em>kill chain 階段&lt;/em> 是否完整（reconnaissance → exfiltration 全套出現 vs 單一 tactic 重複）&lt;/li>
&lt;/ul>
&lt;h2 id="es-配置-step-by-step">ES 配置 step-by-step&lt;/h2>
&lt;h3 id="risk-modifier-從-correlation-search-產出">Risk modifier 從 correlation search 產出&lt;/h3>





&lt;pre tabindex="0">&lt;code class="language-spl" data-lang="spl">| search index=auth user=* unusual_geo=true
| stats count by user, src_ip, _time
| eval risk_score=25
| eval risk_object_type=&amp;#34;user&amp;#34;
| eval risk_object=user
| eval risk_message=&amp;#34;Unusual login geography&amp;#34;
| eval threat_object=src_ip
| eval threat_object_type=&amp;#34;ip_address&amp;#34;
| eval mitre_technique=&amp;#34;T1078&amp;#34;
| collect index=risk&lt;/code>&lt;/pre>&lt;p>關鍵欄位：&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/07-security-data-protection/vendors/splunk/" data-link-title="Splunk" data-link-desc="業界 SIEM 標準、forwarder &#43; indexer &#43; search head 架構、SPL 為核心查詢語言、ingestion-based 計費跟偵測覆蓋率的 trade-off">Splunk</a> overview 的 implementation-layer deep article。Overview 已說明 Splunk Enterprise Security 在 SIEM / Detection 譜系的定位、本文聚焦 <em>Risk-Based Alerting (RBA)</em> 的實作層 — 從「per-rule alert」轉到「score 累積 + threshold 觸發 notable」的方法論轉變、跟 tuning / scaling / 整合的具體做法。</p></blockquote>
<h2 id="為什麼-rbaalert-fatigue-是-detection-engineering-的天花板">為什麼 RBA：alert fatigue 是 detection engineering 的天花板</h2>
<p>Detection engineering 的成熟度上限不是「能寫多少 correlation rule」、是「SOC analyst 能處理多少 alert / day 而不會麻木」。多數 SOC 在 200-500 alert/day 區間就到處理上限、再加 rule 只會推升 false positive、analyst 開始 silent ignore 中低嚴重度 alert。</p>
<p>RBA 的核心轉折是 <em>把 alert 邏輯從「rule 觸發」拆成「score 累積」</em>：每個 detection rule 不直接產 alert、而是給 <em>user / asset / process</em> 加 risk score；多個低嚴重訊號累積到 threshold 才產 notable（高優先 case）。SOC 看的不是「rule X 觸發了」、是「user Y 今天累積 70 分、上週 12 分」。</p>
<p>RBA 不是 <em>寫 detection rule 的替代</em>、是 <em>aggregation 跟 prioritization 的新層</em>。原本 100 條 rule 各自產 alert 變成 100 條 rule 共同貢獻 score、score → notable 是新的 alert 邊界。</p>
<h2 id="rba-三層-modelmodifierscorenotable">RBA 三層 model：modifier、score、notable</h2>
<p>Risk 流程的三個 first-class object：</p>
<table>
  <thead>
      <tr>
          <th>Object</th>
          <th>責任</th>
          <th>例</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>Risk modifier</strong></td>
          <td>一條 detection rule 產出、提供「給誰加多少分、為什麼、什麼類別」</td>
          <td>user <code>alice@corp</code> +25 分、reason <code>unusual_login_geo</code></td>
      </tr>
      <tr>
          <td><strong>Risk index</strong></td>
          <td>累積所有 modifier、依時間衰減；query 出「user / asset 當前 score」</td>
          <td><code>index=risk earliest=-7d</code></td>
      </tr>
      <tr>
          <td><strong>Risk notable</strong></td>
          <td>當 score 累積超過 threshold 觸發、進 SOC case management</td>
          <td>user 累積 50 分 → 開 incident</td>
      </tr>
  </tbody>
</table>
<p>關鍵設計選擇都在 modifier 層：</p>
<ul>
<li><strong>加分維度</strong>：per user / per asset / per process tree / per IP — 維度越細粒度、score 越能對應「個體」、但 query 成本越高</li>
<li><strong>加分 weight</strong>：簡單做法 severity 直接對應（low=5 / med=15 / high=30 / critical=60）；細做要考慮 <em>signal precision</em>（rule 的歷史 FP rate）</li>
<li><strong>MITRE ATT&amp;CK 對應</strong>：每個 modifier 標 tactic / technique、跟 ATT&amp;CK 對應、用來判斷 <em>kill chain 階段</em> 是否完整（reconnaissance → exfiltration 全套出現 vs 單一 tactic 重複）</li>
</ul>
<h2 id="es-配置-step-by-step">ES 配置 step-by-step</h2>
<h3 id="risk-modifier-從-correlation-search-產出">Risk modifier 從 correlation search 產出</h3>





<pre tabindex="0"><code class="language-spl" data-lang="spl">| search index=auth user=* unusual_geo=true
| stats count by user, src_ip, _time
| eval risk_score=25
| eval risk_object_type=&#34;user&#34;
| eval risk_object=user
| eval risk_message=&#34;Unusual login geography&#34;
| eval threat_object=src_ip
| eval threat_object_type=&#34;ip_address&#34;
| eval mitre_technique=&#34;T1078&#34;
| collect index=risk</code></pre><p>關鍵欄位：</p>
<ul>
<li><code>risk_object</code> + <code>risk_object_type</code>：誰被加分、預設 user / system / other</li>
<li><code>risk_score</code>：加多少分、考量 signal precision</li>
<li><code>threat_object</code>：對應的 attacker artifact（IP / hash / domain）、用來跨 modifier 關聯</li>
<li><code>mitre_technique</code>：對應 ATT&amp;CK ID、用於 kill chain analysis</li>
</ul>
<p><em>Tuning 提醒</em>：第一次部署別直接 <code>collect index=risk</code>、先 <code>| table</code> 看 output、估算每天會產多少 modifier；超出 indexer 容量規劃前先做 sampling（<code>| where random()/2147483647&lt;0.1</code> 取 10%）。</p>
<h3 id="risk-notablethreshold-aggregation">Risk notable：threshold aggregation</h3>





<pre tabindex="0"><code class="language-spl" data-lang="spl">| tstats summariesonly=t count, sum(All_Risk.calculated_risk_score) as total_risk
  from datamodel=Risk.All_Risk
  where earliest=-24h
  by All_Risk.risk_object, All_Risk.risk_object_type
| where total_risk &gt; 80
| `risk_score_format`</code></pre><p><code>total_risk &gt; 80</code> 是觸發 notable 的 threshold。Tuning 重點：</p>
<ul>
<li><strong>Time window</strong>：-24h 是預設、但要看 <em>attack pattern average duration</em> 調整；APT 用 7-14 day window、commodity attack 用 4-12h</li>
<li><strong>Threshold value</strong>：80 是 <em>當量</em> 不是普世值、依 modifier weight 分佈調整；ES 7.0+ 預設建議 100、實務多在 60-150 區間</li>
<li><strong>Aggregation 維度</strong>：by user 是 default、但 lateral movement scenario 要 by asset、credential abuse 要 by service account</li>
</ul>
<p><em>Tuning 提醒</em>：第一週跑 <em>shadow mode</em> — 觸發 notable 但不 page、SOC 後續 review、調整 threshold 跟 weight；shadow 跑 1-2 週後再啟 production page。</p>
<h3 id="notable-enrichment人類能看的-case">Notable enrichment：人類能看的 case</h3>





<pre tabindex="0"><code class="language-spl" data-lang="spl">| eval description=&#34;User &#34;.risk_object.&#34; accumulated &#34;.total_risk.&#34; risk over 24h&#34;
| eval mitre_techniques=mvjoin(mitre_technique, &#34;, &#34;)
| eval contributing_rules=mvjoin(search_name, &#34;, &#34;)
| sendalert notable</code></pre><p>Notable 進入 ES Incident Review、SOC analyst 看到的不只 score、還有 <em>組成這 80 分的 N 條 rule + ATT&amp;CK 覆蓋的 tactic</em>；這是 RBA 比 per-rule alert 強的核心 — analyst 直接看完整 narrative、不用拼湊。</p>
<h2 id="tuning-playbook四類常見-drift">Tuning playbook：四類常見 drift</h2>
<h3 id="playbook-afalse-positive-累積">Playbook A：False positive 累積</h3>
<p><strong>徵兆</strong>：某 user 連續 N 天觸發 notable、SOC 每次 review 後 close 為 FP；但 modifier 仍持續加分。</p>
<p><strong>根因</strong>：modifier 加分邏輯沒考慮 baseline — 例：DBA 每天用 <code>psql</code> 連 prod 是正常、<code>unusual_command</code> rule 把它當異常加 15 分、累積到 threshold。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>Modifier 端加 <code>whitelist_lookup</code>：DBA / SRE / approved service account 跳過 specific modifier</li>
<li>進階：modifier 加 <code>signal_precision</code> weight、historical FP rate &gt; 30% 的 rule weight 降到 5 分以下</li>
<li>不能輕易加 <code>NOT user IN (...)</code> exclusion、long whitelist 是反模式 — 用 <em>role-based exclusion</em>（query AD group）</li>
</ol>
<h3 id="playbook-bscore-inflation">Playbook B：Score inflation</h3>
<p><strong>徵兆</strong>：threshold 設 80、SOC 收到的 notable 每 day 從 5 個漲到 25 個、但「實際攻擊」沒對應增加。</p>
<p><strong>根因</strong>：新加的 detection rule 沒對齊既有 weight 分佈、新 rule 都給 +30 / +40、global average 抬升、threshold 變相降低。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>每加新 rule 時跑「+1 rule 對 daily notable 數的影響」shadow simulation</li>
<li>重新 calibrate threshold — 不是固定值、是 <em>p95 daily total_risk 的 1.5 倍</em></li>
<li>季度 review：跑 <code>index=risk | stats sum(risk_score) by source</code> 看 modifier 來源分佈、score 集中在少數 rule 是 inflation 訊號</li>
</ol>
<p><em>Tuning 提醒</em>：score inflation 跟 alert fatigue 是同樣症狀的不同根因；前者改 threshold + rule weight calibration、後者改 modifier 維度跟 whitelist。</p>
<h3 id="playbook-cthreshold-drift">Playbook C：Threshold drift</h3>
<p><strong>徵兆</strong>：threshold 設定半年沒動、但 attack landscape / business 行為都變了；要嘛 notable 太多（threshold 低於 baseline）、要嘛 missed detection（threshold 高於實際攻擊累積）。</p>
<p><strong>根因</strong>：threshold 是 <em>static value、但 baseline 是 dynamic</em>；business 流程變動（雲端遷移 / 新部門 / WFH 比例變化）影響 modifier 觸發頻率。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>Quarterly tuning cadence：每季跑 <code>tstats sum(All_Risk.calculated_risk_score) by user | stats p50, p95, p99</code> 看分佈</li>
<li>Adaptive threshold：用 <code>p95 × 1.3</code> 動態計算、寫 macro 自動 update</li>
<li>不要把 threshold drift 當「rule 不準」、是 <em>基準漂移</em>、不是 rule 錯</li>
</ol>
<h3 id="playbook-ddecay-設計">Playbook D：Decay 設計</h3>
<p><strong>徵兆</strong>：user 7 天前的低分異常持續累積在 score 內、threshold 觸發 notable 但實際是 <em>7 天分散事件</em>、不是 <em>當前攻擊 episode</em>。</p>
<p><strong>根因</strong>：default RBA 在 <code>-24h</code> window 內 sum、沒考慮 <em>時間衰減</em>；7 天前的低分跟今天的低分權重一樣。</p>
<p><strong>修法</strong>：加 decay function、modifier weight 隨時間衰減：</p>





<pre tabindex="0"><code class="language-spl" data-lang="spl">| eval age_hours=(now() - _time)/3600
| eval decayed_score = calculated_risk_score * exp(-age_hours / 48)
| stats sum(decayed_score) as total_risk by risk_object</code></pre><p><code>exp(-age/48)</code> 是 48 小時半衰期、24h 前的事件權重剩 60%、48h 剩 37%、7 天前剩 &lt; 3%。half-life 依 attack pattern 調整：commodity attack 12-24h、APT 5-14 day。</p>
<h2 id="capacity-規劃">Capacity 規劃</h2>
<p>RBA 的 capacity 三個面向：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>估算方式</th>
          <th>警戒值</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Risk index event/day</td>
          <td><code>總 detection rule × 平均 trigger 次數/day</code></td>
          <td>中型 SOC ~100K-500K / day</td>
      </tr>
      <tr>
          <td>Risk datamodel size</td>
          <td><code>event/day × 365 day × 1KB avg</code></td>
          <td>100K/day × 365 × 1KB ≈ 36GB / year</td>
      </tr>
      <tr>
          <td>Search head load</td>
          <td>RBA tstats 比 raw search 便宜 ~10x、但 by-user aggregation 在 1M+ user 仍重</td>
          <td>跑 hourly notable trigger search、不是 streaming</td>
      </tr>
      <tr>
          <td>Indexer ingest</td>
          <td>RBA 不大增 ingest（已 ingest 的 log 處理出 modifier）、但 datamodel acceleration 要 CPU</td>
          <td>每 indexer 預留 10-15% CPU 給 datamodel accel</td>
      </tr>
  </tbody>
</table>
<p>實務 sizing：500K modifier/day、用戶 5K、tstats hourly trigger search、需要 <em>3 indexer + 1 search head</em>（含 RBA 之外的工作）。</p>
<blockquote>
<p>注意 <a href="/blog/backend/07-security-data-protection/vendors/splunk/" data-link-title="Splunk" data-link-desc="業界 SIEM 標準、forwarder &#43; indexer &#43; search head 架構、SPL 為核心查詢語言、ingestion-based 計費跟偵測覆蓋率的 trade-off">SC4S / Splunk Cloud</a> ingest pricing — RBA 不增 ingest GB / day、但 datamodel acceleration 算 CPU 工作量、Splunk Cloud 是另外計費的 vCPU；on-prem 自管 indexer 沒這個 cost。</p></blockquote>
<h2 id="整合--下一步">整合 / 下一步</h2>
<h3 id="跟-soar--case-management">跟 SOAR / case management</h3>
<p>Notable 觸發後接 SOAR：</p>
<ul>
<li><strong>enrichment</strong>：自動 query AD / asset DB / threat intel、把 user role / asset criticality / known IoC 補進 case</li>
<li><strong>decision tree</strong>：根據 risk score 區間決定 SOC tier（&lt; 100 tier 1 / 100-200 tier 2 / 200+ tier 3 + page）</li>
<li><strong>playbook automation</strong>：disable user / isolate endpoint / rotate credential 走 SOAR pipeline、不要 SOC analyst 手動 click</li>
</ul>
<h3 id="跟-elastic-security--sentinel-對照">跟 <a href="/blog/backend/07-security-data-protection/vendors/elastic-security/" data-link-title="Elastic Security" data-link-desc="Elastic Stack 上的 SIEM &#43; EDR &#43; Cloud Security 套件、OSS 起源、KQL/EQL/Lucene/ES|QL 多查詢語言、resource-based pricing">Elastic Security</a> / <a href="/blog/backend/07-security-data-protection/vendors/google-security-operations/" data-link-title="Google Security Operations" data-link-desc="Google 雲原生 SIEM &#43; SOAR &#43; Mandiant threat intel 三合一（前 Chronicle）、UDM &#43; YARA-L、fixed-price by data tier、PB-scale 友善">Sentinel</a> 對照</h3>
<p>各家對 RBA 的實作命名不同：Splunk 叫 RBA、Elastic 叫 Risk Engine、Microsoft Sentinel 叫 Fusion + UEBA aggregation、Sumo Logic 叫 Insight Trainer；底層概念相同（score aggregation + threshold notable）、細節差在 <em>modifier 寫法跟 ML 自動化程度</em>。跨平台遷移時 modifier 邏輯多半要重寫、threshold + decay tuning 經驗可以平移。</p>
<h3 id="跟-ueba">跟 UEBA</h3>
<p>RBA 跟 UEBA（user / entity behavior analytics）是 <em>互補不是替代</em> — UEBA 用 ML 算 baseline 偏差、輸出 anomaly score 餵進 RBA 當一個 modifier 來源。實作順序通常是 <em>先靜態 rule + RBA、再加 UEBA 補充</em>；直接從 ML-first 開始通常 tuning 成本爆炸。</p>
<h3 id="下一步議題">下一步議題</h3>
<ul>
<li><strong>Threat object correlation</strong>：跨 modifier 用 threat_object 串相同 attacker artifact、score 跨 user 跨 asset 聚合</li>
<li><strong>Kill chain coverage analysis</strong>：notable 拆成「ATT&amp;CK tactic 覆蓋 N/14」、覆蓋越廣 priority 越高</li>
<li><strong>Risk-based response automation</strong>：score 區間自動觸發不同 SOAR playbook、人工只 review tier 3</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li>上游 vendor 頁：<a href="/blog/backend/07-security-data-protection/vendors/splunk/" data-link-title="Splunk" data-link-desc="業界 SIEM 標準、forwarder &#43; indexer &#43; search head 架構、SPL 為核心查詢語言、ingestion-based 計費跟偵測覆蓋率的 trade-off">Splunk</a></li>
<li>對照案例：<a href="/blog/backend/07-security-data-protection/cases/okta-cross-tenant-impersonation-2023/" data-link-title="7.C6 Okta：Cross-tenant Impersonation 防禦回寫" data-link-desc="跨租戶 impersonation 風險如何轉成身份治理與偵測策略。">Okta Cross-Tenant Impersonation 2023</a>、<a href="/blog/backend/07-security-data-protection/cases/microsoft-storm-0558-signing-key-2023/" data-link-title="7.C4 Microsoft：Storm-0558 簽章金鑰事件" data-link-desc="簽章金鑰事件如何回寫 identity 信任邊界與觀測證據鏈。">Microsoft Storm-0558</a></li>
<li>上游 chapter：<a href="/blog/backend/07-security-data-protection/detection-coverage-and-signal-governance/" data-link-title="7.13 偵測覆蓋率與訊號治理" data-link-desc="定義偵測覆蓋、訊號品質與誤報成本的治理問題">7.13 偵測覆蓋率與訊號治理</a></li>
<li>平行 vendor：<a href="/blog/backend/07-security-data-protection/vendors/elastic-security/" data-link-title="Elastic Security" data-link-desc="Elastic Stack 上的 SIEM &#43; EDR &#43; Cloud Security 套件、OSS 起源、KQL/EQL/Lucene/ES|QL 多查詢語言、resource-based pricing">Elastic Security</a> / <a href="/blog/backend/07-security-data-protection/vendors/google-security-operations/" data-link-title="Google Security Operations" data-link-desc="Google 雲原生 SIEM &#43; SOAR &#43; Mandiant threat intel 三合一（前 Chronicle）、UDM &#43; YARA-L、fixed-price by data tier、PB-scale 友善">Google Security Operations</a></li>
<li>平行 deep article：<a href="/blog/backend/07-security-data-protection/vendors/hashicorp-vault/dynamic-credential/" data-link-title="HashiCorp Vault Dynamic Credential：lease 治理跟 application 整合的實作層" data-link-desc="Vault database secrets engine 怎麼配、application 怎麼 renew lease、production 五大踩雷（lease 過期 race、DB max_connections 撞牆、Vault sealed、token expire、scope 過寬）、容量規劃跟 vault-agent injector 整合">Vault Dynamic Credential</a></li>
<li>Methodology：<a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章的寫作方法論</a></li>
</ul>
]]></content:encoded></item><item><title>AWS ElastiCache 的責任邊界：managed 接手了什麼、又默默留下什麼</title><link>https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/aws-elasticache/managed-responsibility-boundary/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/aws-elasticache/managed-responsibility-boundary/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/aws-elasticache/" data-link-title="AWS ElastiCache" data-link-desc="AWS managed Redis / Valkey / Memcached">AWS ElastiCache&lt;/a> overview 的 implementation-layer deep article。選型層（為何用 managed、engine 選擇、跟自管取捨）見 overview；本文只處理「決定用 ElastiCache 後，哪些是 AWS 的責任、哪些仍是你的」。CLI 與計費以 &lt;a href="https://docs.aws.amazon.com/elasticache/">AWS ElastiCache 官方文件&lt;/a>、&lt;a href="https://aws.amazon.com/elasticache/pricing/">ElastiCache 定價&lt;/a> 為準、最後檢查日 2026-06-16（managed 服務的引數與價格會變、以官方為準）。&lt;/p>&lt;/blockquote>
&lt;h2 id="managed-不等於-hands-off">managed 不等於 hands-off&lt;/h2>
&lt;p>把 cache 換成 ElastiCache 之後，最危險的心態是「現在 AWS 全包了」。AWS 確實接走了一大塊運維——它幫你做 failover、patching、snapshot、跨 AZ 複製，你不用再自己部署 Sentinel、不用半夜起來手動切 master。但有一類問題 ElastiCache 一個都沒幫你解，而且因為「以為 AWS 會處理」，這些問題在 managed 環境反而更容易被忽略到上線才爆。&lt;/p>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/tinder-elasticache-valkey-matching/" data-link-title="9.C6 Tinder：ElastiCache for Valkey 撐 4700 萬月活的配對引擎" data-link-desc="Tinder 用 Amazon ElastiCache for Valkey 提供配對引擎所需的次毫秒延遲快取層">Tinder 的配對引擎&lt;/a>跑在 ElastiCache for Valkey 上、4700 萬月活、sub-millisecond 延遲——這證明 managed 撐得起極大規模，但 Tinder 仍要自己設計 key、處理 cache miss、控制 client 行為。ElastiCache for Redis 7.1 在 r7g.4xlarge 上單 node 可達約 100 萬 RPS、單 cluster 約 5 億 RPS（引自 &lt;a href="https://aws.amazon.com/blogs/database/achieve-over-500-million-requests-per-second-per-cluster-with-amazon-elasticache-for-redis-7-1/">AWS Database Blog&lt;/a>）——這個吞吐是 AWS 給的，但用不用得好取決於你的 key 分布與 client 設計。&lt;/p>
&lt;p>理解 ElastiCache 就是劃清這條責任邊界。本文按 shared responsibility 展開：AWS 管什麼、你管什麼、邊界上的踩坑在哪。&lt;/p>
&lt;h2 id="核心概念shared-responsibility-的兩側">核心概念：shared responsibility 的兩側&lt;/h2>
&lt;p>ElastiCache 的責任劃分可以列成一張清楚的表，這張表是判讀所有 ElastiCache 事故的起點：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>面向&lt;/th>
 &lt;th>AWS 的責任（managed）&lt;/th>
 &lt;th>你的責任（仍要自己做）&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>硬體 / OS / patching&lt;/td>
 &lt;td>全包&lt;/td>
 &lt;td>—&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>failover&lt;/td>
 &lt;td>自動偵測 + replica 晉升&lt;/td>
 &lt;td>client 要有 reconnect 邏輯&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>跨 AZ 複製&lt;/td>
 &lt;td>Multi-AZ 自動複製&lt;/td>
 &lt;td>接受非同步複製的 stale window&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>snapshot / backup&lt;/td>
 &lt;td>自動 + 手動 snapshot&lt;/td>
 &lt;td>決定保留策略、驗證能還原&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>eviction&lt;/td>
 &lt;td>提供 maxmemory-policy 參數&lt;/td>
 &lt;td>選對 policy、設對 TTL&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>cache stampede&lt;/td>
 &lt;td>不管&lt;/td>
 &lt;td>client-side jitter / singleflight 自己做&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>key 設計 / hot key&lt;/td>
 &lt;td>不管&lt;/td>
 &lt;td>key 分布、hot key 兩層 cache 自己處理&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>連線管理&lt;/td>
 &lt;td>提供 endpoint&lt;/td>
 &lt;td>連線池、socket timeout 自己設&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>左欄是用 managed 換到的，右欄是用 managed 換不掉的。&lt;a href="https://tarrragon.github.io/blog/backend/02-cache-redis/cases/failure-cache-stampede-rollout-regression/" data-link-title="2.C9 反例：快取切換引發 Stampede 回歸" data-link-desc="快取策略切換若缺乏保護，會導致回源壓力與錯誤率連鎖上升。">2.C9 cache stampede&lt;/a> 的雪崩、&lt;a href="https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/redis/connection-pipeline-latency/" data-link-title="Redis 連線與 pipeline：RTT 稅、連線池與一次往返打包多命令" data-link-desc="Redis 單命令通常微秒級執行，但 application 端量到的延遲是毫秒級——差距全在網路往返（RTT）。pipelining 的本質不是『批次發命令』，是把 N 次 RTT 壓成 1 次。本文展開 RTT 會計、連線池配置、pipeline 與 MULTI 的差異、5 個把連線與往返寫成延遲與正確性問題的 production 踩坑，以及連線模型撞牆的邊界">連線風暴&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/redis/memory-eviction-tuning/" data-link-title="Redis 記憶體與淘汰調校：maxmemory-policy、LFU 與碎片化的實戰判讀" data-link-desc="Redis 的記憶體是一條會在半夜爆掉的曲線：maxmemory 設多少、policy 選 LRU 還 LFU、碎片化什麼時候開始吃掉 30% RAM、OOM 時 noeviction 怎麼讓寫入全部失敗。本文展開 Redis 記憶體會計模型、eviction policy 的選型判讀、5 個把記憶體配置寫成 production 事故的踩坑，以及單機記憶體撞牆後該往 cluster 還是 DragonflyDB 走的邊界">eviction 選錯&lt;/a> 在 ElastiCache 上跟自管 Redis 一模一樣會發生——因為這些是 cache 使用方式的問題，不是運維的問題。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/02-cache-redis/vendors/aws-elasticache/" data-link-title="AWS ElastiCache" data-link-desc="AWS managed Redis / Valkey / Memcached">AWS ElastiCache</a> overview 的 implementation-layer deep article。選型層（為何用 managed、engine 選擇、跟自管取捨）見 overview；本文只處理「決定用 ElastiCache 後，哪些是 AWS 的責任、哪些仍是你的」。CLI 與計費以 <a href="https://docs.aws.amazon.com/elasticache/">AWS ElastiCache 官方文件</a>、<a href="https://aws.amazon.com/elasticache/pricing/">ElastiCache 定價</a> 為準、最後檢查日 2026-06-16（managed 服務的引數與價格會變、以官方為準）。</p></blockquote>
<h2 id="managed-不等於-hands-off">managed 不等於 hands-off</h2>
<p>把 cache 換成 ElastiCache 之後，最危險的心態是「現在 AWS 全包了」。AWS 確實接走了一大塊運維——它幫你做 failover、patching、snapshot、跨 AZ 複製，你不用再自己部署 Sentinel、不用半夜起來手動切 master。但有一類問題 ElastiCache 一個都沒幫你解，而且因為「以為 AWS 會處理」，這些問題在 managed 環境反而更容易被忽略到上線才爆。</p>
<p><a href="/blog/backend/09-performance-capacity/cases/tinder-elasticache-valkey-matching/" data-link-title="9.C6 Tinder：ElastiCache for Valkey 撐 4700 萬月活的配對引擎" data-link-desc="Tinder 用 Amazon ElastiCache for Valkey 提供配對引擎所需的次毫秒延遲快取層">Tinder 的配對引擎</a>跑在 ElastiCache for Valkey 上、4700 萬月活、sub-millisecond 延遲——這證明 managed 撐得起極大規模，但 Tinder 仍要自己設計 key、處理 cache miss、控制 client 行為。ElastiCache for Redis 7.1 在 r7g.4xlarge 上單 node 可達約 100 萬 RPS、單 cluster 約 5 億 RPS（引自 <a href="https://aws.amazon.com/blogs/database/achieve-over-500-million-requests-per-second-per-cluster-with-amazon-elasticache-for-redis-7-1/">AWS Database Blog</a>）——這個吞吐是 AWS 給的，但用不用得好取決於你的 key 分布與 client 設計。</p>
<p>理解 ElastiCache 就是劃清這條責任邊界。本文按 shared responsibility 展開：AWS 管什麼、你管什麼、邊界上的踩坑在哪。</p>
<h2 id="核心概念shared-responsibility-的兩側">核心概念：shared responsibility 的兩側</h2>
<p>ElastiCache 的責任劃分可以列成一張清楚的表，這張表是判讀所有 ElastiCache 事故的起點：</p>
<table>
  <thead>
      <tr>
          <th>面向</th>
          <th>AWS 的責任（managed）</th>
          <th>你的責任（仍要自己做）</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>硬體 / OS / patching</td>
          <td>全包</td>
          <td>—</td>
      </tr>
      <tr>
          <td>failover</td>
          <td>自動偵測 + replica 晉升</td>
          <td>client 要有 reconnect 邏輯</td>
      </tr>
      <tr>
          <td>跨 AZ 複製</td>
          <td>Multi-AZ 自動複製</td>
          <td>接受非同步複製的 stale window</td>
      </tr>
      <tr>
          <td>snapshot / backup</td>
          <td>自動 + 手動 snapshot</td>
          <td>決定保留策略、驗證能還原</td>
      </tr>
      <tr>
          <td>eviction</td>
          <td>提供 maxmemory-policy 參數</td>
          <td>選對 policy、設對 TTL</td>
      </tr>
      <tr>
          <td>cache stampede</td>
          <td>不管</td>
          <td>client-side jitter / singleflight 自己做</td>
      </tr>
      <tr>
          <td>key 設計 / hot key</td>
          <td>不管</td>
          <td>key 分布、hot key 兩層 cache 自己處理</td>
      </tr>
      <tr>
          <td>連線管理</td>
          <td>提供 endpoint</td>
          <td>連線池、socket timeout 自己設</td>
      </tr>
  </tbody>
</table>
<p>左欄是用 managed 換到的，右欄是用 managed 換不掉的。<a href="/blog/backend/02-cache-redis/cases/failure-cache-stampede-rollout-regression/" data-link-title="2.C9 反例：快取切換引發 Stampede 回歸" data-link-desc="快取策略切換若缺乏保護，會導致回源壓力與錯誤率連鎖上升。">2.C9 cache stampede</a> 的雪崩、<a href="/blog/backend/02-cache-redis/vendors/redis/connection-pipeline-latency/" data-link-title="Redis 連線與 pipeline：RTT 稅、連線池與一次往返打包多命令" data-link-desc="Redis 單命令通常微秒級執行，但 application 端量到的延遲是毫秒級——差距全在網路往返（RTT）。pipelining 的本質不是『批次發命令』，是把 N 次 RTT 壓成 1 次。本文展開 RTT 會計、連線池配置、pipeline 與 MULTI 的差異、5 個把連線與往返寫成延遲與正確性問題的 production 踩坑，以及連線模型撞牆的邊界">連線風暴</a>、<a href="/blog/backend/02-cache-redis/vendors/redis/memory-eviction-tuning/" data-link-title="Redis 記憶體與淘汰調校：maxmemory-policy、LFU 與碎片化的實戰判讀" data-link-desc="Redis 的記憶體是一條會在半夜爆掉的曲線：maxmemory 設多少、policy 選 LRU 還 LFU、碎片化什麼時候開始吃掉 30% RAM、OOM 時 noeviction 怎麼讓寫入全部失敗。本文展開 Redis 記憶體會計模型、eviction policy 的選型判讀、5 個把記憶體配置寫成 production 事故的踩坑，以及單機記憶體撞牆後該往 cluster 還是 DragonflyDB 走的邊界">eviction 選錯</a> 在 ElastiCache 上跟自管 Redis 一模一樣會發生——因為這些是 cache 使用方式的問題，不是運維的問題。</p>
<h3 id="engine-選擇與-cluster-mode">engine 選擇與 cluster mode</h3>
<p>ElastiCache 的兩個結構性決策：</p>
<p><strong>engine</strong>：2024 起 default 是 Valkey（成本約低 20%、OSI 開源、Redis 7.2.4 fork、API 相容）；Redis OSS 仍可選但 AWS 不推；Memcached 是另一條線（純 KV、無 cluster mode 概念）。新部署或既有 Redis 遷移都走 Valkey（相容、便宜），純 cache 才考慮 Memcached。</p>
<p><strong>cluster mode</strong>：disabled 是 1 primary + 最多 5 replica、單 shard、上限約 340GB；enabled 是多 shard（最多 500）、自動 sharding、橫向擴展。判讀：dataset &lt; 300GB 且不需 sharding 用 disabled（簡單），&gt; 300GB 或要橫向擴展用 enabled（但 client 要 cluster-aware）。</p>
<h2 id="配置建立與治理的設定路徑">配置：建立與治理的設定路徑</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 建立 Valkey replication group（Multi-AZ、auto failover、cluster mode disabled）</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">aws elasticache create-replication-group <span class="se">\
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="se"></span>  --replication-group-id prod-cache <span class="se">\
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="se"></span>  --replication-group-description <span class="s2">&#34;prod cache&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="se"></span>  --engine valkey <span class="se">\
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="se"></span>  --cache-node-type cache.r7g.large <span class="se">\
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="se"></span>  --num-cache-clusters <span class="m">3</span> <span class="se">\ </span>          <span class="c1"># 1 primary + 2 replica</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  --automatic-failover-enabled <span class="se">\
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="se"></span>  --multi-az-enabled <span class="se">\
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="se"></span>  --snapshot-retention-limit <span class="m">7</span> <span class="se">\ </span>    <span class="c1"># 自動 snapshot 保留 7 天</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  --at-rest-encryption-enabled <span class="se">\
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="se"></span>  --transit-encryption-enabled
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1"># 自訂 parameter group（maxmemory-policy 等仍是你的責任）</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">aws elasticache create-cache-parameter-group <span class="se">\
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="se"></span>  --cache-parameter-group-name prod-params <span class="se">\
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="se"></span>  --cache-parameter-group-family valkey8 <span class="se">\
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="se"></span>  --description <span class="s2">&#34;prod cache params&#34;</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">aws elasticache modify-cache-parameter-group <span class="se">\
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="se"></span>  --cache-parameter-group-name prod-params <span class="se">\
</span></span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="se"></span>  --parameter-name-values <span class="s2">&#34;ParameterName=maxmemory-policy,ParameterValue=allkeys-lru&#34;</span></span></span></code></pre></div><p>配置判讀：</p>
<ul>
<li><code>--automatic-failover-enabled</code> + <code>--multi-az-enabled</code> 是 HA 的核心，把 <a href="/blog/backend/02-cache-redis/vendors/redis/sentinel-ha-failover/" data-link-title="Redis Sentinel 與 failover 時序：從 master 死掉到 client 重連的每一段" data-link-desc="Redis Sentinel 的 failover 不是一個瞬間動作，是 down 偵測 → quorum 確認 → 選主 → 提升 → 配置廣播 → client 重連的一條時序鏈，每一段都有自己的延遲與失敗模式。本文展開 Sentinel 的判定模型與這條時序、5 個讓 failover 卡住或丟資料的 production 踩坑，以及 Sentinel 撐不住該往 Cluster 或 managed 走的邊界">Sentinel 那條 failover 時序鏈</a>託管掉</li>
<li><code>maxmemory-policy</code> 透過 parameter group 設定——AWS 給旋鈕、選哪個是你的責任（見 <a href="/blog/backend/02-cache-redis/vendors/redis/memory-eviction-tuning/" data-link-title="Redis 記憶體與淘汰調校：maxmemory-policy、LFU 與碎片化的實戰判讀" data-link-desc="Redis 的記憶體是一條會在半夜爆掉的曲線：maxmemory 設多少、policy 選 LRU 還 LFU、碎片化什麼時候開始吃掉 30% RAM、OOM 時 noeviction 怎麼讓寫入全部失敗。本文展開 Redis 記憶體會計模型、eviction policy 的選型判讀、5 個把記憶體配置寫成 production 事故的踩坑，以及單機記憶體撞牆後該往 cluster 還是 DragonflyDB 走的邊界">eviction 調校</a>）</li>
<li><code>--transit-encryption-enabled</code> 加 TLS，但 TLS 增加 client 建連成本，連線池更重要</li>
<li>IAM authentication（Redis 7+）取代 AUTH password，對應 <a href="/blog/backend/07-security-data-protection/" data-link-title="模組七：資安與資料保護" data-link-desc="以問題驅動方式擴充資安知識網：先定義服務環節問題，再以案例作為觸發式參考">security 模組</a></li>
</ul>
<h2 id="production-故障演練">Production 故障演練</h2>
<h3 id="case-1failover-期間-client-持續-error">Case 1：failover 期間 client 持續 error</h3>
<p><strong>徵兆</strong>：ElastiCache 觸發 failover（看 <code>describe-events</code>），AWS 端 replica 晉升完成，但 application 持續 30 秒到幾分鐘大量連線 error。</p>
<p><strong>根因</strong>：failover 時 primary endpoint 的 DNS 切到新 primary，但 client 的連線池還握著舊 primary 的連線、DNS 也可能有快取。AWS 完成了 failover，但 client 重連是你的責任——ElastiCache 不會幫你的 application 重連。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>client 用支援自動重連的 library，設合理的 socket timeout 與 retry（見 <a href="/blog/backend/02-cache-redis/vendors/redis/connection-pipeline-latency/" data-link-title="Redis 連線與 pipeline：RTT 稅、連線池與一次往返打包多命令" data-link-desc="Redis 單命令通常微秒級執行，但 application 端量到的延遲是毫秒級——差距全在網路往返（RTT）。pipelining 的本質不是『批次發命令』，是把 N 次 RTT 壓成 1 次。本文展開 RTT 會計、連線池配置、pipeline 與 MULTI 的差異、5 個把連線與往返寫成延遲與正確性問題的 production 踩坑，以及連線模型撞牆的邊界">連線調校</a>）</li>
<li>連到 primary endpoint（會跟著 failover 更新 DNS），不要連到特定 node 的 endpoint</li>
<li>縮短 client 的 DNS 快取 TTL，讓 failover 後的 DNS 切換更快被看到</li>
<li>failover 期間的寫入中斷無法完全避免（非同步複製 + 重連時間），latency-sensitive 服務要設計降級</li>
</ol>
<h3 id="case-2跨-az-replication-lag-造成-stale-read">Case 2：跨 AZ replication lag 造成 stale read</h3>
<p><strong>徵兆</strong>：寫入 primary 後立刻從 replica 讀，偶爾讀到舊值；CloudWatch 的 <code>ReplicationLag</code> 在高寫入時段上升。</p>
<p><strong>根因</strong>：ElastiCache 的跨 AZ 複製是非同步的，replica 有 lag。AWS 保證複製會發生，但不保證即時——read-from-replica 在寫後立即讀的場景會看到 stale window。這跟自管 Redis 的 replica 行為一致，managed 沒有消除它。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>寫後需要立即一致讀的路徑，強制 read from primary</li>
<li>監控 CloudWatch <code>ReplicationLag</code>，持續高代表寫入超過複製能力，要 scale up node 或降寫入</li>
<li>接受 cache 的最終一致性——這是 cache copy 的本質，不是 bug（見 <a href="/blog/backend/02-cache-redis/cache-copy-freshness-boundary/" data-link-title="2.7 Cache Copy Boundary 與 Freshness" data-link-desc="說明快取何時只是可重建副本，何時會影響交易、權限或配額正確性。">cache copy boundary</a>）</li>
<li>需要強一致 + durability 走 MemoryDB（見本文 Capacity / cost 邊界段）</li>
</ol>
<h3 id="case-3serverless-計費超出預期">Case 3：Serverless 計費超出預期</h3>
<p><strong>徵兆</strong>：用了 ElastiCache Serverless 想省容量規劃，月底帳單遠超預期。</p>
<p><strong>根因</strong>：Serverless 按 ECPU（運算）+ storage 計費，流量尖峰或低效的 access pattern（大量小命令、大 value）會推高 ECPU 消耗。Serverless 解的是「不想規劃容量」，不是「一定更便宜」——可預測的穩態流量用 node-based + Reserved Instance 通常更省。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>流量可預測、穩態高的 workload 用 node-based + Reserved Instance（1/3 年承諾、折扣約 30-60%）</li>
<li>流量不可預測、有大量閒置時段的才適合 Serverless</li>
<li>監控 ECPU 消耗，找出推高成本的 access pattern（用 pipeline 合併小命令降 ECPU）</li>
<li>成本模型對比要算實際 workload，不要假設 Serverless 一定划算</li>
</ol>
<h3 id="case-4cluster-mode-enabled-但-client-不是-cluster-aware">Case 4：cluster mode enabled 但 client 不是 cluster-aware</h3>
<p><strong>徵兆</strong>：建了 cluster mode enabled 的 cluster，application 連線報 <code>MOVED</code> redirect 或連不上某些 key。</p>
<p><strong>根因</strong>：cluster mode enabled 把 keyspace 分到多 shard，client 必須 cluster-aware（懂 <code>CLUSTER SLOTS</code>、處理 <code>MOVED</code>/<code>ASK</code> redirect）才能正確路由。普通 standalone client 連 cluster mode enabled 會失敗。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>cluster mode enabled 一律用 cluster-aware client（連 configuration endpoint 不是單一 node）</li>
<li>確認 application 的多 key 操作用 hash tag 把相關 key co-locate 同 slot（見 <a href="/blog/backend/02-cache-redis/vendors/redis/cluster-resharding/" data-link-title="Redis Cluster Re-sharding：source = target，但 topology 重劃的 5 段流程" data-link-desc="Redis cluster re-sharding 是 5 type migration 漏類實證 — source / target 同 cluster、無 schema / paradigm 差、但 16384 slot 重分配是核心；本文涵蓋 4 種 re-sharding driver、slot migration 機制、redis-cli --cluster rebalance / reshard 工具、5 個 production 踩雷（cluster busy / replica lag / client cache stale / cross-slot transaction / monitor gap）">cluster re-sharding</a>）</li>
<li>dataset &lt; 300GB 且不需 sharding，用 cluster mode disabled 省掉這層複雜度</li>
<li>從 disabled 升 enabled 是有成本的架構變更，初期規劃就要決定</li>
</ol>
<h3 id="case-5snapshot-期間記憶體尖峰node-不穩">Case 5：snapshot 期間記憶體尖峰、node 不穩</h3>
<p><strong>徵兆</strong>：自動 snapshot 時段 node 延遲上升、<code>DatabaseMemoryUsagePercentage</code> 衝高，偶爾 snapshot 失敗。</p>
<p><strong>根因</strong>：Redis engine 的 snapshot 靠 fork（見 <a href="/blog/backend/02-cache-redis/vendors/redis/persistence-fork-latency/" data-link-title="Redis 持久化與 fork latency：AOF、RDB 與那一次卡住整個 cluster 的 fork" data-link-desc="Redis 的 RDB save 與 AOF rewrite 都靠一次 fork()，而 fork 在大記憶體實例上會凍結主執行緒數百毫秒、複製分頁讓記憶體逼近翻倍。本文展開 AOF / RDB 的機制與 fsync 取捨、copy-on-write 的記憶體放大、5 個把持久化寫成延遲尖峰與資料遺失的 production 踩坑，以及 cache 場景到底要不要持久化的邊界">persistence / fork latency</a>），fork 期間 copy-on-write 推高記憶體。如果 node 記憶體已吃緊，snapshot 的 fork 把它推爆。AWS 託管了 snapshot 排程，但 fork 的記憶體成本仍在 engine 層存在。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>node 記憶體留 headroom（不要長期 &gt; 80%），給 snapshot 的 fork copy-on-write 空間</li>
<li>snapshot window 設在低流量時段，減少 fork 期間被改的 page</li>
<li>監控 CloudWatch <code>DatabaseMemoryUsagePercentage</code>，&gt; 80% 考慮 scale up node type</li>
<li>Valkey engine 繼承 Redis 的 fork 模型，這個成本換 engine 到 Valkey 也還在（fork-less 要 DragonflyDB、但 ElastiCache 不提供）</li>
</ol>
<h2 id="capacity--cost-邊界">Capacity / cost 邊界</h2>
<p>ElastiCache 的容量判讀，混合了 AWS 的 metric 與 engine 層的行為：</p>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>健康區間</th>
          <th>警戒與動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>DatabaseMemoryUsagePercentage</code></td>
          <td>&lt; 80%</td>
          <td>&gt; 80% → scale up node 或調 maxmemory-policy</td>
      </tr>
      <tr>
          <td><code>ReplicationLag</code></td>
          <td>&lt; 1 秒</td>
          <td>持續高 → 寫入超過複製能力</td>
      </tr>
      <tr>
          <td><code>CurrConnections</code></td>
          <td>遠低於 node 上限</td>
          <td>接近上限 → client 連線池問題</td>
      </tr>
      <tr>
          <td><code>CacheHitRate</code></td>
          <td>&gt; 90%（多數 cache）</td>
          <td>下滑 → TTL / eviction / key 設計問題</td>
      </tr>
      <tr>
          <td>Serverless ECPU</td>
          <td>對齊預算</td>
          <td>暴衝 → access pattern 低效、用 pipeline 合併</td>
      </tr>
  </tbody>
</table>
<p>撞牆後的路由判斷：</p>
<ul>
<li><strong>需要 source-of-truth 的 Redis API（不是 cache）</strong>：ElastiCache 是 cache 語意（資料可重建）。需要 durability 走 <strong>AWS MemoryDB</strong>——Redis-compatible 但有 multi-AZ transaction log、提供 source-of-truth 語意，成本約 ElastiCache 的 2-3 倍。判讀：<a href="/blog/backend/09-performance-capacity/cases/tubi-elasticache-ml-feature-store/" data-link-title="9.C25 Tubi：從 ScyllaDB 遷到 ElastiCache、ML feature store 達 sub-10ms p99" data-link-desc="Tubi 把 ML 推薦的 feature store 從 ScyllaDB 遷到 ElastiCache for Redis、99 百分位延遲降到 10ms 以下">Tubi 把 feature store 從 ScyllaDB 遷到 ElastiCache</a> 的前提是「feature 可重新計算」——可重建選 ElastiCache，不可重建選 MemoryDB 或 database。</li>
<li><strong>跨雲 / 不在 AWS 生態</strong>：ElastiCache 綁 AWS，跨雲走自管 <a href="/blog/backend/02-cache-redis/vendors/redis/" data-link-title="Redis" data-link-desc="OSS in-memory data structure store、cache 主流">Redis / Valkey</a> 或 GCP Memorystore / Azure Cache。</li>
<li><strong>極端單機 throughput</strong>：要榨單機多核走自管 <a href="/blog/backend/02-cache-redis/vendors/dragonflydb/" data-link-title="DragonflyDB" data-link-desc="高效能 Redis / Memcached 相容替代、多核架構">DragonflyDB</a>（ElastiCache 不提供 Dragonfly engine）。</li>
<li><strong>跨 region active-passive DR</strong>：ElastiCache 的 Global Datastore（1 primary region + 多 secondary read replica、跨 region lag &lt; 1 秒），不支援 active-active multi-master。</li>
</ul>
<h2 id="整合--下一步">整合 / 下一步</h2>
<p>ElastiCache 的 deep article 本質是「劃清 managed 邊界」，它跟 engine 層的調校知識緊密相連：</p>
<ul>
<li><strong>跟 <a href="/blog/backend/02-cache-redis/vendors/redis/" data-link-title="Redis" data-link-desc="OSS in-memory data structure store、cache 主流">Redis 全系列 deep article</a></strong>：eviction、persistence/fork、連線的調校在 ElastiCache 上仍適用（engine 是 Redis/Valkey），AWS 託管的是 failover/patching/snapshot 排程，不是這些 engine 行為。</li>
<li><strong>跟 <a href="/blog/backend/02-cache-redis/vendors/valkey/redis-compatibility-and-io-threads/" data-link-title="Valkey 相容性驗證與 io-threads 調校：drop-in 切換與多執行緒的實機判讀" data-link-desc="Valkey 跟 Redis 100% 相容這句話要怎麼驗證、切換才敢上線。本文用 INFO server 的雙版本回報拆解相容性的真實邊界、展開 Valkey 8 的 io-threads 多執行緒調校、5 個把 drop-in 切換或執行緒配置寫成事故的 production 踩坑，以及相容性撞牆該怎麼判斷的邊界">Valkey 相容性</a></strong>：ElastiCache 的 default engine 就是 Valkey，相容性與 io-threads 的判讀直接適用。</li>
<li><strong>跟 <a href="/blog/backend/02-cache-redis/cases/netflix-evcache-global-cache-layer/" data-link-title="2.C6 Netflix：EVCache 全域快取層" data-link-desc="快取從本地層演進為跨區分散式能力的案例。">Netflix EVCache</a></strong>：EVCache 是 Netflix 自管的 Memcached-based 全域 cache，對照 ElastiCache for Memcached + Global Datastore——展示了自管跨區 vs managed 跨區的取捨。</li>
<li><strong>跟 <a href="/blog/backend/09-performance-capacity/cases/tinder-elasticache-valkey-matching/" data-link-title="9.C6 Tinder：ElastiCache for Valkey 撐 4700 萬月活的配對引擎" data-link-desc="Tinder 用 Amazon ElastiCache for Valkey 提供配對引擎所需的次毫秒延遲快取層">Tinder</a> / <a href="/blog/backend/09-performance-capacity/cases/tubi-elasticache-ml-feature-store/" data-link-title="9.C25 Tubi：從 ScyllaDB 遷到 ElastiCache、ML feature store 達 sub-10ms p99" data-link-desc="Tubi 把 ML 推薦的 feature store 從 ScyllaDB 遷到 ElastiCache for Redis、99 百分位延遲降到 10ms 以下">Tubi</a></strong>：兩個 ElastiCache 規模化案例，一個是 sub-ms 配對引擎、一個是 ML feature store p99&lt;10ms，都展示了「AWS 給吞吐、你給設計」的邊界。</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li>上游 vendor 頁：<a href="/blog/backend/02-cache-redis/vendors/aws-elasticache/" data-link-title="AWS ElastiCache" data-link-desc="AWS managed Redis / Valkey / Memcached">AWS ElastiCache</a></li>
<li>engine 層 deep article：<a href="/blog/backend/02-cache-redis/vendors/redis/memory-eviction-tuning/" data-link-title="Redis 記憶體與淘汰調校：maxmemory-policy、LFU 與碎片化的實戰判讀" data-link-desc="Redis 的記憶體是一條會在半夜爆掉的曲線：maxmemory 設多少、policy 選 LRU 還 LFU、碎片化什麼時候開始吃掉 30% RAM、OOM 時 noeviction 怎麼讓寫入全部失敗。本文展開 Redis 記憶體會計模型、eviction policy 的選型判讀、5 個把記憶體配置寫成 production 事故的踩坑，以及單機記憶體撞牆後該往 cluster 還是 DragonflyDB 走的邊界">Redis 記憶體與淘汰</a>、<a href="/blog/backend/02-cache-redis/vendors/redis/persistence-fork-latency/" data-link-title="Redis 持久化與 fork latency：AOF、RDB 與那一次卡住整個 cluster 的 fork" data-link-desc="Redis 的 RDB save 與 AOF rewrite 都靠一次 fork()，而 fork 在大記憶體實例上會凍結主執行緒數百毫秒、複製分頁讓記憶體逼近翻倍。本文展開 AOF / RDB 的機制與 fsync 取捨、copy-on-write 的記憶體放大、5 個把持久化寫成延遲尖峰與資料遺失的 production 踩坑，以及 cache 場景到底要不要持久化的邊界">persistence 與 fork latency</a>、<a href="/blog/backend/02-cache-redis/vendors/redis/sentinel-ha-failover/" data-link-title="Redis Sentinel 與 failover 時序：從 master 死掉到 client 重連的每一段" data-link-desc="Redis Sentinel 的 failover 不是一個瞬間動作，是 down 偵測 → quorum 確認 → 選主 → 提升 → 配置廣播 → client 重連的一條時序鏈，每一段都有自己的延遲與失敗模式。本文展開 Sentinel 的判定模型與這條時序、5 個讓 failover 卡住或丟資料的 production 踩坑，以及 Sentinel 撐不住該往 Cluster 或 managed 走的邊界">Sentinel 與 failover 時序</a>、<a href="/blog/backend/02-cache-redis/vendors/valkey/redis-compatibility-and-io-threads/" data-link-title="Valkey 相容性驗證與 io-threads 調校：drop-in 切換與多執行緒的實機判讀" data-link-desc="Valkey 跟 Redis 100% 相容這句話要怎麼驗證、切換才敢上線。本文用 INFO server 的雙版本回報拆解相容性的真實邊界、展開 Valkey 8 的 io-threads 多執行緒調校、5 個把 drop-in 切換或執行緒配置寫成事故的 production 踩坑，以及相容性撞牆該怎麼判斷的邊界">Valkey 相容性</a></li>
<li>上游能力：<a href="/blog/backend/00-service-selection/cost-risk-tradeoffs/" data-link-title="0.6 成本、風險與選型取捨" data-link-desc="用人力成本、雲端成本、操作成本與失敗代價判斷後端能力投入順序">0.6 成本取捨</a>、<a href="/blog/backend/02-cache-redis/cache-copy-freshness-boundary/" data-link-title="2.7 Cache Copy Boundary 與 Freshness" data-link-desc="說明快取何時只是可重建副本，何時會影響交易、權限或配額正確性。">cache copy boundary</a></li>
<li>Methodology：<a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章寫作方法論</a></li>
</ul>
]]></content:encoded></item><item><title>Caffeine + Redis 兩層 cache：搭起來很容易，跨實例失效才是全部的問題</title><link>https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/caffeine/two-tier-cache-invalidation/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/caffeine/two-tier-cache-invalidation/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/caffeine/" data-link-title="Caffeine" data-link-desc="JVM process-local cache、Window TinyLFU、Guava Cache 後繼">Caffeine&lt;/a> overview 的 implementation-layer deep article。選型層（Caffeine vs Redis、process-local 的定位）見 overview；本文只處理「決定用 L1 Caffeine + L2 Redis 後，跨實例一致性怎麼處理」。API 以 &lt;a href="https://github.com/ben-manes/caffeine/wiki">Caffeine wiki&lt;/a> 為準、最後檢查日 2026-06-16。&lt;/p>&lt;/blockquote>
&lt;h2 id="兩層-cache-搭起來容易難的在後面">兩層 cache 搭起來容易，難的在後面&lt;/h2>
&lt;p>L1 Caffeine + L2 Redis 的兩層 cache，讀寫路徑三十行 Java 就寫完：讀的時候先查 L1（process-local、奈秒級），miss 再查 L2（Redis、毫秒級），再 miss 才回源。它擋掉了大部分 Redis 的網路往返，對「每個請求重複讀同一份小資料」的場景效果立竿見影。&lt;/p>
&lt;p>真正的難度不在搭兩層，在「每個 JVM 實例有自己的 L1 副本」這個事實。假設有 10 個 application 實例，就有 10 份獨立的 Caffeine cache。實例 A 更新了某個 user 的資料、寫進 L2 Redis，但實例 B、C、D&amp;hellip; 的 L1 還握著舊值——它們不知道資料變了。下一個打到實例 B 的請求，L1 命中，回的是舊值。Redis 是對的，但讀不到 Redis，因為 L1 先攔截了。&lt;/p>
&lt;p>這就是兩層 cache 的核心問題：L1 的速度來自「不跟任何人協調」，而一致性恰恰需要協調。本文聚焦這個矛盾——兩層讀寫路徑只是背景，跨實例 invalidation 才是全部的工程量。&lt;/p>
&lt;h2 id="核心概念l1-的-stale-從哪裡來">核心概念：L1 的 stale 從哪裡來&lt;/h2>
&lt;p>兩層 cache 的一致性問題，根源是 L1 的三個特性：&lt;/p>
&lt;p>&lt;strong>L1 是 per-instance 的私有副本&lt;/strong>。Caffeine 活在 JVM heap 內，每個實例一份。這是它快的原因（無網路、無序列化），也是它難一致的原因（無法被其他實例直接更新或清除）。L2 Redis 是共享的，所以 L2 一致相對容易；L1 才是 stale 的來源。&lt;/p>
&lt;p>&lt;strong>寫入只更新本地 L1 + 共享 L2&lt;/strong>。實例 A 處理一個更新：寫 L2 Redis（所有實例可見）+ 更新或清除自己的 L1。但 A 沒有辦法直接碰 B 的 L1——B 的 L1 還是舊的，直到它自己過期或被通知。&lt;/p>
&lt;p>&lt;strong>沒有通知機制，L1 只能靠 TTL 自然過期&lt;/strong>。如果不做任何跨實例協調，L1 的 stale window 就等於 L1 的 TTL。把 L1 TTL 設短（幾秒到幾十秒）是最簡單的「容忍 stale」策略——犧牲一點新鮮度換掉協調的複雜度。需要更快失效就得主動廣播。&lt;/p>
&lt;p>跨實例失效的標準解法是用 L2 Redis 的 pub/sub 當廣播通道：任一實例更新資料時，往一個 channel 發一條「key X 失效了」的訊息，所有實例訂閱這個 channel、收到就清掉自己 L1 對應的 entry。這把「各自為政的 L1」連成一個能協同失效的網。&lt;/p>
&lt;h2 id="配置兩層讀寫--pubsub-失效的程式碼">配置：兩層讀寫 + pub/sub 失效的程式碼&lt;/h2>
&lt;p>兩層讀取路徑（L1 → L2 → origin）：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-java" data-lang="java">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">public&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">User&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nf">getUser&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">String&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="p">)&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="c1">// L1：Caffeine、奈秒級、命中就回&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">User&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">u&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">l1&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">getIfPresent&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">id&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">if&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">u&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">!=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">null&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">return&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">u&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>&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="c1">// L1 miss → L2 Redis、毫秒級&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">String&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">json&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">redis&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;user:&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">+&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">id&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="k">if&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">json&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">!=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">null&lt;/span>&lt;span class="p">)&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"> 9&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">u&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">deserialize&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">json&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">l1&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">put&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">u&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">// 回填 L1&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="k">return&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">u&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="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>&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="c1">// L2 miss → 回源 + 雙層回填&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="n">u&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">userRepository&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">findById&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">id&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">redis&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">setex&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;user:&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">+&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">300&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">serialize&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">u&lt;/span>&lt;span class="p">));&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">// L2 TTL 5 分鐘&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">l1&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">put&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">u&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">// L1&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="k">return&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">u&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>跨實例失效（寫入時往 Redis pub/sub 廣播、所有實例清 L1）：&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/02-cache-redis/vendors/caffeine/" data-link-title="Caffeine" data-link-desc="JVM process-local cache、Window TinyLFU、Guava Cache 後繼">Caffeine</a> overview 的 implementation-layer deep article。選型層（Caffeine vs Redis、process-local 的定位）見 overview；本文只處理「決定用 L1 Caffeine + L2 Redis 後，跨實例一致性怎麼處理」。API 以 <a href="https://github.com/ben-manes/caffeine/wiki">Caffeine wiki</a> 為準、最後檢查日 2026-06-16。</p></blockquote>
<h2 id="兩層-cache-搭起來容易難的在後面">兩層 cache 搭起來容易，難的在後面</h2>
<p>L1 Caffeine + L2 Redis 的兩層 cache，讀寫路徑三十行 Java 就寫完：讀的時候先查 L1（process-local、奈秒級），miss 再查 L2（Redis、毫秒級），再 miss 才回源。它擋掉了大部分 Redis 的網路往返，對「每個請求重複讀同一份小資料」的場景效果立竿見影。</p>
<p>真正的難度不在搭兩層，在「每個 JVM 實例有自己的 L1 副本」這個事實。假設有 10 個 application 實例，就有 10 份獨立的 Caffeine cache。實例 A 更新了某個 user 的資料、寫進 L2 Redis，但實例 B、C、D&hellip; 的 L1 還握著舊值——它們不知道資料變了。下一個打到實例 B 的請求，L1 命中，回的是舊值。Redis 是對的，但讀不到 Redis，因為 L1 先攔截了。</p>
<p>這就是兩層 cache 的核心問題：L1 的速度來自「不跟任何人協調」，而一致性恰恰需要協調。本文聚焦這個矛盾——兩層讀寫路徑只是背景，跨實例 invalidation 才是全部的工程量。</p>
<h2 id="核心概念l1-的-stale-從哪裡來">核心概念：L1 的 stale 從哪裡來</h2>
<p>兩層 cache 的一致性問題，根源是 L1 的三個特性：</p>
<p><strong>L1 是 per-instance 的私有副本</strong>。Caffeine 活在 JVM heap 內，每個實例一份。這是它快的原因（無網路、無序列化），也是它難一致的原因（無法被其他實例直接更新或清除）。L2 Redis 是共享的，所以 L2 一致相對容易；L1 才是 stale 的來源。</p>
<p><strong>寫入只更新本地 L1 + 共享 L2</strong>。實例 A 處理一個更新：寫 L2 Redis（所有實例可見）+ 更新或清除自己的 L1。但 A 沒有辦法直接碰 B 的 L1——B 的 L1 還是舊的，直到它自己過期或被通知。</p>
<p><strong>沒有通知機制，L1 只能靠 TTL 自然過期</strong>。如果不做任何跨實例協調，L1 的 stale window 就等於 L1 的 TTL。把 L1 TTL 設短（幾秒到幾十秒）是最簡單的「容忍 stale」策略——犧牲一點新鮮度換掉協調的複雜度。需要更快失效就得主動廣播。</p>
<p>跨實例失效的標準解法是用 L2 Redis 的 pub/sub 當廣播通道：任一實例更新資料時，往一個 channel 發一條「key X 失效了」的訊息，所有實例訂閱這個 channel、收到就清掉自己 L1 對應的 entry。這把「各自為政的 L1」連成一個能協同失效的網。</p>
<h2 id="配置兩層讀寫--pubsub-失效的程式碼">配置：兩層讀寫 + pub/sub 失效的程式碼</h2>
<p>兩層讀取路徑（L1 → L2 → origin）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">public</span><span class="w"> </span><span class="n">User</span><span class="w"> </span><span class="nf">getUser</span><span class="p">(</span><span class="n">String</span><span class="w"> </span><span class="n">id</span><span class="p">)</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="c1">// L1：Caffeine、奈秒級、命中就回</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">User</span><span class="w"> </span><span class="n">u</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">l1</span><span class="p">.</span><span class="na">getIfPresent</span><span class="p">(</span><span class="n">id</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">if</span><span class="w"> </span><span class="p">(</span><span class="n">u</span><span class="w"> </span><span class="o">!=</span><span class="w"> </span><span class="kc">null</span><span class="p">)</span><span class="w"> </span><span class="k">return</span><span class="w"> </span><span class="n">u</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></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">    </span><span class="c1">// L1 miss → L2 Redis、毫秒級</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">String</span><span class="w"> </span><span class="n">json</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">redis</span><span class="p">.</span><span class="na">get</span><span class="p">(</span><span class="s">&#34;user:&#34;</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="n">id</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="k">if</span><span class="w"> </span><span class="p">(</span><span class="n">json</span><span class="w"> </span><span class="o">!=</span><span class="w"> </span><span class="kc">null</span><span class="p">)</span><span class="w"> </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">u</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">deserialize</span><span class="p">(</span><span class="n">json</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">l1</span><span class="p">.</span><span class="na">put</span><span class="p">(</span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="n">u</span><span class="p">);</span><span class="w">                 </span><span class="c1">// 回填 L1</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">        </span><span class="k">return</span><span class="w"> </span><span class="n">u</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="p">}</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w">    </span><span class="c1">// L2 miss → 回源 + 雙層回填</span><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w">    </span><span class="n">u</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">userRepository</span><span class="p">.</span><span class="na">findById</span><span class="p">(</span><span class="n">id</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">redis</span><span class="p">.</span><span class="na">setex</span><span class="p">(</span><span class="s">&#34;user:&#34;</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="n">300</span><span class="p">,</span><span class="w"> </span><span class="n">serialize</span><span class="p">(</span><span class="n">u</span><span class="p">));</span><span class="w">  </span><span class="c1">// L2 TTL 5 分鐘</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">l1</span><span class="p">.</span><span class="na">put</span><span class="p">(</span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="n">u</span><span class="p">);</span><span class="w">                     </span><span class="c1">// L1</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">return</span><span class="w"> </span><span class="n">u</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="p">}</span></span></span></code></pre></div><p>跨實例失效（寫入時往 Redis pub/sub 廣播、所有實例清 L1）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// L1 設短 TTL 當保險（廣播漏掉時的上界）</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">Cache</span><span class="o">&lt;</span><span class="n">String</span><span class="p">,</span><span class="w"> </span><span class="n">User</span><span class="o">&gt;</span><span class="w"> </span><span class="n">l1</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Caffeine</span><span class="p">.</span><span class="na">newBuilder</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="p">.</span><span class="na">maximumSize</span><span class="p">(</span><span class="n">10_000</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="p">.</span><span class="na">expireAfterWrite</span><span class="p">(</span><span class="n">Duration</span><span class="p">.</span><span class="na">ofSeconds</span><span class="p">(</span><span class="n">30</span><span class="p">))</span><span class="w">  </span><span class="c1">// 廣播失效之外的兜底</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">    </span><span class="p">.</span><span class="na">build</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></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w"></span><span class="c1">// 寫入：更新 L2 + 廣播失效</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w"></span><span class="kd">public</span><span class="w"> </span><span class="kt">void</span><span class="w"> </span><span class="nf">updateUser</span><span class="p">(</span><span class="n">User</span><span class="w"> </span><span class="n">u</span><span class="p">)</span><span class="w"> </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">userRepository</span><span class="p">.</span><span class="na">save</span><span class="p">(</span><span class="n">u</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">redis</span><span class="p">.</span><span class="na">setex</span><span class="p">(</span><span class="s">&#34;user:&#34;</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="n">u</span><span class="p">.</span><span class="na">id</span><span class="p">(),</span><span class="w"> </span><span class="n">300</span><span class="p">,</span><span class="w"> </span><span class="n">serialize</span><span class="p">(</span><span class="n">u</span><span class="p">));</span><span class="w">  </span><span class="c1">// 更新 L2（TTL 對齊讀路徑的 300s）</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">redis</span><span class="p">.</span><span class="na">publish</span><span class="p">(</span><span class="s">&#34;cache:invalidate&#34;</span><span class="p">,</span><span class="w"> </span><span class="n">u</span><span class="p">.</span><span class="na">id</span><span class="p">());</span><span class="w">   </span><span class="c1">// 廣播給所有實例</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">l1</span><span class="p">.</span><span class="na">invalidate</span><span class="p">(</span><span class="n">u</span><span class="p">.</span><span class="na">id</span><span class="p">());</span><span class="w">                        </span><span class="c1">// 清自己的 L1</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w"></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></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w"></span><span class="c1">// 每個實例啟動時訂閱、收到就清本地 L1</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">redis</span><span class="p">.</span><span class="na">subscribe</span><span class="p">(</span><span class="s">&#34;cache:invalidate&#34;</span><span class="p">,</span><span class="w"> </span><span class="n">message</span><span class="w"> </span><span class="o">-&gt;</span><span class="w"> </span><span class="n">l1</span><span class="p">.</span><span class="na">invalidate</span><span class="p">(</span><span class="n">message</span><span class="p">));</span></span></span></code></pre></div><p>關鍵：L1 的短 TTL 是廣播機制的兜底——即使某個實例漏掉一條 pub/sub 訊息（pub/sub 是 fire-and-forget、訂閱者離線會錯過），L1 最多 stale 到 TTL 過期。廣播負責「快」，TTL 負責「最終」。</p>
<h2 id="production-故障演練">Production 故障演練</h2>
<h3 id="case-1更新後其他實例持續回舊值">Case 1：更新後其他實例持續回舊值</h3>
<p><strong>徵兆</strong>：使用者改了資料、自己刷新看到新值（打到處理寫入的實例），但同事看到的還是舊值（打到別的實例），且持續好幾分鐘。</p>
<p><strong>根因</strong>：只更新了寫入實例的 L1 與 L2，沒有跨實例廣播。其他實例的 L1 還握著舊值、攔截了讀取、根本沒查到已更新的 L2。stale window 等於 L1 TTL（如果 TTL 設很長就是好幾分鐘）。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>加 Redis pub/sub 廣播失效，寫入時通知所有實例清 L1</li>
<li>廣播之外把 L1 TTL 設短當兜底（幾秒到幾十秒），縮短漏訊息時的 stale 上界</li>
<li>強一致需求的資料根本不該進 L1——L1 的本質就是「容忍一個 stale window 換速度」</li>
<li>對應 <a href="/blog/backend/02-cache-redis/cache-copy-freshness-boundary/" data-link-title="2.7 Cache Copy Boundary 與 Freshness" data-link-desc="說明快取何時只是可重建副本，何時會影響交易、權限或配額正確性。">cache copy boundary</a> 的新鮮度邊界判斷</li>
</ol>
<h3 id="case-2pubsub-漏訊息個別實例-l1-卡舊值">Case 2：pub/sub 漏訊息、個別實例 L1 卡舊值</h3>
<p><strong>徵兆</strong>：多數實例更新後正常，但偶爾某個實例持續回舊值，直到重啟或 TTL 過期。</p>
<p><strong>根因</strong>：Redis pub/sub 是 fire-and-forget——訂閱者在訊息發出的瞬間若斷線（網路抖動、GC pause、重連中），就永久錯過那條失效訊息。沒有兜底的話，那個實例的 L1 會一直 stale 到 TTL。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>L1 TTL 設短是必要兜底，不要依賴 pub/sub 100% 送達（它不保證）</li>
<li>需要可靠失效用 Redis Streams（有 consumer group + 重放）取代 pub/sub，代價是複雜度</li>
<li>監控各實例的 L1 命中率與 stale 投訴，個別實例異常代表漏訊息</li>
<li>接受 pub/sub 的 at-most-once 語意，用 TTL 補足最終一致</li>
</ol>
<h3 id="case-3l1-太大撐爆-heapfull-gc-風暴">Case 3：L1 太大撐爆 heap、Full GC 風暴</h3>
<p><strong>徵兆</strong>：加了 L1 後 application 的 GC 時間變長、偶發 Full GC 導致請求暫停（STW），延遲尖刺。</p>
<p><strong>根因</strong>：Caffeine 預設 on-heap，L1 的 <code>maximumSize</code> 設太大、cache 的物件佔據大量 heap，增加 GC 掃描與回收壓力。大物件 + 大容量直接推高 old gen 佔用。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><code>maximumSize</code> 對齊 heap 預算，用 <code>recordStats()</code> 看實際記憶體佔用</li>
<li>用 <code>maximumWeight</code> + weigher 按物件實際大小限制（不只筆數），避免大物件撐爆</li>
<li>L1 只放「小、熱、重複讀」的資料，大物件留 L2 Redis（off-heap 視角）</li>
<li>監控 GC 時間與 old gen 佔用，L1 容量是可調的 GC 旋鈕</li>
</ol>
<h3 id="case-4l1-快取了不該快取的-per-user-大物件">Case 4：L1 快取了不該快取的 per-user 大物件</h3>
<p><strong>徵兆</strong>：L1 命中率偏低、heap 壓力大、效果不如預期。</p>
<p><strong>根因</strong>：把 per-user 的大物件或低重複率的資料放 L1。L1 的價值在「少量資料被大量重複讀」（如設定檔、熱門商品、權限表），per-user 資料每個 user 一份、重複率低、塞滿 L1 又命中率低。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>L1 只放高重複率的共享熱資料（config、feature flag、熱門 item、權限）</li>
<li>per-user 低重複資料放 L2 Redis 就好，不要進 L1</li>
<li>用 <code>recordStats()</code> 的 hit rate 驗證——L1 命中率低代表放錯資料</li>
<li>對應 <a href="/blog/backend/02-cache-redis/cache-data-shape-access-pattern/" data-link-title="2.8 Cache Data Shape 與 Access Pattern" data-link-desc="說明 cache value、key space、資料結構與存取型態如何反映服務語意。">2.4 cache data shape</a> 的存取形狀判斷</li>
</ol>
<h3 id="case-5refreshafterwrite-與-expireafterwrite-混淆行為不如預期">Case 5：refreshAfterWrite 與 expireAfterWrite 混淆、行為不如預期</h3>
<p><strong>徵兆</strong>：以為設了自動刷新、結果到期還是 miss 阻塞回源；或以為會過期、結果一直回舊值。</p>
<p><strong>根因</strong>：<code>expireAfterWrite</code>（到期 entry 失效、下次讀 miss + 阻塞載入）跟 <code>refreshAfterWrite</code>（到期後第一個讀觸發背景刷新、舊值立即回、不阻塞）語意不同，混用導致行為不符預期。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>要「到期就不可用」用 <code>expireAfterWrite</code>；要「到期背景刷新、舊值先頂」用 <code>refreshAfterWrite</code></li>
<li>兩者可組合：<code>refreshAfterWrite</code> 短 + <code>expireAfterWrite</code> 長，得到「背景刷新 + 最終過期」</li>
<li><code>refreshAfterWrite</code> 避免 <a href="/blog/backend/02-cache-redis/cases/failure-cache-stampede-rollout-regression/" data-link-title="2.C9 反例：快取切換引發 Stampede 回歸" data-link-desc="快取策略切換若缺乏保護，會導致回源壓力與錯誤率連鎖上升。">stampede</a>（舊值先服務、單一背景刷新），適合熱 key</li>
<li>用 <code>LoadingCache</code> 的 <code>build(key -&gt; load)</code> 配 refresh，行為以官方 wiki 為準</li>
</ol>
<h2 id="capacity--cost-邊界">Capacity / cost 邊界</h2>
<p>兩層 cache 的容量判讀，核心在 L1 命中率、stale window 與 GC：</p>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>健康區間</th>
          <th>警戒與動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>L1 hit rate</td>
          <td>高（放對高重複資料）</td>
          <td>低 → 放錯資料（per-user 大物件）、改放 L2</td>
      </tr>
      <tr>
          <td>L1 stale window</td>
          <td>≤ L1 TTL（廣播正常更短）</td>
          <td>過長 → TTL 太長或廣播沒做</td>
      </tr>
      <tr>
          <td>GC 時間 / old gen 佔用</td>
          <td>穩定、無 Full GC 風暴</td>
          <td>升高 → L1 太大、降 maximumSize / maximumWeight</td>
      </tr>
      <tr>
          <td>pub/sub 失效送達率</td>
          <td>高（但不保證 100%）</td>
          <td>漏訊息 → TTL 兜底、或改 Streams</td>
      </tr>
      <tr>
          <td>L1 vs L2 命中分層</td>
          <td>L1 擋大部分、L2 擋 L1 miss</td>
          <td>L1 命中低 → 兩層沒分工好</td>
      </tr>
  </tbody>
</table>
<p>撞牆後的路由判斷：</p>
<ul>
<li><strong>需要強一致 / 不能容忍任何 stale</strong>：L1 process-local 本質有 stale window，不該放這類資料。強一致只用 <a href="/blog/backend/02-cache-redis/vendors/redis/" data-link-title="Redis" data-link-desc="OSS in-memory data structure store、cache 主流">Redis / Valkey</a> 共享層（甚至直接回源）。</li>
<li><strong>L1 容量需求超過 heap</strong>：on-heap Caffeine 撐不住，用 off-heap 方案（Ehcache off-heap tier）或把資料留 L2 Redis。</li>
<li><strong>可靠失效（不能漏）</strong>：pub/sub 是 at-most-once，要可靠用 Redis Streams 的 consumer group，代價是複雜度。</li>
<li><strong>非 JVM 服務</strong>：Caffeine 綁 JVM，其他語言用對應的 process-local cache（Go ristretto、Rust moka），兩層架構的思路相同。</li>
</ul>
<h2 id="整合--下一步">整合 / 下一步</h2>
<p>兩層 cache 的工程量集中在跨實例一致性，它跟多個議題交織：</p>
<ul>
<li><strong>跟 <a href="/blog/backend/02-cache-redis/vendors/caffeine/" data-link-title="Caffeine" data-link-desc="JVM process-local cache、Window TinyLFU、Guava Cache 後繼">Caffeine overview</a></strong>：overview 點到「跨實例 invalidation 是固有限制」、本文展開 pub/sub 廣播 + TTL 兜底的具體解法。</li>
<li><strong>跟 <a href="/blog/backend/02-cache-redis/vendors/redis/connection-pipeline-latency/" data-link-title="Redis 連線與 pipeline：RTT 稅、連線池與一次往返打包多命令" data-link-desc="Redis 單命令通常微秒級執行，但 application 端量到的延遲是毫秒級——差距全在網路往返（RTT）。pipelining 的本質不是『批次發命令』，是把 N 次 RTT 壓成 1 次。本文展開 RTT 會計、連線池配置、pipeline 與 MULTI 的差異、5 個把連線與往返寫成延遲與正確性問題的 production 踩坑，以及連線模型撞牆的邊界">Redis connection / pipeline</a></strong>：L1 的價值正是消除 L2 Redis 的 RTT 稅，兩層 cache 是 RTT 優化的極致（L1 命中連網路都省）。</li>
<li><strong>跟 <a href="/blog/backend/02-cache-redis/high-concurrency-access/" data-link-title="2.1 高併發下的 Redis 讀寫邊界" data-link-desc="說明高併發服務如何共用 Redis client、控制 pipeline 與避免 cache stampede">2.6 high concurrency</a></strong>：hot key 的兩層解法（local cache + Redis）就是這個架構，L1 擋掉打在單一熱 key 的洪峰。</li>
<li><strong>跟 <a href="/blog/backend/09-performance-capacity/cases/tinder-elasticache-valkey-matching/" data-link-title="9.C6 Tinder：ElastiCache for Valkey 撐 4700 萬月活的配對引擎" data-link-desc="Tinder 用 Amazon ElastiCache for Valkey 提供配對引擎所需的次毫秒延遲快取層">Tinder</a></strong>：每次互動查多個 cache 的服務，L1 Caffeine 可擋掉重複讀、降低 L2（ElastiCache）的壓力與 RTT——但 per-user 配對資料重複率低、要判斷哪些放得進 L1。</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li>上游 vendor 頁：<a href="/blog/backend/02-cache-redis/vendors/caffeine/" data-link-title="Caffeine" data-link-desc="JVM process-local cache、Window TinyLFU、Guava Cache 後繼">Caffeine</a></li>
<li>L2 對照：<a href="/blog/backend/02-cache-redis/vendors/redis/memory-eviction-tuning/" data-link-title="Redis 記憶體與淘汰調校：maxmemory-policy、LFU 與碎片化的實戰判讀" data-link-desc="Redis 的記憶體是一條會在半夜爆掉的曲線：maxmemory 設多少、policy 選 LRU 還 LFU、碎片化什麼時候開始吃掉 30% RAM、OOM 時 noeviction 怎麼讓寫入全部失敗。本文展開 Redis 記憶體會計模型、eviction policy 的選型判讀、5 個把記憶體配置寫成 production 事故的踩坑，以及單機記憶體撞牆後該往 cluster 還是 DragonflyDB 走的邊界">Redis 記憶體與淘汰</a>、<a href="/blog/backend/02-cache-redis/vendors/redis/connection-pipeline-latency/" data-link-title="Redis 連線與 pipeline：RTT 稅、連線池與一次往返打包多命令" data-link-desc="Redis 單命令通常微秒級執行，但 application 端量到的延遲是毫秒級——差距全在網路往返（RTT）。pipelining 的本質不是『批次發命令』，是把 N 次 RTT 壓成 1 次。本文展開 RTT 會計、連線池配置、pipeline 與 MULTI 的差異、5 個把連線與往返寫成延遲與正確性問題的 production 踩坑，以及連線模型撞牆的邊界">Redis 連線 / pipeline</a></li>
<li>上游概念：<a href="/blog/backend/02-cache-redis/high-concurrency-access/" data-link-title="2.1 高併發下的 Redis 讀寫邊界" data-link-desc="說明高併發服務如何共用 Redis client、控制 pipeline 與避免 cache stampede">2.6 high concurrency</a>、<a href="/blog/backend/02-cache-redis/cache-copy-freshness-boundary/" data-link-title="2.7 Cache Copy Boundary 與 Freshness" data-link-desc="說明快取何時只是可重建副本，何時會影響交易、權限或配額正確性。">2.7 cache copy boundary</a></li>
<li>Methodology：<a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章寫作方法論</a></li>
</ul>
]]></content:encoded></item><item><title>DragonflyDB shared-nothing 多核架構：用 scale-up 取代 Redis Cluster</title><link>https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/dragonflydb/shared-nothing-multicore-architecture/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/dragonflydb/shared-nothing-multicore-architecture/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/dragonflydb/" data-link-title="DragonflyDB" data-link-desc="高效能 Redis / Memcached 相容替代、多核架構">DragonflyDB&lt;/a> overview 的 implementation-layer deep article。選型層（為何選 DragonflyDB、BSL 授權、相容度）見 overview；本文只處理「決定用 DragonflyDB 後，多核架構怎麼用、相容邊界在哪」。命令實機驗證於 dragonfly df-v1.39.0（&lt;code>redis_version:7.4.0&lt;/code>）、最後檢查日 2026-06-16；效能數字以 &lt;a href="https://www.dragonflydb.io/">DragonflyDB 官方 benchmark&lt;/a> 為準。&lt;/p>&lt;/blockquote>
&lt;h2 id="scale-up-還是-scale-out一個架構賭注">scale-up 還是 scale-out：一個架構賭注&lt;/h2>
&lt;p>把一台 32 核機器交給 Redis，Redis 的主執行緒只用得到其中一核處理命令——要榨乾這台機器，你得在同一台上跑好幾個 Redis 進程、組成 Cluster、用 hash slot 把 key 分片。多核利用變成了一個分散式系統問題（&lt;a href="https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/redis/cluster-resharding/" data-link-title="Redis Cluster Re-sharding：source = target，但 topology 重劃的 5 段流程" data-link-desc="Redis cluster re-sharding 是 5 type migration 漏類實證 — source / target 同 cluster、無 schema / paradigm 差、但 16384 slot 重分配是核心；本文涵蓋 4 種 re-sharding driver、slot migration 機制、redis-cli --cluster rebalance / reshard 工具、5 個 production 踩雷（cluster busy / replica lag / client cache stale / cross-slot transaction / monitor gap）">cluster re-sharding&lt;/a>、cross-slot transaction、hash tag 治理全都來了）。&lt;/p>
&lt;p>DragonflyDB 賭的是相反方向：一個進程、thread-per-core、shared-nothing，讓單機在不分片的情況下用滿所有核。它的論點是——多數「需要 Redis Cluster」的場景，真正的需求是吞吐與記憶體，不是跨機器分散；如果單機就能撐到那個規模，Cluster 的複雜度就不必付。實機可以看到這個架構：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">redis-cli INFO server &lt;span class="p">|&lt;/span> grep -E &lt;span class="s2">&amp;#34;thread_count|redis_version|dragonfly_version&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1"># thread_count:8 ← 自動對齊 CPU 核數&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="c1"># redis_version:7.4.0 ← 對 client 裝成 Redis 7.4&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1"># dragonfly_version:df-v1.39.0&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>thread_count:8&lt;/code> 在一個進程內，不是 8 個 Redis 進程組 Cluster。這就是賭注的核心：把 Redis Cluster 的水平分片，收進單一進程的垂直多核。理解 DragonflyDB 就是理解這個賭注成立的條件與它撞牆的地方。&lt;/p>
&lt;p>對高吞吐單機 workload，這個賭注有現成的對照。&lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/snap-gcp-keydb-cross-cloud/" data-link-title="9.C35 Snap：GCP &amp;#43; KeyDB 在 multi-cloud 架構下的低延遲快取" data-link-desc="Snap 用 GCP 上的 KeyDB cluster 減少跨 cloud cache 延遲、用 TPU 訓練廣告推薦模型">Snap 在 multi-cloud 用 KeyDB&lt;/a>（Redis 的 multi-threaded fork、單實例吞吐提升 5-10x）撐超高吞吐 cache，正是「不想為了多核去組 Cluster」的同一類需求；DragonflyDB 是這條路線更激進的版本（從零用 C++ 重寫、不是在 Redis 上加 thread）。&lt;/p>
&lt;h2 id="核心概念thread-per-core-與-shared-nothing">核心概念：thread-per-core 與 shared-nothing&lt;/h2>
&lt;p>DragonflyDB 的多核不是「多個執行緒搶同一份資料」，而是把資料切給各個執行緒、彼此不共享——這是它能線性擴展到多核的關鍵。&lt;/p>
&lt;p>&lt;strong>thread-per-core + 資料分區&lt;/strong>。每個 thread 綁一個核，keyspace 被 hash 切成多個 slice，每個 slice 只由一個 thread 擁有。一個命令進來，被路由到擁有該 key 的 thread 處理。因為一個 key 只有一個 thread 碰，單 key 操作不需要鎖——這消除了 Redis 多執行緒方案最大的開銷（lock contention）。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/02-cache-redis/vendors/dragonflydb/" data-link-title="DragonflyDB" data-link-desc="高效能 Redis / Memcached 相容替代、多核架構">DragonflyDB</a> overview 的 implementation-layer deep article。選型層（為何選 DragonflyDB、BSL 授權、相容度）見 overview；本文只處理「決定用 DragonflyDB 後，多核架構怎麼用、相容邊界在哪」。命令實機驗證於 dragonfly df-v1.39.0（<code>redis_version:7.4.0</code>）、最後檢查日 2026-06-16；效能數字以 <a href="https://www.dragonflydb.io/">DragonflyDB 官方 benchmark</a> 為準。</p></blockquote>
<h2 id="scale-up-還是-scale-out一個架構賭注">scale-up 還是 scale-out：一個架構賭注</h2>
<p>把一台 32 核機器交給 Redis，Redis 的主執行緒只用得到其中一核處理命令——要榨乾這台機器，你得在同一台上跑好幾個 Redis 進程、組成 Cluster、用 hash slot 把 key 分片。多核利用變成了一個分散式系統問題（<a href="/blog/backend/02-cache-redis/vendors/redis/cluster-resharding/" data-link-title="Redis Cluster Re-sharding：source = target，但 topology 重劃的 5 段流程" data-link-desc="Redis cluster re-sharding 是 5 type migration 漏類實證 — source / target 同 cluster、無 schema / paradigm 差、但 16384 slot 重分配是核心；本文涵蓋 4 種 re-sharding driver、slot migration 機制、redis-cli --cluster rebalance / reshard 工具、5 個 production 踩雷（cluster busy / replica lag / client cache stale / cross-slot transaction / monitor gap）">cluster re-sharding</a>、cross-slot transaction、hash tag 治理全都來了）。</p>
<p>DragonflyDB 賭的是相反方向：一個進程、thread-per-core、shared-nothing，讓單機在不分片的情況下用滿所有核。它的論點是——多數「需要 Redis Cluster」的場景，真正的需求是吞吐與記憶體，不是跨機器分散；如果單機就能撐到那個規模，Cluster 的複雜度就不必付。實機可以看到這個架構：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">redis-cli INFO server <span class="p">|</span> grep -E <span class="s2">&#34;thread_count|redis_version|dragonfly_version&#34;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># thread_count:8               ← 自動對齊 CPU 核數</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"># redis_version:7.4.0          ← 對 client 裝成 Redis 7.4</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># dragonfly_version:df-v1.39.0</span></span></span></code></pre></div><p><code>thread_count:8</code> 在一個進程內，不是 8 個 Redis 進程組 Cluster。這就是賭注的核心：把 Redis Cluster 的水平分片，收進單一進程的垂直多核。理解 DragonflyDB 就是理解這個賭注成立的條件與它撞牆的地方。</p>
<p>對高吞吐單機 workload，這個賭注有現成的對照。<a href="/blog/backend/09-performance-capacity/cases/snap-gcp-keydb-cross-cloud/" data-link-title="9.C35 Snap：GCP &#43; KeyDB 在 multi-cloud 架構下的低延遲快取" data-link-desc="Snap 用 GCP 上的 KeyDB cluster 減少跨 cloud cache 延遲、用 TPU 訓練廣告推薦模型">Snap 在 multi-cloud 用 KeyDB</a>（Redis 的 multi-threaded fork、單實例吞吐提升 5-10x）撐超高吞吐 cache，正是「不想為了多核去組 Cluster」的同一類需求；DragonflyDB 是這條路線更激進的版本（從零用 C++ 重寫、不是在 Redis 上加 thread）。</p>
<h2 id="核心概念thread-per-core-與-shared-nothing">核心概念：thread-per-core 與 shared-nothing</h2>
<p>DragonflyDB 的多核不是「多個執行緒搶同一份資料」，而是把資料切給各個執行緒、彼此不共享——這是它能線性擴展到多核的關鍵。</p>
<p><strong>thread-per-core + 資料分區</strong>。每個 thread 綁一個核，keyspace 被 hash 切成多個 slice，每個 slice 只由一個 thread 擁有。一個命令進來，被路由到擁有該 key 的 thread 處理。因為一個 key 只有一個 thread 碰，單 key 操作不需要鎖——這消除了 Redis 多執行緒方案最大的開銷（lock contention）。</p>
<p><strong>dashtable 取代 Redis 的 dict</strong>。DragonflyDB 用自製的 dashtable（一種 hash table）取代 Redis 的 dictionary，記憶體佈局更緊湊、resize 時不需要像 Redis 那樣漸進式 rehash 全表，同樣的 dataset 通常比 Redis 省 20-40% 記憶體（依資料形狀，以官方 benchmark 為準）。</p>
<p><strong>fork-less snapshot</strong>。Redis 的持久化靠 <code>fork()</code>，大記憶體下會凍結主執行緒並讓記憶體接近翻倍（見 <a href="/blog/backend/02-cache-redis/vendors/redis/persistence-fork-latency/" data-link-title="Redis 持久化與 fork latency：AOF、RDB 與那一次卡住整個 cluster 的 fork" data-link-desc="Redis 的 RDB save 與 AOF rewrite 都靠一次 fork()，而 fork 在大記憶體實例上會凍結主執行緒數百毫秒、複製分頁讓記憶體逼近翻倍。本文展開 AOF / RDB 的機制與 fsync 取捨、copy-on-write 的記憶體放大、5 個把持久化寫成延遲尖峰與資料遺失的 production 踩坑，以及 cache 場景到底要不要持久化的邊界">Redis persistence deep article</a>）。DragonflyDB 不用 fork——它用自己的快照演算法在不複製整個進程的前提下做一致性快照，大記憶體場景不付 fork 的延遲尖峰與記憶體翻倍代價。這是它對「fork 是 Redis 結構性瓶頸」這個痛點的直接回答。</p>
<p><strong>多執行緒的代價：沒有 Redis Cluster mode</strong>。資料分區在單進程內，DragonflyDB 不提供 Redis Cluster mode（它的哲學是單機撐大、不跨機器分片）。這個取捨決定了它的相容邊界與容量天花板，是後面踩坑的根源。</p>
<h2 id="配置多核與持久化的設定路徑">配置：多核與持久化的設定路徑</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">docker run -d --name dragonfly -p 6379:6379 <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  docker.dragonflydb.io/dragonflydb/dragonfly <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>    --threads <span class="m">8</span> <span class="se">\ </span>             <span class="c1"># thread 數、預設等於 CPU 核數（一般不需手動設）</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    --maxmemory 4gb <span class="se">\ </span>         <span class="c1"># 記憶體上限、行為類似 Redis maxmemory</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    --cache_mode <span class="nb">true</span> <span class="se">\ </span>       <span class="c1"># 純 cache 模式：記憶體滿時自動 evict（類似 allkeys-lru）</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    --snapshot_cron <span class="s2">&#34;0 3 * * *&#34;</span> <span class="c1"># fork-less snapshot 排程（cron 格式、這裡每天 3 點）</span></span></span></code></pre></div><p>調校判讀：</p>
<ul>
<li><code>--threads</code> 預設對齊 CPU 核數，多數情況不需手動設；設小於核數會浪費核，設大於核數沒有意義</li>
<li><code>--cache_mode true</code> 讓 DragonflyDB 在記憶體滿時自動淘汰（純 cache 行為）；不開則記憶體滿時拒絕寫入（類似 Redis noeviction）</li>
<li><code>--maxmemory</code> 留 headroom，但因為 fork-less，headroom 不需要像 Redis 留那麼多給 fork copy-on-write</li>
<li>snapshot 用 <code>--snapshot_cron</code> 排程，fork-less 機制讓大記憶體快照不產生延遲尖峰</li>
</ul>
<h2 id="production-故障演練">Production 故障演練</h2>
<h3 id="case-1client-配-cluster-mode連不上">Case 1：client 配 Cluster mode、連不上</h3>
<p><strong>徵兆</strong>：從 Redis Cluster 遷來，application 的 client library 還配著 cluster mode，連 DragonflyDB 報錯或 hang，<code>CLUSTER</code> 相關命令行為不如預期。</p>
<p><strong>根因</strong>：DragonflyDB 不提供 Redis Cluster mode（單進程多核、不跨機器分片）。cluster-aware client 會嘗試 <code>CLUSTER SLOTS</code> 之類的拓樸發現，跟 standalone 的 DragonflyDB 對不上。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>client 改回 standalone 配置（不要 cluster mode）</li>
<li>評估原本用 Cluster 的理由：若是為了多核吞吐，DragonflyDB 單進程多核已涵蓋，不需要 cluster mode</li>
<li>若原本用 Cluster 是為了超過單機的容量 / 跨機器分散，DragonflyDB 的 scale-up 模型撐不住，該留在 <a href="/blog/backend/02-cache-redis/vendors/redis/cluster-resharding/" data-link-title="Redis Cluster Re-sharding：source = target，但 topology 重劃的 5 段流程" data-link-desc="Redis cluster re-sharding 是 5 type migration 漏類實證 — source / target 同 cluster、無 schema / paradigm 差、但 16384 slot 重分配是核心；本文涵蓋 4 種 re-sharding driver、slot migration 機制、redis-cli --cluster rebalance / reshard 工具、5 個 production 踩雷（cluster busy / replica lag / client cache stale / cross-slot transaction / monitor gap）">Redis Cluster</a></li>
<li>確認 application 沒有依賴 cluster-specific 行為（hash tag 的跨 slot 語意等）</li>
</ol>
<h3 id="case-2某些-redis-命令--module-不支援">Case 2：某些 Redis 命令 / module 不支援</h3>
<p><strong>徵兆</strong>：核心 SET/GET/HASH 等正常，但某個命令報 <code>unknown command</code> 或行為跟 Redis 不同，特別是 module 命令（RedisJSON / RedisSearch）與部分冷門命令。</p>
<p><strong>根因</strong>：DragonflyDB 相容大多數 Redis 命令但不是 100%；它宣稱相容 <code>redis_version:7.4.0</code>，但部分 module、部分冷門命令、部分 Lua 行為有差異。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>遷移前盤點 application 用到的命令，對照 DragonflyDB 的 API 相容清單（官方 docs）</li>
<li>module 重度依賴（RedisJSON / RedisSearch）要特別確認——DragonflyDB 的 module 生態比 Redis 淺</li>
<li>Lua script 行為差異要實測，不要假設跟 Redis 完全一致</li>
<li>相容性是遷移的主要風險，跟 <a href="/blog/backend/02-cache-redis/vendors/valkey/redis-compatibility-and-io-threads/" data-link-title="Valkey 相容性驗證與 io-threads 調校：drop-in 切換與多執行緒的實機判讀" data-link-desc="Valkey 跟 Redis 100% 相容這句話要怎麼驗證、切換才敢上線。本文用 INFO server 的雙版本回報拆解相容性的真實邊界、展開 Valkey 8 的 io-threads 多執行緒調校、5 個把 drop-in 切換或執行緒配置寫成事故的 production 踩坑，以及相容性撞牆該怎麼判斷的邊界">Valkey 的相容性驗證</a>同理但 DragonflyDB 邊界更寬（重寫而非 fork）</li>
</ol>
<h3 id="case-3thread-沒對齊核數多核優勢沒發揮">Case 3：thread 沒對齊核數、多核優勢沒發揮</h3>
<p><strong>徵兆</strong>：吞吐沒有達到預期、CPU 使用率不均（部分核閒置），<code>thread_count</code> 跟機器核數對不上。</p>
<p><strong>根因</strong>：<code>--threads</code> 被手動設成小於 CPU 核數，或容器的 CPU limit 限制了實際可用核數，DragonflyDB 沒能用滿所有核。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><code>redis-cli INFO server | grep thread_count</code> 確認 thread 數對齊實體核數</li>
<li>容器環境確認 CPU limit 沒有卡住 DragonflyDB 的核數（cgroup CPU quota）</li>
<li>不要手動把 <code>--threads</code> 設小，預設對齊核數就是最佳</li>
<li>吞吐沒到預期也可能是 workload 本身（大命令、網路 RTT），用 <a href="/blog/backend/02-cache-redis/vendors/redis/connection-pipeline-latency/" data-link-title="Redis 連線與 pipeline：RTT 稅、連線池與一次往返打包多命令" data-link-desc="Redis 單命令通常微秒級執行，但 application 端量到的延遲是毫秒級——差距全在網路往返（RTT）。pipelining 的本質不是『批次發命令』，是把 N 次 RTT 壓成 1 次。本文展開 RTT 會計、連線池配置、pipeline 與 MULTI 的差異、5 個把連線與往返寫成延遲與正確性問題的 production 踩坑，以及連線模型撞牆的邊界">連線 / pipeline</a> 的 RTT 分析交叉判斷</li>
</ol>
<h3 id="case-4跨-partition-的多-key-操作有額外成本">Case 4：跨 partition 的多 key 操作有額外成本</h3>
<p><strong>徵兆</strong>：大量多 key 命令（MGET 跨很多 key、跨 key 的 Lua）的延遲比預期高，單 key 操作則很快。</p>
<p><strong>根因</strong>：shared-nothing 下 key 分散在不同 thread，多 key 操作要跨 thread 協調——單 key 免鎖的好處在多 key 跨 partition 時要付協調成本。這跟 Redis Cluster 的 cross-slot 是類似的本質（資料分散的代價），只是發生在單進程內。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>高頻的多 key 操作盡量讓 key 落在同 partition（DragonflyDB 的 key 分布規則）</li>
<li>評估能否用單 key 結構（hash）取代多個 key 的聚合</li>
<li>跨 partition 協調是分區架構的固有成本，不是 bug，量大時要設計繞過</li>
<li>對照 <a href="/blog/backend/02-cache-redis/vendors/redis/connection-pipeline-latency/" data-link-title="Redis 連線與 pipeline：RTT 稅、連線池與一次往返打包多命令" data-link-desc="Redis 單命令通常微秒級執行，但 application 端量到的延遲是毫秒級——差距全在網路往返（RTT）。pipelining 的本質不是『批次發命令』，是把 N 次 RTT 壓成 1 次。本文展開 RTT 會計、連線池配置、pipeline 與 MULTI 的差異、5 個把連線與往返寫成延遲與正確性問題的 production 踩坑，以及連線模型撞牆的邊界">Redis Cluster 的 cross-slot 限制</a>，兩者都是「資料分散換吞吐」的代價</li>
</ol>
<h3 id="case-5bsl-授權踩到商業使用限制">Case 5：BSL 授權踩到商業使用限制</h3>
<p><strong>徵兆</strong>：準備把 DragonflyDB 包成對外的 managed service 提供給客戶，法務 review 卡關。</p>
<p><strong>根因</strong>：DragonflyDB 用 BSL（Business Source License），商業使用受限——具體限制是不可把 DragonflyDB 當成 managed service 對外提供（4 年後該版本轉 Apache 2.0）。內部使用無限制，但 SaaS 對外提供 DragonflyDB 即服務受限。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>內部使用（多數企業場景）無限制，直接用</li>
<li>要把 DragonflyDB 當 managed service 對外賣，聯絡 DragonflyDB 取得商業 license</li>
<li>開源合規敏感（公部門 / 企業 OSI 政策）走 OSI 認可的 <a href="/blog/backend/02-cache-redis/vendors/valkey/" data-link-title="Valkey" data-link-desc="Redis fork、Linux Foundation 託管、BSD 授權">Valkey</a>（BSD）</li>
<li>授權法律解讀諮詢法務，不要憑技術判斷</li>
</ol>
<h2 id="capacity--cost-邊界">Capacity / cost 邊界</h2>
<p>DragonflyDB 的容量判讀，核心在 scale-up 的天花板與多核效率：</p>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>健康區間</th>
          <th>警戒與動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>thread_count</code></td>
          <td>= CPU 實體核數</td>
          <td>&lt; 核數 → 沒用滿多核、查 &ndash;threads / cgroup</td>
      </tr>
      <tr>
          <td>單機吞吐</td>
          <td>遠高於單 Redis 進程</td>
          <td>撞單機網路 / CPU 上限 → scale-up 到頂</td>
      </tr>
      <tr>
          <td>記憶體效率</td>
          <td>比 Redis 省 20-40%（依形狀）</td>
          <td>以官方 benchmark + 自己量為準</td>
      </tr>
      <tr>
          <td>snapshot 延遲尖峰</td>
          <td>接近 0（fork-less）</td>
          <td>有尖峰 → 確認用的是 DragonflyDB 快照不是相容路徑</td>
      </tr>
      <tr>
          <td>單機容量 / 跨 AZ 需求</td>
          <td>單機 + replica 撐得住</td>
          <td>超單機 / 要跨機器分散 → DragonflyDB 撐不住</td>
      </tr>
  </tbody>
</table>
<p>撞牆後的路由判斷：</p>
<ul>
<li><strong>超過單機容量、需要跨機器分散</strong>：DragonflyDB 的 scale-up 賭注在這裡輸——它沒有 Cluster mode。要跨機器分片走 <a href="/blog/backend/02-cache-redis/vendors/redis/cluster-resharding/" data-link-title="Redis Cluster Re-sharding：source = target，但 topology 重劃的 5 段流程" data-link-desc="Redis cluster re-sharding 是 5 type migration 漏類實證 — source / target 同 cluster、無 schema / paradigm 差、但 16384 slot 重分配是核心；本文涵蓋 4 種 re-sharding driver、slot migration 機制、redis-cli --cluster rebalance / reshard 工具、5 個 production 踩雷（cluster busy / replica lag / client cache stale / cross-slot transaction / monitor gap）">Redis / Valkey Cluster</a>。</li>
<li><strong>需要 OSI 認可開源授權</strong>：BSL 不是 OSI 認可，合規敏感走 <a href="/blog/backend/02-cache-redis/vendors/valkey/" data-link-title="Valkey" data-link-desc="Redis fork、Linux Foundation 託管、BSD 授權">Valkey</a>（BSD）。</li>
<li><strong>不想自管</strong>：DragonflyDB 目前沒有 fully managed offering（無 ElastiCache for Dragonfly），必須自管。要 managed 走 <a href="/blog/backend/02-cache-redis/vendors/aws-elasticache/" data-link-title="AWS ElastiCache" data-link-desc="AWS managed Redis / Valkey / Memcached">ElastiCache</a>（Redis / Valkey / Memcached）。</li>
<li><strong>跨 AZ / 跨 region HA</strong>：DragonflyDB 有 replica 模式（primary-replica）跨 AZ 可行，但跨 region 需自建——大規模跨區走 managed 的 Global Datastore。</li>
</ul>
<h2 id="整合--下一步">整合 / 下一步</h2>
<p>DragonflyDB 的定位是「Redis 相容 + 激進多核」，它在 Redis 相容服務的光譜上有明確座標：</p>
<ul>
<li><strong>跟 <a href="/blog/backend/02-cache-redis/vendors/valkey/" data-link-title="Valkey" data-link-desc="Redis fork、Linux Foundation 託管、BSD 授權">Valkey</a></strong>：兩者都打「Redis 相容 + 更好的多核」，但 Valkey 是 fork（同源、最高相容、漸進加 thread），DragonflyDB 是 C++ 重寫（相容核心但架構激進、多核更徹底）。相容度要極致選 Valkey，多核吞吐要極致選 DragonflyDB。</li>
<li><strong>跟 <a href="/blog/backend/02-cache-redis/vendors/keydb/" data-link-title="KeyDB" data-link-desc="Redis multi-threaded fork、active-replication、Snap 採用">KeyDB</a> / Garnet</strong>：KeyDB 是 Redis 的 multi-threaded fork（<a href="/blog/backend/09-performance-capacity/cases/snap-gcp-keydb-cross-cloud/" data-link-title="9.C35 Snap：GCP &#43; KeyDB 在 multi-cloud 架構下的低延遲快取" data-link-desc="Snap 用 GCP 上的 KeyDB cluster 減少跨 cloud cache 延遲、用 TPU 訓練廣告推薦模型">Snap 採用</a>、Snap 收購後相對停滯）；Garnet 是 Microsoft 的研究型高吞吐 store（生態淺）。DragonflyDB 是這個「高吞吐 Redis 替代」群裡商業化最積極、生態最活躍的。</li>
<li><strong>跟 <a href="/blog/backend/02-cache-redis/vendors/redis/cluster-resharding/" data-link-title="Redis Cluster Re-sharding：source = target，但 topology 重劃的 5 段流程" data-link-desc="Redis cluster re-sharding 是 5 type migration 漏類實證 — source / target 同 cluster、無 schema / paradigm 差、但 16384 slot 重分配是核心；本文涵蓋 4 種 re-sharding driver、slot migration 機制、redis-cli --cluster rebalance / reshard 工具、5 個 production 踩雷（cluster busy / replica lag / client cache stale / cross-slot transaction / monitor gap）">Redis Cluster re-sharding</a></strong>：如果你的 Redis Cluster re-sharding 頻繁觸發、運維負擔重，DragonflyDB 的 scale-up 模型可能用單機取代整個 Cluster——這是評估遷移的主要動機。</li>
<li><strong>跟 <a href="/blog/backend/02-cache-redis/cases/shopify-write-through-cache-at-scale/" data-link-title="2.C5 Shopify：Write-through Cache 在高讀流量的實作" data-link-desc="read-heavy 服務如何轉向 write-through 快取模型。">Shopify write-through</a></strong>：write-through 在 DragonflyDB 上行為一致，但單進程多核能承接比單 Redis 進程更大的 throughput，是 read-heavy + write-through 場景的 scale-up 選項。</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li>上游 vendor 頁：<a href="/blog/backend/02-cache-redis/vendors/dragonflydb/" data-link-title="DragonflyDB" data-link-desc="高效能 Redis / Memcached 相容替代、多核架構">DragonflyDB</a></li>
<li>對照 vendor：<a href="/blog/backend/02-cache-redis/vendors/valkey/redis-compatibility-and-io-threads/" data-link-title="Valkey 相容性驗證與 io-threads 調校：drop-in 切換與多執行緒的實機判讀" data-link-desc="Valkey 跟 Redis 100% 相容這句話要怎麼驗證、切換才敢上線。本文用 INFO server 的雙版本回報拆解相容性的真實邊界、展開 Valkey 8 的 io-threads 多執行緒調校、5 個把 drop-in 切換或執行緒配置寫成事故的 production 踩坑，以及相容性撞牆該怎麼判斷的邊界">Valkey 相容性與 io-threads</a>、<a href="/blog/backend/02-cache-redis/vendors/redis/persistence-fork-latency/" data-link-title="Redis 持久化與 fork latency：AOF、RDB 與那一次卡住整個 cluster 的 fork" data-link-desc="Redis 的 RDB save 與 AOF rewrite 都靠一次 fork()，而 fork 在大記憶體實例上會凍結主執行緒數百毫秒、複製分頁讓記憶體逼近翻倍。本文展開 AOF / RDB 的機制與 fsync 取捨、copy-on-write 的記憶體放大、5 個把持久化寫成延遲尖峰與資料遺失的 production 踩坑，以及 cache 場景到底要不要持久化的邊界">Redis persistence 與 fork latency</a>（fork-less 對照的痛點）</li>
<li>相關 migration：<a href="/blog/backend/02-cache-redis/vendors/redis/migrate-to-dragonflydb/" data-link-title="Redis → DragonflyDB：drop-in 相容下的容量躍升 &#43; 5 個踩雷" data-link-desc="DragonflyDB 號稱 Redis drop-in 替代、單機 throughput 25x、記憶體效率 30% 提升；遷移流程簡單但有 5 個 production 踩雷（RDB 版本差 / Lua 腳本不全支援 / Pub-Sub fanout 行為差異 / Cluster mode 兼容度 / Modules 不支援）、跟 Sentinel / Cluster 模式對位">Redis → DragonflyDB</a></li>
<li>Methodology：<a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章寫作方法論</a></li>
</ul>
]]></content:encoded></item><item><title>Google Pub/Sub push vs pull：不是實作偏好，是下游容量的判讀</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/google-pubsub/push-pull-ack-flow-control/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/google-pubsub/push-pull-ack-flow-control/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Google Cloud Pub/Sub&lt;/a> overview 的 implementation-layer deep article。選型層（Pub/Sub vs Kafka / SQS）見 overview；本文只處理「決定用 Pub/Sub 後，subscription 與 ack 怎麼設」。Pub/Sub 是 managed SaaS、無法本機 docker 驗證，本文 config 依 &lt;a href="https://cloud.google.com/pubsub/docs/subscriber">Pub/Sub 官方文件&lt;/a> 與下列 production case、最後檢查日 2026-06-16；引數與計費以官方為準。&lt;/p>&lt;/blockquote>
&lt;h2 id="push-vs-pull-不是實作偏好">push vs pull 不是實作偏好&lt;/h2>
&lt;p>把 Pub/Sub 的 subscription 設成 push 還是 pull，常被當成「看團隊習慣」的實作選擇。但它其實是一個關於下游容量的判讀。差別在流量控制權在誰手上：push subscription 由 Pub/Sub 主動把訊息 HTTP POST 到目標 endpoint——流量節奏由 Pub/Sub 決定，尖峰時瞬間打過來；pull subscription 由 consumer 主動拉，要拉多少、多快由 consumer 自己控制。&lt;/p>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-mercari-line-flow-control/" data-link-title="3.C65 Mercari LINE：Pull subscription 對齊外部 RPS" data-link-desc="Mercari LINE webhook 轉 Pub/Sub、worker pull subscription 精確控制 RPS、應 LINE API 限制。">Mercari 的 LINE 整合&lt;/a>把這個判讀講得很具體：Braze webhook 進來轉成 Pub/Sub event，下游要呼叫 LINE API——而 &lt;strong>LINE API 有 RPS 限制&lt;/strong>。如果用 push，Pub/Sub 會把訊息瞬間打到 worker、worker 再打 LINE、直接超過 LINE 的 RPS 上限。所以他們用 pull subscription，worker「精確控制每秒處理訊息數」來對齊 LINE 的限制。這個案例揭露的原則是——&lt;strong>push vs pull 不是實作偏好，是「下游能不能承受 push 的流量衝擊」的判讀&lt;/strong>：下游有速率限制、處理能力有限、或需要平滑流量，就走 pull 自我節流。&lt;/p>
&lt;p>本文展開 subscription 模型、ack deadline、flow control 與 dead-letter topic——這些決定了訊息怎麼被可靠地、以下游能承受的速度消費。&lt;/p>
&lt;h2 id="核心概念subscriptionack-deadline-與-flow-control">核心概念：subscription、ack deadline 與 flow control&lt;/h2>
&lt;p>Pub/Sub 把「topic（發布）」跟「subscription（訂閱）」分開，可靠消費的旋鈕都在 subscription 上。&lt;/p>
&lt;p>&lt;strong>一個 topic、多個 subscription、各自獨立&lt;/strong>。發布者發到 topic，每個 subscription 收到一份完整的訊息流、各自維護消費進度。這天然支援 fanout（多個服務各建一個 subscription）。&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/pubsub-mercari-item-feed-dlt/" data-link-title="3.C64 Mercari Item Feed：DLT 防 poison message 阻塞" data-link-desc="Mercari 商品 feed 同步、ack 整批 / nack 重送、重試多次仍失敗送 DLT、topic 同時當 load-leveling buffer。">Mercari 的另一個案例&lt;/a>還揭露 topic 的雙重角色——它同時是「dispatch」跟「load-leveling buffer」，突發流量先進 topic 緩衝、consumer 按自己節奏消化。&lt;/p>
&lt;p>&lt;strong>ack deadline 是 Pub/Sub 版的可見性逾時&lt;/strong>。consumer 收到訊息後，有一段 ack deadline 來處理並 &lt;code>ack&lt;/code>。在 deadline 內沒 ack，Pub/Sub 重新投遞（at-least-once）。跟 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/aws-sqs/visibility-polling-lambda-cost/" data-link-title="AWS SQS：Visibility timeout、long polling 與 Lambda event source 的成本與失敗形狀" data-link-desc="SQS deep article：visibility timeout 對齊 consumer 處理時間（ChangeMessageVisibility）、long vs short polling 的 cost 取捨（WaitTimeSeconds）、SQS &amp;#43; Lambda event source mapping（batch size / batch window / 並行 ramp-up）、DLQ &amp;#43; redrive policy（maxReceiveCount）、message size 與 extended client、per-request cost 模型；含 5 個 production 故障演練（VT &amp;lt; 處理時間 redelivery、polling 設定省成本、Lambda 部分失敗整批重投、DLQ maxReceiveCount、FIFO 吞吐上限）">SQS visibility timeout&lt;/a> 同樣是雙邊風險：太短→處理中就重投、太長→失敗後恢復慢。處理中可用 &lt;code>modifyAckDeadline&lt;/code>（client library 通常自動 lease extension）延長。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Google Cloud Pub/Sub</a> overview 的 implementation-layer deep article。選型層（Pub/Sub vs Kafka / SQS）見 overview；本文只處理「決定用 Pub/Sub 後，subscription 與 ack 怎麼設」。Pub/Sub 是 managed SaaS、無法本機 docker 驗證，本文 config 依 <a href="https://cloud.google.com/pubsub/docs/subscriber">Pub/Sub 官方文件</a> 與下列 production case、最後檢查日 2026-06-16；引數與計費以官方為準。</p></blockquote>
<h2 id="push-vs-pull-不是實作偏好">push vs pull 不是實作偏好</h2>
<p>把 Pub/Sub 的 subscription 設成 push 還是 pull，常被當成「看團隊習慣」的實作選擇。但它其實是一個關於下游容量的判讀。差別在流量控制權在誰手上：push subscription 由 Pub/Sub 主動把訊息 HTTP POST 到目標 endpoint——流量節奏由 Pub/Sub 決定，尖峰時瞬間打過來；pull subscription 由 consumer 主動拉，要拉多少、多快由 consumer 自己控制。</p>
<p><a href="/blog/backend/03-message-queue/cases/pubsub-mercari-line-flow-control/" data-link-title="3.C65 Mercari LINE：Pull subscription 對齊外部 RPS" data-link-desc="Mercari LINE webhook 轉 Pub/Sub、worker pull subscription 精確控制 RPS、應 LINE API 限制。">Mercari 的 LINE 整合</a>把這個判讀講得很具體：Braze webhook 進來轉成 Pub/Sub event，下游要呼叫 LINE API——而 <strong>LINE API 有 RPS 限制</strong>。如果用 push，Pub/Sub 會把訊息瞬間打到 worker、worker 再打 LINE、直接超過 LINE 的 RPS 上限。所以他們用 pull subscription，worker「精確控制每秒處理訊息數」來對齊 LINE 的限制。這個案例揭露的原則是——<strong>push vs pull 不是實作偏好，是「下游能不能承受 push 的流量衝擊」的判讀</strong>：下游有速率限制、處理能力有限、或需要平滑流量，就走 pull 自我節流。</p>
<p>本文展開 subscription 模型、ack deadline、flow control 與 dead-letter topic——這些決定了訊息怎麼被可靠地、以下游能承受的速度消費。</p>
<h2 id="核心概念subscriptionack-deadline-與-flow-control">核心概念：subscription、ack deadline 與 flow control</h2>
<p>Pub/Sub 把「topic（發布）」跟「subscription（訂閱）」分開，可靠消費的旋鈕都在 subscription 上。</p>
<p><strong>一個 topic、多個 subscription、各自獨立</strong>。發布者發到 topic，每個 subscription 收到一份完整的訊息流、各自維護消費進度。這天然支援 fanout（多個服務各建一個 subscription）。<a href="/blog/backend/03-message-queue/cases/pubsub-mercari-item-feed-dlt/" data-link-title="3.C64 Mercari Item Feed：DLT 防 poison message 阻塞" data-link-desc="Mercari 商品 feed 同步、ack 整批 / nack 重送、重試多次仍失敗送 DLT、topic 同時當 load-leveling buffer。">Mercari 的另一個案例</a>還揭露 topic 的雙重角色——它同時是「dispatch」跟「load-leveling buffer」，突發流量先進 topic 緩衝、consumer 按自己節奏消化。</p>
<p><strong>ack deadline 是 Pub/Sub 版的可見性逾時</strong>。consumer 收到訊息後，有一段 ack deadline 來處理並 <code>ack</code>。在 deadline 內沒 ack，Pub/Sub 重新投遞（at-least-once）。跟 <a href="/blog/backend/03-message-queue/vendors/aws-sqs/visibility-polling-lambda-cost/" data-link-title="AWS SQS：Visibility timeout、long polling 與 Lambda event source 的成本與失敗形狀" data-link-desc="SQS deep article：visibility timeout 對齊 consumer 處理時間（ChangeMessageVisibility）、long vs short polling 的 cost 取捨（WaitTimeSeconds）、SQS &#43; Lambda event source mapping（batch size / batch window / 並行 ramp-up）、DLQ &#43; redrive policy（maxReceiveCount）、message size 與 extended client、per-request cost 模型；含 5 個 production 故障演練（VT &lt; 處理時間 redelivery、polling 設定省成本、Lambda 部分失敗整批重投、DLQ maxReceiveCount、FIFO 吞吐上限）">SQS visibility timeout</a> 同樣是雙邊風險：太短→處理中就重投、太長→失敗後恢復慢。處理中可用 <code>modifyAckDeadline</code>（client library 通常自動 lease extension）延長。</p>
<p><strong>flow control 限制 client 端同時持有的未 ack 量</strong>。pull subscription 的 client library 可設 <code>max_outstanding_messages</code> / <code>max_outstanding_bytes</code>——consumer 最多同時持有多少未 ack 訊息。這是 consumer 端自我節流的旋鈕，避免一次拉太多撐爆自己或下游。Mercari 對齊 LINE RPS 靠的就是這層控制。</p>
<p><strong>dead-letter topic（DLT）給毒訊息出口</strong>。subscription 設 dead-letter policy（<code>maxDeliveryAttempts</code> + dead-letter topic）後，重投超過上限的訊息被轉到 DLT，不再阻塞後續。Mercari item feed 正是「重試多次仍失敗送 DLT、後續訊息優先處理」——避免 poison message 卡住 pipeline。</p>
<h2 id="配置subscription--ack-deadline--dlt依官方文件">配置：subscription + ack deadline + DLT（依官方文件）</h2>
<p>Pub/Sub 是 managed、以下 gcloud 依官方文件（未本機 docker 驗證、引數以官方為準）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 1. 建 topic + dead-letter topic</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">gcloud pubsub topics create orders
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">gcloud pubsub topics create orders-dlt
</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"><span class="c1"># 2. pull subscription：ack deadline + dead-letter policy</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">gcloud pubsub subscriptions create orders-worker <span class="se">\
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="se"></span>  --topic<span class="o">=</span>orders <span class="se">\
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="se"></span>  --ack-deadline<span class="o">=</span><span class="m">60</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="se"></span>  --dead-letter-topic<span class="o">=</span>orders-dlt <span class="se">\
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="se"></span>  --max-delivery-attempts<span class="o">=</span><span class="m">5</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"># 3. consumer 端 flow control（client library、以 Python 為例、概念跨語言一致）</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1">#    flow_control = FlowControl(max_messages=100, max_bytes=10*1024*1024)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1">#    subscriber.subscribe(sub_path, callback=handle, flow_control=flow_control)</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="c1">#    handle 內：處理成功 message.ack()、失敗 message.nack()</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="c1"># push subscription（僅當下游能承受 Pub/Sub 主動推的流量時）：</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="c1"># gcloud pubsub subscriptions create orders-push \</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="c1">#   --topic=orders --push-endpoint=https://my-svc/handler --ack-deadline=60</span></span></span></code></pre></div><p>判讀：</p>
<ul>
<li>下游有 RPS 限制 / 處理能力有限 → pull + flow control（self-throttle，Mercari 模式）</li>
<li>下游能吸收推送尖峰、要 serverless 簡單 → push</li>
<li><code>ack-deadline</code> 略高於處理時間；長任務靠 client library 的 lease extension</li>
<li><code>max-delivery-attempts</code> + DLT 給毒訊息出口</li>
</ul>
<h2 id="production-故障演練">Production 故障演練</h2>
<h3 id="case-1用-push下游被瞬間流量打爆">Case 1：用 push、下游被瞬間流量打爆</h3>
<p><strong>徵兆</strong>：流量尖峰時下游 endpoint 5xx 暴增、或下游的第三方 API 回 429（rate limited），訊息大量重投惡化。</p>
<p><strong>根因</strong>：用 push subscription，Pub/Sub 把訊息瞬間 POST 到 endpoint，超過下游（或下游依賴的外部 API）的處理 / 速率上限。正是 <a href="/blog/backend/03-message-queue/cases/pubsub-mercari-line-flow-control/" data-link-title="3.C65 Mercari LINE：Pull subscription 對齊外部 RPS" data-link-desc="Mercari LINE webhook 轉 Pub/Sub、worker pull subscription 精確控制 RPS、應 LINE API 限制。">Mercari LINE</a> 要避開的情形。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>下游有速率限制改用 pull subscription + flow control，由 consumer 自我節流</li>
<li>flow control 的 <code>max_outstanding_messages</code> 對齊下游能承受的並發</li>
<li>push 只用在下游能吸收推送尖峰的場景</li>
<li>push 場景下游要自己擋（rate limit / 佇列），不能假設 Pub/Sub 會幫你平滑</li>
</ol>
<h3 id="case-2ack-deadline-太短訊息處理中就被重投">Case 2：ack deadline 太短、訊息處理中就被重投</h3>
<p><strong>徵兆</strong>：同一則訊息被處理多次，尤其處理較慢時；訂閱的 redelivery 指標偏高。</p>
<p><strong>根因</strong>：ack deadline 設得比處理時間短，訊息在處理途中 deadline 到期、Pub/Sub 重投。跟 SQS visibility timeout 太短同類。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>ack deadline 設成略高於處理時間 p99</li>
<li>用 client library 的自動 lease extension（modifyAckDeadline）處理長尾任務</li>
<li>消費端冪等——at-least-once 本來就可能重投（見 <a href="/blog/backend/06-reliability/idempotency-replay/" data-link-title="6.12 Idempotency 與 Replay 驗證" data-link-desc="把重試、重播與冪等性從口頭約定變成可驗證屬性">6.12 idempotency</a>）</li>
<li>監控 redelivery 率，偏高代表 deadline 偏短或處理變慢</li>
</ol>
<h3 id="case-3沒設-dlt毒訊息一直重投阻塞">Case 3：沒設 DLT、毒訊息一直重投阻塞</h3>
<p><strong>徵兆</strong>：某則訊息一直失敗、一直被重投，後續訊息處理被拖慢。</p>
<p><strong>根因</strong>：subscription 沒設 dead-letter policy。處理失敗（nack 或沒 ack）的訊息一再重投、沒有上限與出口，毒訊息反覆消耗 consumer。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>設 dead-letter policy（<code>max-delivery-attempts</code> + DLT），重投達上限轉 DLT</li>
<li>DLT 是另一個 topic，要有處理 / 告警流程（Mercari「送 DLT、後續訊息優先處理」）</li>
<li><code>max-delivery-attempts</code> 平衡暫時性失敗重試與毒訊息隔離</li>
<li>對照 <a href="/blog/backend/03-message-queue/vendors/aws-sqs/visibility-polling-lambda-cost/" data-link-title="AWS SQS：Visibility timeout、long polling 與 Lambda event source 的成本與失敗形狀" data-link-desc="SQS deep article：visibility timeout 對齊 consumer 處理時間（ChangeMessageVisibility）、long vs short polling 的 cost 取捨（WaitTimeSeconds）、SQS &#43; Lambda event source mapping（batch size / batch window / 並行 ramp-up）、DLQ &#43; redrive policy（maxReceiveCount）、message size 與 extended client、per-request cost 模型；含 5 個 production 故障演練（VT &lt; 處理時間 redelivery、polling 設定省成本、Lambda 部分失敗整批重投、DLQ maxReceiveCount、FIFO 吞吐上限）">SQS redrive</a>：兩者都是 managed 原生 DLQ/DLT、比自建省事</li>
</ol>
<h3 id="case-4flow-control-沒設consumer-一次拉太多撐爆">Case 4：flow control 沒設、consumer 一次拉太多撐爆</h3>
<p><strong>徵兆</strong>：consumer 記憶體暴增 / OOM，或一次拉太多把下游打爆。</p>
<p><strong>根因</strong>：pull subscription 沒設 flow control，client library 預設可能持有大量未 ack 訊息，consumer 端記憶體與下游壓力失控。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>設 <code>max_outstanding_messages</code> / <code>max_outstanding_bytes</code> 限制同時持有量</li>
<li>對齊 consumer 處理能力與下游容量（Mercari 對齊 LINE RPS）</li>
<li>監控 consumer 記憶體與未 ack 數，調 flow control 參數</li>
<li>flow control 是 pull 自我節流的核心，不設等於放棄背壓</li>
</ol>
<h3 id="case-5誤用-ordering-key吞吐受限">Case 5：誤用 ordering key、吞吐受限</h3>
<p><strong>徵兆</strong>：開了 message ordering 後吞吐明顯下降、特定 ordering key 的訊息處理變慢。</p>
<p><strong>根因</strong>：Pub/Sub 的順序保證是 per-ordering-key 的——同一個 ordering key 的訊息嚴格按序、必須序列處理（前一則 ack 才處理下一則）。把所有訊息塞同一個 ordering key 等於序列化整條流、吞吐崩。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>ordering key 用細粒度（per-entity，如 per-user），讓不同 key 可並行</li>
<li>不需要嚴格順序的就別開 ordering（預設無序、吞吐高）</li>
<li>評估順序需求的真實範圍——多數場景只需 per-entity 順序，不是全域</li>
<li>嚴格全域順序 + 高吞吐有本質衝突，重新審視需求或走 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka</a> 的 partition 模型</li>
</ol>
<h2 id="capacity--cost-邊界">Capacity / cost 邊界</h2>
<p>Pub/Sub 的容量判讀（managed、無 broker 運維）：</p>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>健康區間</th>
          <th>警戒與動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>subscription backlog（未 ack 數 / 最舊訊息 age）</td>
          <td>在 SLA 內</td>
          <td>持續成長 → consumer 跟不上、加 consumer / 調 flow control</td>
      </tr>
      <tr>
          <td>redelivery 率</td>
          <td>低</td>
          <td>偏高 → ack deadline 太短 / 下游失敗</td>
      </tr>
      <tr>
          <td>DLT 深度</td>
          <td>低且有處理流程</td>
          <td>成長 → 上游系統性失敗</td>
      </tr>
      <tr>
          <td>consumer 記憶體 / 未 ack 量</td>
          <td>在 flow control 限制內</td>
          <td>暴增 → flow control 沒設好</td>
      </tr>
      <tr>
          <td>訊息量（計費基礎）</td>
          <td>對齊預算</td>
          <td>暴增 → 評估 throughput 計費、batch / 壓縮</td>
      </tr>
  </tbody>
</table>
<p>撞牆後的路由判斷：</p>
<ul>
<li><strong>需要長期保留 + 任意 replay</strong>：Pub/Sub 有 retention（可設、seek 到時間點）但事件流長期 replay + 生態走 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka</a>。</li>
<li><strong>嚴格全域順序 + 高吞吐</strong>：Pub/Sub ordering 是 per-key 序列化，全域順序高吞吐走 Kafka partition 設計。</li>
<li><strong>不在 GCP 生態</strong>：Pub/Sub 綁 GCP，跨雲走 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka</a> / <a href="/blog/backend/03-message-queue/vendors/nats/" data-link-title="NATS" data-link-desc="Lightweight messaging、JetStream 加持久化與 streams">NATS</a> 或對應雲的 managed（<a href="/blog/backend/03-message-queue/vendors/aws-sqs/" data-link-title="AWS SQS" data-link-desc="AWS managed queue、簡單可靠、無 ordering（standard）">SQS</a>）。</li>
<li><strong>複雜 routing（topic exchange 式）</strong>：Pub/Sub 是 topic→subscription 扇出，複雜 routing 規則走 <a href="/blog/backend/03-message-queue/vendors/rabbitmq/" data-link-title="RabbitMQ" data-link-desc="Classic message broker、AMQP routing 為主">RabbitMQ</a> exchange。</li>
</ul>
<h2 id="整合--下一步">整合 / 下一步</h2>
<p>push/pull 判讀與 ack 是 Pub/Sub 可靠消費的核心，它跟其他議題交織：</p>
<ul>
<li><strong>跟 <a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer design</a></strong>：push/pull、ack deadline、flow control 是 consumer 設計的具體選項。</li>
<li><strong>跟 <a href="/blog/backend/06-reliability/idempotency-replay/" data-link-title="6.12 Idempotency 與 Replay 驗證" data-link-desc="把重試、重播與冪等性從口頭約定變成可驗證屬性">6.12 idempotency / replay</a></strong>：at-least-once + 重投要求消費冪等。</li>
<li><strong>跟 <a href="/blog/backend/03-message-queue/vendors/aws-sqs/visibility-polling-lambda-cost/" data-link-title="AWS SQS：Visibility timeout、long polling 與 Lambda event source 的成本與失敗形狀" data-link-desc="SQS deep article：visibility timeout 對齊 consumer 處理時間（ChangeMessageVisibility）、long vs short polling 的 cost 取捨（WaitTimeSeconds）、SQS &#43; Lambda event source mapping（batch size / batch window / 並行 ramp-up）、DLQ &#43; redrive policy（maxReceiveCount）、message size 與 extended client、per-request cost 模型；含 5 個 production 故障演練（VT &lt; 處理時間 redelivery、polling 設定省成本、Lambda 部分失敗整批重投、DLQ maxReceiveCount、FIFO 吞吐上限）">SQS visibility timeout</a></strong>：ack deadline 對應 visibility timeout、DLT 對應 redrive，兩個 managed queue 的可靠消費模型高度對位、可對照閱讀。</li>
<li><strong>跟 webhook buffer 模式</strong>：Pub/Sub topic 當 load-leveling buffer（Mercari）對應 <a href="/blog/backend/03-message-queue/cases/sqs-twilio-webhook-buffer/" data-link-title="3.C58 Twilio：SQS 緩衝高流量 webhook" data-link-desc="Twilio 教用 SQS 緩衝 SMS / status callback webhook、分 queue（SMS vs callback）、long polling 減 cost、FIFO 300 TPS 上限要分片。">SQS Twilio webhook buffer</a>——把不可控的外部 webhook 流量先緩衝再按自己節奏消化。</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li>上游 vendor 頁：<a href="/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Google Cloud Pub/Sub</a></li>
<li>對照 vendor：<a href="/blog/backend/03-message-queue/vendors/aws-sqs/visibility-polling-lambda-cost/" data-link-title="AWS SQS：Visibility timeout、long polling 與 Lambda event source 的成本與失敗形狀" data-link-desc="SQS deep article：visibility timeout 對齊 consumer 處理時間（ChangeMessageVisibility）、long vs short polling 的 cost 取捨（WaitTimeSeconds）、SQS &#43; Lambda event source mapping（batch size / batch window / 並行 ramp-up）、DLQ &#43; redrive policy（maxReceiveCount）、message size 與 extended client、per-request cost 模型；含 5 個 production 故障演練（VT &lt; 處理時間 redelivery、polling 設定省成本、Lambda 部分失敗整批重投、DLQ maxReceiveCount、FIFO 吞吐上限）">AWS SQS visibility timeout</a>、<a href="/blog/backend/03-message-queue/vendors/rabbitmq/dlq-retry-escalation/" data-link-title="RabbitMQ DLQ 與分層 retry：別把失敗訊息 requeue 回隊首" data-link-desc="RabbitMQ 處理失敗訊息最常見的錯是直接 requeue 回原隊列——它回到隊首、反覆失敗、把後面的訊息全卡住（head-of-line blocking）。正解是用 dead-letter exchange &#43; TTL 組出 work → delay → DLQ 的分層 escalation。本文展開 DLX 求值模型、實機驗證的三層拓樸、5 個把 retry 寫成無限迴圈與隊列阻塞的 production 踩坑，以及 retry 拓樸的容量邊界">RabbitMQ DLQ</a></li>
<li>對應案例：<a href="/blog/backend/03-message-queue/cases/pubsub-mercari-line-flow-control/" data-link-title="3.C65 Mercari LINE：Pull subscription 對齊外部 RPS" data-link-desc="Mercari LINE webhook 轉 Pub/Sub、worker pull subscription 精確控制 RPS、應 LINE API 限制。">3.C65 Mercari LINE flow control</a>、<a href="/blog/backend/03-message-queue/cases/pubsub-mercari-item-feed-dlt/" data-link-title="3.C64 Mercari Item Feed：DLT 防 poison message 阻塞" data-link-desc="Mercari 商品 feed 同步、ack 整批 / nack 重送、重試多次仍失敗送 DLT、topic 同時當 load-leveling buffer。">3.C64 Mercari item feed DLT</a></li>
<li>上游概念：<a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer design</a></li>
</ul>
]]></content:encoded></item><item><title>Kafka Consumer Group Rebalance 與 Lag 診斷：從 protocol 到故障演練</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/consumer-rebalance-lag-diagnosis/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/consumer-rebalance-lag-diagnosis/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka&lt;/a> overview「進階主題」的 implementation-layer deep article，承接 overview「Consumer lag 暴增」與「Rebalance storm」兩段判讀原則的展開。Overview 給判讀方向，本文給 protocol 機制、診斷指令與故障演練。&lt;/p>&lt;/blockquote>
&lt;h2 id="rebalance-是-consumer-group-重新分配-partition-所有權的協調過程">Rebalance 是 consumer group 重新分配 partition 所有權的協調過程&lt;/h2>
&lt;p>Rebalance 是 consumer group coordinator 把 topic 的 partition 重新分配給 group 內 consumer 的協調動作，承擔「在成員數變動時維持每個 partition 恰好被一個 consumer 消費」這個責任。觸發條件是 group membership 改變：consumer 加入、consumer 離開、consumer 被判定失效，或 topic partition 數增加。Rebalance 完成前，受影響的 partition 暫停消費，這段空窗就是 rebalance 對 lag 的直接代價。&lt;/p>
&lt;p>Consumer group 是 Kafka 把「一份 event stream 分給多個 worker 平行處理」與「同一份 stream 給多個獨立應用各自 replay」兩種需求統一的抽象。同一個 group 內的 consumer 瓜分 partition、彼此不重複消費；不同 group 各自維護 offset、互不干擾。Rebalance 只在 group 內部發生，調整的是 group 內 partition 對 consumer 的 mapping。本文聚焦 group 內 rebalance 的機制與診斷，group 概念本身見 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer-group/" data-link-title="Consumer Group" data-link-desc="說明一組 consumer 如何共同分攤 stream 或 topic 的處理責任">consumer group 知識卡&lt;/a>。&lt;/p>
&lt;p>實機觀察 partition 如何在兩個 consumer 間分配：同一 group 起兩個 consumer，coordinator 把 3 個 partition 拆給它們。&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">GROUP CONSUMER-ID CLIENT-ID #PARTITIONS CURRENT-ASSIGNMENT
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">live-cg consumer-A-... consumer-A 2 orders:0,1
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">live-cg consumer-B-... consumer-B 1 orders:2
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">GROUP ASSIGNMENT-STRATEGY STATE #MEMBERS
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">live-cg range Stable 2&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>consumer-A 拿到 partition 0、1，consumer-B 拿到 partition 2，STATE 是 Stable 代表 rebalance 已收斂。&lt;code>ASSIGNMENT-STRATEGY&lt;/code> 顯示 range，是預設的 partition 分配演算法。&lt;/p>
&lt;h2 id="eager-與-cooperative-incremental-是兩種-rebalance-protocol">Eager 與 cooperative incremental 是兩種 rebalance protocol&lt;/h2>
&lt;p>Rebalance protocol 決定「rebalance 期間 consumer 要不要交出手上全部 partition」，這個選擇直接決定 rebalance 的 stop-the-world 範圍。Kafka 提供兩種：eager 與 cooperative incremental。&lt;/p>
&lt;p>Eager rebalance 是早期預設行為：rebalance 觸發時，group 內所有 consumer 先放棄手上全部 partition（revoke all），等 coordinator 算完新分配後再各自重新 assign。代價是 rebalance 期間整個 group 完全停止消費，即使某個 consumer 的 partition 在新舊分配中根本沒變，它也得先放掉再拿回。Group 規模越大、partition 越多，這個全停窗口越痛。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka</a> overview「進階主題」的 implementation-layer deep article，承接 overview「Consumer lag 暴增」與「Rebalance storm」兩段判讀原則的展開。Overview 給判讀方向，本文給 protocol 機制、診斷指令與故障演練。</p></blockquote>
<h2 id="rebalance-是-consumer-group-重新分配-partition-所有權的協調過程">Rebalance 是 consumer group 重新分配 partition 所有權的協調過程</h2>
<p>Rebalance 是 consumer group coordinator 把 topic 的 partition 重新分配給 group 內 consumer 的協調動作，承擔「在成員數變動時維持每個 partition 恰好被一個 consumer 消費」這個責任。觸發條件是 group membership 改變：consumer 加入、consumer 離開、consumer 被判定失效，或 topic partition 數增加。Rebalance 完成前，受影響的 partition 暫停消費，這段空窗就是 rebalance 對 lag 的直接代價。</p>
<p>Consumer group 是 Kafka 把「一份 event stream 分給多個 worker 平行處理」與「同一份 stream 給多個獨立應用各自 replay」兩種需求統一的抽象。同一個 group 內的 consumer 瓜分 partition、彼此不重複消費；不同 group 各自維護 offset、互不干擾。Rebalance 只在 group 內部發生，調整的是 group 內 partition 對 consumer 的 mapping。本文聚焦 group 內 rebalance 的機制與診斷，group 概念本身見 <a href="/blog/backend/knowledge-cards/consumer-group/" data-link-title="Consumer Group" data-link-desc="說明一組 consumer 如何共同分攤 stream 或 topic 的處理責任">consumer group 知識卡</a>。</p>
<p>實機觀察 partition 如何在兩個 consumer 間分配：同一 group 起兩個 consumer，coordinator 把 3 個 partition 拆給它們。</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">GROUP    CONSUMER-ID    CLIENT-ID    #PARTITIONS  CURRENT-ASSIGNMENT
</span></span><span class="line"><span class="ln">2</span><span class="cl">live-cg  consumer-A-... consumer-A   2            orders:0,1
</span></span><span class="line"><span class="ln">3</span><span class="cl">live-cg  consumer-B-... consumer-B   1            orders:2
</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">GROUP    ASSIGNMENT-STRATEGY  STATE    #MEMBERS
</span></span><span class="line"><span class="ln">6</span><span class="cl">live-cg  range                Stable   2</span></span></code></pre></div><p>consumer-A 拿到 partition 0、1，consumer-B 拿到 partition 2，STATE 是 Stable 代表 rebalance 已收斂。<code>ASSIGNMENT-STRATEGY</code> 顯示 range，是預設的 partition 分配演算法。</p>
<h2 id="eager-與-cooperative-incremental-是兩種-rebalance-protocol">Eager 與 cooperative incremental 是兩種 rebalance protocol</h2>
<p>Rebalance protocol 決定「rebalance 期間 consumer 要不要交出手上全部 partition」，這個選擇直接決定 rebalance 的 stop-the-world 範圍。Kafka 提供兩種：eager 與 cooperative incremental。</p>
<p>Eager rebalance 是早期預設行為：rebalance 觸發時，group 內所有 consumer 先放棄手上全部 partition（revoke all），等 coordinator 算完新分配後再各自重新 assign。代價是 rebalance 期間整個 group 完全停止消費，即使某個 consumer 的 partition 在新舊分配中根本沒變，它也得先放掉再拿回。Group 規模越大、partition 越多，這個全停窗口越痛。</p>
<p>Cooperative incremental rebalance 改成「只 revoke 真正要換手的 partition」。Consumer 先回報自己想保留的 partition，coordinator 算出哪些 partition 需要從 A 搬到 B，只有這些 partition 經歷一次 revoke + reassign，其餘 partition 持續消費不中斷。代價是一次完整 rebalance 可能需要兩輪（第一輪 revoke、第二輪 assign），但每輪只影響少數 partition，整體可用性遠高於 eager。Kafka 2.4 起的 <code>CooperativeStickyAssignor</code> 實作這套協議。</p>
<p>實機驗證 cooperative-sticky 可由 consumer 端 config 啟用，<code>ASSIGNMENT-STRATEGY</code> 欄位反映實際生效的策略：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">kafka-console-consumer.sh --topic orders --bootstrap-server localhost:9092 <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --group coop-cg <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --consumer-property partition.assignment.strategy<span class="o">=</span>org.apache.kafka.clients.consumer.CooperativeStickyAssignor</span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">GROUP    ASSIGNMENT-STRATEGY  STATE    #MEMBERS
</span></span><span class="line"><span class="ln">2</span><span class="cl">coop-cg  cooperative-sticky   Stable   1</span></span></code></pre></div><p>選 protocol 的判準是 group 規模與消費中斷的容忍度：</p>
<table>
  <thead>
      <tr>
          <th>Protocol</th>
          <th>revoke 範圍</th>
          <th>rebalance 期間消費</th>
          <th>適用</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Eager (range / sticky)</td>
          <td>全部 partition</td>
          <td>全停</td>
          <td>小 group、partition 少、rebalance 不頻繁</td>
      </tr>
      <tr>
          <td>Cooperative incremental</td>
          <td>僅換手 partition</td>
          <td>未換手 partition 持續</td>
          <td>大 group、partition 多、要求消費連續性</td>
      </tr>
  </tbody>
</table>
<p>對 partition 數上百、consumer 數十的 group，eager 的全停窗口會讓每次 deploy 都產生明顯 lag spike。Walmart 每天 trillions of message、25K+ consumer 跑在 K8s，pod scaling 與 deploy 觸發的 rebalance 是最大痛點（<a href="/blog/backend/03-message-queue/cases/kafka-walmart-mps-rebalance/" data-link-title="3.C17 Walmart：Messaging Proxy Service 解 rebalance storm" data-link-desc="Walmart 每天 trillions of message、25K&#43; consumer 在 K8s、partition-consumer 1:1 模型撞到擴張極限。">3.C17</a>）；這種規模下 eager 的全停代價無法接受，cooperative 把中斷限縮到換手 partition 是基本要求。但 Walmart 進一步發現，即使換成 cooperative，partition-consumer 1:1 模型本身在 K8s 規模仍撞到擴張極限，最終把 consumer 解耦成 stateless service。Protocol 選擇降低單次 rebalance 代價，架構解耦才解決 rebalance 頻率本身。</p>
<p>切換 protocol 不能直接全量改：eager 與 cooperative 的 consumer 不能在同一 group 共存。滾動升級時，consumer 需先支援兩種 protocol、再分批切換 config，否則混用會導致 rebalance 失敗或 assignment 不一致。</p>
<h2 id="三個-timeout-各自負責不同的失效判定">三個 timeout 各自負責不同的失效判定</h2>
<p>Consumer 存活由三個 timeout 共同把關，每個負責不同層次的失效訊號，混為一談是 rebalance 誤判的主要來源。</p>
<p><code>session.timeout.ms</code> 是 coordinator 等待 consumer heartbeat 的上限。Consumer 背景執行緒週期性送 heartbeat，coordinator 在這個時間內沒收到就判定 consumer 死亡、觸發 rebalance。預設 45 秒（早期版本 10 秒）。值太小，短暫 GC pause 或網路抖動就誤判離線；值太大，真正死掉的 consumer 要拖很久才被踢出，lag 持續累積。</p>
<p><code>heartbeat.interval.ms</code> 是 consumer 送 heartbeat 的頻率，必須明顯小於 <code>session.timeout.ms</code>，慣例設成 1/3。它決定 coordinator 多快能感知 consumer 變化，也決定 rebalance 訊號的傳播速度。值太大，session window 內 heartbeat 次數不足，容錯空間消失。</p>
<p><code>max.poll.interval.ms</code> 是兩次 <code>poll()</code> 呼叫之間的上限，負責偵測「consumer 活著但卡住」。Consumer 主執行緒在 <code>poll()</code> 之間處理拉到的訊息，如果單批處理太久（下游 I/O 慢、batch 太大、業務邏輯重）超過這個時間，coordinator 判定 consumer 失去處理能力、把它踢出 group。預設 5 分鐘。它跟 session.timeout.ms 的分工是：heartbeat 偵測「行程是否還在」，max.poll.interval 偵測「行程是否還在前進」。</p>
<table>
  <thead>
      <tr>
          <th>Timeout</th>
          <th>偵測對象</th>
          <th>預設</th>
          <th>調整方向</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>session.timeout.ms</code></td>
          <td>heartbeat 是否中斷</td>
          <td>45000</td>
          <td>環境抖動大調高、要求快速偵測死亡調低</td>
      </tr>
      <tr>
          <td><code>heartbeat.interval.ms</code></td>
          <td>heartbeat 傳送頻率</td>
          <td>3000</td>
          <td>維持在 session.timeout 的 1/3 左右</td>
      </tr>
      <tr>
          <td><code>max.poll.interval.ms</code></td>
          <td>兩次 poll 的間隔</td>
          <td>300000</td>
          <td>單批處理慢就調高，或縮小 max.poll.records</td>
      </tr>
  </tbody>
</table>
<p>這三個值的常見錯配，是把處理變慢誤當成 consumer 死亡。下游 DB 變慢導致每批處理超過 <code>max.poll.interval.ms</code>，consumer 被踢出觸發 rebalance，partition 搬到別的 consumer，那個 consumer 同樣被同一個慢下游拖垮，再次被踢，形成連環 rebalance。這種情況調 <code>session.timeout.ms</code> 沒用，因為 heartbeat 執行緒一直正常送；要調的是 <code>max.poll.interval.ms</code> 或縮小 <code>max.poll.records</code> 讓單批更快做完。</p>
<h2 id="static-group-membership-讓-consumer-重啟不觸發-rebalance">Static group membership 讓 consumer 重啟不觸發 rebalance</h2>
<p>Static membership 給 consumer 一個固定身分 <code>group.instance.id</code>，讓 coordinator 在 consumer 短暫離線後保留它的 partition 分配，承擔「滾動重啟與短暫中斷不觸發 rebalance」的責任。沒有 static membership 時，consumer 每次重啟都產生一個新的 member id，coordinator 視為「舊成員離開、新成員加入」、觸發兩次 rebalance。</p>
<p>設定方式是給每個 consumer 一個跨重啟穩定的 <code>group.instance.id</code>。Coordinator 看到帶 instance id 的 consumer 離線時，不立即 revoke 它的 partition，而是等到 <code>session.timeout.ms</code> 真正超時才判定永久離線。在這個窗口內 consumer 帶同一個 instance id 回來，直接接回原本的 partition，不觸發 rebalance。</p>
<p>實機驗證 <code>group.instance.id</code> 生效後，<code>--members</code> 輸出多出 <code>GROUP-INSTANCE-ID</code> 欄位：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">kafka-console-consumer.sh --topic orders --bootstrap-server localhost:9092 <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --group static-cg --consumer-property group.instance.id<span class="o">=</span>static-member-1</span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">GROUP      CONSUMER-ID            GROUP-INSTANCE-ID  CLIENT-ID  #PARTITIONS
</span></span><span class="line"><span class="ln">2</span><span class="cl">static-cg  static-member-1-...    static-member-1    static-A   3</span></span></code></pre></div><p>static membership 的關鍵搭配是把 <code>session.timeout.ms</code> 設得比預期的重啟時間長。K8s 滾動更新一個 pod 重啟可能 10-30 秒，session.timeout.ms 要涵蓋這段，否則 pod 還在重啟、coordinator 已判定永久離線、partition 已搬走，static membership 失去意義。代價是真正死掉的 consumer 也要拖到 session.timeout.ms 才被踢出，這段 partition 無人消費。Static membership 用「容忍較長的真實故障偵測延遲」換「消除重啟造成的 rebalance」，適合重啟頻繁但硬故障罕見的環境。</p>
<h2 id="用-kafka-consumer-groupssh-讀-lag-分布">用 kafka-consumer-groups.sh 讀 lag 分布</h2>
<p>診斷 lag 的起點是 <code>kafka-consumer-groups.sh --describe</code>，它逐 partition 列出 current offset、log end offset 與兩者差值 lag，承擔「定位 lag 集中在哪、規模多大」的責任。Lag 是某 partition 已產出的最新 offset 減去 consumer 已 commit 的 offset，代表還沒被消費的訊息量。</p>
<p>實機製造 lag：produce 30 筆訊息、consumer 只消費 12 筆就停掉，<code>--describe</code> 顯示逐 partition 的消費進度落後：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group analytics-cg</span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">GROUP         TOPIC   PARTITION  CURRENT-OFFSET  LOG-END-OFFSET  LAG  CONSUMER-ID
</span></span><span class="line"><span class="ln">2</span><span class="cl">analytics-cg  orders  0          9               9               0    -
</span></span><span class="line"><span class="ln">3</span><span class="cl">analytics-cg  orders  1          3               9               6    -
</span></span><span class="line"><span class="ln">4</span><span class="cl">analytics-cg  orders  2          0               12              12   -</span></span></code></pre></div><p>這份輸出本身就是診斷的第一個分岔點：lag 是均勻分布還是集中在少數 partition。這裡 partition 0 lag=0、partition 1 lag=6、partition 2 lag=12，明顯集中在後兩個 partition，指向 partition 層的不平衡而非整體 consumer 不足。</p>
<p><code>--state</code> 看 group 的健康狀態與分配策略，<code>--members --verbose</code> 看每個 consumer 實際拿到哪些 partition：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group live-cg --state</span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">GROUP    COORDINATOR (ID)     ASSIGNMENT-STRATEGY  STATE    #MEMBERS
</span></span><span class="line"><span class="ln">2</span><span class="cl">live-cg  localhost:9092 (1)   range                Stable   2</span></span></code></pre></div><p>STATE 的取值是診斷訊號：<code>Stable</code> 代表分配已收斂正常消費；<code>PreparingRebalance</code> / <code>CompletingRebalance</code> 代表正在 rebalance；<code>Empty</code> 代表 group 沒有 active member（offset 還在但沒人消費），對應上面 lag 輸出裡 <code>CONSUMER-ID</code> 全是 <code>-</code> 的情況。看到 lag 持續累積又長期停在 rebalance 狀態，問題就在 rebalance 本身而非消費速度。</p>
<h2 id="lag-均勻分布與集中單一-partition-指向不同根因">Lag 均勻分布與集中單一 partition 指向不同根因</h2>
<p>Lag 的分布形狀是診斷的主軸：均勻分布指向消費總能力不足，集中在少數 partition 指向 key 分布或單 partition 的局部問題。同樣是 lag 高，這兩種形狀的修法完全相反，先讀分布再決定方向。</p>
<p>Lag 均勻分布在所有 partition，代表 consumer group 整體消費速度跟不上 producer 寫入速度。根因在消費側的總吞吐：consumer 數量不足、單 consumer 處理慢（CPU / GC / 下游 I/O）、或 producer 突發流量超過 group 設計容量。修法是擴消費能力：加 consumer（上限是 partition 數）、優化單筆處理、或對下游加 batch。如果 lag 隨時間線性成長且各 partition 同步成長，是穩態的容量不足，要重新評估 partition 數與 consumer 數。</p>
<p>Lag 集中在少數 partition、其餘 partition lag 接近零，代表負載不均，根因通常在 key 分布。Producer 用 key 決定 partition（<code>hash(key) % partition_count</code>），如果某些 key 是熱點（例如某個大客戶的 id、某個 null key 全落同一 partition），對應 partition 的訊息量遠高於其他，負責它的 consumer 再快也追不上，而其他 consumer 閒著。加 consumer 不解決這個問題，因為瓶頸 partition 仍只能被一個 consumer 消費。修法在 key 設計：拆熱點 key、加 salt 打散、或對熱點走獨立 topic。</p>
<p>Airbnb 的 logging pipeline 遇到的正是 partition 層 skew：event size 從幾百 bytes 到幾百 KB、QPS 跨數個量級，Spark 一個 partition 對一個 task，造成 data skew，catch-up 一個 4 小時 lag 要再花 4 小時（<a href="/blog/backend/03-message-queue/cases/kafka-airbnb-spark-streaming-rebalance/" data-link-title="3.C15 Airbnb：Spark Streaming Kafka reader rebalance" data-link-desc="Airbnb logging pipeline 解 partition-task 1:1 造成的 data skew、catch-up 4 小時 lag 要再花 4 小時的反效率。">3.C15</a>）。它的解法揭露一個關鍵判準：partition 數不該等同 consumer parallelism。當 lag 集中在少數重 partition，加 consumer 受限於 partition 數的天花板無效，要把 parallelism 從 partition 數解耦、按 event volume × size 重新分派 work。這把「lag 集中」的診斷從 key 分布延伸到了 work 分派模型本身。</p>
<table>
  <thead>
      <tr>
          <th>Lag 分布形狀</th>
          <th>根因方向</th>
          <th>修法</th>
          <th>加 consumer 是否有效</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>均勻分布、各 partition 相近</td>
          <td>消費總能力不足</td>
          <td>加 consumer、優化處理、batch 下游</td>
          <td>有效（上限 partition 數）</td>
      </tr>
      <tr>
          <td>集中少數 partition</td>
          <td>key 分布熱點 / data skew</td>
          <td>拆 key、salt、熱點獨立 topic、解耦 parallelism</td>
          <td>無效（瓶頸 partition 仍單線）</td>
      </tr>
  </tbody>
</table>
<p>判讀順序固定：先 <code>--describe</code> 看分布形狀，再決定往「擴容」還是「重分布」走。跳過分布判讀直接加 consumer，遇到熱點 partition 場景會白花資源還解不了 lag。</p>
<h2 id="production-故障演練">Production 故障演練</h2>
<h3 id="case-1consumer-處理慢被踢出-group-形成-rebalance-連環">Case 1：consumer 處理慢被踢出 group 形成 rebalance 連環</h3>
<p>徵兆：consumer log 反覆出現 <code>Member ... sending LeaveGroup request</code> 與 <code>Attempt to heartbeat failed since group is rebalancing</code>；lag 持續成長；group STATE 在 <code>Stable</code> 與 <code>PreparingRebalance</code> 之間反覆跳；同一批 partition 在不同 consumer 間反覆搬移。</p>
<p>根因：下游 I/O 變慢（DB 連線池打滿、外部 API 延遲升高），consumer 單批 <code>poll()</code> 後處理超過 <code>max.poll.interval.ms</code>（預設 5 分鐘），coordinator 判定該 consumer 失去處理能力、踢出 group、觸發 rebalance。partition 搬到另一個 consumer，後者面對同樣慢的下游、同樣超時被踢，rebalance 連環觸發，每次 rebalance 又讓所有 consumer 暫停消費，lag 加速惡化。</p>
<p>修法：</p>
<ol>
<li>確認瓶頸是處理慢而非 heartbeat 中斷：consumer log 若有正常 heartbeat 但仍被踢，問題在 <code>max.poll.interval.ms</code> 不是 <code>session.timeout.ms</code>。</li>
<li>縮小 <code>max.poll.records</code>：一次拉少一點，讓單批在 <code>max.poll.interval.ms</code> 內做完，這是不改下游就能止血的第一步。</li>
<li>拉高 <code>max.poll.interval.ms</code>：給單批更長處理時間，但這只是延後而非解決，要搭配下游修復。</li>
<li>修復下游根因：DB 連線池、外部 API 超時、batch 寫入策略，這才是消除連環 rebalance 的根本。</li>
</ol>
<h3 id="case-2lag-集中單一-partition加-consumer-無效">Case 2：lag 集中單一 partition、加 consumer 無效</h3>
<p>徵兆：<code>--describe</code> 顯示一兩個 partition lag 數十萬、其餘 partition lag 接近零；加了 consumer 之後 lag 不降，新 consumer 處於閒置（<code>--members</code> 顯示它分到的 partition 都沒 lag）。</p>
<p>根因：producer 的 key 分布有熱點，大量訊息落在同一 partition。Partition 是 Kafka 平行消費的最小單位，一個 partition 只能被 group 內一個 consumer 消費，熱點 partition 的消費速度被單 consumer 鎖死，加再多 consumer 都分不到這個 partition 的工作。</p>
<p>修法：</p>
<ol>
<li><code>--describe</code> 確認 lag 集中形狀，排除「整體容量不足」的均勻分布情境。</li>
<li>找出熱點 key：抽樣訊息看 key 分布，常見是 null key（全落同一 partition）或單一大租戶 id。</li>
<li>重設計 key：對熱點加 salt 打散到多 partition，或讓熱點走獨立 topic 用更多 partition。</li>
<li>若 work 本身有 skew（單筆訊息處理成本差異大），把 parallelism 從 partition 數解耦，按工作量重新分派，如 Airbnb 的 balanced reader（<a href="/blog/backend/03-message-queue/cases/kafka-airbnb-spark-streaming-rebalance/" data-link-title="3.C15 Airbnb：Spark Streaming Kafka reader rebalance" data-link-desc="Airbnb logging pipeline 解 partition-task 1:1 造成的 data skew、catch-up 4 小時 lag 要再花 4 小時的反效率。">3.C15</a>）。</li>
</ol>
<blockquote>
<p>key 重分布需要 producer 端配合改 key 策略，對既有 topic 是破壞性變更（舊訊息 key 不變），通常搭配新 topic 切換。本文未實機驗證 producer key 重設計的線上切換流程，依官方分區語義說明。</p></blockquote>
<h3 id="case-3deploy-每次都產生-lag-spike">Case 3：deploy 每次都產生 lag spike</h3>
<p>徵兆：每次滾動部署 consumer 服務，lag 在部署窗口內明顯上升、部署完成後緩慢回落；group STATE 在部署期間進入 rebalance；部署越頻繁，累積 lag 越明顯。</p>
<p>根因：每個 consumer pod 重啟，coordinator 看到舊 member 離開、新 member 加入，觸發 rebalance；若用 eager protocol，每次 rebalance 全 group 停止消費；滾動部署逐個重啟 N 個 pod 就觸發 N 次 rebalance，每次全停，lag 在這串全停窗口中累積。</p>
<p>修法：</p>
<ol>
<li>啟用 static membership：給每個 consumer 固定 <code>group.instance.id</code>，重啟時帶同一身分回來、不觸發 rebalance。</li>
<li>把 <code>session.timeout.ms</code> 設得比 pod 重啟時間長：涵蓋 K8s 重啟一個 pod 的 10-30 秒，否則 static membership 在窗口內失效。</li>
<li>切換到 cooperative incremental protocol：即使仍有 rebalance，只有換手 partition 中斷，未換手 partition 持續消費。</li>
<li>控制部署並行度：一次重啟太多 pod 會放大同時 rebalance 的影響，分批滾動。</li>
</ol>
<p>Walmart 在 25K+ consumer 規模下，正是 pod scaling / deploy / heartbeat fail 三類事件持續觸發 rebalance lag spike（<a href="/blog/backend/03-message-queue/cases/kafka-walmart-mps-rebalance/" data-link-title="3.C17 Walmart：Messaging Proxy Service 解 rebalance storm" data-link-desc="Walmart 每天 trillions of message、25K&#43; consumer 在 K8s、partition-consumer 1:1 模型撞到擴張極限。">3.C17</a>）；static membership 與 cooperative 降低單次代價，但它最終把 consumer 解耦成可獨立 auto-scale 的 stateless service，從架構層消除 rebalance 與 partition 數的綁定。</p>
<h3 id="case-4scale-to-zero-後冷啟動-lag">Case 4：scale-to-zero 後冷啟動 lag</h3>
<p>徵兆：低流量時段 consumer 被縮到 0，流量回來時 lag 已累積一批、需要一段 catch-up；autoscaler 若看 CPU / memory 反應遲鈍，因為 sink 多為 I/O bottleneck、CPU 平坦不觸發擴容。</p>
<p>根因：event-driven workload 的工作量是 backlog（lag）而非 resource usage。用 CPU / memory 當 scaling signal，在 I/O-bound 的 sink consumer 上失靈：訊息堆積但 CPU 不高，autoscaler 不動，lag 持續成長。</p>
<p>修法：</p>
<ol>
<li>用 consumer lag 當 scaling signal：lag 超過閾值就擴 consumer、lag 清空就縮，直接對齊工作量。</li>
<li>接受 scale-to-zero 的冷啟動 lag 為設計取捨：minReplicaCount=0 省下 idle 成本，代價是流量回來時的 catch-up 窗口，對非即時 sink 可接受。</li>
<li>設 lag 閾值與擴容步長：閾值太高 catch-up 久、太低頻繁擴縮，依 SLA 對 backlog 的容忍度設定。</li>
</ol>
<p>Trivago 跨 3 region 跑 50+ Kafka sink、每個 always-on 用 1 CPU + 1 GB，CPU/mem autoscaling 對 I/O-bound sink 無效；改用 KEDA 以 consumer lag 為 scaling signal、minReplicaCount=0 達到 scale-to-zero，daily replica-hour 從 50 降到 1-2（<a href="/blog/backend/03-message-queue/cases/kafka-trivago-keda-scale-to-zero/" data-link-title="3.C22 Trivago：KEDA scale-to-zero by Kafka lag" data-link-desc="Trivago 50&#43; Kafka sink、CPU/mem autoscaling 無效（I/O bottleneck）、KEDA 以 consumer lag 為訊號達到 scale-to-zero。">3.C22</a>）。這個案例的判準是 resource usage 不等於工作量，event-driven 場景該看 backlog signal。</p>
<h2 id="capacity-與-cost">Capacity 與 cost</h2>
<p>Rebalance 與 lag 的容量規劃圍繞三個變數：partition 數、consumer 數、單次 rebalance 的中斷成本。partition 數是消費平行度的天花板，consumer 數超過 partition 數時多出的 consumer 閒置，所以 partition 數要按峰值需要的平行度規劃，但 partition 過多會推高 metadata 壓力與 rebalance 計算成本。</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>估算</th>
          <th>警戒</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Consumer 數上限</td>
          <td>等於 partition 數，超出即閒置</td>
          <td>consumer = partition 仍跟不上要加 partition</td>
      </tr>
      <tr>
          <td>Eager rebalance 中斷</td>
          <td>全 group 停止消費直到分配收斂</td>
          <td>partition 多、group 大時窗口顯著</td>
      </tr>
      <tr>
          <td>Cooperative rebalance</td>
          <td>僅換手 partition 中斷，可能兩輪</td>
          <td>換手比例高時優勢縮小</td>
      </tr>
      <tr>
          <td>session.timeout.ms 窗口</td>
          <td>consumer 死亡到被踢出、partition 無人消費</td>
          <td>設太大則故障偵測慢、lag 累積</td>
      </tr>
      <tr>
          <td>加 partition 的代價</td>
          <td>提高平行度上限，但增加 rebalance 與 metadata 成本</td>
          <td>過度分區推高 controller 壓力</td>
      </tr>
  </tbody>
</table>
<p>實務 default：partition 數按峰值平行度設、保留成長餘量但不過度分區；consumer 數對齊 partition 數、用 lag 而非 CPU 當 autoscaling signal；rebalance 頻繁的環境優先 static membership + cooperative，再評估是否需要把 consumer 從 partition 解耦。加 partition 是單向操作（無法縮回），且改變既有 key 的 partition 對應，要在規劃期一次設足而非事後頻繁調整。</p>
<h2 id="整合與下一步">整合與下一步</h2>
<p>Rebalance 與 lag 診斷接在 consumer 設計與交付語義之上：commit 策略決定 lag 的計算基準與 rebalance 後的重複消費風險，交付語義決定 rebalance 中斷期間訊息是否可能丟失或重放。</p>
<h3 id="跟-consumer-設計對位">跟 consumer 設計對位</h3>
<p><a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計</a> 涵蓋 commit 策略（auto vs manual）、commit 時機與 partition 分配的整體設計。本文的 rebalance 是 consumer 設計在「成員變動」維度的展開，lag 是 commit 進度的可觀測量。commit 策略選錯會在 rebalance 後放大重複消費或丟失。</p>
<h3 id="跟交付與復原語義對位">跟交付與復原語義對位</h3>
<p><a href="/blog/backend/03-message-queue/processing-recovery-semantics/" data-link-title="3.6 Processing Semantics 與 Recovery Semantics" data-link-desc="說明投遞成功、處理成功與恢復成功為何是三個不同判斷。">3.6 processing 與 recovery 語義</a> 涵蓋 rebalance 中斷期間的 at-least-once / at-most-once 行為。rebalance revoke partition 時，未 commit 的進度會在新 consumer 接手後重放（at-least-once）；commit 太早則可能在 rebalance 中丟失（at-most-once）。idempotency 與 replay 的整體設計見 <a href="/blog/backend/06-reliability/idempotency-replay/" data-link-title="6.12 Idempotency 與 Replay 驗證" data-link-desc="把重試、重播與冪等性從口頭約定變成可驗證屬性">6.12 idempotency / replay</a>。</p>
<h3 id="相關案例">相關案例</h3>
<ul>
<li><a href="/blog/backend/03-message-queue/cases/kafka-airbnb-spark-streaming-rebalance/" data-link-title="3.C15 Airbnb：Spark Streaming Kafka reader rebalance" data-link-desc="Airbnb logging pipeline 解 partition-task 1:1 造成的 data skew、catch-up 4 小時 lag 要再花 4 小時的反效率。">3.C15 Airbnb Spark Streaming</a> — partition-task 1:1 造成 data skew、parallelism 從 partition 數解耦</li>
<li><a href="/blog/backend/03-message-queue/cases/kafka-walmart-mps-rebalance/" data-link-title="3.C17 Walmart：Messaging Proxy Service 解 rebalance storm" data-link-desc="Walmart 每天 trillions of message、25K&#43; consumer 在 K8s、partition-consumer 1:1 模型撞到擴張極限。">3.C17 Walmart MPS</a> — 25K+ consumer 在 K8s 的 rebalance storm、consumer 解耦成 stateless service</li>
<li><a href="/blog/backend/03-message-queue/cases/kafka-trivago-keda-scale-to-zero/" data-link-title="3.C22 Trivago：KEDA scale-to-zero by Kafka lag" data-link-desc="Trivago 50&#43; Kafka sink、CPU/mem autoscaling 無效（I/O bottleneck）、KEDA 以 consumer lag 為訊號達到 scale-to-zero。">3.C22 Trivago KEDA</a> — consumer lag 驅動 scale-to-zero、backlog signal 取代 resource usage</li>
</ul>
<h3 id="相關連結">相關連結</h3>
<ul>
<li>上游 vendor 頁：<a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Apache Kafka</a></li>
<li>知識卡：<a href="/blog/backend/knowledge-cards/consumer-lag/" data-link-title="Consumer Lag" data-link-desc="說明 consumer lag 如何反映訊息堆積、處理能力與容量風險">consumer lag</a>、<a href="/blog/backend/knowledge-cards/consumer-group/" data-link-title="Consumer Group" data-link-desc="說明一組 consumer 如何共同分攤 stream 或 topic 的處理責任">consumer group</a>、<a href="/blog/backend/knowledge-cards/partition/" data-link-title="Partition" data-link-desc="說明事件流如何切分成多個可並行處理的有序片段">partition</a></li>
<li>下游能力：<a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計</a>、<a href="/blog/backend/03-message-queue/processing-recovery-semantics/" data-link-title="3.6 Processing Semantics 與 Recovery Semantics" data-link-desc="說明投遞成功、處理成功與恢復成功為何是三個不同判斷。">3.6 processing 與 recovery 語義</a></li>
</ul>
]]></content:encoded></item><item><title>KeyDB active-active 多主複製：last-write-wins 會默默吃掉哪一筆寫入</title><link>https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/keydb/active-active-replication/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/keydb/active-active-replication/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/keydb/" data-link-title="KeyDB" data-link-desc="Redis multi-threaded fork、active-replication、Snap 採用">KeyDB&lt;/a> overview 的 implementation-layer deep article。選型層（KeyDB vs Redis / DragonflyDB / Valkey、為何選 fork）見 overview；本文只處理「決定用 KeyDB active-active 後，衝突與一致性怎麼判」。命令實機驗證於 eqalpha/keydb image、最後檢查日 2026-06-16；複製機制以 &lt;a href="https://docs.keydb.dev/docs/active-rep/">KeyDB active-replication 文件&lt;/a> 為準。&lt;/p>&lt;/blockquote>
&lt;h2 id="兩邊都能寫聽起來太美好">兩邊都能寫，聽起來太美好&lt;/h2>
&lt;p>Redis 的複製是單向的：一個 master 寫、replica 唯讀。要跨區讓兩邊都能就近寫入，Redis 本身做不到（得靠應用層分區或外部工具）。KeyDB 的 active-active 把這個限制拿掉——兩個（含以上）KeyDB 節點都是 master、都能接受寫入、互相把寫入同步給對方。對「兩個 region 都要低延遲寫入同一份 cache」的場景，這聽起來解決了所有問題。&lt;/p>
&lt;p>問題藏在「兩邊同時寫同一個 key」的那一刻。active-active 沒有全域協調者來仲裁誰對誰錯，它用 last-write-wins（LWW）：比較兩筆寫入的時間戳，留下較晚的、默默丟掉較早的。多數時候沒事，但當兩個 region 在幾毫秒內各自更新同一個 key，其中一筆寫入會無聲消失——沒有錯誤、沒有日誌、application 以為自己寫成功了。&lt;/p>
&lt;p>理解 KeyDB active-active 就是理解這個取捨：它用 LWW 換到了「兩邊都能寫」的可用性，代價是放棄了強一致與「不丟寫入」的保證。本文展開複製機制、衝突語意，以及哪些資料放得進這個模型、哪些放進去就是 bug。&lt;/p>
&lt;h2 id="核心概念active-active-的複製與衝突語意">核心概念：active-active 的複製與衝突語意&lt;/h2>
&lt;p>active-active 不是「分散式交易」，它是「雙向非同步複製 + LWW 衝突解決」。理解它要抓三個點：&lt;/p>
&lt;p>&lt;strong>每個節點都是 active-replica&lt;/strong>。一般 Redis replica 是唯讀的；KeyDB 的 active-replica 既接受本地寫入、又接收對方的複製流。兩個節點互相設定對方為 master，形成雙向複製環。實機看到的 role 就是 &lt;code>active-replica&lt;/code>（不是 master / slave）。&lt;/p>
&lt;p>&lt;strong>複製是非同步的&lt;/strong>。本地寫入立即回 OK 給 client，之後才非同步傳給對方節點。這意味著兩個節點之間永遠有一個複製延遲窗口——在這個窗口內，兩邊看到的資料可能不同。這是 active-active 是 AP（可用性 + 分區容忍）而非 CP 的根本原因。&lt;/p>
&lt;p>&lt;strong>衝突用 last-write-wins 解決&lt;/strong>。同一個 key 在兩個節點被並發修改時，KeyDB 比較版本，保留較晚的寫入、丟棄較早的。沒有 merge、沒有 vector clock、沒有 application callback——就是比誰較晚。KeyDB 用 hybrid logical clock（HLC）排序、不是純 wall-clock，但 HLC 仍綁節點實體時鐘——時鐘不同步（clock skew）會直接影響哪一筆被判定為「較晚」。同步的是 key 的「值」不是「操作」，這也是為什麼並發 INCR 會互相覆蓋而非累加（見故障演練 Case 1）。&lt;/p>
&lt;p>&lt;strong>每筆寫入帶來源標記避免無限迴圈&lt;/strong>。A 的寫入同步給 B 後，B 不會再把它當成新寫入傳回 A（否則會無限循環）。KeyDB 用來源標記處理這個，但複製拓樸設計錯（例如環狀多節點）仍可能放大流量。&lt;/p>
&lt;h2 id="配置兩節點-active-active-的設定路徑">配置：兩節點 active-active 的設定路徑&lt;/h2>
&lt;p>實機驗證的最小雙主設定（兩個節點互相複製）：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 節點 A 與 B 都開 active-replica + multi-master&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">docker run -d --name kdb-a --network kdbnet -p 6401:6379 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> eqalpha/keydb keydb-server --active-replica yes --multi-master yes
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">docker run -d --name kdb-b --network kdbnet -p 6402:6379 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> eqalpha/keydb keydb-server --active-replica yes --multi-master yes
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="c1"># 互相指向對方（形成雙向複製）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">keydb-cli -p &lt;span class="m">6401&lt;/span> replicaof kdb-b &lt;span class="m">6379&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">keydb-cli -p &lt;span class="m">6402&lt;/span> replicaof kdb-a &lt;span class="m">6379&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>實機驗證雙向同步（最後檢查日 2026-06-16）：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 寫 A、讀 B&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">keydb-cli -p &lt;span class="m">6401&lt;/span> SET fromA hello &lt;span class="c1"># → OK&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">keydb-cli -p &lt;span class="m">6402&lt;/span> GET fromA &lt;span class="c1"># → hello （A 的寫入同步到 B）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="c1"># 寫 B、讀 A（雙向）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">keydb-cli -p &lt;span class="m">6402&lt;/span> SET fromB world &lt;span class="c1"># → OK&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">keydb-cli -p &lt;span class="m">6401&lt;/span> GET fromB &lt;span class="c1"># → world （B 的寫入同步到 A）&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="c1"># 確認 role 與複製鏈路&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">keydb-cli -p &lt;span class="m">6401&lt;/span> INFO replication &lt;span class="p">|&lt;/span> grep -E &lt;span class="s2">&amp;#34;role|master_link_status|connected_slaves&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="c1"># role:active-replica&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="c1"># master_link_status:up&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="c1"># connected_slaves:1&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>兩個節點都回報 &lt;code>role:active-replica&lt;/code>（不是傳統的 master / slave），&lt;code>master_link_status:up&lt;/code> 確認複製鏈路健康。寫入任一節點、另一節點都讀得到，這就是 active-active 的核心行為。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/02-cache-redis/vendors/keydb/" data-link-title="KeyDB" data-link-desc="Redis multi-threaded fork、active-replication、Snap 採用">KeyDB</a> overview 的 implementation-layer deep article。選型層（KeyDB vs Redis / DragonflyDB / Valkey、為何選 fork）見 overview；本文只處理「決定用 KeyDB active-active 後，衝突與一致性怎麼判」。命令實機驗證於 eqalpha/keydb image、最後檢查日 2026-06-16；複製機制以 <a href="https://docs.keydb.dev/docs/active-rep/">KeyDB active-replication 文件</a> 為準。</p></blockquote>
<h2 id="兩邊都能寫聽起來太美好">兩邊都能寫，聽起來太美好</h2>
<p>Redis 的複製是單向的：一個 master 寫、replica 唯讀。要跨區讓兩邊都能就近寫入，Redis 本身做不到（得靠應用層分區或外部工具）。KeyDB 的 active-active 把這個限制拿掉——兩個（含以上）KeyDB 節點都是 master、都能接受寫入、互相把寫入同步給對方。對「兩個 region 都要低延遲寫入同一份 cache」的場景，這聽起來解決了所有問題。</p>
<p>問題藏在「兩邊同時寫同一個 key」的那一刻。active-active 沒有全域協調者來仲裁誰對誰錯，它用 last-write-wins（LWW）：比較兩筆寫入的時間戳，留下較晚的、默默丟掉較早的。多數時候沒事，但當兩個 region 在幾毫秒內各自更新同一個 key，其中一筆寫入會無聲消失——沒有錯誤、沒有日誌、application 以為自己寫成功了。</p>
<p>理解 KeyDB active-active 就是理解這個取捨：它用 LWW 換到了「兩邊都能寫」的可用性，代價是放棄了強一致與「不丟寫入」的保證。本文展開複製機制、衝突語意，以及哪些資料放得進這個模型、哪些放進去就是 bug。</p>
<h2 id="核心概念active-active-的複製與衝突語意">核心概念：active-active 的複製與衝突語意</h2>
<p>active-active 不是「分散式交易」，它是「雙向非同步複製 + LWW 衝突解決」。理解它要抓三個點：</p>
<p><strong>每個節點都是 active-replica</strong>。一般 Redis replica 是唯讀的；KeyDB 的 active-replica 既接受本地寫入、又接收對方的複製流。兩個節點互相設定對方為 master，形成雙向複製環。實機看到的 role 就是 <code>active-replica</code>（不是 master / slave）。</p>
<p><strong>複製是非同步的</strong>。本地寫入立即回 OK 給 client，之後才非同步傳給對方節點。這意味著兩個節點之間永遠有一個複製延遲窗口——在這個窗口內，兩邊看到的資料可能不同。這是 active-active 是 AP（可用性 + 分區容忍）而非 CP 的根本原因。</p>
<p><strong>衝突用 last-write-wins 解決</strong>。同一個 key 在兩個節點被並發修改時，KeyDB 比較版本，保留較晚的寫入、丟棄較早的。沒有 merge、沒有 vector clock、沒有 application callback——就是比誰較晚。KeyDB 用 hybrid logical clock（HLC）排序、不是純 wall-clock，但 HLC 仍綁節點實體時鐘——時鐘不同步（clock skew）會直接影響哪一筆被判定為「較晚」。同步的是 key 的「值」不是「操作」，這也是為什麼並發 INCR 會互相覆蓋而非累加（見故障演練 Case 1）。</p>
<p><strong>每筆寫入帶來源標記避免無限迴圈</strong>。A 的寫入同步給 B 後，B 不會再把它當成新寫入傳回 A（否則會無限循環）。KeyDB 用來源標記處理這個，但複製拓樸設計錯（例如環狀多節點）仍可能放大流量。</p>
<h2 id="配置兩節點-active-active-的設定路徑">配置：兩節點 active-active 的設定路徑</h2>
<p>實機驗證的最小雙主設定（兩個節點互相複製）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 節點 A 與 B 都開 active-replica + multi-master</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">docker run -d --name kdb-a --network kdbnet -p 6401:6379 <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  eqalpha/keydb keydb-server --active-replica yes --multi-master yes
</span></span><span class="line"><span class="ln">4</span><span class="cl">docker run -d --name kdb-b --network kdbnet -p 6402:6379 <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  eqalpha/keydb keydb-server --active-replica yes --multi-master yes
</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 class="c1"># 互相指向對方（形成雙向複製）</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">keydb-cli -p <span class="m">6401</span> replicaof kdb-b <span class="m">6379</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl">keydb-cli -p <span class="m">6402</span> replicaof kdb-a <span class="m">6379</span></span></span></code></pre></div><p>實機驗證雙向同步（最後檢查日 2026-06-16）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 寫 A、讀 B</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">keydb-cli -p <span class="m">6401</span> SET fromA hello   <span class="c1"># → OK</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">keydb-cli -p <span class="m">6402</span> GET fromA         <span class="c1"># → hello   （A 的寫入同步到 B）</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"># 寫 B、讀 A（雙向）</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">keydb-cli -p <span class="m">6402</span> SET fromB world   <span class="c1"># → OK</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">keydb-cli -p <span class="m">6401</span> GET fromB         <span class="c1"># → world   （B 的寫入同步到 A）</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="c1"># 確認 role 與複製鏈路</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">keydb-cli -p <span class="m">6401</span> INFO replication <span class="p">|</span> grep -E <span class="s2">&#34;role|master_link_status|connected_slaves&#34;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"># role:active-replica</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"># master_link_status:up</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1"># connected_slaves:1</span></span></span></code></pre></div><p>兩個節點都回報 <code>role:active-replica</code>（不是傳統的 master / slave），<code>master_link_status:up</code> 確認複製鏈路健康。寫入任一節點、另一節點都讀得到，這就是 active-active 的核心行為。</p>
<h2 id="production-故障演練">Production 故障演練</h2>
<h3 id="case-1並發寫同一-key一筆寫入無聲消失">Case 1：並發寫同一 key、一筆寫入無聲消失</h3>
<p><strong>徵兆</strong>：兩個 region 的 application 各自更新同一個 user 的 cache（例如 profile），事後發現其中一個 region 的更新「沒生效」——但寫入時 application 收到的是 OK，沒有任何錯誤。</p>
<p><strong>根因</strong>：active-active 的 LWW。兩筆寫入在複製延遲窗口內並發發生，KeyDB 比較時間戳保留較晚的、默默丟棄較早的。application 兩邊都以為自己寫成功了（本地確實 OK），但同步後只有一筆存活。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>不要讓同一個 key 被多個 region 並發寫——按 key 分區（user X 的寫入永遠路由到 region A），把多主退化成「就近讀 + 單點寫」</li>
<li>真的需要多點寫的計數器類資料，用 CRDT 語意的結構（KeyDB 的 LWW 不適合 counter，並發 INCR 會互相覆蓋而非累加）</li>
<li>接受 LWW 是 cache 的取捨——可重建的 cache 副本丟一筆寫入可回源重算，不可重建的資料不該放 active-active</li>
<li>衝突無聲是最危險的——加應用層的寫入審計（不靠 KeyDB 告警）</li>
</ol>
<h3 id="case-2clock-skew-讓較晚的判定錯亂">Case 2：clock skew 讓「較晚」的判定錯亂</h3>
<p><strong>徵兆</strong>：明明 region B 後寫的值，最後存活的卻是 region A 先寫的值——LWW 的「後寫者勝」失效。</p>
<p><strong>根因</strong>：LWW 比較時間戳，但兩個節點的系統時鐘若沒同步（clock skew），「較晚」的判定就錯了。B 的時鐘慢了 200ms，B 後寫的值帶的時間戳反而比 A 早，被判定為「較舊」丟棄。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>所有 KeyDB 節點強制 NTP 時鐘同步，把 skew 壓到毫秒級</li>
<li>監控節點間的時鐘偏差，skew 超過複製延遲就有 LWW 判定錯亂風險</li>
<li>對時間敏感的衝突，LWW 本質不可靠——時鐘永遠無法完美同步，這是 LWW 模型的固有弱點</li>
<li>需要正確衝突解決的場景，不要用 LWW 的 active-active，改強一致儲存</li>
</ol>
<h3 id="case-3複製延遲下的-stale-read">Case 3：複製延遲下的 stale read</h3>
<p><strong>徵兆</strong>：region A 寫入後，立刻有請求打到 region B 讀同一 key，讀到舊值；幾百毫秒後再讀才是新值。</p>
<p><strong>根因</strong>：active-active 是非同步複製，A 的寫入要經過網路傳到 B 才可見。在這個複製延遲窗口內，B 讀到的是 stale 值。跨 region 的延遲窗口比同 AZ 大得多。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>寫後需要立即一致讀的路徑，讀同一個寫入的節點（read-your-writes 綁定到寫入 region）</li>
<li>監控節點間複製延遲，跨 region 的延遲是 stale window 的下界</li>
<li>接受最終一致——這是 active-active 的本質，cache 場景多數可容忍短暫 stale</li>
<li>不可容忍 stale 的資料不適合 active-active，走單寫入點 + 跨區唯讀 replica</li>
</ol>
<h3 id="case-4複製拓樸設計錯流量放大或迴圈">Case 4：複製拓樸設計錯、流量放大或迴圈</h3>
<p><strong>徵兆</strong>：加了第三個 active 節點組成環狀後，節點間流量異常放大、CPU 升高，甚至同一筆寫入被反覆傳遞。</p>
<p><strong>根因</strong>：active-active 多節點（&gt; 2）的拓樸需要小心設計。全互連（full mesh）下每筆寫入要傳給所有其他節點、流量隨節點數平方成長；環狀拓樸若來源標記處理不當可能放大傳遞。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>多節點 active-active 優先用 full mesh 但控制節點數（active-active 不適合大量節點）</li>
<li>監控節點間複製流量，異常放大代表拓樸或來源標記問題</li>
<li>大規模多區優先考慮「每區單寫入點 + 跨區唯讀」而非全 active-active</li>
<li>active-active 的甜蜜點是 2-3 個區的雙向就近寫，不是大規模 mesh</li>
</ol>
<h3 id="case-5節點重連後的全量重同步衝擊">Case 5：節點重連後的全量重同步衝擊</h3>
<p><strong>徵兆</strong>：一個節點短暫斷線後重連，重連瞬間 CPU / 網路尖峰，期間延遲升高。</p>
<p><strong>根因</strong>：節點斷線時間過長、超過複製 backlog 能覆蓋的範圍，重連時要做全量重同步（full resync）——對方節點要產生快照（fork、見 <a href="/blog/backend/02-cache-redis/vendors/redis/persistence-fork-latency/" data-link-title="Redis 持久化與 fork latency：AOF、RDB 與那一次卡住整個 cluster 的 fork" data-link-desc="Redis 的 RDB save 與 AOF rewrite 都靠一次 fork()，而 fork 在大記憶體實例上會凍結主執行緒數百毫秒、複製分頁讓記憶體逼近翻倍。本文展開 AOF / RDB 的機制與 fsync 取捨、copy-on-write 的記憶體放大、5 個把持久化寫成延遲尖峰與資料遺失的 production 踩坑，以及 cache 場景到底要不要持久化的邊界">Redis persistence 的 fork 成本</a>，KeyDB 繼承 Redis 的 fork 機制）並傳輸整個 dataset。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>設足夠大的 <code>repl-backlog-size</code>，讓短暫斷線走部分同步（partial resync）而非全量</li>
<li>重同步的 fork 成本跟記憶體 headroom 相關，節點要留 fork 空間</li>
<li>監控 <code>master_link_status</code>，頻繁 down / up 代表網路不穩、要先修網路</li>
<li>跨 region 的 active-active 對網路穩定性敏感，不穩的鏈路會頻繁觸發重同步</li>
</ol>
<h2 id="capacity--cost-邊界">Capacity / cost 邊界</h2>
<p>active-active 的容量判讀，核心在衝突率與複製健康：</p>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>健康區間</th>
          <th>警戒與動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>同 key 跨節點並發寫入率</td>
          <td>接近 0（key 按區分區）</td>
          <td>高 → LWW 丟寫入風險、改 key 分區</td>
      </tr>
      <tr>
          <td>節點間 clock skew</td>
          <td>&lt; 複製延遲（毫秒級）</td>
          <td>大 → LWW 判定錯亂、強制 NTP</td>
      </tr>
      <tr>
          <td>節點間複製延遲</td>
          <td>跨 region 可接受的 stale 窗</td>
          <td>過大 → stale read 嚴重、檢查網路</td>
      </tr>
      <tr>
          <td><code>master_link_status</code></td>
          <td><code>up</code></td>
          <td>頻繁 down → 網路不穩、會觸發重同步</td>
      </tr>
      <tr>
          <td>active 節點數</td>
          <td>2-3（雙向就近寫）</td>
          <td>過多 → mesh 流量平方成長、改單寫入點拓樸</td>
      </tr>
  </tbody>
</table>
<p>撞牆後的路由判斷：</p>
<ul>
<li><strong>需要正確的衝突解決 / 不能丟寫入</strong>：LWW 不保證，走強一致儲存（<a href="/blog/backend/01-database/" data-link-title="模組一：資料庫與持久化" data-link-desc="整理 SQL、transaction、migration 與 repository adapter 的後端實務">database 模組</a> 的 multi-region 一致性方案）或單寫入點架構。</li>
<li><strong>需要 counter / 累加語意的多點寫</strong>：LWW 會讓並發 INCR 互相覆蓋，KeyDB active-active 不適合，改 CRDT 或單點 counter。</li>
<li><strong>跨 region 但可接受單寫入點</strong>：用 Redis / Valkey 的單向複製（一區寫、其他區唯讀），比 active-active 簡單且無衝突。</li>
<li><strong>大規模多區</strong>：active-active 的甜蜜點是 2-3 區，更大規模走 managed 的跨區方案（<a href="/blog/backend/02-cache-redis/vendors/aws-elasticache/" data-link-title="AWS ElastiCache" data-link-desc="AWS managed Redis / Valkey / Memcached">ElastiCache Global Datastore</a> 的 active-passive）。</li>
</ul>
<h2 id="整合--下一步">整合 / 下一步</h2>
<p>active-active 是 KeyDB 區別於 Redis 的核心能力之一，但它的取捨跨多個子系統：</p>
<ul>
<li><strong>跟 <a href="/blog/backend/02-cache-redis/vendors/keydb/" data-link-title="KeyDB" data-link-desc="Redis multi-threaded fork、active-replication、Snap 採用">KeyDB overview</a></strong>：overview 點到 active-active 是 last-write-wins、本文展開它什麼時候默默丟資料。</li>
<li><strong>跟 <a href="/blog/backend/02-cache-redis/vendors/redis/persistence-fork-latency/" data-link-title="Redis 持久化與 fork latency：AOF、RDB 與那一次卡住整個 cluster 的 fork" data-link-desc="Redis 的 RDB save 與 AOF rewrite 都靠一次 fork()，而 fork 在大記憶體實例上會凍結主執行緒數百毫秒、複製分頁讓記憶體逼近翻倍。本文展開 AOF / RDB 的機制與 fsync 取捨、copy-on-write 的記憶體放大、5 個把持久化寫成延遲尖峰與資料遺失的 production 踩坑，以及 cache 場景到底要不要持久化的邊界">Redis persistence / fork latency</a></strong>：KeyDB 繼承 Redis 的 fork 機制，節點重連的全量重同步付 fork 成本。</li>
<li><strong>跟 <a href="/blog/backend/02-cache-redis/cache-copy-freshness-boundary/" data-link-title="2.7 Cache Copy Boundary 與 Freshness" data-link-desc="說明快取何時只是可重建副本，何時會影響交易、權限或配額正確性。">cache copy boundary</a></strong>：active-active 的 stale window 與 LWW 丟寫入，本質是「cache 副本的新鮮度與一致性邊界」議題的多主版本。</li>
<li><strong>跟 <a href="/blog/backend/09-performance-capacity/cases/snap-gcp-keydb-cross-cloud/" data-link-title="9.C35 Snap：GCP &#43; KeyDB 在 multi-cloud 架構下的低延遲快取" data-link-desc="Snap 用 GCP 上的 KeyDB cluster 減少跨 cloud cache 延遲、用 TPU 訓練廣告推薦模型">Snap KeyDB cross-cloud case</a></strong>：Snap 用 KeyDB 的主因是 cross-cloud latency 治理（cache 與 application 共置），active-active 的雙向就近寫是這類 multi-cloud 場景的工具，但要按 key 分區避開 LWW 衝突。</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li>上游 vendor 頁：<a href="/blog/backend/02-cache-redis/vendors/keydb/" data-link-title="KeyDB" data-link-desc="Redis multi-threaded fork、active-replication、Snap 採用">KeyDB</a></li>
<li>對照 vendor：<a href="/blog/backend/02-cache-redis/vendors/dragonflydb/shared-nothing-multicore-architecture/" data-link-title="DragonflyDB shared-nothing 多核架構：用 scale-up 取代 Redis Cluster" data-link-desc="Redis 要靠 Cluster 分片才能用滿一台多核機器，DragonflyDB 賭的是相反方向——單一進程 thread-per-core、shared-nothing、把單機推到 Redis 要好幾個 shard 才達到的規模。本文展開 thread-per-core 與 dashtable 的架構、fork-less snapshot、5 個把架構假設寫成 production 事故的踩坑，以及 scale-up 撞牆該回 Cluster 的邊界">DragonflyDB 多核架構</a>、<a href="/blog/backend/02-cache-redis/vendors/redis/sentinel-ha-failover/" data-link-title="Redis Sentinel 與 failover 時序：從 master 死掉到 client 重連的每一段" data-link-desc="Redis Sentinel 的 failover 不是一個瞬間動作，是 down 偵測 → quorum 確認 → 選主 → 提升 → 配置廣播 → client 重連的一條時序鏈，每一段都有自己的延遲與失敗模式。本文展開 Sentinel 的判定模型與這條時序、5 個讓 failover 卡住或丟資料的 production 踩坑，以及 Sentinel 撐不住該往 Cluster 或 managed 走的邊界">Redis Sentinel failover</a>（單向複製的 HA）</li>
<li>上游概念：<a href="/blog/backend/02-cache-redis/cache-copy-freshness-boundary/" data-link-title="2.7 Cache Copy Boundary 與 Freshness" data-link-desc="說明快取何時只是可重建副本，何時會影響交易、權限或配額正確性。">2.7 cache copy boundary</a></li>
<li>Methodology：<a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章寫作方法論</a></li>
</ul>
]]></content:encoded></item><item><title>Memcached slab allocator 與記憶體經濟學：明明有記憶體卻在 evict</title><link>https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/memcached/slab-allocator-memory-economics/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/memcached/slab-allocator-memory-economics/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/memcached/" data-link-title="Memcached" data-link-desc="純記憶體 key-value cache、無持久化">Memcached&lt;/a> overview 的 implementation-layer deep article。選型層（純 KV vs Redis data types、何時選 Memcached）見 overview；本文只處理「決定用 Memcached 後，slab 記憶體怎麼配才不會莫名淘汰」。命令實機驗證於 &lt;code>memcached:1.6&lt;/code>（VERSION 1.6.42）、最後檢查日 2026-06-16；機制以 &lt;a href="https://github.com/memcached/memcached/wiki/UserInternals">Memcached 官方 wiki&lt;/a> 為準。&lt;/p>&lt;/blockquote>
&lt;h2 id="明明有記憶體卻在-evict">明明有記憶體、卻在 evict&lt;/h2>
&lt;p>Memcached 最違反直覺的故障是這樣：監控顯示 &lt;code>evictions&lt;/code> 持續上升、hit rate 在掉，但 &lt;code>stats&lt;/code> 算下來實際用掉的記憶體遠低於 &lt;code>-m&lt;/code> 設的上限——機器明明還有空間，Memcached 卻在淘汰資料。換成 Redis 思維的人會卡住，因為 Redis 是一個共用的記憶體池，不會出現「有空間卻淘汰」。&lt;/p>
&lt;p>這個現象叫 slab calcification，根因在 Memcached 的記憶體模型：它把記憶體預先切成許多固定大小的格子（slab class），每個 class 各自管自己那塊，跟 Redis 共用一個記憶體池的模型相反。記憶體一旦分配給某個 class，預設不會還回去給別的 class 用。如果你的 value 大小分布隨時間改變（早期都是小 value、後來都是大 value），早期被小 value 佔走的記憶體還鎖在小 class 裡，大 value 的 class 沒有足夠空間、開始淘汰——即使整體還有大量「屬於別人」的空閒記憶體。&lt;/p>
&lt;p>理解 Memcached 就是理解這套 slab 經濟學。它用「放棄記憶體的靈活性」換到了「永不碎片化、O(1) 分配、可預測的多執行緒擴展」。這個取捨在純 cache 場景非常划算，但它的失敗模式跟 Redis 完全不同，要用 slab 的語言來判讀。&lt;/p>
&lt;h2 id="核心概念slab-allocator-的會計模型">核心概念：slab allocator 的會計模型&lt;/h2>
&lt;p>Memcached 啟動時不會把 &lt;code>-m&lt;/code> 指定的記憶體一次配掉，而是按需求以 &lt;strong>page&lt;/strong>（預設 1MB）為單位分配給 &lt;strong>slab class&lt;/strong>，每個 class 存放某個大小區間的 item。&lt;/p>
&lt;p>&lt;strong>slab class 與 chunk size&lt;/strong>。每個 slab class 對應一個固定的 chunk size，item 被放進「裝得下它的最小 class」。class 的 chunk size 按 &lt;code>growth_factor&lt;/code> 等比成長——實機看預設值：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nb">printf&lt;/span> &lt;span class="s1">&amp;#39;stats settings\r\nquit\r\n&amp;#39;&lt;/span> &lt;span class="p">|&lt;/span> nc localhost &lt;span class="m">11211&lt;/span> &lt;span class="p">|&lt;/span> grep growth_factor
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1"># STAT growth_factor 1.25&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="nb">printf&lt;/span> &lt;span class="s1">&amp;#39;set k1 0 0 5\r\nhello\r\nstats slabs\r\nquit\r\n&amp;#39;&lt;/span> &lt;span class="p">|&lt;/span> nc localhost &lt;span class="m">11211&lt;/span> &lt;span class="p">|&lt;/span> grep -E &lt;span class="s2">&amp;#34;chunk_size|active_slabs&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="c1"># STAT 1:chunk_size 96 ← 最小的 slab class、chunk 96 bytes&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="c1"># STAT active_slabs 1&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>growth_factor 1.25&lt;/code> 表示每個 class 的 chunk size 是前一個的 1.25 倍：class 1 是 96 bytes、class 2 約 120、class 3 約 152……一路到 item 大小上限。一個 100 bytes 的 value 放不進 96 bytes 的 class 1，被放進 120 bytes 的 class 2——浪費 20 bytes。這個「向上取整到 chunk size」的浪費是 slab 模型的固有成本。&lt;/p>
&lt;p>&lt;strong>page 分配是單向的&lt;/strong>。當某個 class 需要空間，Memcached 給它一個 1MB 的 page，切成該 class 的 chunk。這個 page 預設永久屬於這個 class——這就是 calcification 的來源。&lt;code>-o slab_automove&lt;/code> 與手動 &lt;code>slabs reassign&lt;/code> 可以把 page 在 class 間搬移，但預設行為偏保守。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/02-cache-redis/vendors/memcached/" data-link-title="Memcached" data-link-desc="純記憶體 key-value cache、無持久化">Memcached</a> overview 的 implementation-layer deep article。選型層（純 KV vs Redis data types、何時選 Memcached）見 overview；本文只處理「決定用 Memcached 後，slab 記憶體怎麼配才不會莫名淘汰」。命令實機驗證於 <code>memcached:1.6</code>（VERSION 1.6.42）、最後檢查日 2026-06-16；機制以 <a href="https://github.com/memcached/memcached/wiki/UserInternals">Memcached 官方 wiki</a> 為準。</p></blockquote>
<h2 id="明明有記憶體卻在-evict">明明有記憶體、卻在 evict</h2>
<p>Memcached 最違反直覺的故障是這樣：監控顯示 <code>evictions</code> 持續上升、hit rate 在掉，但 <code>stats</code> 算下來實際用掉的記憶體遠低於 <code>-m</code> 設的上限——機器明明還有空間，Memcached 卻在淘汰資料。換成 Redis 思維的人會卡住，因為 Redis 是一個共用的記憶體池，不會出現「有空間卻淘汰」。</p>
<p>這個現象叫 slab calcification，根因在 Memcached 的記憶體模型：它把記憶體預先切成許多固定大小的格子（slab class），每個 class 各自管自己那塊，跟 Redis 共用一個記憶體池的模型相反。記憶體一旦分配給某個 class，預設不會還回去給別的 class 用。如果你的 value 大小分布隨時間改變（早期都是小 value、後來都是大 value），早期被小 value 佔走的記憶體還鎖在小 class 裡，大 value 的 class 沒有足夠空間、開始淘汰——即使整體還有大量「屬於別人」的空閒記憶體。</p>
<p>理解 Memcached 就是理解這套 slab 經濟學。它用「放棄記憶體的靈活性」換到了「永不碎片化、O(1) 分配、可預測的多執行緒擴展」。這個取捨在純 cache 場景非常划算，但它的失敗模式跟 Redis 完全不同，要用 slab 的語言來判讀。</p>
<h2 id="核心概念slab-allocator-的會計模型">核心概念：slab allocator 的會計模型</h2>
<p>Memcached 啟動時不會把 <code>-m</code> 指定的記憶體一次配掉，而是按需求以 <strong>page</strong>（預設 1MB）為單位分配給 <strong>slab class</strong>，每個 class 存放某個大小區間的 item。</p>
<p><strong>slab class 與 chunk size</strong>。每個 slab class 對應一個固定的 chunk size，item 被放進「裝得下它的最小 class」。class 的 chunk size 按 <code>growth_factor</code> 等比成長——實機看預設值：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="nb">printf</span> <span class="s1">&#39;stats settings\r\nquit\r\n&#39;</span> <span class="p">|</span> nc localhost <span class="m">11211</span> <span class="p">|</span> grep growth_factor
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># STAT growth_factor 1.25</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="nb">printf</span> <span class="s1">&#39;set k1 0 0 5\r\nhello\r\nstats slabs\r\nquit\r\n&#39;</span> <span class="p">|</span> nc localhost <span class="m">11211</span> <span class="p">|</span> grep -E <span class="s2">&#34;chunk_size|active_slabs&#34;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># STAT 1:chunk_size 96      ← 最小的 slab class、chunk 96 bytes</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"># STAT active_slabs 1</span></span></span></code></pre></div><p><code>growth_factor 1.25</code> 表示每個 class 的 chunk size 是前一個的 1.25 倍：class 1 是 96 bytes、class 2 約 120、class 3 約 152……一路到 item 大小上限。一個 100 bytes 的 value 放不進 96 bytes 的 class 1，被放進 120 bytes 的 class 2——浪費 20 bytes。這個「向上取整到 chunk size」的浪費是 slab 模型的固有成本。</p>
<p><strong>page 分配是單向的</strong>。當某個 class 需要空間，Memcached 給它一個 1MB 的 page，切成該 class 的 chunk。這個 page 預設永久屬於這個 class——這就是 calcification 的來源。<code>-o slab_automove</code> 與手動 <code>slabs reassign</code> 可以把 page 在 class 間搬移，但預設行為偏保守。</p>
<p><strong>LRU 是 per-slab-class 的</strong>。淘汰不是全域的，是每個 slab class 維護自己的 LRU。所以「class 2 滿了開始淘汰、但 class 5 有空閒 page」是正常現象——淘汰看的是該 class 自己的空間，不是全域記憶體。</p>
<p>這三點合起來解釋了開頭的悖論：evict 發生在某個 class 內，跟全域剩餘記憶體無關。</p>
<h2 id="配置slab-與多執行緒的設定路徑">配置：slab 與多執行緒的設定路徑</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 啟動參數（Memcached 的調校多在啟動參數、不像 Redis 有大量 runtime CONFIG SET）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">docker run -d --name memcached -p 11211:11211 memcached:1.6 <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  memcached <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>    -m <span class="m">1024</span> <span class="se">\ </span>         <span class="c1"># 記憶體上限 1024 MB</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    -t <span class="m">4</span> <span class="se">\ </span>            <span class="c1"># worker thread 數（多執行緒、對齊 CPU 核數）</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    -f 1.25 <span class="se">\ </span>         <span class="c1"># slab growth factor（預設 1.25、調小→class 更密集→浪費更少但 class 更多）</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    -I 2m <span class="se">\ </span>           <span class="c1"># 單一 item 大小上限（預設 1MB、超過要調大或拆 value）</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">    -o <span class="nv">slab_automove</span><span class="o">=</span><span class="m">1</span> <span class="c1"># 自動把空閒 page 從一個 class 搬到吃緊的 class（緩解 calcification）</span></span></span></code></pre></div><p>調校判讀：</p>
<ul>
<li><code>-m</code> 是給 item 資料的上限，Memcached 自身的 hash table、連線 buffer 等 overhead 在 <code>-m</code> 之外，機器要留 headroom</li>
<li><code>-t</code> 對齊 CPU 核數——Memcached 從早期就是 multi-threaded，這是它跟早期單執行緒 Redis 的核心差異</li>
<li><code>-f</code> 調小（例如 1.08）讓 slab class 更密集、向上取整浪費更少，代價是 class 數變多、管理開銷略增</li>
<li><code>-I</code> 是單 item 上限，超過會 store 失敗（見故障演練 Case 3）</li>
<li><code>slab_automove=1</code> 是緩解 calcification 的關鍵，預設視版本而定，明確開啟較穩</li>
</ul>
<h2 id="production-故障演練">Production 故障演練</h2>
<h3 id="case-1slab-calcificationvalue-大小漂移造成假性記憶體不足">Case 1：slab calcification——value 大小漂移造成假性記憶體不足</h3>
<p><strong>徵兆</strong>：<code>evictions</code> 上升、hit rate 下降，但 <code>stats</code> 顯示 <code>bytes</code> 遠低於 <code>limit_maxbytes</code>。<code>stats slabs</code> 看到某個 class 的 page 用滿在淘汰，另一個 class 有大量空閒 chunk。</p>
<p><strong>根因</strong>：value 大小分布隨時間漂移。早期 value 小、記憶體被分配給小 slab class；後來 value 變大、需要大 class，但 page 已被小 class 鎖住不還，大 class 空間不足開始淘汰。整體記憶體沒滿，但「對的 class」沒空間。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>開 <code>-o slab_automove=1</code>，讓 Memcached 自動把空閒 page 從冷 class 搬到吃緊的 class</li>
<li>手動觸發搬移：<code>slabs reassign &lt;src_class&gt; &lt;dst_class&gt;</code>（緊急救火用）</li>
<li>監控 <code>stats slabs</code> 各 class 的 <code>used_chunks</code> vs <code>total_chunks</code> 與 <code>stats items</code> 的 per-class evicted，找出失衡的 class</li>
<li>從源頭穩定 value 大小分布——序列化格式統一、避免同類資料時大時小</li>
</ol>
<h3 id="case-2chunk-向上取整浪費大量記憶體">Case 2：chunk 向上取整浪費大量記憶體</h3>
<p><strong>徵兆</strong>：存的 value 總大小算起來只有 600MB，但 Memcached 報用掉接近 1GB，記憶體效率異常低。</p>
<p><strong>根因</strong>：value 大小剛好落在 slab class chunk size 的「上緣之外」，被向上取整到下一個更大的 class，每個 item 浪費接近一個 growth step 的空間。例如大量 130 bytes 的 value 被放進 152 bytes 的 class，每個浪費 22 bytes，量大就顯著。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><code>-f</code> 調小（1.25 → 1.08）讓 class 粒度更細，向上取整的浪費變小</li>
<li><code>stats slabs</code> 看主要 class 的 <code>chunk_size</code> 跟你的 value 實際大小差多少，量化浪費</li>
<li>value 設計上靠近 chunk 邊界（例如壓縮或裁剪 metadata 讓 value 剛好塞進較小的 class）</li>
<li>浪費是 slab 模型的固有成本，純 KV 的 trade-off——換到的是永不碎片化與 O(1) 分配</li>
</ol>
<h3 id="case-3value-超過-item-大小上限store-直接失敗">Case 3：value 超過 item 大小上限、store 直接失敗</h3>
<p><strong>徵兆</strong>：某些大 value 的寫入回 <code>SERVER_ERROR object too large for cache</code>，application 端 cache 寫入靜默失敗、之後一直 miss。</p>
<p><strong>根因</strong>：單一 item 超過 <code>-I</code> 設的上限（預設 1MB）。Memcached 設計上不適合存大 object，預設 1MB 是刻意的純 cache 邊界。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>確認 value 大小分布，大 value 是否真該進 Memcached（純 KV cache 不適合大 blob）</li>
<li>必要時調大 <code>-I</code>（例如 <code>-I 2m</code>），但這會改變 slab class 結構、增加大 chunk 的記憶體佔用</li>
<li>大 object 考慮壓縮、或拆成多個小 key、或改放適合的儲存（物件儲存 / <a href="/blog/backend/02-cache-redis/vendors/redis/" data-link-title="Redis" data-link-desc="OSS in-memory data structure store、cache 主流">Redis</a> 的 hash）</li>
<li>application 端要處理 store 失敗，不要假設 set 一定成功——失敗就走 origin</li>
</ol>
<h3 id="case-4thread-數設太高lock-contention-反而拖慢">Case 4：thread 數設太高、lock contention 反而拖慢</h3>
<p><strong>徵兆</strong>：把 <code>-t</code> 從 4 調到 32 想榨多核效能，throughput 沒升反降，CPU 在 system time 飆高。</p>
<p><strong>根因</strong>：Memcached 的多執行緒有 per-item lock（hash bucket lock），thread 數遠超核數時，執行緒互搶 lock 與 CPU、context switch 開銷超過平行收益。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><code>-t</code> 對齊實體核數，不要超配（多數場景 4-8 已足夠，極高核機器再往上調並壓測）</li>
<li>用實際 workload 壓測對比不同 <code>-t</code> 的 throughput，找拐點</li>
<li>hot key 集中時 lock contention 更明顯（同 bucket），這是資料分布問題不是 thread 數問題</li>
<li>跨機器水平擴展（client-side consistent hashing）比單機堆 thread 更能解規模，見本文整合段</li>
</ol>
<h3 id="case-5連線數打到上限新連線被拒">Case 5：連線數打到上限、新連線被拒</h3>
<p><strong>徵兆</strong>：高並發下新連線報錯或 hang，<code>stats</code> 的 <code>curr_connections</code> 接近 <code>max_connections</code>，<code>listen_disabled_num</code> 在增加。</p>
<p><strong>根因</strong>：每個 client 連線佔一個 connection slot，Memcached 預設 <code>-c 1024</code>。大量 client（尤其沒用連線池、每請求建連）會打滿 connection 上限。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>client 端用連線池重用連線，不要每請求建連</li>
<li>調高 <code>-c</code>（例如 <code>-c 4096</code>），但連線本身有記憶體 overhead（在 <code>-m</code> 之外），要算進機器容量</li>
<li>監控 <code>curr_connections</code> 與 <code>listen_disabled_num</code>，後者非零代表曾達上限拒絕連線</li>
<li>連線數爆炸常是 client fan-out 問題，跨多 Memcached node 分散（consistent hashing）能攤平單 node 連線壓力</li>
</ol>
<h2 id="capacity--cost-邊界">Capacity / cost 邊界</h2>
<p>Memcached 的容量判讀，核心在 slab 效率與多執行緒擴展：</p>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>健康區間</th>
          <th>警戒與動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>evictions</code> 速率</td>
          <td>接近 0（working set 放得下）</td>
          <td>持續高但記憶體沒滿 → calcification、開 slab_automove</td>
      </tr>
      <tr>
          <td>各 class <code>used / total chunks</code></td>
          <td>各 class 均衡</td>
          <td>單 class 滿、其他空 → calcification</td>
      </tr>
      <tr>
          <td>chunk 向上取整浪費</td>
          <td>小（value 貼近 chunk size）</td>
          <td>大 → 調小 <code>-f</code> 或調整 value 大小</td>
      </tr>
      <tr>
          <td><code>curr_connections / -c</code></td>
          <td>&lt; 80%</td>
          <td>接近上限 → 用連線池或調高 <code>-c</code></td>
      </tr>
      <tr>
          <td>多執行緒 CPU</td>
          <td>核數內、system time 低</td>
          <td>system time 高 → <code>-t</code> 超配、lock contention</td>
      </tr>
  </tbody>
</table>
<p>撞牆後的路由判斷：</p>
<ul>
<li><strong>需要 data types / 持久化 / distributed lock</strong>：Memcached 是純 KV、刻意不做這些。需要這些走 <a href="/blog/backend/02-cache-redis/vendors/redis/" data-link-title="Redis" data-link-desc="OSS in-memory data structure store、cache 主流">Redis / Valkey</a>，這是 capability 差異不是調校能補。</li>
<li><strong>單機容量 / throughput 不夠</strong>：Memcached 沒有 server-side cluster，靠 client-side consistent hashing（ketama）水平擴展到多 node，見整合。</li>
<li><strong>想要 Memcached 的多執行緒 + Redis 的 data types</strong>：<a href="/blog/backend/02-cache-redis/vendors/dragonflydb/" data-link-title="DragonflyDB" data-link-desc="高效能 Redis / Memcached 相容替代、多核架構">DragonflyDB</a> 兼具多核與 Redis 相容，是兩者的中間點。</li>
</ul>
<h2 id="整合--下一步">整合 / 下一步</h2>
<p>Memcached 的單機很簡單，它的工程深度在「如何把多個 Memcached node 組成一個 cache 層」——而這發生在 client 端與代理層，不在 server：</p>
<ul>
<li><strong>client-side consistent hashing（ketama）</strong>：Memcached server 之間互不知道彼此，sharding 由 client library 用 consistent hashing 決定 key 去哪個 node，加減 node 時最小化 key 重新分布。這是 Memcached 水平擴展的基礎。</li>
<li><strong>跟 <a href="/blog/backend/02-cache-redis/cases/meta-mcrouter-global-cache-routing/" data-link-title="2.C2 Meta：mcrouter 與跨區快取路由" data-link-desc="快取從單點最佳化演進到分散式路由層的案例。">Meta mcrouter</a></strong>：Meta 的 mcrouter 是 Memcached 專屬的 protocol-aware routing proxy，把跨叢集 / 跨區的流量收斂、失效隔離、pool 管理從 client 端移到代理層——這是 Memcached 大規模治理的標準答案。</li>
<li><strong>跟 <a href="/blog/backend/02-cache-redis/cases/netflix-evcache-global-cache-layer/" data-link-title="2.C6 Netflix：EVCache 全域快取層" data-link-desc="快取從本地層演進為跨區分散式能力的案例。">Netflix EVCache</a></strong>：EVCache 基於 Memcached，Netflix 在上面加跨 AZ replication 與 client-side smart routing，補足 Memcached 沒有的跨區 HA。</li>
<li><strong>跟 <a href="/blog/backend/02-cache-redis/cases/meta-tao-social-graph-cache-evolution/" data-link-title="2.C8 Meta：TAO 社交圖快取演進" data-link-desc="社交圖查詢在規模化下如何把快取做成資料層能力。">Meta TAO</a></strong>：TAO 底層用 Memcached 作為 social graph 的 cache 層，上層加一致性與關聯查詢——展示了純 KV 之上如何疊加語意。</li>
<li><strong>跟 <a href="/blog/backend/02-cache-redis/cases/meta-cachelib-kangaroo-tiered-cache/" data-link-title="2.C4 Meta：CacheLib / Kangaroo 分層快取" data-link-desc="快取從 DRAM-only 轉向分層快取架構的實務案例。">Meta CacheLib + Kangaroo</a></strong>：當 DRAM 的記憶體經濟撞到極限，Meta 用 CacheLib 把 cache 分層到 flash——這是 slab 記憶體經濟學的下一個邊界。</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li>上游 vendor 頁：<a href="/blog/backend/02-cache-redis/vendors/memcached/" data-link-title="Memcached" data-link-desc="純記憶體 key-value cache、無持久化">Memcached</a></li>
<li>對照 vendor：<a href="/blog/backend/02-cache-redis/vendors/redis/memory-eviction-tuning/" data-link-title="Redis 記憶體與淘汰調校：maxmemory-policy、LFU 與碎片化的實戰判讀" data-link-desc="Redis 的記憶體是一條會在半夜爆掉的曲線：maxmemory 設多少、policy 選 LRU 還 LFU、碎片化什麼時候開始吃掉 30% RAM、OOM 時 noeviction 怎麼讓寫入全部失敗。本文展開 Redis 記憶體會計模型、eviction policy 的選型判讀、5 個把記憶體配置寫成 production 事故的踩坑，以及單機記憶體撞牆後該往 cluster 還是 DragonflyDB 走的邊界">Redis 記憶體與淘汰調校</a>（jemalloc 池 vs slab class 的差異）、<a href="/blog/backend/02-cache-redis/vendors/dragonflydb/" data-link-title="DragonflyDB" data-link-desc="高效能 Redis / Memcached 相容替代、多核架構">DragonflyDB</a></li>
<li>相關 migration：<a href="/blog/backend/02-cache-redis/vendors/redis/migrate-to-memcached/" data-link-title="Redis → Memcached：Memcached 不是 simpler Redis、是 cache paradigm" data-link-desc="Redis → Memcached 是 Type E paradigm reduction migration — 從 multi-paradigm（KV &#43; 資料結構 &#43; pub/sub &#43; Lua &#43; streams）退到 pure cache；不是「remove Redis features」、是「重新分配 Redis-specific feature 到對應 specialized 服務」；5 個 production 踩雷 &#43; paradigm reduction 路線">Redis → Memcached</a></li>
<li>Methodology：<a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章寫作方法論</a></li>
</ul>
]]></content:encoded></item><item><title>NATS core 到 JetStream：fire-and-forget 在哪裡不夠、跨過去要付什麼</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/nats/jetstream-durability-consumer/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/nats/jetstream-durability-consumer/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/nats/" data-link-title="NATS" data-link-desc="Lightweight messaging、JetStream 加持久化與 streams">NATS&lt;/a> overview 的 implementation-layer deep article、定位在「要不要從 core NATS 跨進 JetStream」的決策入口。選型層（NATS vs Kafka / RabbitMQ）見 overview；本文只處理 core 與 JetStream 的邊界與基本 consumer 設定。決定採用 JetStream 後的完整實作（stream / consumer 每個旋鈕、跨區拓樸、多租戶）見 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/nats/jetstream-supercluster-design/" data-link-title="NATS JetStream 設計與 supercluster / leaf node：stream、consumer、跨區拓樸與多租戶" data-link-desc="NATS JetStream 的 implementation-layer deep article：stream 設計（storage / retention / discard / 容量上限）、consumer 設計（pull/push、explicit ack、AckWait、MaxDeliver、replay）、Cluster Raft / Supercluster gateway / Leaf node edge 三層拓樸、subject-based ACL 多租戶；含 4 個 production 故障演練（AckWait 太短重投、discard policy 選錯丟訊息、leaf node 斷線重連、stream replica 失去 quorum）。">JetStream 設計與 supercluster / leaf node&lt;/a>。JetStream 實機驗證於 nats:latest（-js）、最後檢查日 2026-06-16；機制以 &lt;a href="https://docs.nats.io/nats-concepts/jetstream">NATS JetStream 官方文件&lt;/a> 為準。&lt;/p>&lt;/blockquote>
&lt;h2 id="fire-and-forget-在-rolling-deploy-那一刻掉訊息">fire-and-forget 在 rolling deploy 那一刻掉訊息&lt;/h2>
&lt;p>Core NATS 的低延遲來自它什麼都不記——一則訊息發布出去，當下有訂閱者就送達、沒有就丟棄。沒有儲存、沒有 ack、沒有重送。這適合「即時但可丟」的場景（metrics、presence、即時通知）：訂閱者暫時離線錯過幾則無所謂，下一則馬上來。&lt;/p>
&lt;p>但這個設計有一條清楚的邊界。&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/nats-clarifai-async-task-queue/" data-link-title="3.C38 Clarifai：NATS Streaming ML 平台非同步任務" data-link-desc="Clarifai custom model 訓練、rolling deploy 掉訊息、改 NATS Streaming queue group、3 週遷移 1 服務、5 月 5 服務、每日 100k&amp;#43; 訊息 100% uptime。">Clarifai 用 NATS 跑 ML 模型訓練的非同步任務&lt;/a>，任務從幾秒到幾分鐘，原本同步呼叫——結果每次 rolling deployment（pod 輪流重啟）就掉訊息：訊息發布的瞬間目標 worker 正在重啟，core NATS 找不到訂閱者就丟了。他們的解法是改用 NATS（當時是 NATS Streaming、JetStream 的前身）的 &lt;strong>at-least-once delivery + redelivery + queue group&lt;/strong>，每日 100k+ 訊息、達成 100% uptime。這個案例揭露的邊界是——&lt;strong>ML 長尾任務不能容忍 rolling deploy 掉訊息，core NATS 的 fire-and-forget 到此為止，要跨進 JetStream。&lt;/strong>&lt;/p>
&lt;p>JetStream 在 core NATS 之上加了一層持久化的 stream + 可重送的 consumer。本文處理這條邊界：什麼時候 core 夠用、什麼時候要 JetStream、跨過去的 consumer 模型怎麼設才不會丟訊息或重投風暴。&lt;/p>
&lt;h2 id="核心概念stream-與-consumer-的求值模型">核心概念：stream 與 consumer 的求值模型&lt;/h2>
&lt;p>JetStream 把「訊息儲存」跟「消費進度」拆成兩個獨立物件——stream（存什麼、留多久）跟 consumer（誰讀、怎麼 ack）。理解 JetStream 就是理解這兩者。&lt;/p>
&lt;p>&lt;strong>stream 決定訊息怎麼被儲存與保留&lt;/strong>。一個 stream 綁定一組 subject、把符合的訊息持久化。三個關鍵維度：storage（&lt;code>file&lt;/code> 持久 / &lt;code>memory&lt;/code> 重啟即失）、retention（&lt;code>limits&lt;/code> 依大小/時間/數量保留、&lt;code>workqueue&lt;/code> 消費後即刪、&lt;code>interest&lt;/code> 有訂閱者才留）、limits（max-msgs / max-bytes / max-age）。retention 選錯是常見陷阱——&lt;code>workqueue&lt;/code> 是「每則訊息只被一個 consumer 消費一次就刪」，&lt;code>limits&lt;/code> 是「保留著、多個 consumer 各自讀」。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/03-message-queue/vendors/nats/" data-link-title="NATS" data-link-desc="Lightweight messaging、JetStream 加持久化與 streams">NATS</a> overview 的 implementation-layer deep article、定位在「要不要從 core NATS 跨進 JetStream」的決策入口。選型層（NATS vs Kafka / RabbitMQ）見 overview；本文只處理 core 與 JetStream 的邊界與基本 consumer 設定。決定採用 JetStream 後的完整實作（stream / consumer 每個旋鈕、跨區拓樸、多租戶）見 <a href="/blog/backend/03-message-queue/vendors/nats/jetstream-supercluster-design/" data-link-title="NATS JetStream 設計與 supercluster / leaf node：stream、consumer、跨區拓樸與多租戶" data-link-desc="NATS JetStream 的 implementation-layer deep article：stream 設計（storage / retention / discard / 容量上限）、consumer 設計（pull/push、explicit ack、AckWait、MaxDeliver、replay）、Cluster Raft / Supercluster gateway / Leaf node edge 三層拓樸、subject-based ACL 多租戶；含 4 個 production 故障演練（AckWait 太短重投、discard policy 選錯丟訊息、leaf node 斷線重連、stream replica 失去 quorum）。">JetStream 設計與 supercluster / leaf node</a>。JetStream 實機驗證於 nats:latest（-js）、最後檢查日 2026-06-16；機制以 <a href="https://docs.nats.io/nats-concepts/jetstream">NATS JetStream 官方文件</a> 為準。</p></blockquote>
<h2 id="fire-and-forget-在-rolling-deploy-那一刻掉訊息">fire-and-forget 在 rolling deploy 那一刻掉訊息</h2>
<p>Core NATS 的低延遲來自它什麼都不記——一則訊息發布出去，當下有訂閱者就送達、沒有就丟棄。沒有儲存、沒有 ack、沒有重送。這適合「即時但可丟」的場景（metrics、presence、即時通知）：訂閱者暫時離線錯過幾則無所謂，下一則馬上來。</p>
<p>但這個設計有一條清楚的邊界。<a href="/blog/backend/03-message-queue/cases/nats-clarifai-async-task-queue/" data-link-title="3.C38 Clarifai：NATS Streaming ML 平台非同步任務" data-link-desc="Clarifai custom model 訓練、rolling deploy 掉訊息、改 NATS Streaming queue group、3 週遷移 1 服務、5 月 5 服務、每日 100k&#43; 訊息 100% uptime。">Clarifai 用 NATS 跑 ML 模型訓練的非同步任務</a>，任務從幾秒到幾分鐘，原本同步呼叫——結果每次 rolling deployment（pod 輪流重啟）就掉訊息：訊息發布的瞬間目標 worker 正在重啟，core NATS 找不到訂閱者就丟了。他們的解法是改用 NATS（當時是 NATS Streaming、JetStream 的前身）的 <strong>at-least-once delivery + redelivery + queue group</strong>，每日 100k+ 訊息、達成 100% uptime。這個案例揭露的邊界是——<strong>ML 長尾任務不能容忍 rolling deploy 掉訊息，core NATS 的 fire-and-forget 到此為止，要跨進 JetStream。</strong></p>
<p>JetStream 在 core NATS 之上加了一層持久化的 stream + 可重送的 consumer。本文處理這條邊界：什麼時候 core 夠用、什麼時候要 JetStream、跨過去的 consumer 模型怎麼設才不會丟訊息或重投風暴。</p>
<h2 id="核心概念stream-與-consumer-的求值模型">核心概念：stream 與 consumer 的求值模型</h2>
<p>JetStream 把「訊息儲存」跟「消費進度」拆成兩個獨立物件——stream（存什麼、留多久）跟 consumer（誰讀、怎麼 ack）。理解 JetStream 就是理解這兩者。</p>
<p><strong>stream 決定訊息怎麼被儲存與保留</strong>。一個 stream 綁定一組 subject、把符合的訊息持久化。三個關鍵維度：storage（<code>file</code> 持久 / <code>memory</code> 重啟即失）、retention（<code>limits</code> 依大小/時間/數量保留、<code>workqueue</code> 消費後即刪、<code>interest</code> 有訂閱者才留）、limits（max-msgs / max-bytes / max-age）。retention 選錯是常見陷阱——<code>workqueue</code> 是「每則訊息只被一個 consumer 消費一次就刪」，<code>limits</code> 是「保留著、多個 consumer 各自讀」。</p>
<p><strong>consumer 是 stream 上的一個可重播視圖</strong>。同一個 stream 可以有多個 consumer，各自維護自己的消費位置。consumer 的關鍵屬性：</p>
<ul>
<li>push vs pull：push 由 server 主動推給訂閱者；pull 由 client 主動拉（<code>consumer next</code>），pull 對流量控制與 worker pool 更可控</li>
<li>durable vs ephemeral：durable consumer 的進度持久（重啟後從上次位置續讀），ephemeral 在 client 斷線後消失（進度丟失）</li>
<li>ack policy：<code>explicit</code>（每則都要 ack、at-least-once 的基礎）/ <code>all</code>（ack 一則等於 ack 之前所有）/ <code>none</code>（不需 ack、近似 fire-and-forget）</li>
<li>max_deliver + ack_wait：沒 ack 的訊息在 <code>ack_wait</code> 後重送，最多 <code>max_deliver</code> 次</li>
</ul>
<p><strong>at-least-once 來自「explicit ack + redelivery」</strong>。consumer 取出訊息、處理、明確 ack；沒 ack（處理失敗或 crash）的訊息在 ack_wait 逾時後重送。這就是 Clarifai 要的「rolling deploy 不丟訊息」——worker 重啟時沒 ack 的任務會被重送給其他 worker。</p>
<h2 id="配置durable-pull-consumer實機驗證">配置：durable pull consumer（實機驗證）</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 啟動 JetStream（server 加 -js）</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"># docker run -d --name nats nats:latest -js</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"># 1. 建 stream：file storage、limits retention</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">nats stream add ORDERS --subjects <span class="s2">&#34;orders.&gt;&#34;</span> --storage file --defaults
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1">#   Subjects: orders.&gt;   Storage: File   Retention: Limits   Replicas: 1</span>
</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 class="c1"># 2. publish</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">nats pub orders.new <span class="s2">&#34;order-1&#34;</span>   <span class="c1"># Published 7 bytes to &#34;orders.new&#34;</span>
</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 class="c1"># 3. stream info 確認持久化</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">nats stream info ORDERS
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1">#   Storage: File   Messages: 3   Bytes: 141 B   ← 訊息已落盤、consumer 重啟不丟</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="c1"># 4. durable pull consumer（explicit ack、可重送）</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">nats consumer add ORDERS workers --pull --ack explicit --deliver all --defaults
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="c1">#   Pull Mode: true   Ack Policy: Explicit</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="c1"># 5. 拉取消費（worker pool 多個實例共用同一 durable consumer = queue group 語意）</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">nats consumer next ORDERS workers --count <span class="m">3</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="c1">#   order-1  order-2  order-3</span></span></span></code></pre></div><p>實機驗證於 nats:latest（最後檢查日 2026-06-16）：file storage 的 stream 把訊息落盤（Messages: 3）、durable pull consumer 用 explicit ack 消費。多個 worker 連到同一個 durable pull consumer 形成 worker pool（訊息分給其中一個），這正是 Clarifai 的 queue group 模式。</p>
<p>判讀：</p>
<ul>
<li>worker pool 用同一個 durable pull consumer（共享進度、訊息分流），不是每個 worker 一個 consumer</li>
<li><code>--ack explicit</code> 是 at-least-once 的前提；處理成功才 ack</li>
<li>pull 模式比 push 對 worker pool 更可控（worker 按自己能力拉、不會被 push 淹）</li>
</ul>
<h2 id="production-故障演練">Production 故障演練</h2>
<h3 id="case-1用-core-nats-跑該持久的任務rolling-deploy-掉訊息">Case 1：用 core NATS 跑該持久的任務、rolling deploy 掉訊息</h3>
<p><strong>徵兆</strong>：平時正常，但每次部署（pod 輪流重啟）就有一批任務消失、沒有錯誤。</p>
<p><strong>根因</strong>：用 core NATS（fire-and-forget）跑需要可靠處理的任務。發布瞬間目標訂閱者正在重啟，core NATS 找不到訂閱者就丟棄——這是 core 的設計，不是故障。正是 <a href="/blog/backend/03-message-queue/cases/nats-clarifai-async-task-queue/" data-link-title="3.C38 Clarifai：NATS Streaming ML 平台非同步任務" data-link-desc="Clarifai custom model 訓練、rolling deploy 掉訊息、改 NATS Streaming queue group、3 週遷移 1 服務、5 月 5 服務、每日 100k&#43; 訊息 100% uptime。">Clarifai 的原始問題</a>。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>需要不丟的任務用 JetStream（持久 stream + durable consumer + explicit ack）</li>
<li>訊息落盤後 consumer 重啟從上次位置續讀，rolling deploy 不丟</li>
<li>釐清邊界：可丟的即時資料（metrics / presence）留 core NATS、不可丟的跨 JetStream</li>
<li>不要用 core NATS 當任務隊列——它沒有持久化與重送</li>
</ol>
<h3 id="case-2ephemeral-consumer-斷線消費進度全丟">Case 2：ephemeral consumer 斷線、消費進度全丟</h3>
<p><strong>徵兆</strong>：consumer 重連後從頭重讀整個 stream、或漏掉斷線期間的訊息，進度不連續。</p>
<p><strong>根因</strong>：用了 ephemeral consumer——它的進度不持久，client 斷線後 consumer 本身消失。重連是建一個全新 consumer，從 <code>deliver</code> policy 的起點開始（all 從頭、new 只看新的），不接續之前的進度。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>需要跨重啟接續的用 durable consumer（具名、進度持久）</li>
<li>ephemeral 只適合臨時、一次性的讀取（debug、一次性掃描）</li>
<li>worker pool 一定用 durable（多 worker 共享持久進度）</li>
<li>確認 <code>deliver</code> policy（all / new / last）符合預期的起讀位置</li>
</ol>
<h3 id="case-3ack_wait-太短處理還沒完就重送風暴">Case 3：ack_wait 太短、處理還沒完就重送風暴</h3>
<p><strong>徵兆</strong>：長任務還在處理中就被重送給另一個 worker，同一任務被多個 worker 重複執行，負載放大。</p>
<p><strong>根因</strong>：<code>ack_wait</code>（等 ack 的逾時）設得比任務處理時間短。JetStream 以為訊息處理失敗（沒在 ack_wait 內 ack），重送給別人——但其實第一個 worker 還在跑。ML 長尾任務（幾秒到幾分鐘）特別容易踩。</p>
<p><strong>修法（本文層級的判讀）</strong>：ack_wait 必須涵蓋任務的 p99 處理時間，否則長任務會在處理中被重送。設值方法（量測 p99、長任務用 in-progress ack 延長 deadline、消費端冪等兜底）與實機重現（AckWait 設 1s 觀察 tries 1→2、Redelivered 計數）在 <a href="/blog/backend/03-message-queue/vendors/nats/jetstream-supercluster-design/" data-link-title="NATS JetStream 設計與 supercluster / leaf node：stream、consumer、跨區拓樸與多租戶" data-link-desc="NATS JetStream 的 implementation-layer deep article：stream 設計（storage / retention / discard / 容量上限）、consumer 設計（pull/push、explicit ack、AckWait、MaxDeliver、replay）、Cluster Raft / Supercluster gateway / Leaf node edge 三層拓樸、subject-based ACL 多租戶；含 4 個 production 故障演練（AckWait 太短重投、discard policy 選錯丟訊息、leaf node 斷線重連、stream replica 失去 quorum）。">JetStream 設計與 supercluster/leaf node</a> 的故障演練有完整步驟，採用 JetStream 後依該篇落地。</p>
<h3 id="case-4retention-選-workqueue-但想多-consumer-fanout">Case 4：retention 選 workqueue 但想多 consumer fanout</h3>
<p><strong>徵兆</strong>：想讓多個獨立服務各自消費同一 stream，但發現訊息被一個消費掉就消失、其他服務讀不到。</p>
<p><strong>根因</strong>：stream retention 設成 <code>workqueue</code>——每則訊息只被消費一次就從 stream 刪除（隊列語意）。它不適合 fanout（多個 consumer 各自要完整一份）。fanout 要 <code>limits</code> 或 <code>interest</code> retention。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>fanout（多服務各讀一份）用 <code>limits</code> retention（訊息保留、多 consumer 各自 offset）</li>
<li>單一 worker pool 競爭消費用 <code>workqueue</code>（消費即刪、省空間）</li>
<li>釐清需求：競爭消費（worker pool）vs 廣播消費（fanout）對應不同 retention</li>
<li>Clarifai 用「3 個獨立 NATS 實例做 fanout 隔離」是另一種 fanout 做法，按隔離需求選</li>
</ol>
<h3 id="case-5memory-storage-的-stream-重啟全失">Case 5：memory storage 的 stream 重啟全失</h3>
<p><strong>徵兆</strong>：broker 重啟後 stream 裡的訊息全沒了，consumer 從空的開始。</p>
<p><strong>根因</strong>：stream storage 設成 <code>memory</code>——快但不持久，broker 重啟即失。誤把它當持久 stream 用。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>需要持久的 stream 用 <code>file</code> storage（落盤、重啟不丟，實機驗證過）</li>
<li><code>memory</code> 只適合「快取式、可重建」的 stream（如即時聚合的中間狀態）</li>
<li>要更高可靠性加 <code>replicas</code>（JetStream 用 Raft 跨節點複製 stream）</li>
<li>容量規劃時 file storage 的磁碟與 memory 的 RAM 是不同維度</li>
</ol>
<h2 id="capacity--cost-邊界">Capacity / cost 邊界</h2>
<p>JetStream 的容量判讀：</p>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>健康區間</th>
          <th>警戒與動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>stream storage 用量</td>
          <td>在 max-bytes / max-age 內</td>
          <td>接近上限 → 訊息被 discard、調 limits 或加容量</td>
      </tr>
      <tr>
          <td>redelivery 次數</td>
          <td>低（多數一次 ack 成功）</td>
          <td>高 → ack_wait 太短或處理卡住</td>
      </tr>
      <tr>
          <td>consumer pending</td>
          <td>可消化</td>
          <td>持續堆高 → consumer 跟不上 producer</td>
      </tr>
      <tr>
          <td>ack_wait vs 處理時間</td>
          <td>ack_wait &gt; p99 處理時間</td>
          <td>反了 → 重送風暴</td>
      </tr>
      <tr>
          <td>storage 型別</td>
          <td>持久需求用 file</td>
          <td>誤用 memory → 重啟丟訊息</td>
      </tr>
  </tbody>
</table>
<p>撞牆後的路由判斷：</p>
<ul>
<li><strong>可丟的即時資料</strong>：不需要 JetStream 的持久化開銷，用 core NATS（更快更輕）。</li>
<li><strong>超大吞吐 + 長期保留 + 複雜 replay</strong>：JetStream 適合中等規模可靠 messaging；超大規模 event streaming + 長期保留走 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka</a>（log-based、生態成熟）。</li>
<li><strong>複雜 routing / 任務隊列語意</strong>：JetStream 的 subject 是樹狀，複雜 routing + DLQ 拓樸用 <a href="/blog/backend/03-message-queue/vendors/rabbitmq/" data-link-title="RabbitMQ" data-link-desc="Classic message broker、AMQP routing 為主">RabbitMQ</a> 更直接。</li>
<li><strong>不想自管</strong>：NATS 的 managed 選項（Synadia Cloud）或其他 managed broker。</li>
</ul>
<h2 id="整合--下一步">整合 / 下一步</h2>
<p>JetStream 的邊界判斷是 NATS 使用的核心，它跟其他議題交織：</p>
<ul>
<li><strong>跟 <a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer design</a></strong>：push/pull、durable/ephemeral、ack policy 是 consumer 設計的具體選項。</li>
<li><strong>跟 <a href="/blog/backend/03-message-queue/durable-queue/" data-link-title="3.2 durable queue 與重試策略" data-link-desc="整理持久化佇列、DLQ 與重試流程">3.2 durable queue</a></strong>：JetStream 的 file storage stream 是 NATS 的 durable queue 實現。</li>
<li><strong>跟 <a href="/blog/backend/06-reliability/idempotency-replay/" data-link-title="6.12 Idempotency 與 Replay 驗證" data-link-desc="把重試、重播與冪等性從口頭約定變成可驗證屬性">6.12 idempotency / replay</a></strong>：at-least-once + redelivery 要求消費冪等，否則重送造成重複副作用。</li>
<li><strong>跟 <a href="/blog/backend/03-message-queue/vendors/rabbitmq/dlq-retry-escalation/" data-link-title="RabbitMQ DLQ 與分層 retry：別把失敗訊息 requeue 回隊首" data-link-desc="RabbitMQ 處理失敗訊息最常見的錯是直接 requeue 回原隊列——它回到隊首、反覆失敗、把後面的訊息全卡住（head-of-line blocking）。正解是用 dead-letter exchange &#43; TTL 組出 work → delay → DLQ 的分層 escalation。本文展開 DLX 求值模型、實機驗證的三層拓樸、5 個把 retry 寫成無限迴圈與隊列阻塞的 production 踩坑，以及 retry 拓樸的容量邊界">RabbitMQ DLQ deep article</a></strong>：max_deliver 達上限後的處理對應 RabbitMQ 的 DLQ，兩者都是「重試上限後往哪去」的問題。</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li>上游 vendor 頁：<a href="/blog/backend/03-message-queue/vendors/nats/" data-link-title="NATS" data-link-desc="Lightweight messaging、JetStream 加持久化與 streams">NATS</a></li>
<li>對照 vendor：<a href="/blog/backend/03-message-queue/vendors/rabbitmq/dlq-retry-escalation/" data-link-title="RabbitMQ DLQ 與分層 retry：別把失敗訊息 requeue 回隊首" data-link-desc="RabbitMQ 處理失敗訊息最常見的錯是直接 requeue 回原隊列——它回到隊首、反覆失敗、把後面的訊息全卡住（head-of-line blocking）。正解是用 dead-letter exchange &#43; TTL 組出 work → delay → DLQ 的分層 escalation。本文展開 DLX 求值模型、實機驗證的三層拓樸、5 個把 retry 寫成無限迴圈與隊列阻塞的 production 踩坑，以及 retry 拓樸的容量邊界">RabbitMQ DLQ 與分層 retry</a>、<a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka</a></li>
<li>對應案例：<a href="/blog/backend/03-message-queue/cases/nats-clarifai-async-task-queue/" data-link-title="3.C38 Clarifai：NATS Streaming ML 平台非同步任務" data-link-desc="Clarifai custom model 訓練、rolling deploy 掉訊息、改 NATS Streaming queue group、3 週遷移 1 服務、5 月 5 服務、每日 100k&#43; 訊息 100% uptime。">3.C38 Clarifai NATS ML 非同步任務</a></li>
<li>上游概念：<a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer design</a>、<a href="/blog/backend/03-message-queue/durable-queue/" data-link-title="3.2 durable queue 與重試策略" data-link-desc="整理持久化佇列、DLQ 與重試流程">3.2 durable queue</a></li>
</ul>
]]></content:encoded></item><item><title>Pub/Sub Ordering Key、Dead-Letter Topic 與 Schema Enforcement：三道交付治理</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/google-pubsub/ordering-dlt-schema/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/google-pubsub/ordering-dlt-schema/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Google Cloud Pub/Sub&lt;/a> overview 的 implementation-layer deep article。Overview 回答「Pub/Sub 該不該選、跟 Kafka / SQS 差在哪」；本文回答「ordering key 怎麼設、DLT 怎麼擋 poison message、schema 怎麼守契約，各自踩哪些坑」。閱讀前可先讀 overview 的 ordering / DLT / schema 各段建立 context。&lt;/p>
&lt;p>文中 gcloud 指令的語法以 Pub/Sub emulator 實機驗證（topic / subscription / schema / ordering key / DLT / push 各操作均跑通），標準版的雲端配額、IAM 與計費行為依官方文件。&lt;/p>&lt;/blockquote>
&lt;h2 id="三道治理共用同一個交付骨架">三道治理共用同一個交付骨架&lt;/h2>
&lt;p>Pub/Sub 的 ordering key、dead-letter topic、schema enforcement 看似三個獨立功能，實際都掛在同一個交付骨架上：subscription 是消費進度的 first-class 抽象、訊息經 ackDeadline 控制重投、失敗訊息經投遞次數計數決定去留。理解這個骨架之後，三道治理只是骨架上的三個切面 — ordering 切的是「投遞順序」、DLT 切的是「投遞次數上限」、schema 切的是「投遞前的內容守門」。&lt;/p>
&lt;p>這條骨架跟 Kafka 思路不同。Kafka 的消費進度綁在 consumer group + partition offset；Pub/Sub 的 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic&lt;/a> 是 first-class，subscription 才是 consumer 抽象，一個 topic 可以掛 N 個 subscription、各自有獨立進度與獨立的 ackDeadline / DLT / ordering 設定。同一份 event 流，A subscription 可以開 ordering 嚴格有序、B subscription 可以不開 ordering 換吞吐，互不影響。&lt;/p>
&lt;p>把這三道治理寫進一篇的理由是：它們在 production 會互相牽制。Ordering key 開了之後 DLT 的隔離行為會變（有序流裡一則 poison message 會卡住整把 key 的後續訊息）；schema enforcement 擋下的不相容 publish 不會進 DLT（根本沒進 topic）。分開讀三個官方頁面看不到這層耦合。&lt;/p>
&lt;h2 id="subscription-是-first-classackdeadline-與-extension">subscription 是 first-class：ackDeadline 與 extension&lt;/h2>
&lt;p>subscription 承擔「這個消費者讀到哪、還有多少沒 ack」的責任。每則訊息投遞給 subscriber 後，Pub/Sub 啟動一個 ackDeadline 倒數；倒數內收到 ack 就移除訊息、倒數結束沒收到 ack 就重投。預設 ackDeadline 是 10 秒、上限 600 秒。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1"># subscription 的 ackDeadline 預設 10 秒、retention 預設 7 天&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">gcloud pubsub subscriptions describe demo-sub
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="c1"># ackDeadlineSeconds: 10&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="c1"># messageRetentionDuration: 604800s # 7 天&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="c1"># 建 subscription 時可顯式設更長的 ackDeadline 與更短的 retention&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">gcloud pubsub subscriptions create cfg-sub &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --topic&lt;span class="o">=&lt;/span>demo-topic &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --ack-deadline&lt;span class="o">=&lt;/span>&lt;span class="m">120&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --message-retention-duration&lt;span class="o">=&lt;/span>3d
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="c1"># ackDeadlineSeconds: 120&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="c1"># messageRetentionDuration: 259200s # 3 天&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>ackDeadline 是一道「處理時間預算」。設太短，處理還沒完訊息就被重投，consumer 會收到重複；設太長，consumer crash 後訊息要等滿 deadline 才重投，延遲拉高。長任務不靠把 ackDeadline 一次設到 600 秒解決，而是靠 ack deadline extension：consumer 在處理中週期性發 &lt;code>modifyAckDeadline&lt;/code> 把單則訊息的 deadline 往後延，處理完才 ack。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Google Cloud Pub/Sub</a> overview 的 implementation-layer deep article。Overview 回答「Pub/Sub 該不該選、跟 Kafka / SQS 差在哪」；本文回答「ordering key 怎麼設、DLT 怎麼擋 poison message、schema 怎麼守契約，各自踩哪些坑」。閱讀前可先讀 overview 的 ordering / DLT / schema 各段建立 context。</p>
<p>文中 gcloud 指令的語法以 Pub/Sub emulator 實機驗證（topic / subscription / schema / ordering key / DLT / push 各操作均跑通），標準版的雲端配額、IAM 與計費行為依官方文件。</p></blockquote>
<h2 id="三道治理共用同一個交付骨架">三道治理共用同一個交付骨架</h2>
<p>Pub/Sub 的 ordering key、dead-letter topic、schema enforcement 看似三個獨立功能，實際都掛在同一個交付骨架上：subscription 是消費進度的 first-class 抽象、訊息經 ackDeadline 控制重投、失敗訊息經投遞次數計數決定去留。理解這個骨架之後，三道治理只是骨架上的三個切面 — ordering 切的是「投遞順序」、DLT 切的是「投遞次數上限」、schema 切的是「投遞前的內容守門」。</p>
<p>這條骨架跟 Kafka 思路不同。Kafka 的消費進度綁在 consumer group + partition offset；Pub/Sub 的 <a href="/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic</a> 是 first-class，subscription 才是 consumer 抽象，一個 topic 可以掛 N 個 subscription、各自有獨立進度與獨立的 ackDeadline / DLT / ordering 設定。同一份 event 流，A subscription 可以開 ordering 嚴格有序、B subscription 可以不開 ordering 換吞吐，互不影響。</p>
<p>把這三道治理寫進一篇的理由是：它們在 production 會互相牽制。Ordering key 開了之後 DLT 的隔離行為會變（有序流裡一則 poison message 會卡住整把 key 的後續訊息）；schema enforcement 擋下的不相容 publish 不會進 DLT（根本沒進 topic）。分開讀三個官方頁面看不到這層耦合。</p>
<h2 id="subscription-是-first-classackdeadline-與-extension">subscription 是 first-class：ackDeadline 與 extension</h2>
<p>subscription 承擔「這個消費者讀到哪、還有多少沒 ack」的責任。每則訊息投遞給 subscriber 後，Pub/Sub 啟動一個 ackDeadline 倒數；倒數內收到 ack 就移除訊息、倒數結束沒收到 ack 就重投。預設 ackDeadline 是 10 秒、上限 600 秒。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># subscription 的 ackDeadline 預設 10 秒、retention 預設 7 天</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">gcloud pubsub subscriptions describe demo-sub
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"># ackDeadlineSeconds: 10</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"># messageRetentionDuration: 604800s   # 7 天</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"># 建 subscription 時可顯式設更長的 ackDeadline 與更短的 retention</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">gcloud pubsub subscriptions create cfg-sub <span class="se">\
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="se"></span>  --topic<span class="o">=</span>demo-topic <span class="se">\
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="se"></span>  --ack-deadline<span class="o">=</span><span class="m">120</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="se"></span>  --message-retention-duration<span class="o">=</span>3d
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"># ackDeadlineSeconds: 120</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"># messageRetentionDuration: 259200s   # 3 天</span></span></span></code></pre></div><p>ackDeadline 是一道「處理時間預算」。設太短，處理還沒完訊息就被重投，consumer 會收到重複；設太長，consumer crash 後訊息要等滿 deadline 才重投，延遲拉高。長任務不靠把 ackDeadline 一次設到 600 秒解決，而是靠 ack deadline extension：consumer 在處理中週期性發 <code>modifyAckDeadline</code> 把單則訊息的 deadline 往後延，處理完才 ack。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># pull 一則但不 auto-ack，拿到 ackId</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nv">ACKID</span><span class="o">=</span><span class="k">$(</span>gcloud pubsub subscriptions pull demo-sub --limit<span class="o">=</span><span class="m">1</span> --format<span class="o">=</span><span class="s1">&#39;value(ackId)&#39;</span><span class="k">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 處理中動態延長這則訊息的 ackDeadline 到 300 秒</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">gcloud pubsub subscriptions modify-message-ack-deadline demo-sub <span class="se">\
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="se"></span>  --ack-ids<span class="o">=</span><span class="s2">&#34;</span><span class="nv">$ACKID</span><span class="s2">&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="se"></span>  --ack-deadline<span class="o">=</span><span class="m">300</span></span></span></code></pre></div><p>實務上不手動發 <code>modifyAckDeadline</code>，而是用 client library 的自動 lease 管理：client 在背景對 outstanding 訊息週期性續約，直到 application code 回 ack / nack。這跟 SQS 的 visibility timeout 語意類似 — 都是「訊息正在被處理、暫時別重投」的租約 — 但 Pub/Sub 是 per-message lease + client 自動續約，SQS 是 per-receive visibility window + 手動 <code>ChangeMessageVisibility</code>。</p>
<blockquote>
<p>ackDeadline 的陷阱在 batch 邊界。client library 常以 batch 為單位 pull，但 ackDeadline lease 是 per-message。若 application 把整個 batch 當一個工作單元處理、處理時間超過單則 ackDeadline 且 client 未對每則續約，未 ack 的訊息會被重投。Mercari 的 actionable history pipeline 揭露的正是這個 client library 行為：ack deadline 以整批 batch 為粒度運作，同批只要有一則過期或被 nack，已 ack 的訊息會跟著一起重投（<a href="/blog/backend/03-message-queue/cases/pubsub-mercari-actionable-history/" data-link-title="3.C63 Mercari Actionable History：ack deadline 是 batch-level" data-link-desc="Merpay 支付流水帳用 Pub/Sub、ack deadline 是整批 batch 而非單訊息、acked 訊息會跟同批 expired 一起 redeliver。">3.C63</a>）。</p></blockquote>
<h2 id="pushpullstreaming-pull-與-flow-control">Push、Pull、Streaming Pull 與 flow control</h2>
<p>subscription 有兩種交付方向，pull 之下又分 unary pull 與 streaming pull。三者對應不同的下游承壓能力。</p>
<table>
  <thead>
      <tr>
          <th>交付模型</th>
          <th>機制</th>
          <th>適合場景</th>
          <th>flow control 由誰掌握</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Push</td>
          <td>Pub/Sub 主動 POST 到 HTTPS endpoint</td>
          <td>無狀態 worker、Cloud Run、Cloud Functions</td>
          <td>Pub/Sub（按 ack 動態調速）</td>
      </tr>
      <tr>
          <td>Unary Pull</td>
          <td>consumer 每次發一個 pull 請求拿一批</td>
          <td>低頻、批次拉取、簡單腳本</td>
          <td>consumer（自己控拉取頻率）</td>
      </tr>
      <tr>
          <td>Streaming Pull</td>
          <td>consumer 開長連線、Pub/Sub 持續推送到該連線</td>
          <td>高吞吐長 worker、需要精確 flow control</td>
          <td>consumer（client lib 設定）</td>
      </tr>
  </tbody>
</table>
<p>Push 把投遞節奏交給 Pub/Sub：endpoint 回 2xx 視為 ack、回非 2xx 或逾時視為 nack 並 backoff 重投。Pull 把節奏交給 consumer：consumer 想拉才拉、拉多少自己定。Streaming pull 是 production 高吞吐場景的主力 — client library 預設用它，因為它能在單一長連線上做精細的 flow control。</p>
<p>flow control 是 pull 的核心優勢：consumer 用 <code>max_outstanding_messages</code> 與 <code>max_outstanding_bytes</code> 設定「同時最多持有多少未 ack 訊息」，超過上限 client 就暫停從連線拉取，等 application ack 釋放額度才繼續。這讓 consumer 能把消費速率對齊到下游能吃的速率，而不是被 broker 灌爆。</p>
<blockquote>
<p>Push vs pull 不是實作偏好，是「下游能否接受 push 衝擊」的判讀。Mercari 把外部行銷 webhook（Braze）轉成 Pub/Sub event 後，下游 worker 刻意用 pull subscription 精確控制每秒處理訊息數，因為下游要呼叫的外部 LINE API 有 RPS 限制 — push 會把瞬間流量直接打到受限的外部 API（<a href="/blog/backend/03-message-queue/cases/pubsub-mercari-line-flow-control/" data-link-title="3.C65 Mercari LINE：Pull subscription 對齊外部 RPS" data-link-desc="Mercari LINE webhook 轉 Pub/Sub、worker pull subscription 精確控制 RPS、應 LINE API 限制。">3.C65</a>）。下游有硬性 RPS 上限時，pull + flow control 是讓消費速率可控的手段。</p></blockquote>
<h2 id="ordering-key有序的代價是吞吐">Ordering Key：有序的代價是吞吐</h2>
<p>Ordering key 讓「帶同一個 ordering key 的訊息，在 subscription 端按 publish 順序投遞」。它把全域無序的 Pub/Sub 變成 per-key 有序 — 不同 key 之間仍可並行、亂序，只有同 key 內部保證順序。要生效需要兩端配合：subscription 建立時開 <code>--enable-message-ordering</code>，publish 時帶 <code>--ordering-key</code>。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># subscription 端開啟 ordering</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">gcloud pubsub subscriptions create ord-sub <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --topic<span class="o">=</span>ord-topic <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --enable-message-ordering
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># describe 可見 enableMessageOrdering: true</span>
</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 class="c1"># publish 端帶 ordering key（同一 key 的訊息會保序）</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">gcloud pubsub topics publish ord-topic --message<span class="o">=</span>m1 --ordering-key<span class="o">=</span>user-123
</span></span><span class="line"><span class="ln">9</span><span class="cl">gcloud pubsub topics publish ord-topic --message<span class="o">=</span>m2 --ordering-key<span class="o">=</span>user-123</span></span></code></pre></div><p>Ordering key 的設計責任在於選對 key 的粒度。粒度太粗（例如所有訊息共用一個 key）會把整條 topic 退化成單線序列、吞吐崩塌；粒度太細（例如每則訊息一個 key）等於沒開 ordering。正確做法是按「需要保序的業務實體」選 key — 同一個 <code>user-123</code> 的事件要保序、不同 user 之間不需要 — 這樣並行度等於活躍 key 數，既保序又不犧牲整體吞吐。</p>
<p>跟 Kafka 對照能看清取捨。Kafka 用 partition + 同 key hash 到同 partition 達成保序，partition 數是固定預先規劃的並行上限；Pub/Sub 沒有顯式 partition，ordering key 的並行度是動態的、由活躍 key 數決定。代價是 Pub/Sub 的有序投遞要求同 key 訊息送到同一個內部處理單元，這個約束讓單一 ordering key 的吞吐有上限（官方標稱單 ordering key 約 1 MB/s）。</p>
<blockquote>
<p>Ordering 跟 DLT 在 production 會耦合：有序流裡若一則訊息反覆失敗、Pub/Sub 為維持順序不會跳過它去投後面的訊息，整把 key 的後續訊息全卡住，直到該訊息 ack 或送進 DLT。沒開 ordering 時 poison message 只卡自己；開了 ordering 後它卡住整條 key 序列。這是下一節 DLT 要解的問題在 ordering 場景下被放大的原因。</p></blockquote>
<h2 id="dead-letter-topic投遞次數上限決定隔離時機">Dead-Letter Topic：投遞次數上限決定隔離時機</h2>
<p>Dead-letter topic 是 <a href="/blog/backend/knowledge-cards/poison-message-quarantine/" data-link-title="Poison-Message Quarantine" data-link-desc="說明把毒訊息從主處理路徑隔離出來的機制，讓正常訊息繼續前進">poison-message quarantine</a> 在 Pub/Sub 的實作：subscription 對每則訊息計數投遞次數，超過 <code>max-delivery-attempts</code> 就把訊息轉發到另一個 topic（DLT），主 subscription 不再重投它，後續正常訊息得以前進。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl">gcloud pubsub topics create main-topic
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">gcloud pubsub topics create dl-topic
</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">gcloud pubsub subscriptions create main-sub <span class="se">\
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="se"></span>  --topic<span class="o">=</span>main-topic <span class="se">\
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="se"></span>  --dead-letter-topic<span class="o">=</span>dl-topic <span class="se">\
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="se"></span>  --max-delivery-attempts<span class="o">=</span><span class="m">5</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"># deadLetterPolicy:</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1">#   deadLetterTopic: projects/&lt;proj&gt;/topics/dl-topic</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1">#   maxDeliveryAttempts: 5</span></span></span></code></pre></div><p>DLT 是 topic 不是 queue，這是 Pub/Sub 跟 SQS DLQ 的關鍵差異。SQS 的 DLQ 是另一個 queue、消費者直接 receive；Pub/Sub 的 DLT 是 topic，要再掛一個 subscription 才能讀。好處是 DLT 上可以同時掛多個 subscription — 一個給人工檢視、一個給自動 replay、一個給長期歸檔 — fan-out 內建。代價是多一層 subscription 配置，且 DLT 也有自己的 retention（同樣預設 7 天，poison message 要在這之內處理掉）。</p>
<p><code>max-delivery-attempts</code> 設定的是「容忍多少次暫時性失敗」與「多快放棄」之間的平衡。設太低（例如 1-2 次），下游短暫抖動就把訊息丟進 DLT、誤殺可恢復的訊息；設太高（例如 50 次），一則真正壞掉的訊息會反覆重試半天、占用 consumer 資源、在有序流裡還會長時間卡住整條 key。官方允許範圍 5-100，常見起點是 5。</p>
<p>搭配 retry policy 的 backoff 能讓重投不至於太密集：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">gcloud pubsub subscriptions create retry-sub <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --topic<span class="o">=</span>main-topic <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --min-retry-delay<span class="o">=</span>10s <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --max-retry-delay<span class="o">=</span>600s
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># retryPolicy:</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1">#   minimumBackoff: 10s</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1">#   maximumBackoff: 600s</span></span></span></code></pre></div><blockquote>
<p>啟用 DLT 需要把 Pub/Sub service account 授權對主 subscription 有 subscriber、對 DLT 有 publisher（emulator 不校驗 IAM，正式環境若漏授權，訊息超過 max attempts 後不會進 DLT、而是繼續留在主 subscription 重投，看起來像 DLT 沒生效）。授權細節依 GCP 官方 IAM 文件。</p></blockquote>
<p>Mercari 的商品 feed 同步示範了 DLT 的標準用法：pull subscription + 自家 batch requester、成功 ack 整批、失敗 nack 讓 Pub/Sub 重送、重試多次仍失敗送 DLT、後續訊息優先處理；同一個 topic 還兼當突發流量的 load-leveling buffer（<a href="/blog/backend/03-message-queue/cases/pubsub-mercari-item-feed-dlt/" data-link-title="3.C64 Mercari Item Feed：DLT 防 poison message 阻塞" data-link-desc="Mercari 商品 feed 同步、ack 整批 / nack 重送、重試多次仍失敗送 DLT、topic 同時當 load-leveling buffer。">3.C64</a>）。</p>
<h2 id="schema-enforcement投遞前的契約守門">Schema Enforcement：投遞前的契約守門</h2>
<p>Schema enforcement 把 <a href="/blog/backend/knowledge-cards/event-schema-compatibility/" data-link-title="Event Schema Compatibility" data-link-desc="說明 event schema 演進時，新舊 producer 與 consumer 能否互通的相容性等級">event schema compatibility</a> 從「應用層約定」提升到「broker 強制」。topic 綁定一個 Avro 或 Protobuf schema 後，不符 schema 的 publish 在進 topic 前就被拒絕 — 訊息根本不會被儲存、不會投遞、不會進 DLT。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 1. 建 schema（Avro，一個必填 string 欄位 id）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">gcloud pubsub schemas create order-schema <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --type<span class="o">=</span>avro <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --definition<span class="o">=</span><span class="s1">&#39;{&#34;type&#34;:&#34;record&#34;,&#34;name&#34;:&#34;Order&#34;,&#34;fields&#34;:[{&#34;name&#34;:&#34;id&#34;,&#34;type&#34;:&#34;string&#34;}]}&#39;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"># 2. topic 綁 schema + 指定 message encoding</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">gcloud pubsub topics create sch-topic <span class="se">\
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="se"></span>  --schema<span class="o">=</span>order-schema <span class="se">\
</span></span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="se"></span>  --message-encoding<span class="o">=</span>json</span></span></code></pre></div><p>綁定後的 publish 行為（emulator 實機驗證 enforce）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 符合 schema：通過</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">gcloud pubsub topics publish sch-topic --message<span class="o">=</span><span class="s1">&#39;{&#34;id&#34;:&#34;abc&#34;}&#39;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"># messageIds: [&#39;4&#39;]</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"># 欄位不符 schema：被拒</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">gcloud pubsub topics publish sch-topic --message<span class="o">=</span><span class="s1">&#39;{&#34;wrong&#34;:123}&#39;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"># ERROR: INVALID_ARGUMENT: Could not parse message</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="c1"># 非 JSON 垃圾：被拒</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">gcloud pubsub topics publish sch-topic --message<span class="o">=</span><span class="s1">&#39;not-json&#39;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"># ERROR: INVALID_ARGUMENT: Could not parse message</span></span></span></code></pre></div><p>schema 守門的價值在於把契約破壞擋在 producer 端、而不是 consumer 端。沒有 schema enforcement 時，producer 改了 payload 結構、不相容的訊息照樣進 topic、要到 consumer 解析失敗才爆 — 此時訊息已經在系統裡流動、可能已 fan-out 到多個 subscription、修復成本高。有 schema enforcement 時，不相容的 publish 在源頭就失敗，問題暴露在「誰送了壞訊息」而不是「誰收到壞訊息」。</p>
<p>schema evolution 要在「擋住破壞性改版」與「不阻塞合理演進」之間取捨。新增可選欄位或帶預設值的欄位維持相容、可以平滑演進；新增必填欄位、刪欄位、改型別是破壞性改版，會讓既有 producer 或 consumer 失效。設計上先定相容性等級（backward / forward / full）再演進，刪欄位分兩步（先停用再移除），避免一次破壞性改版打掛下游。</p>
<p>跟 Kafka Schema Registry 對照：Kafka 的 schema 校驗在 client 端（producer / consumer 各自向 Registry 查 schema、序列化時校驗），broker 本身不認識 schema；Pub/Sub 的 schema 綁在 topic、校驗在 broker 端 publish 路徑上。前者校驗點分散、靈活但要求所有 client 守規矩；後者校驗點集中在 broker、強制但耦合到 topic 配置。</p>
<h2 id="五個-production-故障演練">五個 Production 故障演練</h2>
<p>deep article 的差異化價值在故障演練。以下五個徵兆對應前述三道治理在 production 的典型失效。</p>
<h3 id="演練一ordering-key-把吞吐限到單線">演練一：Ordering key 把吞吐限到單線</h3>
<p><strong>徵兆</strong>：開了 ordering 後整條 topic 的吞吐從數萬 msg/s 掉到數百 msg/s，subscription backlog（<code>num_undelivered_messages</code>）持續攀升、<code>oldest_unacked_message_age</code> 越拉越長，但 consumer CPU 並不滿載 — consumer 在等訊息、不是在忙。</p>
<p><strong>根因</strong>：ordering key 粒度太粗。最常見是「所有訊息共用同一個 ordering key」（例如固定字串、或單一租戶 ID），整條 topic 退化成單一有序序列，並行度等於 1。單一 ordering key 的吞吐有上限（官方標稱約 1 MB/s），所有訊息擠進一個 key 就被這個上限封頂。</p>
<p><strong>判讀與修法</strong>：</p>
<ol>
<li>確認 ordering key 的基數（cardinality）。<code>gcloud pubsub topics publish</code> 帶的 <code>--ordering-key</code> 在 production 是業務欄位映射來的 — 檢查映射邏輯是否塌縮成低基數。</li>
<li>把 key 粒度對齊到「真正需要保序的業務實體」：同一筆訂單 / 同一個 user / 同一個 device 內要保序，跨實體不需要。粒度從「全域一個 key」改成「per-user 一個 key」，並行度從 1 拉到活躍 user 數。</li>
<li>評估是否真的需要 ordering。多數 pipeline 靠 consumer 端 idempotency + 版本號就能容忍亂序，不需要 broker 層保序 — 把保序成本從吞吐換成 consumer 設計（見 <a href="/blog/backend/03-message-queue/event-contract-replay-boundary/" data-link-title="3.7 Event Contract 與 Replay Boundary" data-link-desc="說明 event schema、idempotency key、replay window 與補償如何先於 broker 選型。">3.7 event contract</a> 的 idempotency key 段）。</li>
</ol>
<h3 id="演練二ack-deadline-太短導致重複投遞">演練二：Ack deadline 太短導致重複投遞</h3>
<p><strong>徵兆</strong>：consumer 處理邏輯正確、下游也成功，但同一則訊息被處理多次；<code>DELIVERY_ATTEMPT</code> 計數異常偏高、下游出現重複副作用（重複扣款 / 重複發信）。Backlog 不一定高，但「處理量」遠大於「publish 量」。</p>
<p><strong>根因</strong>：ackDeadline 比實際處理時間短。預設 10 秒對「呼叫一個慢的外部 API」「處理大 payload」這類任務不夠，訊息在 application 還沒 ack 前就過了 deadline、被 Pub/Sub 重投，於是同一則訊息有多個 consumer 副本在跑。若 client library 的自動 lease extension 沒生效（例如 application 阻塞在同步呼叫、background lease thread 餓死），重投更嚴重。</p>
<p><strong>判讀與修法</strong>：</p>
<ol>
<li>量測 p99 處理時間，把 ackDeadline 設到 p99 之上留 buffer，但不要不加判斷地設到 600 秒上限 — deadline 越長，consumer crash 後訊息重投的延遲越長。</li>
<li>長任務靠 lease extension 而非長 ackDeadline：確認 client library 的自動續約有在跑，application code 不要在處理迴圈裡阻塞到讓 background 續約 thread 餓死。</li>
<li>consumer 端做 idempotency：用 message 的 dedup key（<a href="/blog/backend/03-message-queue/event-contract-replay-boundary/" data-link-title="3.7 Event Contract 與 Replay Boundary" data-link-desc="說明 event schema、idempotency key、replay window 與補償如何先於 broker 選型。">3.7</a>）讓重複投遞變成無害 — at-least-once 交付下重複是常態，不靠調 ackDeadline 消除、靠 consumer 設計吸收。</li>
</ol>
<h3 id="演練三dlt-max-delivery-attempts-設定誤判">演練三：DLT max delivery attempts 設定誤判</h3>
<p><strong>徵兆</strong>：兩種反向徵兆。其一，DLT 堆滿了「其實能恢復」的訊息 — 下游一抖動就被丟進 DLT，DLT backlog 暴增、人工 replay 不完。其二，主 subscription 卡著一則壞訊息反覆重投半天都不進 DLT、後面訊息（尤其在 ordering 流裡）全堵住。</p>
<p><strong>根因</strong>：第一種是 <code>max-delivery-attempts</code> 設太低（1-2 次），暫時性失敗就被當成 poison。第二種是設太高（數十次）或根本沒設 DLT，真正的 poison message 反覆重試、占資源、卡序列。</p>
<p><strong>判讀與修法</strong>：</p>
<ol>
<li>區分「暫時性失敗」與「結構性失敗」。暫時性（下游超時、限流）需要重試容忍度，結構性（payload 解析不了、業務規則永久拒絕）越早隔離越好。</li>
<li><code>max-delivery-attempts</code> 起點設 5，搭配 retry policy backoff（<code>--min-retry-delay</code> / <code>--max-retry-delay</code>）讓重試之間有間隔、給下游恢復時間，而不是密集重打。</li>
<li>確認 DLT 真的接得到訊息：檢查 Pub/Sub service account 對 DLT 的 publisher 授權（漏授權會讓訊息超過 attempts 後繼續留在主 subscription、看起來像沒進 DLT）。</li>
<li>DLT 要掛 subscription 才讀得到 — DLT 是 topic 不是 queue，建完 DLT 還要建 DLT 的 subscription 並設好 retention，否則 poison message 在 DLT 裡放滿 7 天後一樣丟失。</li>
</ol>
<h3 id="演練四push-endpoint-500-觸發-retry-storm">演練四：Push endpoint 500 觸發 retry storm</h3>
<p><strong>徵兆</strong>：push subscription 的下游 HTTP endpoint 開始大量回 500，Pub/Sub backoff 重投、但 endpoint 仍 500，重投量隨 backlog 累積越滾越大；endpoint 一旦短暫恢復就被積壓的重投流量瞬間打回 500，形成「恢復即再掛」的震盪。</p>
<p><strong>根因</strong>：push 的 flow control 由 Pub/Sub 掌握、按 ack 動態調速 — endpoint 回 2xx 視為 ack、非 2xx 視為 nack 並重投。當 endpoint 因下游依賴（DB / 外部 API）掛掉而持續 500，Pub/Sub 的 backoff 重投跟累積的 backlog 疊加，恢復瞬間的流量遠超 endpoint 平時負載。這正是「下游能否接受 push 衝擊」的反面 — push 沒有 consumer 端的 flow control 閥門。</p>
<p><strong>判讀與修法</strong>：</p>
<ol>
<li>先判訊息毒性 vs endpoint 健康。若是 endpoint 整體掛（所有訊息都 500），是容量 / 依賴問題；若是特定訊息 500（多數成功、少數失敗），是 poison message，該走 DLT。</li>
<li>endpoint 整體掛的場景，push 不是好選擇 — 改 pull + flow control，讓 consumer 用 <code>max_outstanding_messages</code> 把消費速率對齊到下游能吃的速率，避免恢復瞬間被積壓流量打垮（對照 <a href="/blog/backend/03-message-queue/cases/pubsub-mercari-line-flow-control/" data-link-title="3.C65 Mercari LINE：Pull subscription 對齊外部 RPS" data-link-desc="Mercari LINE webhook 轉 Pub/Sub、worker pull subscription 精確控制 RPS、應 LINE API 限制。">3.C65</a> 的下游 RPS 限制場景）。</li>
<li>對 push 配 DLT，把反覆 500 的特定訊息隔離出去，避免單一 poison message 混在正常流量裡放大 retry。</li>
<li>endpoint 側對「Pub/Sub 重投」做 idempotency，因為 push 也是 at-least-once、500 後的重投會帶來重複。</li>
</ol>
<h3 id="演練五schema-enforcement-擋下不相容-publish">演練五：Schema enforcement 擋下不相容 publish</h3>
<p><strong>徵兆</strong>：某次 producer 部署後，該 service 的 publish 開始大量回 <code>INVALID_ARGUMENT: Could not parse message</code>，訊息發不出去；但 consumer 端風平浪靜、沒有任何解析錯誤、backlog 也沒異常。</p>
<p><strong>根因</strong>：這通常不是故障、是 schema enforcement 正常運作。producer 改了 payload 結構（加必填欄位 / 改型別 / 漏欄位），新 payload 不符 topic 綁定的 schema，broker 在 publish 路徑上擋下、訊息根本沒進 topic。徵兆出現在 producer 端（publish 失敗）而非 consumer 端（解析失敗），正是 schema 守門把問題前移到源頭的設計意圖。</p>
<p><strong>判讀與修法</strong>：</p>
<ol>
<li>先確認是「該擋」還是「誤擋」。對照 producer 的新 payload 與 topic schema：若是破壞性改版（加必填欄位 / 改型別），enforcement 擋對了 — 該回滾 producer 或先演進 schema。</li>
<li>用 <code>gcloud pubsub schemas validate-message</code> 在部署前 dry-run 校驗 payload 對 schema，把「不相容」暴露在 CI 而不是 production publish。</li>
<li>schema 演進走相容路徑：新增欄位帶預設或設可選、刪欄位分兩步、避免一次破壞性改版。先升 schema 再升 producer，順序反了就會出現這個徵兆。</li>
<li>區分 schema enforcement 失敗與 DLT：schema 擋下的訊息不進 topic、不進 DLT（DLT 隔離的是「進了 topic 但消費反覆失敗」的訊息）。兩者是交付管線的不同關卡，徵兆與修法都不同。</li>
</ol>
<h2 id="容量與選型邊界標準版-vs-pubsub-lite">容量與選型邊界：標準版 vs Pub/Sub Lite</h2>
<p>前述配置適用標準版 Pub/Sub。標準版的計費與容量模型偏向「全域路由內建、按用量計費、不需預先規劃容量」；當吞吐極高且 region 確定時，Pub/Sub Lite 的 partition-based / zonal 模型成本更低。</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>標準版 Pub/Sub</th>
          <th>Pub/Sub Lite</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>路由</td>
          <td>全域、無 region 概念</td>
          <td>zonal / regional、需指定</td>
      </tr>
      <tr>
          <td>容量模型</td>
          <td>自動擴縮、按用量計費</td>
          <td>partition-based、預先 provision throughput</td>
      </tr>
      <tr>
          <td>成本</td>
          <td>高吞吐時單位成本較高</td>
          <td>高吞吐 + 確定 region 時顯著較低</td>
      </tr>
      <tr>
          <td>CLI surface</td>
          <td><code>gcloud pubsub topics</code></td>
          <td><code>gcloud pubsub lite-topics</code>（獨立）</td>
      </tr>
      <tr>
          <td>適用</td>
          <td>全域分發、彈性流量、不想管容量</td>
          <td>已知高且穩定的吞吐、成本敏感、region 確定</td>
      </tr>
  </tbody>
</table>
<p>Pub/Sub Lite 是獨立的 CLI surface（<code>gcloud pubsub lite-topics</code> / <code>gcloud pubsub lite-subscriptions</code>），不是標準版的一個 flag。選 Lite 的代價是要自己 provision partition 數與 throughput capacity（回到接近 Kafka 的容量規劃），換來的是高吞吐穩定流量下顯著更低的成本。判準是吞吐「夠高且夠穩定到值得自己管容量」— 流量彈性大、或不想管 partition 的場景仍該留在標準版。</p>
<blockquote>
<p>Spotify 的 autoscaling 案例揭露 backlog 不等於 consumer healthy：下游 export 失敗時 consumer 不 ack 仍持續耗 CPU，autoscaling 把 CPU 越拉越高、反而擴出更多空轉 consumer；解法是 exponential backoff 抑制 CPU 消耗（<a href="/blog/backend/03-message-queue/cases/pubsub-spotify-autoscaling-consumers/" data-link-title="3.C61 Spotify：Autoscaling Pub/Sub consumer 反效果" data-link-desc="Spotify 下游失敗時 consumer 不 ack 仍耗 CPU、autoscaling 越拉越高、解法是 exponential backoff 抑制 CPU。">3.C61</a>）。容量規劃的 autoscale signal 要看「處理成功率」而非「CPU + backlog」，否則擴縮方向會反。</p></blockquote>
<h2 id="整合與下一步">整合與下一步</h2>
<h3 id="bigquery--cloud-storage-subscription免-consumer-的落地路徑">BigQuery / Cloud Storage subscription：免 consumer 的落地路徑</h3>
<p>標準版提供兩種「不需要自寫 consumer」的 subscription，直接把訊息落地到分析 / 儲存層：</p>
<ul>
<li><strong>BigQuery subscription</strong>（<code>--bigquery-table</code>）：訊息直接寫進 BQ table，免 Dataflow 中介，適合 streaming analytics。可搭配 <code>--use-topic-schema</code> 讓 BQ table schema 對齊 topic schema — schema enforcement 在這裡延伸成「落地結構也受契約約束」。</li>
<li><strong>Cloud Storage subscription</strong>（<code>--cloud-storage-bucket</code>）：訊息批次寫成 GCS object，適合 data lake / 歸檔。</li>
</ul>
<p>這兩種 subscription 把「event 流 → 分析 / 儲存」的常見管線收進 Pub/Sub 配置，省掉一層自管 consumer。它們仍受同一套 ackDeadline / DLT 骨架管轄。</p>
<h3 id="cross-link">Cross-link</h3>
<ul>
<li>上游 vendor 頁：<a href="/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Google Cloud Pub/Sub overview</a> — 選型層、跟 Kafka / SQS 取捨</li>
<li>契約與重播邊界：<a href="/blog/backend/03-message-queue/event-contract-replay-boundary/" data-link-title="3.7 Event Contract 與 Replay Boundary" data-link-desc="說明 event schema、idempotency key、replay window 與補償如何先於 broker 選型。">3.7 Event Contract 與 Replay Boundary</a> — schema / idempotency key / replay window 先於 broker 選型</li>
<li>知識卡：<a href="/blog/backend/knowledge-cards/event-schema-compatibility/" data-link-title="Event Schema Compatibility" data-link-desc="說明 event schema 演進時，新舊 producer 與 consumer 能否互通的相容性等級">Event Schema Compatibility</a>（schema enforcement 守的契約等級）、<a href="/blog/backend/knowledge-cards/poison-message-quarantine/" data-link-title="Poison-Message Quarantine" data-link-desc="說明把毒訊息從主處理路徑隔離出來的機制，讓正常訊息繼續前進">Poison-Message Quarantine</a>（DLT 的隔離機制）</li>
<li>對應 case：<a href="/blog/backend/03-message-queue/cases/pubsub-mercari-item-feed-dlt/" data-link-title="3.C64 Mercari Item Feed：DLT 防 poison message 阻塞" data-link-desc="Mercari 商品 feed 同步、ack 整批 / nack 重送、重試多次仍失敗送 DLT、topic 同時當 load-leveling buffer。">3.C64 Mercari Item Feed DLT</a>、<a href="/blog/backend/03-message-queue/cases/pubsub-mercari-line-flow-control/" data-link-title="3.C65 Mercari LINE：Pull subscription 對齊外部 RPS" data-link-desc="Mercari LINE webhook 轉 Pub/Sub、worker pull subscription 精確控制 RPS、應 LINE API 限制。">3.C65 Mercari LINE flow control</a>、<a href="/blog/backend/03-message-queue/cases/pubsub-spotify-autoscaling-consumers/" data-link-title="3.C61 Spotify：Autoscaling Pub/Sub consumer 反效果" data-link-desc="Spotify 下游失敗時 consumer 不 ack 仍耗 CPU、autoscaling 越拉越高、解法是 exponential backoff 抑制 CPU。">3.C61 Spotify autoscaling</a>、<a href="/blog/backend/03-message-queue/cases/pubsub-mercari-actionable-history/" data-link-title="3.C63 Mercari Actionable History：ack deadline 是 batch-level" data-link-desc="Merpay 支付流水帳用 Pub/Sub、ack deadline 是整批 batch 而非單訊息、acked 訊息會跟同批 expired 一起 redeliver。">3.C63 Mercari actionable history</a></li>
<li>方法論：<a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章的寫作方法論</a></li>
</ul>
<h3 id="何時-revisit">何時 revisit</h3>
<ul>
<li>ordering key 吞吐撞上單 key 上限、且無法再細分 key：評估改用 Kafka partition 模型，或把保序成本移到 consumer 端 idempotency</li>
<li>高吞吐穩定流量 + 成本壓力浮現：評估標準版 → Pub/Sub Lite，接受自管 partition 容量換成本</li>
<li>schema 需要跨多 vendor 共用契約（同一份 event 同時進 Pub/Sub 與 Kafka）：評估把 schema source of truth 抽到 broker 外的 registry</li>
</ul>
]]></content:encoded></item><item><title>RabbitMQ DLQ 與分層 retry：別把失敗訊息 requeue 回隊首</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/rabbitmq/dlq-retry-escalation/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/rabbitmq/dlq-retry-escalation/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/rabbitmq/" data-link-title="RabbitMQ" data-link-desc="Classic message broker、AMQP routing 為主">RabbitMQ&lt;/a> overview 的 implementation-layer deep article。選型層（RabbitMQ vs Kafka / SQS、何時選 RabbitMQ）見 overview；本文只處理「決定用 RabbitMQ 後，失敗訊息怎麼 retry 才不會卡死隊列」。DLX 拓樸實機驗證於 rabbitmq:3-management、最後檢查日 2026-06-16；機制以 &lt;a href="https://www.rabbitmq.com/docs/dlx">RabbitMQ DLX 官方文件&lt;/a> 為準。&lt;/p>&lt;/blockquote>
&lt;h2 id="失敗訊息-requeue-回隊首會卡住整條隊列">失敗訊息 requeue 回隊首，會卡住整條隊列&lt;/h2>
&lt;p>消費一則訊息失敗了——下游 API 超時、資料還沒就緒、暫時性錯誤。最直覺的處理是 &lt;code>nack&lt;/code> 加 &lt;code>requeue=true&lt;/code>，讓它重新排隊再試一次。問題是 RabbitMQ 的 requeue 把訊息放回&lt;strong>原隊列的隊首&lt;/strong>，於是它立刻又被同一個 consumer 取出、再次失敗、再 requeue……在「下游還沒恢復」的那段時間裡，這則訊息反覆佔據隊首，後面所有正常訊息全被卡住。這就是 head-of-line blocking：一則毒訊息（poison message）拖垮整條隊列。&lt;/p>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/rabbitmq-indeed-delay-dlq-escalation/" data-link-title="3.C25 Indeed：Delay queue &amp;#43; DLQ 三層 escalation" data-link-desc="Indeed 每天 35M&amp;#43; 職缺、設計 Requeue → Delay queue → DLQ 三層 escalation 避開 head-of-line blocking。">Indeed 每天處理 35M+ 職缺訊息&lt;/a>，原本的架構正是把失敗訊息 requeue 回隊首，造成阻塞。他們的解法是設計 &lt;strong>Requeue → Delay queue → Dead Letter Queue 三層 escalation&lt;/strong>：retry 幾次後讓訊息進延遲隊列（隔一段時間再試）、再失敗幾次才進 DLQ（停止重試、留待人工或專門處理）。這個案例揭露的核心原則是——&lt;strong>retry 策略要跟隊列拓樸一起設計，不是純 client 端的 backoff&lt;/strong>。&lt;/p>
&lt;p>本文展開 RabbitMQ 實現這套分層 retry 的機制（dead-letter exchange + TTL）、實機驗證的拓樸、以及把它寫成事故的踩坑。&lt;/p>
&lt;h2 id="核心概念dead-letter-exchange-的求值模型">核心概念：dead-letter exchange 的求值模型&lt;/h2>
&lt;p>RabbitMQ 的失敗訊息處理建立在 dead-letter exchange（DLX）上。理解它要抓住「訊息在什麼條件下被 dead-letter、去哪裡」。&lt;/p>
&lt;p>&lt;strong>訊息在三種情況被 dead-letter&lt;/strong>。一則訊息會從它所在的隊列被轉送到該隊列設定的 DLX：(1) 被 consumer &lt;code>nack&lt;/code> / &lt;code>reject&lt;/code> 且 &lt;code>requeue=false&lt;/code>；(2) 訊息 TTL 到期（&lt;code>x-message-ttl&lt;/code> 或 per-message expiration）；(3) 隊列達到長度上限（&lt;code>x-max-length&lt;/code>）被擠掉。這三種 reason 會記在訊息的 &lt;code>x-death&lt;/code> header 裡。&lt;/p>
&lt;p>&lt;strong>DLX 是隊列的屬性、不是訊息的&lt;/strong>。在宣告隊列時用 &lt;code>x-dead-letter-exchange&lt;/code> 指定這個隊列的「死信要送去哪個 exchange」，搭配 &lt;code>x-dead-letter-routing-key&lt;/code> 指定送過去時用什麼 routing key。死信被當成一則新訊息發布到那個 exchange，再依綁定路由到 DLQ。&lt;/p>
&lt;p>&lt;strong>TTL + DLX 組出「延遲隊列」&lt;/strong>。RabbitMQ 沒有原生的延遲投遞，但可以用「一個沒有 consumer、只設 TTL + DLX 的隊列」模擬：訊息進這個隊列、躺到 TTL 到期、被 dead-letter 回工作 exchange——等於延遲了 TTL 那麼久才重新可被消費。這是分層 retry 的關鍵積木。&lt;/p>
&lt;p>&lt;strong>&lt;code>x-death&lt;/code> header 累積重試歷史&lt;/strong>。每次 dead-letter，RabbitMQ 在 &lt;code>x-death&lt;/code> header 追加一筆記錄（哪個隊列、什麼 reason、次數 count）。消費端讀這個 count 就能判斷「這則訊息重試幾次了」，決定要再延遲還是進 DLQ。這是實現「retry n 次後升級」的依據。&lt;/p>
&lt;h2 id="配置work--delay--dlq-三層拓樸">配置：work → delay → DLQ 三層拓樸&lt;/h2>
&lt;p>實機驗證的最小 DLX 拓樸（工作隊列的訊息 TTL 到期後 dead-letter 到 DLQ）：&lt;/p>





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





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





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





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">docker &lt;span class="nb">exec&lt;/span> valkey valkey-cli INFO server &lt;span class="p">|&lt;/span> grep -E &lt;span class="s2">&amp;#34;redis_version|valkey_version|server_name&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1"># redis_version:7.2.4 ← client library 以此協商相容行為&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="c1"># server_name:valkey&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1"># valkey_version:8.1.8 ← Valkey 自身的演進線&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個雙版本回報就是相容性的機制本身：client library 看到 &lt;code>redis_version:7.2.4&lt;/code>，就以 Redis 7.2.4 的協定與行為運作，完全不知道背後是 Valkey；&lt;code>valkey_version&lt;/code> 才是 Valkey 自己的版本，記錄它在 fork 之後加了什麼（例如 8.x 的多執行緒）。理解這條雙線——「對外裝成 Redis 7.2.4、對內持續演進」——是判斷相容性邊界的鑰匙。&lt;/p>
&lt;p>對大規模生產驗證，&lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/tinder-elasticache-valkey-matching/" data-link-title="9.C6 Tinder：ElastiCache for Valkey 撐 4700 萬月活的配對引擎" data-link-desc="Tinder 用 Amazon ElastiCache for Valkey 提供配對引擎所需的次毫秒延遲快取層">Tinder 的配對引擎&lt;/a>是現成的證據：4700 萬月活、每次滑動讀多個 cache、sub-millisecond 延遲，跑在 Amazon ElastiCache for Valkey 上——這個規模的服務跑在 Valkey 上，本身就是相容性的背書。另一個訊號是 AWS 在 2024 把 ElastiCache 的 default engine 從 Redis 改成 Valkey（AWS 宣稱成本較 Redis OSS 低約 20%、以 &lt;a href="https://aws.amazon.com/elasticache/pricing/">ElastiCache 定價&lt;/a> 為準、最後檢查日 2026-06-16）。這些都是外部背書，但各服務有自己的 client library、module 與邊角用法，仍需自行驗證。&lt;/p>
&lt;h2 id="核心概念相容性的三層邊界">核心概念：相容性的三層邊界&lt;/h2>
&lt;p>「100% 相容」在不同層次有不同的精確度，驗證要分三層做。&lt;/p>
&lt;p>&lt;strong>協定與核心指令層：完全相容&lt;/strong>。string / hash / list / set / sorted set / stream / hyperloglog / geo 的所有指令、TTL / eviction / persistence / pub-sub / transaction、RESP 協定——這層是 fork 自 Redis 7.2.4 的部分，行為一致。所有標準 Redis client library 透過 &lt;code>redis_version&lt;/code> 協商，直接連、不改 code。&lt;/p>
&lt;p>&lt;strong>檔案格式層：相容&lt;/strong>。RDB 與 AOF 的檔案格式跟 Redis 7.2.4 一致，可以直接把 Redis 的資料目錄拷給 Valkey 載入——這是 drop-in 遷移的基礎，不需要 dump / reload。&lt;/p>
&lt;p>&lt;strong>生態與新功能層：要逐項確認&lt;/strong>。Redis 7.4+ 在 fork 之後新增的功能（Valkey 不一定跟進）、Redis Stack 的商業 module（RedisJSON / RedisSearch，Valkey 有自己的 valkey-search / valkey-bloom 但不是同一套）、偏 Redis Inc 的監控工具（RedisInsight 部分 vendor-specific 命令）——這層是相容性的真實風險所在，驗證要集中在這裡。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/02-cache-redis/vendors/valkey/" data-link-title="Valkey" data-link-desc="Redis fork、Linux Foundation 託管、BSD 授權">Valkey</a> overview 的 implementation-layer deep article。選型層（為何 fork、授權治理、何時選 Valkey）見 overview；本文只處理「決定用 Valkey 後，相容性怎麼驗、執行緒怎麼調」。命令實機驗證於 <code>valkey/valkey:8</code> image（valkey_version 8.1.8）、最後檢查日 2026-06-16；效能數字以 <a href="https://valkey.io/blog/">valkey.io 官方 benchmark</a> 為準。</p></blockquote>
<h2 id="100-相容要能驗證才敢切">「100% 相容」要能驗證才敢切</h2>
<p>Valkey 從 Redis 7.2.4 fork、宣稱 100% API 相容、drop-in 替換——這對選型是好消息，對上線前的工程師卻是一個需要證據的斷言。把 production 的 Redis 換成 Valkey，最怕的不是「大部分指令能跑」，而是某個邊角行為、某個 client library 的版本協商、某個 module 沒有對應 fork，在切換後才浮現。相容性不能靠信任，要靠驗證。</p>
<p>驗證的起點是一個容易被忽略的細節：Valkey 的 <code>INFO server</code> 同時回報兩個版本號。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">docker <span class="nb">exec</span> valkey valkey-cli INFO server <span class="p">|</span> grep -E <span class="s2">&#34;redis_version|valkey_version|server_name&#34;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># redis_version:7.2.4    ← client library 以此協商相容行為</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"># server_name:valkey</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># valkey_version:8.1.8   ← Valkey 自身的演進線</span></span></span></code></pre></div><p>這個雙版本回報就是相容性的機制本身：client library 看到 <code>redis_version:7.2.4</code>，就以 Redis 7.2.4 的協定與行為運作，完全不知道背後是 Valkey；<code>valkey_version</code> 才是 Valkey 自己的版本，記錄它在 fork 之後加了什麼（例如 8.x 的多執行緒）。理解這條雙線——「對外裝成 Redis 7.2.4、對內持續演進」——是判斷相容性邊界的鑰匙。</p>
<p>對大規模生產驗證，<a href="/blog/backend/09-performance-capacity/cases/tinder-elasticache-valkey-matching/" data-link-title="9.C6 Tinder：ElastiCache for Valkey 撐 4700 萬月活的配對引擎" data-link-desc="Tinder 用 Amazon ElastiCache for Valkey 提供配對引擎所需的次毫秒延遲快取層">Tinder 的配對引擎</a>是現成的證據：4700 萬月活、每次滑動讀多個 cache、sub-millisecond 延遲，跑在 Amazon ElastiCache for Valkey 上——這個規模的服務跑在 Valkey 上，本身就是相容性的背書。另一個訊號是 AWS 在 2024 把 ElastiCache 的 default engine 從 Redis 改成 Valkey（AWS 宣稱成本較 Redis OSS 低約 20%、以 <a href="https://aws.amazon.com/elasticache/pricing/">ElastiCache 定價</a> 為準、最後檢查日 2026-06-16）。這些都是外部背書，但各服務有自己的 client library、module 與邊角用法，仍需自行驗證。</p>
<h2 id="核心概念相容性的三層邊界">核心概念：相容性的三層邊界</h2>
<p>「100% 相容」在不同層次有不同的精確度，驗證要分三層做。</p>
<p><strong>協定與核心指令層：完全相容</strong>。string / hash / list / set / sorted set / stream / hyperloglog / geo 的所有指令、TTL / eviction / persistence / pub-sub / transaction、RESP 協定——這層是 fork 自 Redis 7.2.4 的部分，行為一致。所有標準 Redis client library 透過 <code>redis_version</code> 協商，直接連、不改 code。</p>
<p><strong>檔案格式層：相容</strong>。RDB 與 AOF 的檔案格式跟 Redis 7.2.4 一致，可以直接把 Redis 的資料目錄拷給 Valkey 載入——這是 drop-in 遷移的基礎，不需要 dump / reload。</p>
<p><strong>生態與新功能層：要逐項確認</strong>。Redis 7.4+ 在 fork 之後新增的功能（Valkey 不一定跟進）、Redis Stack 的商業 module（RedisJSON / RedisSearch，Valkey 有自己的 valkey-search / valkey-bloom 但不是同一套）、偏 Redis Inc 的監控工具（RedisInsight 部分 vendor-specific 命令）——這層是相容性的真實風險所在，驗證要集中在這裡。</p>
<p>驗證的操作順序：先確認 client library 連得上且核心指令正常（第一層），再確認資料能載入（第二層），最後盤點你實際用到的 module 與 7.4+ 功能（第三層）。前兩層幾乎必過，工夫花在第三層。</p>
<h2 id="配置io-threads-多執行緒調校">配置：io-threads 多執行緒調校</h2>
<p>Valkey 跟 Redis 7.2.4 拉開的第一個實質技術差異是執行緒模型。Redis 的命令處理是單執行緒（I/O threads 只分擔 socket 讀寫，命令仍在主執行緒），Valkey 8.x 把更多 I/O 路徑非同步化，在多核機器上能讓單實例吞吐明顯高於 Redis——具體倍數依 workload 與核數而定，以 <a href="https://valkey.io/blog/">valkey.io 官方 benchmark</a> 為準，這裡不複述未經自己壓測的數字。</p>
<p>執行緒由 <code>io-threads</code> 控制，預設 1（單執行緒，跟 Redis 行為一致）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 確認目前執行緒數（預設 1）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">valkey-cli CONFIG GET io-threads
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"># 1) &#34;io-threads&#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 2) &#34;1&#34;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"># 調高 I/O 執行緒數（建議不超過機器實體核數、留核給其他進程）</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"># redis.conf / valkey.conf:</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1">#   io-threads 4</span></span></span></code></pre></div><p>調校判讀：</p>
<ul>
<li><code>io-threads</code> 是啟動參數，多數版本需要重啟生效（不是所有 CONFIG SET 都能熱套），改 conf 後 rolling restart</li>
<li>設定值對齊機器核數但留 headroom，例如 8 核機器設 4-6，不要設滿</li>
<li>單核或低核機器設 1（預設）即可，多執行緒在核數不足時沒有收益反而增加切換開銷</li>
<li>I/O 密集（大量小命令、高連線數）的 workload 收益最明顯；CPU 密集的重命令（大 Lua、大 collection 操作）收益有限</li>
</ul>
<p>調完用實際 workload 壓測驗證，不要假設「開了就快」——執行緒配置的收益高度依賴 workload 形狀。</p>
<h2 id="production-故障演練">Production 故障演練</h2>
<h3 id="case-1切換後-module-指令報-unknown-command">Case 1：切換後 module 指令報 unknown command</h3>
<p><strong>徵兆</strong>：drop-in 換成 Valkey 後核心功能正常，但某些路徑報 <code>ERR unknown command 'JSON.SET'</code> 或 <code>FT.SEARCH</code>，application 部分功能失效。</p>
<p><strong>根因</strong>：用到了 Redis Stack 的商業 module（RedisJSON / RedisSearch）。這些 module 不在 fork 範圍內，Valkey 有自己的 valkey-search / valkey-bloom，但不是同一套指令、需要另外安裝。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>切換前用 <code>MODULE LIST</code> 在原 Redis 上盤點所有載入的 module</li>
<li>逐個確認 Valkey 是否有對應替代（valkey-search 對 RedisSearch 等），確認指令相容度</li>
<li>沒有對應的 module，評估改用 module-free 設計（例如把 JSON 操作拉回 application 層）</li>
<li>重度依賴 Redis Stack 商業 module 的場景，相容性邊界在這裡，可能該留在 <a href="/blog/backend/02-cache-redis/vendors/redis/" data-link-title="Redis" data-link-desc="OSS in-memory data structure store、cache 主流">Redis</a> Inc 商業版</li>
</ol>
<h3 id="case-2client-library-太舊協商失敗">Case 2：client library 太舊、協商失敗</h3>
<p><strong>徵兆</strong>：絕大多數 client 正常，但某個老服務的 client library 連 Valkey 報協定錯誤或行為異常。</p>
<p><strong>根因</strong>：Valkey 回報 <code>redis_version:7.2.4</code>，client library 若太舊（不支援 Redis 7.2 對應的協定特性，例如 RESP3）會協商失敗。這不是 Valkey 的問題，是 client 本來就跟不上 Redis 7.2。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><code>valkey-cli INFO server</code> 確認回報的 <code>redis_version</code>，對照 client library 支援到哪個 Redis 版本</li>
<li>升級過舊的 client library 到支援 Redis 7.2 的版本</li>
<li>必要時 client 端強制用 RESP2（多數 library 可配置），避開 RESP3 協商</li>
<li>這類問題在升級 Redis 7.2 時也會遇到，不是 Valkey 特有</li>
</ol>
<h3 id="case-3監控工具部分指標消失">Case 3：監控工具部分指標消失</h3>
<p><strong>徵兆</strong>：切換後 RedisInsight 或某監控 dashboard 部分面板空白、某些 vendor-specific 命令回錯。</p>
<p><strong>根因</strong>：RedisInsight 等 Redis Inc 工具有部分偏 Redis 商業版的命令，Valkey 不一定實作。核心指標（memory / hit rate / connections）通用，但 vendor-specific 的進階面板可能缺。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>監控改用通用工具：<code>valkey-cli INFO</code>、Prometheus + redis_exporter（相容 Valkey）、Grafana</li>
<li>核心指標（<code>used_memory</code> / <code>keyspace_hits</code> / <code>connected_clients</code>）在 Valkey 完全相容，監控覆蓋不受影響</li>
<li>把監控的相容性納入切換前驗證清單，不要切換後才發現面板空白</li>
<li>對應 <a href="/blog/backend/02-cache-redis/vendors/redis/memory-eviction-tuning/" data-link-title="Redis 記憶體與淘汰調校：maxmemory-policy、LFU 與碎片化的實戰判讀" data-link-desc="Redis 的記憶體是一條會在半夜爆掉的曲線：maxmemory 設多少、policy 選 LRU 還 LFU、碎片化什麼時候開始吃掉 30% RAM、OOM 時 noeviction 怎麼讓寫入全部失敗。本文展開 Redis 記憶體會計模型、eviction policy 的選型判讀、5 個把記憶體配置寫成 production 事故的踩坑，以及單機記憶體撞牆後該往 cluster 還是 DragonflyDB 走的邊界">記憶體</a> 與 <a href="/blog/backend/02-cache-redis/vendors/redis/connection-pipeline-latency/" data-link-title="Redis 連線與 pipeline：RTT 稅、連線池與一次往返打包多命令" data-link-desc="Redis 單命令通常微秒級執行，但 application 端量到的延遲是毫秒級——差距全在網路往返（RTT）。pipelining 的本質不是『批次發命令』，是把 N 次 RTT 壓成 1 次。本文展開 RTT 會計、連線池配置、pipeline 與 MULTI 的差異、5 個把連線與往返寫成延遲與正確性問題的 production 踩坑，以及連線模型撞牆的邊界">連線</a> 調校用到的 INFO 指標，這些在 Valkey 都通用</li>
</ol>
<h3 id="case-4io-threads-開太多效能反而下降">Case 4：io-threads 開太多、效能反而下降</h3>
<p><strong>徵兆</strong>：把 <code>io-threads</code> 從 1 調到 16 想榨效能，結果延遲不降反升、CPU 使用率異常。</p>
<p><strong>根因</strong>：<code>io-threads</code> 設成超過機器實體核數，執行緒互搶 CPU、context switch 開銷超過平行收益。或 workload 是 CPU 密集（重命令），I/O 多執行緒對它沒幫助。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><code>io-threads</code> 不超過實體核數，留 headroom 給 OS 與其他進程（8 核設 4-6）</li>
<li>用實際 workload 壓測對比不同 io-threads 值的延遲與吞吐，不要憑感覺調滿</li>
<li>CPU 密集 workload 收益有限，問題可能在命令本身太重（大 collection / 大 Lua），先優化命令</li>
<li>多執行緒解的是 I/O 平行度，不是單命令執行速度，分清楚瓶頸在哪</li>
</ol>
<h3 id="case-5以為換-valkey-就解決了-redis-的記憶體--fork-問題">Case 5：以為換 Valkey 就解決了 Redis 的記憶體 / fork 問題</h3>
<p><strong>徵兆</strong>：因為 Redis 的 fork 延遲尖峰或記憶體 OOM 而切到 Valkey，切完發現同樣的尖峰與 OOM 還在。</p>
<p><strong>根因</strong>：Valkey fork 自 Redis 7.2.4，繼承了 Redis 的記憶體模型、eviction 演算法、AOF/RDB fork 機制。這些行為在 Valkey 上完全一致——Valkey 的差異在執行緒與授權，不在記憶體與持久化架構。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>記憶體 / 淘汰 / fork 的調校在 Valkey 上跟 Redis 完全一樣，直接套用 <a href="/blog/backend/02-cache-redis/vendors/redis/memory-eviction-tuning/" data-link-title="Redis 記憶體與淘汰調校：maxmemory-policy、LFU 與碎片化的實戰判讀" data-link-desc="Redis 的記憶體是一條會在半夜爆掉的曲線：maxmemory 設多少、policy 選 LRU 還 LFU、碎片化什麼時候開始吃掉 30% RAM、OOM 時 noeviction 怎麼讓寫入全部失敗。本文展開 Redis 記憶體會計模型、eviction policy 的選型判讀、5 個把記憶體配置寫成 production 事故的踩坑，以及單機記憶體撞牆後該往 cluster 還是 DragonflyDB 走的邊界">Redis 記憶體調校</a> 與 <a href="/blog/backend/02-cache-redis/vendors/redis/persistence-fork-latency/" data-link-title="Redis 持久化與 fork latency：AOF、RDB 與那一次卡住整個 cluster 的 fork" data-link-desc="Redis 的 RDB save 與 AOF rewrite 都靠一次 fork()，而 fork 在大記憶體實例上會凍結主執行緒數百毫秒、複製分頁讓記憶體逼近翻倍。本文展開 AOF / RDB 的機制與 fsync 取捨、copy-on-write 的記憶體放大、5 個把持久化寫成延遲尖峰與資料遺失的 production 踩坑，以及 cache 場景到底要不要持久化的邊界">persistence / fork latency</a></li>
<li>fork 尖峰是 Redis 系列的共同架構限制，要根治走 <a href="/blog/backend/02-cache-redis/vendors/dragonflydb/" data-link-title="DragonflyDB" data-link-desc="高效能 Redis / Memcached 相容替代、多核架構">DragonflyDB</a> 的 fork-less 機制，不是換 Valkey</li>
<li>切換 Valkey 的理由應該是授權合規、多執行緒吞吐或 managed 成本，不是記憶體問題</li>
<li>切換前釐清痛點：是授權 / 成本（Valkey 解）還是記憶體 / fork 架構（Valkey 不解）</li>
</ol>
<h2 id="capacity--cost-邊界">Capacity / cost 邊界</h2>
<p>Valkey 的容量判讀，多數沿用 Redis（同源），差異集中在執行緒與授權成本：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>Valkey 的情況</th>
          <th>判讀</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>核心指標（記憶體 / hit rate）</td>
          <td>跟 Redis 完全一致</td>
          <td>直接套用 Redis 的容量判讀</td>
      </tr>
      <tr>
          <td><code>io-threads</code></td>
          <td>預設 1、可調至接近核數</td>
          <td>多核 + I/O 密集才有收益、需壓測驗證</td>
      </tr>
      <tr>
          <td>單實例吞吐</td>
          <td>多執行緒下高於 Redis（依 workload）</td>
          <td>以 valkey.io benchmark 為準、自己壓測</td>
      </tr>
      <tr>
          <td>授權成本</td>
          <td>BSD 3-clause、商業使用無限制</td>
          <td>合規敏感場景的決定性優勢</td>
      </tr>
      <tr>
          <td>managed 成本</td>
          <td>ElastiCache for Valkey 約低 Redis 20%</td>
          <td>AWS 生態的成本優化路徑</td>
      </tr>
  </tbody>
</table>
<p>撞牆後的路由判斷：</p>
<ul>
<li><strong>記憶體 / fork 是瓶頸</strong>：Valkey 同源、不解這層，走 <a href="/blog/backend/02-cache-redis/vendors/dragonflydb/" data-link-title="DragonflyDB" data-link-desc="高效能 Redis / Memcached 相容替代、多核架構">DragonflyDB</a>（fork-less + 更省記憶體）或 Redis 系列的 <a href="/blog/backend/02-cache-redis/vendors/redis/cluster-resharding/" data-link-title="Redis Cluster Re-sharding：source = target，但 topology 重劃的 5 段流程" data-link-desc="Redis cluster re-sharding 是 5 type migration 漏類實證 — source / target 同 cluster、無 schema / paradigm 差、但 16384 slot 重分配是核心；本文涵蓋 4 種 re-sharding driver、slot migration 機制、redis-cli --cluster rebalance / reshard 工具、5 個 production 踩雷（cluster busy / replica lag / client cache stale / cross-slot transaction / monitor gap）">Cluster 分片</a>。</li>
<li><strong>需要 Redis Stack 商業 module</strong>：Valkey 的 valkey-search / valkey-bloom 覆蓋不到全部，重度依賴走 <a href="/blog/backend/02-cache-redis/vendors/redis/" data-link-title="Redis" data-link-desc="OSS in-memory data structure store、cache 主流">Redis</a> Inc 商業版。</li>
<li><strong>不想自管</strong>：<a href="/blog/backend/02-cache-redis/vendors/aws-elasticache/" data-link-title="AWS ElastiCache" data-link-desc="AWS managed Redis / Valkey / Memcached">ElastiCache for Valkey</a> 是 AWS 的 default engine，managed failover / snapshot / patching 全託管，成本比 ElastiCache for Redis 低約 20%。</li>
</ul>
<h2 id="整合--下一步">整合 / 下一步</h2>
<p>Valkey 的 deep article 大量複用 Redis 的調校知識（同源），它自己的獨特性在相容性驗證、執行緒與授權：</p>
<ul>
<li><strong>跟 <a href="/blog/backend/02-cache-redis/vendors/redis/" data-link-title="Redis" data-link-desc="OSS in-memory data structure store、cache 主流">Redis 全系列 deep article</a></strong>：記憶體、持久化、Sentinel、連線的調校在 Valkey 上完全一致，Valkey 不重寫這些，直接套用。</li>
<li><strong>跟 <a href="/blog/backend/02-cache-redis/vendors/aws-elasticache/" data-link-title="AWS ElastiCache" data-link-desc="AWS managed Redis / Valkey / Memcached">ElastiCache for Valkey</a></strong>：managed Valkey 把執行緒與 failover 託管，省掉自管的調校與演練。</li>
<li><strong>跟 <a href="/blog/backend/09-performance-capacity/cases/tinder-elasticache-valkey-matching/" data-link-title="9.C6 Tinder：ElastiCache for Valkey 撐 4700 萬月活的配對引擎" data-link-desc="Tinder 用 Amazon ElastiCache for Valkey 提供配對引擎所需的次毫秒延遲快取層">Tinder 的 ElastiCache for Valkey 案例</a></strong>：4700 萬月活的 sub-millisecond 配對引擎是相容性與規模化的生產證據，但 module / client 的相容性仍需逐案驗證。</li>
<li><strong>跟 <a href="/blog/backend/02-cache-redis/vendors/dragonflydb/" data-link-title="DragonflyDB" data-link-desc="高效能 Redis / Memcached 相容替代、多核架構">DragonflyDB</a></strong>：兩者都打「Redis 相容 + 更好的執行緒」，但 Valkey 是 fork（同源、最高相容），DragonflyDB 是 C++ 重寫（相容核心但架構不同），選型差異在相容度 vs 架構激進度。</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li>上游 vendor 頁：<a href="/blog/backend/02-cache-redis/vendors/valkey/" data-link-title="Valkey" data-link-desc="Redis fork、Linux Foundation 託管、BSD 授權">Valkey</a></li>
<li>同源 deep article：<a href="/blog/backend/02-cache-redis/vendors/redis/memory-eviction-tuning/" data-link-title="Redis 記憶體與淘汰調校：maxmemory-policy、LFU 與碎片化的實戰判讀" data-link-desc="Redis 的記憶體是一條會在半夜爆掉的曲線：maxmemory 設多少、policy 選 LRU 還 LFU、碎片化什麼時候開始吃掉 30% RAM、OOM 時 noeviction 怎麼讓寫入全部失敗。本文展開 Redis 記憶體會計模型、eviction policy 的選型判讀、5 個把記憶體配置寫成 production 事故的踩坑，以及單機記憶體撞牆後該往 cluster 還是 DragonflyDB 走的邊界">Redis 記憶體與淘汰調校</a>、<a href="/blog/backend/02-cache-redis/vendors/redis/persistence-fork-latency/" data-link-title="Redis 持久化與 fork latency：AOF、RDB 與那一次卡住整個 cluster 的 fork" data-link-desc="Redis 的 RDB save 與 AOF rewrite 都靠一次 fork()，而 fork 在大記憶體實例上會凍結主執行緒數百毫秒、複製分頁讓記憶體逼近翻倍。本文展開 AOF / RDB 的機制與 fsync 取捨、copy-on-write 的記憶體放大、5 個把持久化寫成延遲尖峰與資料遺失的 production 踩坑，以及 cache 場景到底要不要持久化的邊界">persistence 與 fork latency</a></li>
<li>平行 vendor：<a href="/blog/backend/02-cache-redis/vendors/aws-elasticache/" data-link-title="AWS ElastiCache" data-link-desc="AWS managed Redis / Valkey / Memcached">AWS ElastiCache</a>（default engine 即 Valkey）、<a href="/blog/backend/02-cache-redis/vendors/dragonflydb/" data-link-title="DragonflyDB" data-link-desc="高效能 Redis / Memcached 相容替代、多核架構">DragonflyDB</a></li>
<li>Methodology：<a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章寫作方法論</a></li>
</ul>
]]></content:encoded></item><item><title>PostgreSQL Patroni HA：從 leader 失聯到 client 重連的 5 段 failover lifecycle</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/patroni-ha/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/patroni-ha/</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 已說明 PostgreSQL 在 OLTP 譜系的定位、本文聚焦 &lt;em>Patroni-based HA&lt;/em> 的 lifecycle 設計 — 從正常運作到 failover 完成的 5 段、每段配置 + failure mode + recovery。&lt;/p>&lt;/blockquote>
&lt;h2 id="failover-lifecycle5-段不是一條曲線">Failover lifecycle：5 段不是一條曲線&lt;/h2>
&lt;p>PostgreSQL 原生沒有 auto-failover；primary 掛了、application 卡死、SRE 手動 promote standby — 整個過程通常 5-30 分鐘。Patroni 把這條鏈拆成 &lt;em>自動化的 5 段 lifecycle&lt;/em>、每段有自己的 trigger、配置、失敗模式：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>段&lt;/th>
 &lt;th>觸發&lt;/th>
 &lt;th>動作&lt;/th>
 &lt;th>失敗模式&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;strong>1. Detection&lt;/strong>&lt;/td>
 &lt;td>Leader heartbeat 在 DCS（etcd / Consul）失聯&lt;/td>
 &lt;td>Standby 們開始觀察、累積失聯時間到 TTL&lt;/td>
 &lt;td>DCS 本身分裂 → false detection 啟動失敗 failover&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>2. Election&lt;/strong>&lt;/td>
 &lt;td>TTL 過、DCS 開放 leader lock&lt;/td>
 &lt;td>Standby 競爭寫 leader key（DCS quorum-based）&lt;/td>
 &lt;td>Network partition → 兩邊都自認 leader（split-brain）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>3. Promotion&lt;/strong>&lt;/td>
 &lt;td>新 leader 寫 DCS key 成功&lt;/td>
 &lt;td>跑 &lt;code>pg_ctl promote&lt;/code>、停 streaming replication、開始接寫&lt;/td>
 &lt;td>Standby 落後太多 → 拒 promote 或承接時資料缺&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>4. Reconfiguration&lt;/strong>&lt;/td>
 &lt;td>Patroni REST API 通知 routing 層&lt;/td>
 &lt;td>HAProxy / PgBouncer 切流量到新 leader&lt;/td>
 &lt;td>Routing 層 health check 慢 → 流量持續打舊 leader&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>5. Recovery&lt;/strong>&lt;/td>
 &lt;td>舊 leader 恢復（手動 / 自動）&lt;/td>
 &lt;td>跑 &lt;code>pg_rewind&lt;/code> + 重接 streaming replication 為 standby&lt;/td>
 &lt;td>WAL divergence 太大 → 必須重 base backup&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>每段都有獨立配置、不是「設一個 timeout 就好」。後面分段展開。&lt;/p>
&lt;h2 id="stage-1detection--dcs-heartbeat-跟-ttl">Stage 1：Detection — DCS heartbeat 跟 TTL&lt;/h2>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c"># patroni.yml 核心配置&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="nt">scope&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">myapp-pg-cluster&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="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">/db/&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="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">pg-node-1 &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># 跟 hostname 一致&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>&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="nt">etcd&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="nt">hosts&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">etcd1:2379,etcd2:2379,etcd3:2379 &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># DCS quorum&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="nt">protocol&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">https&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>&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="nt">bootstrap&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="nt">dcs&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="nt">ttl&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">30&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># leader lock TTL&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="nt">loop_wait&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">10&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># patroni 主循環間隔&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="nt">retry_timeout&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">10&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># DCS retry 上限&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="nt">maximum_lag_on_failover&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1048576&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># standby 落後 1MB 內才能 promote&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="nt">synchronous_mode&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># async / sync 取捨&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>關鍵直覺：&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 已說明 PostgreSQL 在 OLTP 譜系的定位、本文聚焦 <em>Patroni-based HA</em> 的 lifecycle 設計 — 從正常運作到 failover 完成的 5 段、每段配置 + failure mode + recovery。</p></blockquote>
<h2 id="failover-lifecycle5-段不是一條曲線">Failover lifecycle：5 段不是一條曲線</h2>
<p>PostgreSQL 原生沒有 auto-failover；primary 掛了、application 卡死、SRE 手動 promote standby — 整個過程通常 5-30 分鐘。Patroni 把這條鏈拆成 <em>自動化的 5 段 lifecycle</em>、每段有自己的 trigger、配置、失敗模式：</p>
<table>
  <thead>
      <tr>
          <th>段</th>
          <th>觸發</th>
          <th>動作</th>
          <th>失敗模式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>1. Detection</strong></td>
          <td>Leader heartbeat 在 DCS（etcd / Consul）失聯</td>
          <td>Standby 們開始觀察、累積失聯時間到 TTL</td>
          <td>DCS 本身分裂 → false detection 啟動失敗 failover</td>
      </tr>
      <tr>
          <td><strong>2. Election</strong></td>
          <td>TTL 過、DCS 開放 leader lock</td>
          <td>Standby 競爭寫 leader key（DCS quorum-based）</td>
          <td>Network partition → 兩邊都自認 leader（split-brain）</td>
      </tr>
      <tr>
          <td><strong>3. Promotion</strong></td>
          <td>新 leader 寫 DCS key 成功</td>
          <td>跑 <code>pg_ctl promote</code>、停 streaming replication、開始接寫</td>
          <td>Standby 落後太多 → 拒 promote 或承接時資料缺</td>
      </tr>
      <tr>
          <td><strong>4. Reconfiguration</strong></td>
          <td>Patroni REST API 通知 routing 層</td>
          <td>HAProxy / PgBouncer 切流量到新 leader</td>
          <td>Routing 層 health check 慢 → 流量持續打舊 leader</td>
      </tr>
      <tr>
          <td><strong>5. Recovery</strong></td>
          <td>舊 leader 恢復（手動 / 自動）</td>
          <td>跑 <code>pg_rewind</code> + 重接 streaming replication 為 standby</td>
          <td>WAL divergence 太大 → 必須重 base backup</td>
      </tr>
  </tbody>
</table>
<p>每段都有獨立配置、不是「設一個 timeout 就好」。後面分段展開。</p>
<h2 id="stage-1detection--dcs-heartbeat-跟-ttl">Stage 1：Detection — DCS heartbeat 跟 TTL</h2>





<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="c"># patroni.yml 核心配置</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">scope</span><span class="p">:</span><span class="w"> </span><span class="l">myapp-pg-cluster</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">namespace</span><span class="p">:</span><span class="w"> </span><span class="l">/db/</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">name</span><span class="p">:</span><span class="w"> </span><span class="l">pg-node-1                               </span><span class="w"> </span><span class="c"># 跟 hostname 一致</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w"></span><span class="nt">etcd</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="nt">hosts</span><span class="p">:</span><span class="w"> </span><span class="l">etcd1:2379,etcd2:2379,etcd3:2379      </span><span class="w"> </span><span class="c"># DCS quorum</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">  </span><span class="nt">protocol</span><span class="p">:</span><span class="w"> </span><span class="l">https</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w"></span><span class="nt">bootstrap</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="nt">dcs</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="nt">ttl</span><span class="p">:</span><span class="w"> </span><span class="m">30</span><span class="w">                                     </span><span class="c"># leader lock TTL</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">    </span><span class="nt">loop_wait</span><span class="p">:</span><span class="w"> </span><span class="m">10</span><span class="w">                               </span><span class="c"># patroni 主循環間隔</span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w">    </span><span class="nt">retry_timeout</span><span class="p">:</span><span class="w"> </span><span class="m">10</span><span class="w">                           </span><span class="c"># DCS retry 上限</span><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w">    </span><span class="nt">maximum_lag_on_failover</span><span class="p">:</span><span class="w"> </span><span class="m">1048576</span><span class="w">            </span><span class="c"># standby 落後 1MB 內才能 promote</span><span class="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w">    </span><span class="nt">synchronous_mode</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="w">                     </span><span class="c"># async / sync 取捨</span></span></span></code></pre></div><p>關鍵直覺：</p>
<ul>
<li><strong>TTL (30s) = leader 失聯多久才被視為 dead</strong>。設太短（&lt; 15s）會把 transient network jitter 當 dead；設太長（&gt; 60s）unavailability 拖長</li>
<li><strong>loop_wait + retry_timeout &lt; TTL</strong>：Patroni 必須在 TTL 內成功跟 DCS 互動 N 次、<code>loop_wait=10 + retry_timeout=10</code> 給每個循環 20s buffer</li>
<li><strong>maximum_lag_on_failover</strong>：standby WAL 落後超過這個閾值就 <em>不參與 election</em>；防止「promote 一個落後 5 分鐘的 standby」資料丟失</li>
</ul>
<h2 id="stage-2election--dcs-quorum--watchdog-防-split-brain">Stage 2：Election — DCS quorum + watchdog 防 split-brain</h2>





<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">watchdog</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">mode</span><span class="p">:</span><span class="w"> </span><span class="l">required                               </span><span class="w"> </span><span class="c"># required / automatic / off</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">device</span><span class="p">:</span><span class="w"> </span><span class="l">/dev/watchdog</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">safety_margin</span><span class="p">:</span><span class="w"> </span><span class="m">5</span></span></span></code></pre></div><p>Election 期間最大風險是 <em>split-brain</em> — network partition 下、舊 leader 還活著但跟 DCS 斷線；新 leader 從 standby 升上來、application 同時連兩個 PostgreSQL 寫。資料 divergence 後 <em>無法自動 reconcile</em>。</p>
<p>防護機制兩層：</p>
<ol>
<li><strong>DCS quorum</strong>：etcd / Consul 至少 3 node、過半 quorum 才能寫 leader key — 少數派 partition 無法 elect 新 leader</li>
<li><strong>Watchdog (Linux kernel)</strong>：required mode 強制 — Patroni 必須定期 <em>poke</em> <code>/dev/watchdog</code>、若 Patroni 自己掛或被 OS 凍結、kernel 自動 reboot 整台機器、避免舊 leader 在 DCS 失聯後繼續接寫</li>
</ol>
<p>Watchdog <code>required</code> 是 production-grade 的硬要求 — <code>automatic</code> / <code>off</code> 在 split-brain 場景下無法防護。</p>
<h2 id="stage-3promotion--pg_ctl--replication-slot-切換">Stage 3：Promotion — pg_ctl + replication slot 切換</h2>
<p>新 leader 寫 DCS key 成功後、Patroni 自動執行：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># Patroni 內部、不要手動跑</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">pg_ctl promote -D /var/lib/postgresql/data
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"># postgresql.auto.conf 移除 primary_conninfo</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># postgresql.auto.conf 重新計算 timeline ID</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># 啟動接寫</span></span></span></code></pre></div><p>Promotion 期間關鍵議題：</p>
<ul>
<li><strong>timeline divergence</strong>：新 leader 開新 timeline ID（從 leader 失聯時的 LSN 開始）；其他 standby 需要 <code>pg_rewind</code> 把自己的 WAL fork 點對齊新 timeline</li>
<li><strong>replication slot 處理</strong>：舊 leader 上的 replication slot 在 DCS 中已 stale、新 leader 重建 slot；如果 logical replication consumer 沒 idempotent、會 replay 部分訊息</li>
<li><strong>promotion latency</strong>：通常 3-10 秒（pg_ctl 本身 &lt; 5s、加 DCS 寫確認）</li>
</ul>
<h2 id="stage-4reconfiguration--client-routing-切換">Stage 4：Reconfiguration — client routing 切換</h2>
<p>PostgreSQL 自己升 leader 還不夠、application 不知道；要靠前端 routing 層轉發。三種典型 pattern：</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">[client] → [HAProxy / pgBouncer] → [pg-node-1 (leader)]
</span></span><span class="line"><span class="ln">2</span><span class="cl">                                 → [pg-node-2 (standby, read)]
</span></span><span class="line"><span class="ln">3</span><span class="cl">                                 → [pg-node-3 (standby, read)]</span></span></code></pre></div><p>Patroni REST API 暴露 <code>/leader</code> / <code>/replica</code> / <code>/health</code> endpoint、HAProxy 用 <em>health check</em> 跑這些 endpoint：</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"># haproxy.cfg
</span></span><span class="line"><span class="ln">2</span><span class="cl">backend pg-write
</span></span><span class="line"><span class="ln">3</span><span class="cl">  option httpchk OPTIONS /leader
</span></span><span class="line"><span class="ln">4</span><span class="cl">  http-check expect status 200
</span></span><span class="line"><span class="ln">5</span><span class="cl">  server pg-node-1 pg-node-1:5432 check port 8008
</span></span><span class="line"><span class="ln">6</span><span class="cl">  server pg-node-2 pg-node-2:5432 check port 8008 backup
</span></span><span class="line"><span class="ln">7</span><span class="cl">  server pg-node-3 pg-node-3:5432 check port 8008 backup</span></span></code></pre></div><p>Reconfiguration 期間關鍵延遲：</p>
<ul>
<li>HAProxy health check 間隔（預設 2s）+ failure threshold（預設 3 次）= ~6s 切換感應</li>
<li>PgBouncer 不主動 health check、要靠 application 端 retry 跟 connection drop 觸發重連</li>
<li>整個 reconfiguration 端到端通常 10-20s（含 PostgreSQL promotion 時間）</li>
</ul>
<h2 id="stage-5recovery--pg_rewind-跟-base-backup-取捨">Stage 5：Recovery — pg_rewind 跟 base backup 取捨</h2>
<p>舊 leader 恢復後變 standby，但 WAL 已 divergence — 必須選一條 recovery path：</p>
<ul>
<li><strong><code>pg_rewind</code></strong>：rewind 舊 leader WAL 到分歧點、重新接 streaming replication；條件 = 分歧 WAL 量小（&lt; 幾 GB）且 timeline 可對齊</li>
<li><strong>重 base backup</strong>：用 <code>pg_basebackup</code> 從新 leader 拉完整 base + WAL；條件 = 任何時候都可、但時間長（TB 級 1-4 小時）</li>
</ul>
<p>Patroni 預設嘗試 pg_rewind、失敗才退 base backup。production 配置：</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">postgresql</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">use_pg_rewind</span><span class="p">:</span><span class="w"> </span><span class="kc">true</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">remove_data_directory_on_rewind_failure</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">   </span><span class="c"># rewind 失敗自動清 data dir、再 base backup</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">remove_data_directory_on_diverged_timelines</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span></span></span></code></pre></div><h2 id="production-故障演練">Production 故障演練</h2>
<h3 id="case-1split-brain-due-to-dcs-partition">Case 1：Split-brain due to DCS partition</h3>
<p><strong>徵兆</strong>：兩個 PostgreSQL node 都在接寫、application 大量寫入 conflict / unique constraint violation。</p>
<p><strong>根因</strong>：DCS（etcd）partition — 兩個 etcd node 在 partition 兩側、都自認 quorum；其實是 split-vote、兩邊都不應該。Patroni 在兩邊各 elect 一個 leader。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>DCS 必須奇數 node（3 / 5 / 7）、過半 quorum 嚴格 enforce</li>
<li>DCS 部署跨 AZ / region 時、quorum size 要考慮 partition 機率（3 AZ 各 1 node 是 production 最低標）</li>
<li>Watchdog <code>required</code> mode 是最後一道閘門 — DCS partition 加 quorum 失靈時、watchdog 強制 reboot 失聯 node</li>
</ol>
<h3 id="case-2standby-落後太多無法-failover">Case 2：Standby 落後太多、無法 failover</h3>
<p><strong>徵兆</strong>：primary 失聯後、Patroni log 顯示 <code>Following members have lag greater than maximum_lag_on_failover</code>、所有 standby 都被拒 promote、cluster unavailable。</p>
<p><strong>根因</strong>：maximum_lag_on_failover 設 1MB、但 standby replication lag 累積到 50MB（write-heavy workload + slow disk on standby）。安全機制觸發、但代價是 <em>無 standby 可升</em>、需要人工降低門檻或等 standby catch up。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>預防</strong>：standby 容量 / IO 對齊 primary、避免 lag 累積；prometheus alert <code>pg_replication_lag_bytes &gt; 10MB</code> 觸發前 catch</li>
<li><strong>臨時</strong>：手動 <code>patronictl edit-config</code> 把 maximum_lag_on_failover 暫時拉到 50MB、接受可能丟 50MB worth of writes、換 availability</li>
<li><strong>長期</strong>：sync replication（一個 standby 強制同步）、保證至少一個 standby zero-lag</li>
</ol>
<h3 id="case-3promotion-後-application-connection-storm">Case 3：Promotion 後 application connection storm</h3>
<p><strong>徵兆</strong>：failover 完成後 30-120 秒內、application log 大量 <code>connection refused</code> / <code>password authentication failed</code>、application 自己 retry storm。</p>
<p><strong>根因</strong>：新 leader 剛 promote、PostgreSQL <code>max_connections</code> 容量還在 warm up（shared memory / cache 未 prime）、application 同時湧入大量 connection request；應用 retry 不夠 jitter、queue 堆積。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>Application 用 <em>exponential backoff with jitter</em>、不要 immediate retry</li>
<li>PgBouncer / connection pool 限制每 application instance 對 PG 的 connection 上限、不直連 PG</li>
<li>預先在 standby 跑 <code>pg_prewarm</code> 把熱表 cache 預熱、promotion 後 cache miss 不爆</li>
</ol>
<h3 id="case-4pg_rewind-失敗退到-base-backup-沒做">Case 4：pg_rewind 失敗、退到 base backup 沒做</h3>
<p><strong>徵兆</strong>：舊 leader 恢復後、Patroni log 顯示 <code>pg_rewind failed</code>、舊 leader 一直 STARTING、無法重接 cluster；SRE 手動跑 pg_basebackup 才恢復。</p>
<p><strong>根因</strong>：<code>remove_data_directory_on_rewind_failure: false</code>（預設）— rewind 失敗時 Patroni 不主動清 data dir、需要 SRE 手動處理；運維沒 runbook、卡在這步幾小時。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>Production 設 <code>remove_data_directory_on_rewind_failure: true</code> + <code>remove_data_directory_on_diverged_timelines: true</code>、讓 Patroni 自動 fallback</li>
<li>data dir 跑在獨立 PV / disk、清掉風險可控（不要跑 root disk）</li>
<li>容量規劃：base backup 時間預估納入 RTO（TB 級 base backup 1-4 小時、不是 RTO 30 分鐘所能承受）</li>
</ol>
<h3 id="case-5watchdog-觸發整機-reboot誤殺">Case 5：Watchdog 觸發整機 reboot、誤殺</h3>
<p><strong>徵兆</strong>：production server 在無故障時 unexpected reboot、<code>dmesg</code> 顯示 <code>watchdog: BUG: soft lockup</code>。</p>
<p><strong>根因</strong>：Patroni 主循環因 etcd 短暫慢回應卡住 60+ 秒、kernel watchdog 觸發 reboot；但實際 PostgreSQL 沒 hang、是 Patroni-watchdog 鏈過敏。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><code>safety_margin</code> 設大一點（10-15）、給 Patroni loop_wait 抖動空間</li>
<li>etcd 跟 Patroni 部署在低延遲 network 內（同 AZ &lt; 5ms）、跨 region etcd 不建議</li>
<li>watchdog device 用 softdog（軟體模擬）vs 硬體 watchdog、debug 時 softdog 容易觀察</li>
</ol>
<h2 id="容量規劃">容量規劃</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>估算</th>
          <th>警戒</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Cluster size</td>
          <td>3-5 node（含 leader + 2-4 standby）</td>
          <td>&lt; 3 不能 HA（單 standby 失敗整 cluster 掛）</td>
      </tr>
      <tr>
          <td>DCS size</td>
          <td>3 / 5 / 7 node（奇數 quorum）</td>
          <td>etcd 5 node 是 prod standard</td>
      </tr>
      <tr>
          <td>TTL</td>
          <td>30s（default 30、production 20-60）</td>
          <td>&lt; 15s 過敏、&gt; 60s 過鈍</td>
      </tr>
      <tr>
          <td>maximum_lag_on_failover</td>
          <td>1MB（default）</td>
          <td>大表 write-heavy 可放 10-100MB</td>
      </tr>
      <tr>
          <td>Synchronous standby</td>
          <td>1 個 sync + N 個 async 是 production 預設</td>
          <td>全 async 容易丟資料、全 sync write latency 爆</td>
      </tr>
      <tr>
          <td>RTO</td>
          <td>10-30 秒（detection 30s 內 + promotion 5-10s + reconfig 5s）</td>
          <td>&gt; 60s 要 audit 鏈路</td>
      </tr>
      <tr>
          <td>RPO</td>
          <td>sync mode 接近 0、async mode 跟 lag 同數量級</td>
          <td>async 在 disk IO 慢時 lag 可能 MB-GB level</td>
      </tr>
  </tbody>
</table>
<h2 id="整合--下一步">整合 / 下一步</h2>
<h3 id="跟-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</a> 整合</h3>
<p>PgBouncer 不主動感知 Patroni failover、要靠：</p>
<ol>
<li><strong>HAProxy 在 PgBouncer 上層</strong>：HAProxy 跑 Patroni health check、PgBouncer connection 重新路由</li>
<li><strong>PgBouncer reload</strong>：failover 後 SRE / automation 跑 <code>pgbouncer -R</code>、強制重連 backend</li>
<li><strong>Connection pool drain</strong>：application 端 connection pool 設 <code>pool_lifetime_max=5min</code>、舊 connection 自然汰換</li>
</ol>
<h3 id="跟-cert-managertls-rotation">跟 cert-manager（TLS rotation）</h3>
<p>Patroni REST API 跟 PostgreSQL streaming replication 都用 TLS、cert rotation 不能停服務：</p>
<ol>
<li>cert-manager 自動換證後、Patroni 跟 PostgreSQL 都需要 reload（不是 restart）</li>
<li><code>patronictl reload &lt;cluster&gt;</code> 不會觸發 failover、只 reload config</li>
<li>PostgreSQL <code>pg_ctl reload</code> 是 SIGHUP、平滑載入新 cert</li>
</ol>
<h3 id="跟-backup--pitr">跟 backup / PITR</h3>
<p>Patroni 不管 backup — 但 standby promotion 後、WAL archive 必須跟新 leader 的 timeline 對齊：</p>
<ol>
<li>WAL archive 命令模板含 <code>%t</code>（timeline）：<code>archive_command = 'wal-g wal-push %p'</code></li>
<li>Backup tool（pgBackRest / WAL-G）支援 timeline 切換、archive 不會中斷</li>
<li>詳見 <a href="/blog/backend/01-database/vendors/postgresql/pitr-wal-archiving/" data-link-title="PostgreSQL PITR &#43; WAL archiving：從 base backup 到 point-in-time recovery 的完整鏈" data-link-desc="Base backup &#43; WAL archive 構成 PITR 的雙軌資料、archive_command &#43; restore_command 配置、用 pgBackRest / WAL-G 替代手寫腳本、5 個 production 踩雷（archive 靜默失敗 / archive lag / 錯誤 target time / base backup 過期未清 / timeline 分歧 recovery 模糊）、跟 Patroni &#43; monitoring 整合">PITR + WAL archiving deep article</a></li>
</ol>
<h3 id="下一步議題">下一步議題</h3>
<ul>
<li><strong>Multi-region Patroni</strong>：跨 region 部署的 DCS quorum 設計、跟單 region 的取捨完全不同</li>
<li><strong>PostgreSQL 16+ streaming replication slot 持久化</strong>：簡化 standby promotion 後 logical consumer 重連</li>
<li><strong>跟 Kubernetes operator 整合</strong>：Patroni 跑在 K8s 時、StatefulSet + pod identity + DCS 部署模式</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li>上游 vendor 頁：<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></li>
<li>上游 chapter：<a href="/blog/backend/01-database/high-concurrency-access/" data-link-title="1.1 高併發下的 SQL 讀寫邊界" data-link-desc="說明高併發服務如何共用資料庫 client、控制 transaction、管理 connection pool、避免資料庫成為瓶頸">High Concurrency Access</a> — connection / replication / HA 全鏈</li>
<li>平行 deep article：<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 配置</a> / <a href="/blog/backend/07-security-data-protection/vendors/hashicorp-vault/dynamic-credential/" data-link-title="HashiCorp Vault Dynamic Credential：lease 治理跟 application 整合的實作層" data-link-desc="Vault database secrets engine 怎麼配、application 怎麼 renew lease、production 五大踩雷（lease 過期 race、DB max_connections 撞牆、Vault sealed、token expire、scope 過寬）、容量規劃跟 vault-agent injector 整合">Vault Dynamic Credential</a></li>
<li>Methodology：<a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章的寫作方法論</a></li>
</ul>
]]></content:encoded></item><item><title>AWS SQS：Visibility timeout、long polling 與 Lambda event source 的成本與失敗形狀</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/aws-sqs/visibility-polling-lambda-cost/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/aws-sqs/visibility-polling-lambda-cost/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/aws-sqs/" data-link-title="AWS SQS" data-link-desc="AWS managed queue、簡單可靠、無 ordering（standard）">AWS SQS&lt;/a> overview 的 implementation-layer deep article。本文的 CLI 指令語法經 LocalStack round-trip 驗證、真實 AWS 的 scaling 行為、Lambda event source mapping 細節與計費數字依 AWS 官方文件。&lt;/p>&lt;/blockquote>
&lt;h2 id="sqs-沒有-broker-ackdelivery-控制全靠-visibility-timeout">SQS 沒有 broker ACK，delivery 控制全靠 visibility timeout&lt;/h2>
&lt;p>SQS 跟自管 broker（RabbitMQ / Kafka）最大的操作差異是：consumer 不會跟 broker 維持一條長連線、也沒有 channel-level 的 ack / nack 協議。SQS 的整個 delivery 保證建立在一個計時器上 — visibility timeout。訊息被 &lt;code>ReceiveMessage&lt;/code> 拉走後進入 in-flight 狀態、在 timeout 視窗內對其他 consumer 不可見；consumer 處理成功就呼叫 &lt;code>DeleteMessage&lt;/code> 把它移除、處理失敗或當機則什麼都不做、等 timeout 到期訊息自動回到 queue 重新可見。&lt;/p>
&lt;p>這個設計把「確認處理完成」的責任從 broker 連線狀態轉移到 consumer 的主動刪除。好處是 consumer 可以隨時死掉、重啟、水平擴縮、不需要維持任何 session 狀態 — 訊息不會因為連線斷掉而遺失。代價是 visibility timeout 這個數字變成最容易設錯、後果最隱蔽的參數：設太短訊息會在 consumer 還在處理時就重新可見、被另一個 consumer 重複領走；設太長則 consumer 當機後訊息要等很久才回到 queue、retry 延遲拉長。&lt;/p>
&lt;p>實機建立一個 queue 並查 default、可以確認這個視窗的起點。新建 queue 的 &lt;code>VisibilityTimeout&lt;/code> 預設 30 秒：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 不帶任何 attribute 建 queue&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">aws sqs create-queue --queue-name demo-default
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1"># 查 default visibility timeout&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">aws sqs get-queue-attributes &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --queue-url &amp;lt;url&amp;gt; &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --attribute-names VisibilityTimeout
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="c1"># =&amp;gt; &amp;#34;VisibilityTimeout&amp;#34;: &amp;#34;30&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>30 秒對「處理時間穩定在數百毫秒」的 task 綽綽有餘、對「呼叫第三方 API、跑批次轉檔、寫多個下游」的 task 則經常不夠。下一節先把這個參數設對，後面的故障演練再展開它設錯時的具體徵兆。&lt;/p>
&lt;h2 id="對齊-visibility-timeout-與-consumer-處理時間">對齊 visibility timeout 與 consumer 處理時間&lt;/h2>
&lt;p>設定 visibility timeout 的判準是「略高於 consumer 處理單則訊息的最大時間」、不是平均時間。Capital One 的官方 tech blog 在講 SQS + Lambda 時明示這條原則：visibility timeout 應比最大處理時間略高 — 因為決定 redelivery 的是尾端那幾則最慢的訊息、不是中位數。處理時間 p50 是 2 秒、p99 是 25 秒時、visibility timeout 要對齊 p99 加緩衝、設到 30-40 秒、而不是看 p50 設 10 秒。&lt;/p>
&lt;p>建 queue 時直接帶 &lt;code>VisibilityTimeout&lt;/code> attribute，或對既有 queue 用 &lt;code>set-queue-attributes&lt;/code> 調整：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 建立時指定（單位：秒；上限 12 小時 = 43200）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">aws sqs create-queue &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --queue-name demo &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --attributes &lt;span class="nv">VisibilityTimeout&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">60&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="c1"># 對既有 queue 調整&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">aws sqs set-queue-attributes &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --queue-url &amp;lt;url&amp;gt; &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --attributes &lt;span class="nv">VisibilityTimeout&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">120&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>處理時間本身不可預測的場景（例如轉檔大小差異大、下游 API 偶發慢）、用一個固定的 queue-level visibility timeout 會兩頭不討好：對齊最壞情況會讓正常訊息當機後 retry 太慢、對齊正常情況會讓慢訊息 redelivery。SQS 給的工具是 &lt;code>ChangeMessageVisibility&lt;/code> — consumer 在處理過程中發現這則會花更久時，主動延長這一則訊息的 visibility timeout，而不影響 queue default：&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/03-message-queue/vendors/aws-sqs/" data-link-title="AWS SQS" data-link-desc="AWS managed queue、簡單可靠、無 ordering（standard）">AWS SQS</a> overview 的 implementation-layer deep article。本文的 CLI 指令語法經 LocalStack round-trip 驗證、真實 AWS 的 scaling 行為、Lambda event source mapping 細節與計費數字依 AWS 官方文件。</p></blockquote>
<h2 id="sqs-沒有-broker-ackdelivery-控制全靠-visibility-timeout">SQS 沒有 broker ACK，delivery 控制全靠 visibility timeout</h2>
<p>SQS 跟自管 broker（RabbitMQ / Kafka）最大的操作差異是：consumer 不會跟 broker 維持一條長連線、也沒有 channel-level 的 ack / nack 協議。SQS 的整個 delivery 保證建立在一個計時器上 — visibility timeout。訊息被 <code>ReceiveMessage</code> 拉走後進入 in-flight 狀態、在 timeout 視窗內對其他 consumer 不可見；consumer 處理成功就呼叫 <code>DeleteMessage</code> 把它移除、處理失敗或當機則什麼都不做、等 timeout 到期訊息自動回到 queue 重新可見。</p>
<p>這個設計把「確認處理完成」的責任從 broker 連線狀態轉移到 consumer 的主動刪除。好處是 consumer 可以隨時死掉、重啟、水平擴縮、不需要維持任何 session 狀態 — 訊息不會因為連線斷掉而遺失。代價是 visibility timeout 這個數字變成最容易設錯、後果最隱蔽的參數：設太短訊息會在 consumer 還在處理時就重新可見、被另一個 consumer 重複領走；設太長則 consumer 當機後訊息要等很久才回到 queue、retry 延遲拉長。</p>
<p>實機建立一個 queue 並查 default、可以確認這個視窗的起點。新建 queue 的 <code>VisibilityTimeout</code> 預設 30 秒：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 不帶任何 attribute 建 queue</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">aws sqs create-queue --queue-name demo-default
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 查 default visibility timeout</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">aws sqs get-queue-attributes <span class="se">\
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="se"></span>  --queue-url &lt;url&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="se"></span>  --attribute-names VisibilityTimeout
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1"># =&gt; &#34;VisibilityTimeout&#34;: &#34;30&#34;</span></span></span></code></pre></div><p>30 秒對「處理時間穩定在數百毫秒」的 task 綽綽有餘、對「呼叫第三方 API、跑批次轉檔、寫多個下游」的 task 則經常不夠。下一節先把這個參數設對，後面的故障演練再展開它設錯時的具體徵兆。</p>
<h2 id="對齊-visibility-timeout-與-consumer-處理時間">對齊 visibility timeout 與 consumer 處理時間</h2>
<p>設定 visibility timeout 的判準是「略高於 consumer 處理單則訊息的最大時間」、不是平均時間。Capital One 的官方 tech blog 在講 SQS + Lambda 時明示這條原則：visibility timeout 應比最大處理時間略高 — 因為決定 redelivery 的是尾端那幾則最慢的訊息、不是中位數。處理時間 p50 是 2 秒、p99 是 25 秒時、visibility timeout 要對齊 p99 加緩衝、設到 30-40 秒、而不是看 p50 設 10 秒。</p>
<p>建 queue 時直接帶 <code>VisibilityTimeout</code> attribute，或對既有 queue 用 <code>set-queue-attributes</code> 調整：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 建立時指定（單位：秒；上限 12 小時 = 43200）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">aws sqs create-queue <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --queue-name demo <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --attributes <span class="nv">VisibilityTimeout</span><span class="o">=</span><span class="m">60</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"># 對既有 queue 調整</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">aws sqs set-queue-attributes <span class="se">\
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="se"></span>  --queue-url &lt;url&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="se"></span>  --attributes <span class="nv">VisibilityTimeout</span><span class="o">=</span><span class="m">120</span></span></span></code></pre></div><p>處理時間本身不可預測的場景（例如轉檔大小差異大、下游 API 偶發慢）、用一個固定的 queue-level visibility timeout 會兩頭不討好：對齊最壞情況會讓正常訊息當機後 retry 太慢、對齊正常情況會讓慢訊息 redelivery。SQS 給的工具是 <code>ChangeMessageVisibility</code> — consumer 在處理過程中發現這則會花更久時，主動延長這一則訊息的 visibility timeout，而不影響 queue default：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># consumer 拿到 ReceiptHandle 後，動態把這則延長到 120 秒</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">aws sqs change-message-visibility <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --queue-url &lt;url&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --receipt-handle &lt;receipt-handle&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  --visibility-timeout <span class="m">120</span></span></span></code></pre></div><p>實務上長任務 consumer 的常見寫法是「heartbeat extension」：每處理一段就呼叫一次 <code>ChangeMessageVisibility</code> 往後推、形成一個續命迴圈、直到處理完成才 <code>DeleteMessage</code>。這把「我還活著、還在處理這則」的訊號明確化、避免用一個保守的 queue-level 大數字一刀切。<code>ReceiptHandle</code> 是每次 <code>ReceiveMessage</code> 回傳的一次性 token、不是 message id — 同一則訊息被重新領取後 ReceiptHandle 會變、延長操作必須用當次領取拿到的那一個。</p>
<h2 id="long-polling-決定空輪詢成本short-polling-是預設陷阱">Long polling 決定空輪詢成本，short polling 是預設陷阱</h2>
<p>Polling 模式直接決定 SQS 的 request 帳單，因為 SQS 按 request 數計費、而 <code>ReceiveMessage</code> 即使沒拿到訊息也算一次 request。Short polling（預設、<code>WaitTimeSeconds=0</code>）的行為是「立即回應」：consumer 發 <code>ReceiveMessage</code>、SQS 抽樣一部分 server 立刻回、queue 空的時候回一個空 response。Consumer 為了即時拿到訊息會緊接著再發一次、形成高頻空輪詢 — 在低流量 queue 上、絕大多數 request 都是空回、帳單全花在「問有沒有訊息」上。</p>
<p>Long polling（<code>WaitTimeSeconds</code> 設 1-20 秒）改變這個行為：SQS 收到 <code>ReceiveMessage</code> 後、若 queue 當下沒訊息、會 hold 住這條連線最多 <code>WaitTimeSeconds</code> 秒、期間一有訊息到達就立刻回傳、整段時間都沒訊息才回空。對 consumer 端來說一個 20 秒的 long poll 取代了 20 秒內可能發出的數十次 short poll、空 request 數量大幅下降。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># long polling：等到有訊息或最多 20 秒才回</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">aws sqs receive-message <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --queue-url &lt;url&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --wait-time-seconds <span class="m">20</span></span></span></code></pre></div><p>設定 long polling 有兩個位置：per-request 帶 <code>--wait-time-seconds</code>、或 queue-level 設 <code>ReceiveMessageWaitTimeSeconds</code> attribute 讓所有 receive 預設走 long polling。後者更穩、不依賴每個 consumer 都記得帶參數。20 秒幾乎總是對的選擇：它把空輪詢壓到最低、而 latency 代價只在「queue 剛好空、訊息在 poll 結束後才到」這個邊界出現 — 大多數有持續流量的 queue 根本碰不到 20 秒上限。唯一要留意的是 consumer 的 socket timeout 必須大於 <code>WaitTimeSeconds</code>、否則 client 會在 SQS 還在 hold 連線時自己先 timeout 斷線。</p>
<h2 id="sqs--lambdaevent-source-mapping-把-polling-交給-aws">SQS + Lambda：event source mapping 把 polling 交給 AWS</h2>
<p>把 SQS 接上 Lambda 時、polling 這件事整個從應用程式碼消失、改由 Lambda 的 event source mapping 接管。Event source mapping 是 Lambda service 內部一組 managed poller、持續對 queue 做 long polling、把拉到的訊息打包成 batch 同步 invoke 函式、函式正常返回就由 service 代為 <code>DeleteMessage</code>。Consumer 端不再寫 receive / delete 迴圈、只寫處理單一 batch 的 handler。</p>
<p>這套 managed poller 的 scaling 不是線性的、有 ramp-up 上限。Capital One 觀察到的行為是：Lambda 初始開 5 個並行的 long polling 連線、隨 queue 累積每分鐘最多增加 60 個 instance、standard queue 的並行 batch 上限到 1000。這意味著 queue 突然湧入大量訊息時、Lambda 不會瞬間炸開到滿並行、而是分鐘級爬升 — 容量規劃時要把這段 ramp-up 期算進 backlog 消化時間、不能假設「訊息一到就有足夠 consumer」。</p>
<p>兩個核心參數決定每次 invoke 的形狀：</p>
<table>
  <thead>
      <tr>
          <th>參數</th>
          <th>作用</th>
          <th>取捨</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Batch size</td>
          <td>一次 invoke 最多打包幾則訊息（standard 上限 10000、FIFO 上限 10）</td>
          <td>大 batch 省 invoke 數與成本、但放大「部分失敗整批重投」風險</td>
      </tr>
      <tr>
          <td>Batch window</td>
          <td>累積訊息的最長等待時間（<code>MaximumBatchingWindowInSeconds</code>、0-300 秒）</td>
          <td>拉長視窗讓 batch 更滿、代價是 latency；流量稀疏時尤其明顯</td>
      </tr>
  </tbody>
</table>
<p>Batch size 拉大表面上省錢 — invoke 次數少、每則訊息分攤的 request 成本低。但它跟下一節的部分失敗處理直接耦合：batch 越大、一則毒訊息拖累整批重投的範圍越大。Batch window 則是流量稀疏時讓 batch 攢滿的手段、流量本來就密集時設不設都差不多、反而會引入不必要的 latency。</p>
<h2 id="dlq-與-redrive-policy用-maxreceivecount-隔離毒訊息">DLQ 與 redrive policy：用 maxReceiveCount 隔離毒訊息</h2>
<p>毒訊息（永遠處理失敗的訊息 — 格式損壞、引用了已刪除的資源、觸發 consumer 確定性 bug）會在 visibility timeout 機制下無限重投：處理失敗、timeout 到期、重新可見、再次被領取、再次失敗。沒有上限的話這則訊息會永遠佔用 consumer 資源、且其他正常訊息的處理被它反覆插隊。Dead-letter queue（DLQ）加 <code>maxReceiveCount</code> 是 SQS 對這個問題的標準解 — 訊息被接收超過 N 次後、SQS 自動把它移到另一個指定的 queue（DLQ）、主 queue 不再被它卡住。</p>
<p>設定分兩步：先建一個普通 queue 當 DLQ、取它的 ARN、再對主 queue 設 redrive policy 指向這個 ARN 並設 <code>maxReceiveCount</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 1. 建 DLQ 並取得 ARN</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">aws sqs create-queue --queue-name demo-dlq
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">aws sqs get-queue-attributes <span class="se">\
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="se"></span>  --queue-url &lt;dlq-url&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="se"></span>  --attribute-names QueueArn
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"># =&gt; &#34;QueueArn&#34;: &#34;arn:aws:sqs:us-east-1:000000000000:demo-dlq&#34;</span>
</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 class="c1"># 2. 對主 queue 設 redrive policy（被接收 5 次後送 DLQ）</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">aws sqs set-queue-attributes <span class="se">\
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="se"></span>  --queue-url &lt;main-url&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="se"></span>  --attributes <span class="s1">&#39;{&#34;RedrivePolicy&#34;:&#34;{\&#34;deadLetterTargetArn\&#34;:\&#34;arn:aws:sqs:us-east-1:000000000000:demo-dlq\&#34;,\&#34;maxReceiveCount\&#34;:\&#34;5\&#34;}&#34;}&#39;</span></span></span></code></pre></div><p>DLQ 不是訊息的墳場、是待診斷的暫存區。對應 <a href="/blog/backend/knowledge-cards/poison-message-quarantine/" data-link-title="Poison-Message Quarantine" data-link-desc="說明把毒訊息從主處理路徑隔離出來的機制，讓正常訊息繼續前進">poison message quarantine</a> 的思路、DLQ 累積要分兩種根因處理：訊息格式錯（永遠失敗、需要修 producer 或人工丟棄）vs 下游服務暫時 down（訊息本身沒問題、修好下游後可以重放）。後者用 redrive 把訊息從 DLQ 批次放回主 queue 重新處理、對應 <a href="/blog/backend/knowledge-cards/dlq-drain/" data-link-title="DLQ Drain" data-link-desc="說明把 dead-letter queue 累積的訊息重新處理或排空的受控流程">dlq drain</a> 的排空流程。判斷之前先看 DLQ 裡訊息的內容、不要不加判斷地 redrive — 把毒訊息 redrive 回去只會再走一輪 maxReceiveCount 又回到 DLQ。</p>
<p><code>maxReceiveCount</code> 設多少是取捨：太小（例如 1-2）會讓「下游短暫抖動」這種暫時性失敗被誤判成毒訊息、過早送進 DLQ；太大（例如 100）會讓真正的毒訊息浪費大量 consumer 重試。多數 task queue 設 3-5 是合理起點 — 足以吸收幾次暫時性失敗、又不至於讓確定性失敗的訊息空轉太久。</p>
<h2 id="message-size-限制與-extended-client">Message size 限制與 extended client</h2>
<p>SQS 單則訊息上限是 256 KB（含 message body 與 attributes）。這對純事件通知、id 引用、小型 payload 足夠、但對「訊息本身要攜帶大檔案內容」的場景不夠 — 例如要傳一份報表、一張圖、一段長文字。直接的反模式是把大內容塞進 message body、撞上 256 KB 限制後 <code>SendMessage</code> 直接報錯。</p>
<p>標準解是 claim-check 模式：大 payload 寫到 S3、訊息只攜帶 S3 的物件引用（bucket + key）、consumer 收到訊息後再去 S3 取內容。AWS 提供的 Extended Client Library（Java / Python 等 SDK）把這個模式封裝起來 — <code>SendMessage</code> 時若 payload 超過門檻、library 自動把內容寫 S3、訊息只帶 pointer；consumer 端 <code>ReceiveMessage</code> 時 library 自動從 S3 取回、對應用程式碼透明。</p>
<p>選擇門檻時要把 S3 的 request 成本與 latency 算進來：每則大訊息變成「一次 S3 PUT + 一次 SQS Send」、consumer 端「一次 SQS Receive + 一次 S3 GET」。對大多數 payload 都超過 256 KB 的 queue、這是必要成本；對 payload 多數很小、偶爾爆量的 queue、extended client 只在超門檻時走 S3、混合成本可接受。Payload 普遍很大且高頻的場景、要重新評估 SQS 是否適合 — 可能該改用 streaming（Kinesis / Kafka）或乾脆讓 producer / consumer 直接交換 S3 引用、SQS 只傳通知。</p>
<h2 id="cost按-request-計費每一次操作都是一個-request">Cost：按 request 計費，每一次操作都是一個 request</h2>
<p>SQS 的計費模型是 per-request、不是 per-message-stored、也沒有固定月費。每一次 API call — <code>SendMessage</code>、<code>ReceiveMessage</code>（含空回）、<code>DeleteMessage</code>、<code>ChangeMessageVisibility</code> — 都算一個 request。這個模型對成本估算的影響是：帳單由「操作次數」驅動、而非「訊息量」或「儲存時長」。一則訊息從 producer 到 consumer 的最小生命週期是 send（1）+ receive（1）+ delete（1）= 3 個 request；空輪詢、retry、visibility 延長都會額外加 request。</p>
<p>兩個降低 request 數的主要手段：</p>
<p>第一是 batch 操作。<code>SendMessageBatch</code> 與 <code>DeleteMessageBatch</code> 一次最多打包 10 則、而 SQS 把一個 batch call 算作一個 request（實際計費以 64 KB 為一個 request 單位、一個 batch 在此範圍內仍是少數 request）。把 10 則訊息的 send 從 10 個 request 壓成 1 個 batch request、在高頻 queue 上是數量級的成本差異：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">aws sqs send-message-batch <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --queue-url &lt;url&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --entries <span class="s1">&#39;Id=m1,MessageBody=a&#39;</span> <span class="s1">&#39;Id=m2,MessageBody=b&#39;</span></span></span></code></pre></div><p>第二是 long polling 消滅空 request — 前面 polling 段已經展開。低流量 queue 的帳單若異常高、第一個要查的就是有沒有開 long polling、consumer 是不是在 short polling 下高頻空轉。</p>
<p>Data transfer cost 只在跨 region 時出現 — 同 region 內 producer / consumer 與 SQS 之間的傳輸不計流量費。把 producer、consumer、queue 放在同一個 region 是預設、跨 region 設計要把 egress 成本明確算進來。FIFO queue 的 per-request 單價比 standard 高、是用成本換 ordering 與去重保證 — 不需要嚴格順序的場景用 standard、把這筆溢價省下來。</p>
<p>Rapid7 的規模參考點說明這個計費模型在極端規模下的份量：Rapid7 公開引述 SQS 撐住「每天數十億則訊息」。在這個量級、per-request 計費乘以訊息數是一筆需要認真建模的成本 — batch、long polling、避免不必要的 visibility 延長、控制 retry 次數、每一項節省都被訊息量放大。SQS 在數十億級可用、但成本結構必須被當作架構參數對待、不是事後才看帳單。</p>
<h2 id="production-故障演練">Production 故障演練</h2>
<h3 id="故障一visibility-timeout-短於處理時間訊息被重複處理">故障一：visibility timeout 短於處理時間，訊息被重複處理</h3>
<p><strong>徵兆</strong>：consumer log 顯示同一個 message id 在短時間內被處理多次、下游出現重複的副作用（重複扣款、重複寄信、重複寫入）；CloudWatch 的 <code>ApproximateNumberOfMessagesNotVisible</code>（in-flight 數）異常高、<code>NumberOfMessagesReceived</code> 遠大於 <code>NumberOfMessagesDeleted</code>。</p>
<p><strong>根因</strong>：visibility timeout 設定值低於 consumer 實際處理單則訊息的時間。訊息在 consumer 還沒處理完、還沒呼叫 <code>DeleteMessage</code> 之前、timeout 就到期、訊息重新可見、被另一個 consumer（或同一個 consumer 的下一輪 poll）領走。新建 queue 的 default 是 30 秒 — 處理時間長於此就會踩到：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">aws sqs get-queue-attributes <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --queue-url &lt;url&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --attribute-names VisibilityTimeout
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 看到 30 而 consumer 處理時間 &gt; 30s，就是這個問題</span></span></span></code></pre></div><p><strong>修法</strong>：把 visibility timeout 對齊 consumer 處理時間的 p99 加緩衝、用 <code>set-queue-attributes</code> 調高；處理時間變異大的長任務改用 <code>ChangeMessageVisibility</code> heartbeat 在處理中動態延長。同時、因為 SQS standard 是 at-least-once、重複投遞在故障與 retry 下本來就會發生、consumer 的處理邏輯必須冪等 — 對齊 visibility timeout 降低重複頻率、冪等性才是真正消除重複副作用的防線。</p>
<h3 id="故障二short-polling-預設導致低流量-queue-帳單異常">故障二：short polling 預設導致低流量 queue 帳單異常</h3>
<p><strong>徵兆</strong>：一個訊息量很低的 queue、月度 SQS 帳單卻很高；CloudWatch 顯示 <code>NumberOfEmptyReceives</code> 佔 <code>ReceiveMessage</code> 總數的絕大比例 — 大量 request 是空回。</p>
<p><strong>根因</strong>：consumer 走 short polling（<code>WaitTimeSeconds=0</code>、預設值）、在 queue 空的時候緊密地反覆發 <code>ReceiveMessage</code>、每次都立即空回、每次都計一個 request。流量越低、空回比例越高、帳單越是花在「問有沒有訊息」上。</p>
<p><strong>修法</strong>：在 queue-level 設 <code>ReceiveMessageWaitTimeSeconds=20</code> 讓所有 receive 預設走 long polling、或在每個 <code>ReceiveMessage</code> 帶 <code>--wait-time-seconds 20</code>。Queue-level 設定更穩、不依賴每個 consumer 記得帶參數。設定後 consumer 在 queue 空時會 hold 住連線最多 20 秒、空 request 數量級下降、帳單同步下降。同時確認 consumer 的 socket timeout 大於 20 秒、避免 client 先於 SQS 斷線。</p>
<h3 id="故障三lambda-batch-部分失敗整批訊息被重投">故障三：Lambda batch 部分失敗，整批訊息被重投</h3>
<p><strong>徵兆</strong>：一個 batch 裡只有少數訊息處理失敗、但整批訊息（含已成功的）全部回到 queue 重新處理；下游對已成功的訊息出現重複副作用；DLQ 累積速度遠超實際毒訊息數量。</p>
<p><strong>根因</strong>：Lambda event source mapping 的 default 行為是「整批成敗一體」— 函式只要拋出錯誤、整個 batch 被視為失敗、所有訊息（包含已經處理成功的）都不會被刪除、全部重新可見重投。Batch size 越大、一則失敗拖累的成功訊息越多。</p>
<p><strong>修法</strong>：啟用 partial batch response — event source mapping 設 <code>ReportBatchItemFailures</code>、handler 返回時只回報失敗的 message id 清單、SQS 只把這些重投、已成功的正常刪除。這把失敗的爆炸半徑從「整批」縮到「真正失敗的那幾則」。配合縮小 batch size 進一步降低單批風險、並確保 handler 冪等以承受不可避免的重投。Handler 必須正確實作 partial response 的返回格式 — 漏回報某則失敗會讓它被當成成功刪除、訊息靜默遺失。</p>
<h3 id="故障四maxreceivecount-設定不當毒訊息空轉或誤判">故障四：maxReceiveCount 設定不當，毒訊息空轉或誤判</h3>
<p><strong>徵兆</strong>：兩種相反的故障形狀。一是 DLQ 幾乎為空但主 queue 有訊息反覆重試數十次、consumer log 同一 message id 重複出現、佔用處理容量 — maxReceiveCount 設太大。二是 DLQ 快速累積大量其實沒問題的訊息、redrive 回去又能正常處理 — maxReceiveCount 設太小、把下游短暫抖動誤判成毒訊息。</p>
<p><strong>根因</strong>：redrive policy 沒設、或 <code>maxReceiveCount</code> 與「暫時性失敗的正常重試次數」不匹配。沒設 redrive policy 時毒訊息無限重投；設太大時毒訊息空轉太久才進 DLQ；設太小時正常訊息在下游抖動期間被過早判死。</p>
<p><strong>修法</strong>：對主 queue 設 redrive policy、<code>maxReceiveCount</code> 取 3-5 作為起點 — 足以吸收幾次暫時性失敗、又不讓確定性失敗的訊息空轉太久。觀察 DLQ 的累積模式再微調：DLQ 累積的多是「下游修好後 redrive 能成功」的訊息就調高、累積的多是「redrive 回去又進 DLQ」的真毒訊息就維持或調低。對 DLQ 設 CloudWatch alarm 監控 <code>ApproximateNumberOfMessagesVisible</code>、累積超過閾值就告警人工介入、區分 redrive vs 丟棄。</p>
<h3 id="故障五fifo-queue-撞上吞吐上限">故障五：FIFO queue 撞上吞吐上限</h3>
<p><strong>徵兆</strong>：把 standard queue 換成 FIFO 取得 ordering 後、高峰流量下 producer 端開始收到 throttling、訊息積壓、<code>SendMessage</code> 報限流錯誤；吞吐怎麼加 consumer 都上不去。</p>
<p><strong>根因</strong>：FIFO queue 為了維持順序與去重、吞吐遠低於 standard。FIFO 的基礎吞吐是每秒 300 則訊息（API call）、開啟 batching 後到每秒 3000 則。更關鍵的是順序保證的粒度在 <code>MessageGroupId</code> — 同一個 group 內的訊息嚴格串行處理、跨 group 才能並行。若所有訊息共用一個 group id、實際並行度退化成 1、無論加多少 consumer 都無法並行消化。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># FIFO send 必須帶 MessageGroupId（決定順序與並行粒度）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">aws sqs send-message <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --queue-url &lt;fifo-url&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --message-body <span class="s2">&#34;ordered-1&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  --message-group-id <span class="s2">&#34;group-a&#34;</span></span></span></code></pre></div><p><strong>修法</strong>：先確認是否真的需要全域順序 — 多數場景只需要「同一個實體（同一用戶、同一訂單）內部有序」、不需要跨實體有序。把 <code>MessageGroupId</code> 設成業務實體 id（用戶 id、訂單 id）、讓不同實體的訊息能跨 group 並行、吞吐隨 group 數量擴展。確定需要嚴格全域順序且吞吐撞頂的場景、FIFO 的設計上限就是天花板 — 此時要重新評估是否該換成 streaming（Kafka 的 partition 模型在 per-key 有序下提供更高並行）、或拆分 queue。不需要任何順序保證的場景、退回 standard queue、把 FIFO 的吞吐限制與成本溢價一起省掉。</p>
<h2 id="整合與下一步">整合與下一步</h2>
<h3 id="跟-consumer-設計能力對接">跟 consumer 設計能力對接</h3>
<p>本文的 visibility timeout heartbeat、partial batch response、冪等處理都是 <a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計</a> 的具體落地 — consumer-design 講語言無關的 consumer 模式、本文是 SQS 上的實作形狀。retry 與 replay 的交接路徑見 <a href="/blog/backend/03-message-queue/queue-consumer-retry-replay-handoff/" data-link-title="3.8 Queue Consumer Retry 與 Replay Handoff（實作示範）" data-link-desc="以 order_created consumer 示範 queue 路徑如何交付 idempotency evidence、DLQ handling、replay runbook 與 incident decision log。">queue consumer retry replay handoff</a>。</p>
<h3 id="跟知識卡對位">跟知識卡對位</h3>
<p>DLQ 段對應 <a href="/blog/backend/knowledge-cards/poison-message-quarantine/" data-link-title="Poison-Message Quarantine" data-link-desc="說明把毒訊息從主處理路徑隔離出來的機制，讓正常訊息繼續前進">poison message quarantine</a>（毒訊息隔離）與 <a href="/blog/backend/knowledge-cards/dlq-drain/" data-link-title="DLQ Drain" data-link-desc="說明把 dead-letter queue 累積的訊息重新處理或排空的受控流程">dlq drain</a>（DLQ 排空）兩張卡 — SQS 的 redrive policy + maxReceiveCount 是這兩個概念在 managed queue 上的具體機制。visibility timeout 的 in-flight 概念見 <a href="/blog/backend/knowledge-cards/in-flight/" data-link-title="In-Flight Work" data-link-desc="目前已接收但尚未完成處理的工作量">in-flight</a>。</p>
<h3 id="跟-case-對位">跟 case 對位</h3>
<p>visibility timeout 與 Lambda event source 的 ramp-up 行為來自 <a href="/blog/backend/03-message-queue/cases/sqs-capital-one-visibility-timeout/" data-link-title="3.C50 Capital One：Visibility timeout 設計與 Lambda event source" data-link-desc="Capital One tech blog 講 SQS &#43; Lambda：visibility timeout 應略高於最大處理時間、Lambda 初 5 個 long polling、可擴 60/min。">3.C50 Capital One</a>；at-least-once + DLQ 在工作排程的取捨來自 <a href="/blog/backend/03-message-queue/cases/sqs-airbnb-dynein-delayed-jobs/" data-link-title="3.C48 Airbnb Dynein：SQS 分散式延遲任務排程" data-link-desc="Airbnb 用 SQS at-least-once &#43; DLQ 取代 Resque 單 Redis 限制、每 scheduler 1000 QPS、SQS wrap DynamoDB 處理 &gt; 15 分鐘 delay。">3.C48 Airbnb Dynein</a>；per-request cost 在極端規模的份量來自 <a href="/blog/backend/03-message-queue/cases/sqs-rapid7-scale-billion-messages/" data-link-title="3.C59 Rapid7：SQS 100 億 message/day 規模" data-link-desc="Rapid7 公開引述：SQS 撐 10s of billions of messages per day、是架構關鍵元件、scale 量級的具體參考。">3.C59 Rapid7</a>。</p>
<h3 id="何時-revisit">何時 revisit</h3>
<p>FIFO 吞吐撞頂、需要 replay / streaming、或 cost 在 streaming 模型下更划算時、回 <a href="/blog/backend/03-message-queue/vendors/aws-sqs/" data-link-title="AWS SQS" data-link-desc="AWS managed queue、簡單可靠、無 ordering（standard）">SQS overview 的「何時改走其他服務」</a> 重新選型。跨雲 managed queue 的對照見 <a href="/blog/backend/03-message-queue/vendors/google-pubsub/" data-link-title="Google Cloud Pub/Sub" data-link-desc="GCP managed pub/sub、global routing、push/pull">Google Pub/Sub</a>。</p>
]]></content:encoded></item><item><title>Kafka Replication、ISR 與 exactly-once：從 acks 到端到端不重不漏</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/replication-isr-exactly-once/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/replication-isr-exactly-once/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka&lt;/a> overview「Replication 與 exactly-once 升級」段的 implementation-layer deep article。Overview 已給出 partition / replication 的選型定位、本文展開 &lt;em>寫入承諾&lt;/em> 跟 &lt;em>處理語義&lt;/em> 兩條獨立軸線怎麼設、邊界在哪、成本是什麼。對應反例 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9 Queue 語義誤配&lt;/a>。&lt;/p>&lt;/blockquote>
&lt;h2 id="寫入承諾與處理語義是兩條獨立軸線">寫入承諾與處理語義是兩條獨立軸線&lt;/h2>
&lt;p>Kafka 的可靠性拆成兩個彼此正交的問題、混在一起談是多數誤配的起點。第一條軸線是 &lt;em>寫入承諾&lt;/em>：一筆訊息寫進 broker 後、在多少 replica 落地才算「成功」、broker 掛掉時這筆訊息會不會消失。這條軸線由 replication factor、ISR、&lt;code>acks&lt;/code> 與 &lt;code>min.insync.replicas&lt;/code> 共同決定、屬於 broker 端的耐久性保證。第二條軸線是 &lt;em>處理語義&lt;/em>：同一筆訊息在 producer 重送、consumer 重啟、partition rebalance 等情境下、會不會被寫進去兩次或被處理兩次。這條軸線由 producer idempotence、transaction 與 consumer 端的 commit 設計決定、屬於端到端的正確性保證。&lt;/p>
&lt;p>兩條軸線可以獨立調整：可以有「寫入承諾很強但處理語義是 at-least-once」的配置（acks=all + 非冪等 consumer）、也可以有「寫入承諾較弱但已開冪等」的配置。把 exactly-once 當成單一開關去找、是因為沒看出這兩條軸線存在。本文先講第一條（replication / ISR / acks）、再講第二條（idempotence / transaction）、最後談兩者疊起來能達成什麼、達不成什麼。&lt;/p>
&lt;p>這個拆分對映 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/delivery-semantics/" data-link-title="Delivery Semantics" data-link-desc="說明事件投遞語意如何定義遺失、重複、順序與補償策略">delivery semantics&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency&lt;/a> 兩張知識卡：前者描述 broker 承諾的送達次數、後者描述處理端怎麼讓「送達多次」不等於「生效多次」。&lt;/p>
&lt;h2 id="isr誰算跟得上的副本">ISR：誰算「跟得上」的副本&lt;/h2>
&lt;p>ISR（in-sync replica、同步副本集）是一個 partition 當前「跟得上 leader」的 replica 集合、是 Kafka 把 replication factor 這個 &lt;em>靜態配置&lt;/em> 轉成 &lt;em>動態保證&lt;/em> 的關鍵概念。Replication factor = 3 只說明這個 partition 有 3 份 replica；但任一時刻真正跟得上 leader 的可能只有 2 份或 1 份。ISR 就是這個「當前實際同步」的集合、寫入承諾的判斷都基於 ISR、不是基於 replication factor。&lt;/p>
&lt;p>一個 follower 留在 ISR 內的條件是：它在 &lt;code>replica.lag.time.max.ms&lt;/code>（預設 30 秒）內持續向 leader 拉取資料、且追上 leader 的 log end offset。當 follower 因為 broker 慢、網路抖動、GC 停頓或 disk 壓力而落後超過這個時間窗、leader 會把它移出 ISR — 這就是 ISR shrink（收縮）。當它恢復、重新追上、再被加回 ISR — 這是 ISR expand（擴張）。&lt;/p>
&lt;p>ISR 收縮本身不是故障、是 Kafka 對「這個 follower 暫時不可信」的誠實表態。真正的風險在於：ISR 收縮到某個程度後、&lt;code>acks=all&lt;/code> 的寫入承諾會無法滿足 &lt;code>min.insync.replicas&lt;/code> 而開始拒絕寫入。下一段的 acks 取捨直接建立在 ISR 這個概念上。&lt;/p>
&lt;p>實機看 ISR 的方式是 &lt;code>kafka-topics.sh --describe&lt;/code>、Isr 欄位列出當前同步的 broker id：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># RF=3、min.insync.replicas=2 的 topic、三 broker 都同步時&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">kafka-topics.sh --describe --topic repl-demo --bootstrap-server kafka1:9092
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="c1"># Topic: repl-demo PartitionCount: 1 ReplicationFactor: 3 Configs: min.insync.replicas=2&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1"># Topic: repl-demo Partition: 0 Leader: 2 Replicas: 2,3,1 Isr: 2,3,1&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Replicas 欄位是 &lt;em>配置上&lt;/em> 的 3 份副本、Isr 欄位是 &lt;em>當前實際同步&lt;/em> 的集合。兩者一致代表健康；Isr 比 Replicas 短代表有副本落後。日常巡檢用 &lt;code>kafka-topics.sh --describe --under-replicated-partitions&lt;/code> 直接列出 Isr 短於 Replicas 的 partition。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka</a> overview「Replication 與 exactly-once 升級」段的 implementation-layer deep article。Overview 已給出 partition / replication 的選型定位、本文展開 <em>寫入承諾</em> 跟 <em>處理語義</em> 兩條獨立軸線怎麼設、邊界在哪、成本是什麼。對應反例 <a href="/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9 Queue 語義誤配</a>。</p></blockquote>
<h2 id="寫入承諾與處理語義是兩條獨立軸線">寫入承諾與處理語義是兩條獨立軸線</h2>
<p>Kafka 的可靠性拆成兩個彼此正交的問題、混在一起談是多數誤配的起點。第一條軸線是 <em>寫入承諾</em>：一筆訊息寫進 broker 後、在多少 replica 落地才算「成功」、broker 掛掉時這筆訊息會不會消失。這條軸線由 replication factor、ISR、<code>acks</code> 與 <code>min.insync.replicas</code> 共同決定、屬於 broker 端的耐久性保證。第二條軸線是 <em>處理語義</em>：同一筆訊息在 producer 重送、consumer 重啟、partition rebalance 等情境下、會不會被寫進去兩次或被處理兩次。這條軸線由 producer idempotence、transaction 與 consumer 端的 commit 設計決定、屬於端到端的正確性保證。</p>
<p>兩條軸線可以獨立調整：可以有「寫入承諾很強但處理語義是 at-least-once」的配置（acks=all + 非冪等 consumer）、也可以有「寫入承諾較弱但已開冪等」的配置。把 exactly-once 當成單一開關去找、是因為沒看出這兩條軸線存在。本文先講第一條（replication / ISR / acks）、再講第二條（idempotence / transaction）、最後談兩者疊起來能達成什麼、達不成什麼。</p>
<p>這個拆分對映 <a href="/blog/backend/knowledge-cards/delivery-semantics/" data-link-title="Delivery Semantics" data-link-desc="說明事件投遞語意如何定義遺失、重複、順序與補償策略">delivery semantics</a> 與 <a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a> 兩張知識卡：前者描述 broker 承諾的送達次數、後者描述處理端怎麼讓「送達多次」不等於「生效多次」。</p>
<h2 id="isr誰算跟得上的副本">ISR：誰算「跟得上」的副本</h2>
<p>ISR（in-sync replica、同步副本集）是一個 partition 當前「跟得上 leader」的 replica 集合、是 Kafka 把 replication factor 這個 <em>靜態配置</em> 轉成 <em>動態保證</em> 的關鍵概念。Replication factor = 3 只說明這個 partition 有 3 份 replica；但任一時刻真正跟得上 leader 的可能只有 2 份或 1 份。ISR 就是這個「當前實際同步」的集合、寫入承諾的判斷都基於 ISR、不是基於 replication factor。</p>
<p>一個 follower 留在 ISR 內的條件是：它在 <code>replica.lag.time.max.ms</code>（預設 30 秒）內持續向 leader 拉取資料、且追上 leader 的 log end offset。當 follower 因為 broker 慢、網路抖動、GC 停頓或 disk 壓力而落後超過這個時間窗、leader 會把它移出 ISR — 這就是 ISR shrink（收縮）。當它恢復、重新追上、再被加回 ISR — 這是 ISR expand（擴張）。</p>
<p>ISR 收縮本身不是故障、是 Kafka 對「這個 follower 暫時不可信」的誠實表態。真正的風險在於：ISR 收縮到某個程度後、<code>acks=all</code> 的寫入承諾會無法滿足 <code>min.insync.replicas</code> 而開始拒絕寫入。下一段的 acks 取捨直接建立在 ISR 這個概念上。</p>
<p>實機看 ISR 的方式是 <code>kafka-topics.sh --describe</code>、Isr 欄位列出當前同步的 broker id：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># RF=3、min.insync.replicas=2 的 topic、三 broker 都同步時</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">kafka-topics.sh --describe --topic repl-demo --bootstrap-server kafka1:9092
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"># Topic: repl-demo  PartitionCount: 1  ReplicationFactor: 3  Configs: min.insync.replicas=2</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">#   Topic: repl-demo  Partition: 0  Leader: 2  Replicas: 2,3,1  Isr: 2,3,1</span></span></span></code></pre></div><p>Replicas 欄位是 <em>配置上</em> 的 3 份副本、Isr 欄位是 <em>當前實際同步</em> 的集合。兩者一致代表健康；Isr 比 Replicas 短代表有副本落後。日常巡檢用 <code>kafka-topics.sh --describe --under-replicated-partitions</code> 直接列出 Isr 短於 Replicas 的 partition。</p>
<h2 id="acks-與-mininsyncreplicas寫入承諾的兩個旋鈕">acks 與 min.insync.replicas：寫入承諾的兩個旋鈕</h2>
<p>寫入承諾由 producer 端的 <code>acks</code> 跟 broker / topic 端的 <code>min.insync.replicas</code> 共同決定、兩者必須一起設才有意義。<code>acks</code> 決定 producer 在收到「成功」回應前、要等多少 replica 確認；<code>min.insync.replicas</code> 決定 broker 在 ISR 不足時是否拒絕寫入。前者是 producer 的等待策略、後者是 broker 的拒絕底線。</p>
<p><code>acks</code> 三個值對應遞增的耐久性與遞增的延遲成本：</p>
<table>
  <thead>
      <tr>
          <th>acks 值</th>
          <th>承諾</th>
          <th>資料風險</th>
          <th>延遲</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>0</td>
          <td>不等任何確認、送出即視為成功</td>
          <td>leader 沒收到也不知道、broker 掛掉直接丟</td>
          <td>最低</td>
      </tr>
      <tr>
          <td>1</td>
          <td>leader 寫入本地 log 即回成功</td>
          <td>leader 確認後、follower 同步前掛掉、這筆訊息遺失</td>
          <td>中</td>
      </tr>
      <tr>
          <td>all</td>
          <td>ISR 內所有 replica 都確認才回成功</td>
          <td>ISR 內任一存活即不丟；ISR 不足 min.insync 時拒絕寫入</td>
          <td>最高</td>
      </tr>
  </tbody>
</table>
<p><code>acks=0</code> 適用「丟一兩筆無所謂」的場景、例如高頻 metric 上報、log shipping 的非關鍵層。它把網路往返成本壓到最低、代價是 producer 完全不知道 broker 有沒有收到。任何牽涉金流、訂單、狀態變更的訊息都不該用 acks=0。</p>
<p><code>acks=1</code> 是一個容易被誤以為安全的中間值。它只等 leader 寫入本地、不等 follower 同步。多數時候運作正常、但存在一個明確的資料遺失窗口：leader 回了成功、follower 還沒拉到這筆訊息、此時 leader 所在 broker 崩潰、新 leader 從 follower 中選出 — 那筆「已回成功」的訊息在新 leader 上不存在、producer 卻以為寫成功了。這個窗口在正常運行時很窄、但在 broker 滾動重啟、硬體故障、AZ 中斷時會被放大。</p>
<p><code>acks=all</code> 是耐久性配置的正解、但只有搭配 <code>min.insync.replicas ≥ 2</code> 才完整。單獨設 acks=all、若 <code>min.insync.replicas=1</code>、那麼當 ISR 收縮到只剩 leader 一份時、acks=all 等同 acks=1 — 「所有 ISR 確認」這個條件在 ISR 只剩 1 份時形同虛設。<code>min.insync.replicas=2</code> 補上這個漏洞：它要求 ISR 至少有 2 份才接受 acks=all 寫入、否則直接拒絕、把「靜默遺失」轉成「明確拒絕」。</p>
<p><code>min.insync.replicas</code> 是 topic-level 可動態調整的配置、不需重啟 broker：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 動態調整單一 topic 的 min.insync.replicas</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">kafka-configs.sh --alter --topic repl-demo <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --add-config min.insync.replicas<span class="o">=</span><span class="m">2</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --bootstrap-server kafka1:9092
</span></span><span class="line"><span class="ln">5</span><span class="cl">
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"># 查當前值、synonyms 會顯示 topic override 蓋過 broker default</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">kafka-configs.sh --describe --topic repl-demo --bootstrap-server kafka1:9092
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1"># min.insync.replicas=2 synonyms={DYNAMIC_TOPIC_CONFIG:min.insync.replicas=2,</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="c1">#   DYNAMIC_DEFAULT_BROKER_CONFIG:min.insync.replicas=1, DEFAULT_CONFIG:min.insync.replicas=1}</span></span></span></code></pre></div><p>RF=3 + acks=all + min.insync.replicas=2 是業界對「不能丟資料」topic 的標準三件組：3 份副本提供冗餘、acks=all 要求同步確認、min.insync=2 在容忍一台 broker 掛掉的同時仍保證每筆寫入落在至少兩份 replica。容忍度的算術是 <code>RF - min.insync.replicas</code>：3 - 2 = 1、代表可以掉一台 broker 仍正常寫入、掉兩台則寫入被拒（但已寫入的資料不丟）。</p>
<h2 id="producer-idempotence去掉重送造成的重複">Producer idempotence：去掉重送造成的重複</h2>
<p>Producer idempotence（冪等生產者、<code>enable.idempotence=true</code>）解決的是 <em>producer 重送</em> 造成的 broker 端重複。它讓「producer 因為沒收到 ack 而重送同一筆訊息」這件事、在 broker 端被去重、不會寫進兩筆。這是處理語義軸線的第一塊、獨立於前面的寫入承諾。</p>
<p>問題的根源是：producer 送出訊息後、若因網路超時沒收到 broker 的 ack、它無法分辨是「訊息沒送到」還是「訊息送到了但 ack 在回程丟了」。預設行為是重送。在沒有冪等保護時、若實際是後者、broker 就收到兩筆相同訊息、partition 裡出現重複。</p>
<p>冪等機制的做法是給每個 producer 分配一個 producer ID（PID）、並為每個 partition 維護一個遞增的 sequence number。Broker 記住每個 (PID, partition) 已接受的最大 sequence；重送的訊息帶相同 sequence、broker 認出是重複、直接丟棄並回成功。這個保證的範圍是 <em>單一 producer session 內、單一 partition</em> 的精確一次寫入。</p>
<p>開啟方式是 producer 端設 <code>enable.idempotence=true</code>。在較新版 Kafka 這已是預設值、且它會隱含要求 <code>acks=all</code>、<code>retries &gt; 0</code>、<code>max.in.flight.requests.per.connection ≤ 5</code> — 因為冪等去重依賴這些前提。冪等的成本極低（broker 多維護 PID/sequence 的少量 metadata）、幾乎沒有理由關閉。</p>
<p>需要明確的邊界是：冪等只覆蓋 <em>同一個 producer session</em>。Producer 重啟後拿到新的 PID、broker 無法把新舊 session 的訊息關聯起來。跨 session 的去重、以及「寫多個 partition 要嘛全成功要嘛全失敗」的需求、要靠下一段的 transaction。</p>
<h2 id="kafka-transaction-與-read_committed跨-partition-的原子寫入">Kafka transaction 與 read_committed：跨 partition 的原子寫入</h2>
<p>Kafka transaction（交易）解決的是 <em>跨多個 partition 的原子寫入</em> 與 <em>consume-process-produce 的原子提交</em>。它讓一組寫入（可能跨多個 topic / partition）以及對應的 consumer offset commit、要嘛全部對下游可見、要嘛全部不可見。這是處理語義軸線的第二塊、建立在冪等之上。</p>
<p>典型場景是 stream processing 的 consume-process-produce 迴圈：consumer 讀入一批訊息、處理後產出結果寫到另一個 topic、然後 commit 讀取進度。若這三步不是原子的、崩潰時可能出現「結果已產出但 offset 沒 commit」（重啟後重複處理、重複產出）或「offset 已 commit 但結果沒寫成功」（訊息遺失）。Transaction 把「產出結果」跟「commit offset」綁成一個原子操作、消除這個窗口。</p>
<p>啟用 transaction 需要 producer 設一個穩定的 <code>transactional.id</code>、並在程式碼中走完整的 transaction 生命週期：</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">producer.initTransactions()      // 向 transaction coordinator 註冊、fence 掉舊 session
</span></span><span class="line"><span class="ln">2</span><span class="cl">producer.beginTransaction()
</span></span><span class="line"><span class="ln">3</span><span class="cl">  producer.send(record1)          // 跨多個 topic/partition 的寫入
</span></span><span class="line"><span class="ln">4</span><span class="cl">  producer.send(record2)
</span></span><span class="line"><span class="ln">5</span><span class="cl">  producer.sendOffsetsToTransaction(offsets, groupMetadata)  // consumer 進度也納入交易
</span></span><span class="line"><span class="ln">6</span><span class="cl">producer.commitTransaction()      // 全部原子提交；失敗則 abortTransaction()</span></span></code></pre></div><p><code>transactional.id</code> 提供跨 session 的 fencing（隔離）：同一個 transactional.id 的新 producer 啟動時、coordinator 會 fence 掉舊的、避免「殭屍 producer」在崩潰後復活還繼續寫。這是冪等的 PID 機制做不到的跨 session 保證。</p>
<blockquote>
<p><strong>實機限制</strong>：<code>kafka-console-producer.sh</code> 帶 <code>--producer-property transactional.id=...</code> 不會自動呼叫 <code>initTransactions()</code>、會直接報 <code>IllegalStateException: Cannot add partition ... before completing a call to initTransactions</code>。完整 transaction 生命週期只能在 client code 中驗證、無法用 console 工具演示。本文的 transaction 行為描述依官方 producer API 語義、生命週期程式碼未經本地 client 實機跑通。</p></blockquote>
<p>Transaction 的另一半在 consumer 端：<code>isolation.level=read_committed</code>。預設的 <code>read_uncommitted</code> 會讀到尚未 commit、甚至最終被 abort 的 transactional 訊息。設成 <code>read_committed</code> 後、consumer 只會看到已 commit 的 transactional 訊息、abort 的訊息對它不可見、未 commit 的訊息會被擋在 last stable offset（LSO）之前等待。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># consumer 以 read_committed 隔離級別讀取、只看已 commit 的 transactional 訊息</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">kafka-console-consumer.sh --topic repl-demo --from-beginning <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --isolation-level read_committed <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --bootstrap-server kafka1:9092</span></span></code></pre></div><p>需要注意：對非 transactional 的普通訊息、read_committed 跟 read_uncommitted 行為相同 — 普通訊息一律可見。隔離級別只對 transactional 訊息產生差異。這也是為什麼若上游沒有任何 transactional producer、把 consumer 改成 read_committed 不會有任何可觀察的效果。</p>
<h2 id="端到端-exactly-once-的邊界與成本">端到端 exactly-once 的邊界與成本</h2>
<p>端到端 exactly-once 的意思是：訊息從 producer 到 consumer 處理結果、整條路徑上「不重不漏」。它由前面所有零件疊出來、但有明確的適用邊界、不是萬用保證。</p>
<p>Kafka 原生能提供 exactly-once 的範圍是 <em>Kafka-to-Kafka 的封閉迴圈</em>：consume from Kafka、process、produce to Kafka、commit offset、整個用 transaction 綁定。Kafka Streams 框架把這套封裝成 <code>processing.guarantee=exactly_once_v2</code> 一個配置、底層就是 transaction + 冪等 + read_committed 的組合。在這個封閉迴圈內、exactly-once 是真實成立的。</p>
<p>邊界出現在 <em>離開 Kafka 的那一刻</em>。當處理結果要寫進外部系統（資料庫、HTTP API、第三方服務、寄信、扣款）、Kafka 的 transaction 管不到外部系統的提交。一筆訊息「已扣款但 offset commit 前崩潰」這種跨系統不一致、Kafka transaction 無法消除 — 它只保證 Kafka 內部的原子性。跨系統的 exactly-once 要靠外部系統自己的冪等鍵（idempotency key）、或 outbox pattern、或兩階段提交、由應用層補上、不是 Kafka 送的。</p>
<p>成本方面、exactly-once 不是免費的耐久性升級：</p>
<table>
  <thead>
      <tr>
          <th>成本維度</th>
          <th>影響</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>吞吐</td>
          <td>transaction 的 begin/commit 與 coordinator 往返增加 per-batch overhead、吞吐下降</td>
      </tr>
      <tr>
          <td>延遲</td>
          <td>read_committed 要等 LSO 推進、consumer 端引入額外延遲</td>
      </tr>
      <tr>
          <td>複雜度</td>
          <td>producer 要管 transaction 生命週期、abort 路徑、fencing；錯誤處理比 fire-forget 重</td>
      </tr>
      <tr>
          <td>coordinator 壓力</td>
          <td>transaction coordinator 與 <code>__transaction_state</code> topic 成為新的關鍵路徑與容量點</td>
      </tr>
  </tbody>
</table>
<p>務實的判斷是：先確認需求真的是 exactly-once、還是「at-least-once + 下游冪等」就夠。多數業務（包括金流）用 at-least-once 送達 + 下游用業務冪等鍵去重、就達到了「效果上不重複」、且吞吐與複雜度成本遠低於完整 transaction exactly-once。完整的 Kafka transaction exactly-once 留給 Kafka-to-Kafka 的 stream processing pipeline、那是它的甜蜜點。這個取捨對映 <a href="/blog/backend/03-message-queue/processing-recovery-semantics/" data-link-title="3.6 Processing Semantics 與 Recovery Semantics" data-link-desc="說明投遞成功、處理成功與恢復成功為何是三個不同判斷。">3.6 processing-recovery-semantics</a> 對「在哪一層放冪等」的判讀。</p>
<h2 id="故障演練">故障演練</h2>
<p>可靠性配置的價值在故障時才顯現。以下演練在 3-broker KRaft 叢集（RF=3、min.insync.replicas=2）上跑、用停 broker 製造 ISR 收縮、觀察各配置的真實行為。</p>
<h3 id="isr-收縮到低於-mininsyncreplicas-時-acksall-被拒">ISR 收縮到低於 min.insync.replicas 時 acks=all 被拒</h3>
<p><strong>演練</strong>：起 3-broker 叢集、建 RF=3 / min.insync.replicas=2 的 topic、初始 ISR = 三台全在。依序停掉兩個 follower broker、觀察 ISR 收縮、再用 acks=all produce。</p>
<p><strong>初始狀態</strong>（ISR 三份全在、acks=all 正常）：</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">Topic: repl-demo  Partition: 0  Leader: 2  Replicas: 2,3,1  Isr: 2,3,1
</span></span><span class="line"><span class="ln">2</span><span class="cl"># acks=all produce → exit=0</span></span></code></pre></div><p><strong>停一個 follower（broker 3）</strong>、ISR 收縮到 2 份、仍滿足 min.insync=2：</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">Topic: repl-demo  Partition: 0  Leader: 2  Replicas: 2,3,1  Isr: 2,1
</span></span><span class="line"><span class="ln">2</span><span class="cl"># acks=all produce → exit=0（ISR=2 仍 &gt;= min.insync=2、寫入接受）</span></span></code></pre></div><p><strong>再停一個 follower（broker 1）</strong>、ISR 收縮到只剩 leader 1 份、低於 min.insync=2：</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"># acks=all produce → broker 拒絕：
</span></span><span class="line"><span class="ln">2</span><span class="cl">[Producer] Got error produce response ... Error: NOT_ENOUGH_REPLICAS, retrying
</span></span><span class="line"><span class="ln">3</span><span class="cl">org.apache.kafka.common.errors.NotEnoughReplicasException:
</span></span><span class="line"><span class="ln">4</span><span class="cl">  Messages are rejected since there are fewer in-sync replicas than required.</span></span></code></pre></div><p><strong>判讀</strong>：這正是 min.insync.replicas 的設計意圖在運作。ISR 不足時、broker 選擇 <em>明確拒絕寫入</em>（NOT_ENOUGH_REPLICAS）、而不是降級成 acks=1 默默接受。對 producer 而言、寫入失敗會觸發 retry、retry 耗盡後拋例外、上游應用感知到「現在寫不進去」、可以 fail-fast 或 backpressure — 而不是寫了一筆只在單一 broker 上、隨時可能隨那台 broker 一起消失的「假成功」訊息。把資料遺失轉成可觀測的寫入拒絕、是這個配置的全部目的。</p>
<p><strong>恢復</strong>：重啟兩個 broker、ISR 自動 expand 回三份、acks=all 恢復接受寫入：</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">Topic: repl-demo  Partition: 0  Leader: 2  Replicas: 2,3,1  Isr: 1,2,3</span></span></code></pre></div><blockquote>
<p>附帶觀察：在 KRaft 模式下、controller 也是 quorum（本演練三台都兼任 controller）。同時停掉兩台、controller quorum 失去多數、<code>kafka-topics.sh --describe</code> 對 metadata 的查詢會 timeout（DisconnectException）。production 叢集應把 controller 數量與 broker 故障域分開規劃、避免 broker 故障連帶打垮 metadata 平面。</p></blockquote>
<h3 id="unclean-leader-election-的取捨">Unclean leader election 的取捨</h3>
<p>當一個 partition 的所有 ISR replica 都不可用、只剩一個 <em>曾經落後、已被踢出 ISR</em> 的 replica 還活著、Kafka 面臨一個無法兩全的選擇。<code>unclean.leader.election.enable=false</code>（預設）會選擇 <em>不選 leader</em>：這個 partition 進入不可用狀態、拒絕讀寫、直到某個 ISR replica 恢復。<code>unclean.leader.election.enable=true</code> 會選擇 <em>把那個落後的 replica 提為 leader</em>：partition 立刻恢復可用、代價是那個 replica 上缺失的訊息（leader 掛掉前已 commit 但它還沒同步到的部分）永久遺失。</p>
<p><strong>判讀</strong>：這是一個 <em>可用性 vs 耐久性</em> 的直接取捨、沒有正確答案、只有對映業務的選擇。對金流、訂單、審計這類「丟一筆都不行」的 topic、保持 false、寧可 partition 短暫不可用也不接受靜默資料遺失。對 metric、log、可重算的衍生資料、開 true 換可用性、丟幾筆可接受。預設 false 是合理的安全預設、但要意識到它的代價是「所有 replica 都不在 ISR 時、partition 會卡住不可用」、這在多 broker 同時故障時會發生。</p>
<h3 id="idempotent-producer-對重送去重">Idempotent producer 對重送去重</h3>
<p><strong>演練</strong>：producer 開 <code>enable.idempotence=true</code>、acks=all、模擬 ack 丟失導致的重送。</p>
<p><strong>判讀</strong>：冪等開啟後、producer 因網路超時重送的訊息帶相同 (PID, partition, sequence)、broker 認出 sequence 重複、丟棄重送並回成功、partition 內不出現重複。實機上 <code>enable.idempotence=true</code> 的 produce 寫入正常（exit=0）、消費端讀回的訊息數等於實際送出的邏輯訊息數、重送不放大。要記住的邊界仍是：這只覆蓋單一 producer session；producer 重啟換 PID 後、跨 session 的重複要靠 transaction 或下游冪等鍵處理。</p>
<h3 id="transaction-中途失敗的-read_committed-隔離">Transaction 中途失敗的 read_committed 隔離</h3>
<p><strong>演練</strong>：transactional producer 在 beginTransaction 後寫入若干訊息、然後 abortTransaction（模擬處理中途失敗）；consumer 分別用 read_uncommitted 與 read_committed 讀取。</p>
<p><strong>判讀</strong>：read_committed 的 consumer 看不到被 abort 的訊息 — 中途失敗的 transaction 對它等於沒發生過、不會讀到「處理一半的髒資料」。read_uncommitted 的 consumer 則會讀到這些最終被 abort 的訊息、若據此處理就產生了不該發生的副作用。這是 transaction 隔離的核心價值：把「transaction 失敗」的可見性控制在 commit 邊界內。</p>
<blockquote>
<p>本段的 abort 行為依官方 transaction 語義描述。本地以 <code>kafka-console-consumer.sh --isolation-level read_committed</code> 驗證了隔離級別參數可用、且對已 commit 的普通訊息 read_committed 與 read_uncommitted 輸出一致（普通訊息一律可見、隔離級別只對 transactional 訊息產生差異）；完整的 begin/abort transaction 生命週期需 client code、未用 console 工具跑通。</p></blockquote>
<h2 id="capacity--cost">Capacity / cost</h2>
<p>各配置的容量與成本影響、決定它適用的規模與 topic 類別：</p>
<table>
  <thead>
      <tr>
          <th>配置</th>
          <th>吞吐 / 延遲影響</th>
          <th>適用</th>
          <th>警戒</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>acks=0</td>
          <td>最低延遲、最高吞吐</td>
          <td>可丟的 metric / log shipping</td>
          <td>任何狀態變更類訊息不可用</td>
      </tr>
      <tr>
          <td>acks=1</td>
          <td>中等、單次往返</td>
          <td>容忍極少量遺失的衍生資料</td>
          <td>誤當安全選項、broker 故障窗口會遺失</td>
      </tr>
      <tr>
          <td>acks=all + min.insync=2 + RF=3</td>
          <td>延遲 +1 次跨 broker 往返、吞吐略降</td>
          <td>不能丟的業務訊息</td>
          <td>min.insync 沒設則 acks=all 在 ISR=1 時失效</td>
      </tr>
      <tr>
          <td>enable.idempotence=true</td>
          <td>幾乎無額外成本</td>
          <td>所有 producer 預設開</td>
          <td>只覆蓋單一 session</td>
      </tr>
      <tr>
          <td>transaction + read_committed</td>
          <td>begin/commit overhead、read 端 LSO 等待延遲</td>
          <td>Kafka-to-Kafka stream processing 封閉迴圈</td>
          <td>跨外部系統不成立、coordinator 成新關鍵路徑</td>
      </tr>
  </tbody>
</table>
<p>務實 default：</p>
<ul>
<li>業務 topic 一律 RF=3 + acks=all + min.insync.replicas=2、idempotence 預設開</li>
<li>容忍度算術 <code>RF - min.insync.replicas</code> 要 ≥ 1、否則單台 broker 維護就會中斷寫入</li>
<li>完整 transaction exactly-once 只給 Kafka-to-Kafka pipeline；跨系統用 at-least-once + 下游冪等鍵</li>
<li>unclean.leader.election 保持 false、除非該 topic 明確可丟資料換可用性</li>
</ul>
<h2 id="整合--下一步">整合 / 下一步</h2>
<h3 id="跟-processing-recovery-semantics-對位">跟 processing-recovery-semantics 對位</h3>
<p>寫入承諾保證訊息留在 broker、但 <em>處理</em> 的不重不漏在 consumer 端。<a href="/blog/backend/03-message-queue/processing-recovery-semantics/" data-link-title="3.6 Processing Semantics 與 Recovery Semantics" data-link-desc="說明投遞成功、處理成功與恢復成功為何是三個不同判斷。">3.6 processing-recovery-semantics</a> 展開 consumer 的 commit 時機、崩潰恢復的 replay 範圍、以及「冪等放在哪一層」的判讀 — 跟本文的 transaction exactly-once 邊界互補：本文界定 Kafka 能送什麼、那篇界定處理端怎麼接才不放大重複。</p>
<h3 id="跟-event-contract-replay-boundary-對位">跟 event-contract-replay-boundary 對位</h3>
<p>Exactly-once 的封閉迴圈假設訊息格式穩定、replay 可重現。<a href="/blog/backend/03-message-queue/event-contract-replay-boundary/" data-link-title="3.7 Event Contract 與 Replay Boundary" data-link-desc="說明 event schema、idempotency key、replay window 與補償如何先於 broker 選型。">3.7 event-contract-replay-boundary</a> 展開 schema 演進與 replay 邊界 — 當 transaction 提供的原子性遇上 schema 變更、replay 舊訊息的可重現性會受 contract 影響、是 exactly-once 在時間維度上的延伸限制。</p>
<h3 id="對應反例-3c9">對應反例 3.C9</h3>
<p><a href="/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9 Queue 語義誤配</a> 是本文兩條軸線混淆的真實後果：broker 遷移後「名稱上相近的 delivery semantics」在失敗重播時產生不同結果、出現重複扣款與狀態漏更新。判讀路徑正是本文的拆分 — 先確認是寫入承諾（acks / ISR）還是處理語義（idempotence / commit 時機）出問題、不要用 queue depth 這種寫入承諾層的指標去判斷處理語義層的故障。</p>
<h3 id="對應案例-3c21-goldman-sachs-msk-遷移">對應案例 3.C21 Goldman Sachs MSK 遷移</h3>
<p><a href="/blog/backend/03-message-queue/cases/kafka-goldman-sachs-msk-migration/" data-link-title="3.C21 Goldman Sachs：MSK 遷移 with MirrorMaker 2" data-link-desc="Goldman Sachs Global Investment Research 從 on-prem Kafka 遷到 MSK、用 MM2 同步 topic/ACL/offset、atomic cutover 7 小時完成。">3.C21 Goldman Sachs MSK 遷移</a> 揭露遷移時可靠性配置的細節風險集中在 client 端的 timeout / flush / LB 配置、而非 broker 本身。本文的 acks=all 在 ISR 不足時拒絕寫入、若 client 端的 retry 與 timeout 沒對齊（如 flush timeout 太短）、會把「broker 正常的 backpressure」誤判成「遷移失敗」。可靠性配置與 client 容錯參數要一起驗證。</p>
<h3 id="下一步路由">下一步路由</h3>
<ul>
<li>上游概念：<a href="/blog/backend/knowledge-cards/delivery-semantics/" data-link-title="Delivery Semantics" data-link-desc="說明事件投遞語意如何定義遺失、重複、順序與補償策略">delivery semantics</a>、<a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a> 知識卡</li>
<li>同 vendor：<a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka overview</a> 的 producer / consumer 設計段</li>
<li>下游能力：<a href="/blog/backend/03-message-queue/processing-recovery-semantics/" data-link-title="3.6 Processing Semantics 與 Recovery Semantics" data-link-desc="說明投遞成功、處理成功與恢復成功為何是三個不同判斷。">3.6 processing-recovery-semantics</a>、<a href="/blog/backend/03-message-queue/event-contract-replay-boundary/" data-link-title="3.7 Event Contract 與 Replay Boundary" data-link-desc="說明 event schema、idempotency key、replay window 與補償如何先於 broker 選型。">3.7 event-contract-replay-boundary</a>、<a href="/blog/backend/06-reliability/idempotency-replay/" data-link-title="6.12 Idempotency 與 Replay 驗證" data-link-desc="把重試、重播與冪等性從口頭約定變成可驗證屬性">6.12 idempotency / replay</a></li>
<li>方法論：<a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章的寫作方法論</a></li>
</ul>
]]></content:encoded></item><item><title>NATS JetStream 設計與 supercluster / leaf node：stream、consumer、跨區拓樸與多租戶</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/nats/jetstream-supercluster-design/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/nats/jetstream-supercluster-design/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/nats/" data-link-title="NATS" data-link-desc="Lightweight messaging、JetStream 加持久化與 streams">NATS&lt;/a> overview 的 implementation-layer deep article。Overview 回答「NATS 該不該選、Core NATS vs JetStream 怎麼分」；要不要從 core NATS 跨進 JetStream 的決策入口見 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/nats/jetstream-durability-consumer/" data-link-title="NATS core 到 JetStream：fire-and-forget 在哪裡不夠、跨過去要付什麼" data-link-desc="Core NATS 的 fire-and-forget 在 consumer 重啟或 rolling deploy 時掉訊息——這不是 bug、是設計。需要訊息不丟就跨進 JetStream（persistence &amp;#43; at-least-once &amp;#43; redelivery）。本文展開 core 與 JetStream 的邊界、stream 與 consumer 的求值模型、實機驗證的 durable pull consumer、5 個把 JetStream consumer 寫成丟訊息與重投風暴的 production 踩坑">core 到 JetStream 的邊界&lt;/a>；本文回答「JetStream stream / consumer 的每個旋鈕怎麼設、設錯踩什麼坑、跨區拓樸怎麼鋪、多租戶怎麼隔離」。寫作結構依 &lt;a href="https://tarrragon.github.io/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章的寫作方法論&lt;/a> 的 6 段框架。&lt;/p>&lt;/blockquote>
&lt;h2 id="jetstream-把-fire-and-forget-升級成-durable-log">JetStream 把 fire-and-forget 升級成 durable log&lt;/h2>
&lt;p>JetStream 是 NATS 內建的持久化層、責任是把 Core NATS 的 fire-and-forget subject 轉成 append-only 的 durable stream、並讓 consumer 能 ack、重投、replay。Core NATS 的訊息一旦沒有 active subscriber 就消失；JetStream 把符合特定 subject 的訊息攔截下來寫進 stream、即使沒有任何 consumer 在線也會留存到 retention 上限。&lt;/p>
&lt;p>兩個概念要先分清楚、後面所有配置都掛在這個分界上。Stream 是 &lt;em>儲存&lt;/em> 責任：定義「哪些 subject 的訊息要存、存多久、存多少、存哪裡」。Consumer 是 &lt;em>投遞&lt;/em> 責任：定義「從 stream 的哪個位置開始讀、怎麼 ack、ack 不回來要不要重投、重投幾次」。同一個 stream 可以掛多個 consumer、各自有獨立的讀取游標跟重投狀態、互不影響。這個 stream / consumer 二分是 JetStream 跟 Kafka（topic / consumer group）對應、但跟 RabbitMQ（queue 本身就綁消費）不同的核心模型差異。&lt;/p>
&lt;p>本文用一個訂單事件流當主線：subject 設計成 &lt;code>orders.created.&amp;lt;region&amp;gt;&lt;/code>、stream 名 &lt;code>orders&lt;/code>、subject filter &lt;code>orders.&amp;gt;&lt;/code>。實機環境用單機 NATS server 加 &lt;code>-js&lt;/code>、CLI 用 &lt;code>natsio/nats-box&lt;/code> 容器；跨節點的 Cluster / quorum 段用 3 節點 docker compose 驗證、Supercluster / Leaf node 因拓樸複雜以 case 敘述加官方文件 caveat 標註。&lt;/p>
&lt;h2 id="stream-設計storageretentiondiscard容量上限">Stream 設計：storage、retention、discard、容量上限&lt;/h2>
&lt;p>Stream 的設計責任是回答四個彼此獨立的問題：訊息存在哪種介質、用什麼規則決定保留、超過上限時丟哪一端、上限本身設多大。這四個旋鈕組合錯了不會在建立時報錯、而是在 production 流量打進來才以丟訊息或塞爆 disk 的形式爆出來。&lt;/p>
&lt;h3 id="storagefile-vs-memory">Storage：file vs memory&lt;/h3>
&lt;p>Storage type 決定訊息寫在 disk 還是 RAM。&lt;code>file&lt;/code> storage 把 stream 寫進 disk、server 重啟後資料還在、是需要 durability 的事件流預設選擇；&lt;code>memory&lt;/code> storage 把 stream 放 RAM、吞吐跟延遲更好但 server 重啟即全失、適合短期 fan-out 或可重建的快取型資料。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/03-message-queue/vendors/nats/" data-link-title="NATS" data-link-desc="Lightweight messaging、JetStream 加持久化與 streams">NATS</a> overview 的 implementation-layer deep article。Overview 回答「NATS 該不該選、Core NATS vs JetStream 怎麼分」；要不要從 core NATS 跨進 JetStream 的決策入口見 <a href="/blog/backend/03-message-queue/vendors/nats/jetstream-durability-consumer/" data-link-title="NATS core 到 JetStream：fire-and-forget 在哪裡不夠、跨過去要付什麼" data-link-desc="Core NATS 的 fire-and-forget 在 consumer 重啟或 rolling deploy 時掉訊息——這不是 bug、是設計。需要訊息不丟就跨進 JetStream（persistence &#43; at-least-once &#43; redelivery）。本文展開 core 與 JetStream 的邊界、stream 與 consumer 的求值模型、實機驗證的 durable pull consumer、5 個把 JetStream consumer 寫成丟訊息與重投風暴的 production 踩坑">core 到 JetStream 的邊界</a>；本文回答「JetStream stream / consumer 的每個旋鈕怎麼設、設錯踩什麼坑、跨區拓樸怎麼鋪、多租戶怎麼隔離」。寫作結構依 <a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章的寫作方法論</a> 的 6 段框架。</p></blockquote>
<h2 id="jetstream-把-fire-and-forget-升級成-durable-log">JetStream 把 fire-and-forget 升級成 durable log</h2>
<p>JetStream 是 NATS 內建的持久化層、責任是把 Core NATS 的 fire-and-forget subject 轉成 append-only 的 durable stream、並讓 consumer 能 ack、重投、replay。Core NATS 的訊息一旦沒有 active subscriber 就消失；JetStream 把符合特定 subject 的訊息攔截下來寫進 stream、即使沒有任何 consumer 在線也會留存到 retention 上限。</p>
<p>兩個概念要先分清楚、後面所有配置都掛在這個分界上。Stream 是 <em>儲存</em> 責任：定義「哪些 subject 的訊息要存、存多久、存多少、存哪裡」。Consumer 是 <em>投遞</em> 責任：定義「從 stream 的哪個位置開始讀、怎麼 ack、ack 不回來要不要重投、重投幾次」。同一個 stream 可以掛多個 consumer、各自有獨立的讀取游標跟重投狀態、互不影響。這個 stream / consumer 二分是 JetStream 跟 Kafka（topic / consumer group）對應、但跟 RabbitMQ（queue 本身就綁消費）不同的核心模型差異。</p>
<p>本文用一個訂單事件流當主線：subject 設計成 <code>orders.created.&lt;region&gt;</code>、stream 名 <code>orders</code>、subject filter <code>orders.&gt;</code>。實機環境用單機 NATS server 加 <code>-js</code>、CLI 用 <code>natsio/nats-box</code> 容器；跨節點的 Cluster / quorum 段用 3 節點 docker compose 驗證、Supercluster / Leaf node 因拓樸複雜以 case 敘述加官方文件 caveat 標註。</p>
<h2 id="stream-設計storageretentiondiscard容量上限">Stream 設計：storage、retention、discard、容量上限</h2>
<p>Stream 的設計責任是回答四個彼此獨立的問題：訊息存在哪種介質、用什麼規則決定保留、超過上限時丟哪一端、上限本身設多大。這四個旋鈕組合錯了不會在建立時報錯、而是在 production 流量打進來才以丟訊息或塞爆 disk 的形式爆出來。</p>
<h3 id="storagefile-vs-memory">Storage：file vs memory</h3>
<p>Storage type 決定訊息寫在 disk 還是 RAM。<code>file</code> storage 把 stream 寫進 disk、server 重啟後資料還在、是需要 durability 的事件流預設選擇；<code>memory</code> storage 把 stream 放 RAM、吞吐跟延遲更好但 server 重啟即全失、適合短期 fan-out 或可重建的快取型資料。</p>
<p>實機建一個 file storage、limits retention、discard old 的 stream：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl">nats --server nats://localhost:4232 stream add orders <span class="se">\
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="se"></span>  --subjects <span class="s1">&#39;orders.&gt;&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="se"></span>  --storage file <span class="se">\
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="se"></span>  --retention limits <span class="se">\
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="se"></span>  --discard old <span class="se">\
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="se"></span>  --max-msgs <span class="m">1000</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="se"></span>  --max-bytes 10MB <span class="se">\
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="se"></span>  --max-age 1h <span class="se">\
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="se"></span>  --replicas <span class="m">1</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="se"></span>  --defaults</span></span></code></pre></div><p><code>nats stream info orders</code> 回報的配置確認旋鈕都生效：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">                     Subjects: orders.&gt;
</span></span><span class="line"><span class="ln">2</span><span class="cl">                      Storage: File
</span></span><span class="line"><span class="ln">3</span><span class="cl">                    Retention: Limits
</span></span><span class="line"><span class="ln">4</span><span class="cl">               Discard Policy: Old
</span></span><span class="line"><span class="ln">5</span><span class="cl">             Maximum Messages: 1,000
</span></span><span class="line"><span class="ln">6</span><span class="cl">                Maximum Bytes: 10 MiB
</span></span><span class="line"><span class="ln">7</span><span class="cl">                  Maximum Age: 1h0m0s</span></span></code></pre></div><p>選 memory 的判讀訊號：訊息可從上游重建（例如 metrics 採樣、可重抓的 snapshot）、或 consumer 一定在線且消費速度跟得上、且單 stream 資料量遠小於可用 RAM。一旦這三條有一條不成立、預設回到 file storage。</p>
<h3 id="retentionlimits-vs-interest-vs-workqueue">Retention：limits vs interest vs workqueue</h3>
<p>Retention policy 決定「訊息什麼時候從 stream 移除」、是 stream 三種使用形態的分水嶺。</p>
<p><code>limits</code> retention 是時間 / 容量驅動：訊息留到撞上 MaxMsgs / MaxBytes / MaxAge 任一上限才移除、跟有沒有人消費無關。這是「事件 log」形態、適合需要 replay、多個獨立 consumer 各讀各的場景。訂單事件流用 limits、因為審計、對帳、即時處理可能是三個獨立 consumer、訊息不能因為某個 consumer ack 了就消失。</p>
<p><code>interest</code> retention 是訂閱驅動：當 stream 上 <em>所有</em> 已註冊的 consumer 都 ack 了某筆訊息、該訊息立刻移除。它介於 limits 跟 workqueue 之間、適合「只要所有關心的 consumer 都收到就不必再留」的扇出場景。</p>
<p><code>workqueue</code> retention 是任務佇列形態：每筆訊息只會被 <em>一個</em> consumer 成功 ack、ack 後立刻刪除。它把 stream 當成工作分派佇列、語意接近 RabbitMQ 的 work queue。實機驗證 workqueue 的 retention 在 info 反映：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">nats --server nats://localhost:4232 stream add wq <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --subjects <span class="s1">&#39;wq.&gt;&#39;</span> --storage memory --retention work <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --max-msgs <span class="m">100</span> --replicas <span class="m">1</span> --defaults
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># nats stream info wq → Retention: WorkQueue</span></span></span></code></pre></div><p>判讀路由：需要多 consumer 各自 replay → limits；需要扇出且所有訂閱者收齊就清 → interest；需要競爭式單次消費的任務派工 → workqueue。選 workqueue 卻又掛兩個 filter 重疊的 consumer 會在建 consumer 時被拒、因為 workqueue 不允許同一筆訊息被兩個 consumer 認領。</p>
<h3 id="discardold-vs-new">Discard：old vs new</h3>
<p>Discard policy 決定 stream <em>撞上 MaxMsgs / MaxBytes 上限後</em> 丟哪一端。這個旋鈕的選擇直接對應業務對「舊資料」跟「新資料」誰更重要的判斷、選錯會靜默丟訊息。</p>
<p><code>discard old</code> 在達上限時丟掉最舊的訊息、騰空間給新訊息。實機驗證：max-msgs 設 3、連發 5 筆、stream 留下最後 3 筆：</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">discard old, max-msgs 3, published 5:
</span></span><span class="line"><span class="ln">2</span><span class="cl">                     Messages: 3
</span></span><span class="line"><span class="ln">3</span><span class="cl">               First Sequence: 3
</span></span><span class="line"><span class="ln">4</span><span class="cl">                Last Sequence: 5</span></span></code></pre></div><p>最舊的 seq 1、2 被丟、保留 seq 3-5。這對應「新資料比舊資料重要」的場景：即時儀表板、最新狀態快照、寧可丟歷史也要保住最新。</p>
<p><code>discard new</code> 在達上限時拒絕新訊息、保住已存的舊訊息。同樣 max-msgs 3、連發 5 筆：</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">discard new, max-msgs 3, published 5:
</span></span><span class="line"><span class="ln">2</span><span class="cl">                     Messages: 3
</span></span><span class="line"><span class="ln">3</span><span class="cl">               First Sequence: 1
</span></span><span class="line"><span class="ln">4</span><span class="cl">                Last Sequence: 3</span></span></code></pre></div><p>保留 seq 1-3、後到的 seq 4、5 進不來。這對應「舊資料是已承諾的工作、不能丟」的場景：任務佇列在塞滿時應拒收新任務（並對上游施加 backpressure）、而不是把排隊中的任務擠掉。</p>
<p>discard new 有個容易踩的投遞行為差異、見故障演練 Case 2。</p>
<h3 id="容量上限maxmsgs--maxbytes--maxage">容量上限：MaxMsgs / MaxBytes / MaxAge</h3>
<p>三個上限是 OR 關係：任一撞到就觸發 discard / 移除。MaxMsgs 限筆數、MaxBytes 限總位元組、MaxAge 限訊息存活時間。實務上三者搭配使用：MaxAge 防止無限累積（例如事件流只保留 7 天）、MaxBytes 是 disk 的硬護欄（防單 stream 撐爆 volume）、MaxMsgs 在訊息大小均勻時當作粗略筆數控制。</p>
<p>容量規劃的判讀順序是先定 MaxAge（業務需要 replay 多久）、再用「平均訊息大小 × 預估 throughput × MaxAge」反推 MaxBytes 是否在 disk 預算內、超出就縮短 MaxAge 或拆 stream。把 MaxBytes 設成 unlimited 而只靠 MaxMsgs 是常見的容量事故來源：訊息大小一旦變大（例如 payload 夾帶了 base64 附件）、筆數沒到上限但 disk 已滿。</p>
<h2 id="consumer-設計pullpushackackwaitmaxdeliverreplay">Consumer 設計：pull/push、ack、AckWait、MaxDeliver、replay</h2>
<p>Consumer 的設計責任是控制「訊息怎麼從 stream 送到處理端、處理端怎麼確認、確認不回來怎麼辦」。它的每個旋鈕都圍繞同一個核心張力：在 at-least-once 投遞下、如何在「不漏處理」跟「不過度重投」之間取得平衡。對應的概念基礎見 <a href="/blog/backend/knowledge-cards/delivery-semantics/" data-link-title="Delivery Semantics" data-link-desc="說明事件投遞語意如何定義遺失、重複、順序與補償策略">Delivery Semantics</a> 與 <a href="/blog/backend/knowledge-cards/processing-semantics/" data-link-title="Processing Semantics" data-link-desc="說明 consumer 處理事件後業務結果是否正確，與投遞成功分屬不同責任">Processing Semantics</a> 知識卡。</p>
<h3 id="pull-vs-push">Pull vs push</h3>
<p>Pull consumer 由處理端主動拉：consumer 發 pull request 帶 batch size、server 才送對應數量的訊息。流量控制天然落在消費端、消費端有多少處理能力就拉多少、是現代 JetStream 應用的預設模式。Push consumer 由 server 主動推到一個 delivery subject、處理端訂閱那個 subject、適合需要 server 端 flow control 或既有 Core NATS 訂閱模型遷移的場景。</p>
<p>實機建一個 pull consumer、explicit ack、AckWait 30s、MaxDeliver 5、replay instant：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">nats --server nats://localhost:4232 consumer add orders worker <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --pull <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --deliver all <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --ack explicit <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  --wait 30s <span class="se">\
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="se"></span>  --max-deliver <span class="m">5</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="se"></span>  --replay instant <span class="se">\
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="se"></span>  --filter <span class="s1">&#39;orders.&gt;&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="se"></span>  --defaults</span></span></code></pre></div><p><code>nats consumer info orders worker</code> 確認配置：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">                    Name: worker
</span></span><span class="line"><span class="ln">2</span><span class="cl">               Pull Mode: true
</span></span><span class="line"><span class="ln">3</span><span class="cl">          Deliver Policy: All
</span></span><span class="line"><span class="ln">4</span><span class="cl">              Ack Policy: Explicit
</span></span><span class="line"><span class="ln">5</span><span class="cl">                Ack Wait: 30.00s
</span></span><span class="line"><span class="ln">6</span><span class="cl">           Replay Policy: Instant
</span></span><span class="line"><span class="ln">7</span><span class="cl">      Maximum Deliveries: 5</span></span></code></pre></div><p>push consumer 改用 <code>--target &lt;subject&gt;</code> 取代 <code>--pull</code>、info 會回報 <code>Delivery Subject:</code> 而非 Pull Mode。</p>
<h3 id="ackpolicyexplicit-是預設選擇">AckPolicy：explicit 是預設選擇</h3>
<p>Ack policy 決定 consumer 怎麼確認訊息已處理。<code>explicit</code> 要求對每一筆訊息單獨 ack、是 at-least-once 處理的基礎、production 預設選擇。<code>all</code> 用累積 ack：ack 第 N 筆等於 ack 了第 N 筆以前全部、吞吐高但一筆處理失敗會讓整段重投。<code>none</code> 完全不 ack、投遞即視為完成、語意退化成接近 fire-and-forget、只適合可容忍丟失的場景。</p>
<p>explicit ack 之所以是預設、是因為它讓每筆訊息的處理結果獨立可追蹤：哪筆 ack 了、哪筆還 outstanding、哪筆重投超限、都能在 consumer info 看到。實機發 3 筆訊息後、consumer info 的 <code>Unprocessed Messages</code> 反映 stream 中尚未投遞的 backlog：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">nats --server nats://localhost:4232 pub orders.created.us-1 <span class="s2">&#34;order-1&#34;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># 發 3 筆後：</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"># nats consumer info orders worker →</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">#     Unprocessed Messages: 3</span></span></span></code></pre></div><p>拉出訊息但不 ack、consumer info 的 <code>Outstanding Acks</code> 反映已投遞但未確認的數量：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">        Outstanding Acks: 3 out of maximum 1,000</span></span></code></pre></div><p>這兩個數字是診斷 consumer 健康的第一手訊號：<code>Unprocessed</code> 高代表 consumer 拉得太慢或停了（stream backlog）；<code>Outstanding Acks</code> 持續高代表訊息拉出去了但處理端沒 ack（處理慢或卡住）。這個區分對應 overview 排錯段的「pending 是 ack-pending 還是 stream backlog」判讀。</p>
<h3 id="ackwait--maxdeliver重投的兩個邊界">AckWait + MaxDeliver：重投的兩個邊界</h3>
<p>AckWait 是 server 等待 ack 的時間窗：訊息投遞後、若 AckWait 內沒收到 ack、server 視為投遞失敗、重新投遞。MaxDeliver 是同一筆訊息的投遞次數上限：達到後不再重投、訊息進入 terminal 狀態（可導向 advisory / DLQ 機制）。</p>
<p>這兩個旋鈕共同定義重投行為。AckWait 要設成 <em>略大於 consumer 處理一筆訊息的 p99 時間</em>：太短會在 consumer 還在正常處理時就誤判失敗重投、造成重複處理（見故障演練 Case 1）；太長會讓真正卡死的訊息遲遲不重投、拖慢 recovery。MaxDeliver 是 poison message 的護欄：一筆訊息若處理永遠失敗（例如 payload 格式壞）、沒有 MaxDeliver 它會無限重投佔住 consumer。對應 <a href="/blog/backend/knowledge-cards/redelivery-loop/" data-link-title="Redelivery Loop" data-link-desc="說明同一訊息反覆投遞失敗如何消耗 consumer 容量">Redelivery Loop</a> 知識卡描述的失控重投。</p>
<h3 id="replayinstant-vs-original">Replay：instant vs original</h3>
<p>Replay policy 只在 consumer 從歷史位置讀（例如 <code>--deliver all</code> 重讀整個 stream）時生效、決定投遞節奏。<code>instant</code> 以 server 最快速度投遞、是處理 backlog 或重建狀態的預設。<code>original</code> 按訊息 <em>原始寫入的時間間隔</em> 重放：若原始訊息間隔 1 秒寫入、replay 也間隔 1 秒投遞、用於需要重現時序的測試或模擬。實機兩種都可建：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">nats consumer add orders replayorig ... --replay original  <span class="c1"># Replay Policy: Original</span></span></span></code></pre></div><h2 id="cluster--supercluster--leaf-node三層拓樸">Cluster / Supercluster / Leaf node：三層拓樸</h2>
<p>NATS 的拓樸分三層、各解一個不同尺度的問題：Cluster 解單區內的高可用、Supercluster 解跨區的延展、Leaf node 解邊緣到中心的連接。三者可組合、但職責不重疊。</p>
<h3 id="cluster單區-raft-高可用">Cluster：單區 Raft 高可用</h3>
<p>Cluster 是同一 region 內多個 NATS server 用 full mesh route 互連、JetStream 的 stream 透過 Raft 在多個 replica 間複製。Replica 數（R1 / R3 / R5）決定容錯：R3 容忍 1 節點失效、R5 容忍 2 節點。Raft 要求多數派（quorum）才能寫入、所以 R3 需要至少 2 節點健康。</p>
<p>實機用 3 節點 docker compose 起 cluster、建 R3 stream、stream info 顯示 Raft group 與 replica 狀態：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">nats --server nats://n1:4222 stream add rep3 <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --subjects <span class="s1">&#39;rep3.&gt;&#39;</span> --storage file --retention limits <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --discard old --max-msgs <span class="m">1000</span> --replicas <span class="m">3</span> --defaults</span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">                     Replicas: 3
</span></span><span class="line"><span class="ln">2</span><span class="cl">Cluster Information:
</span></span><span class="line"><span class="ln">3</span><span class="cl">                Cluster Group: S-R3F-unEqlH8C
</span></span><span class="line"><span class="ln">4</span><span class="cl">                       Leader: n2 (222ms)
</span></span><span class="line"><span class="ln">5</span><span class="cl">                      Replica: n1, current, seen 217ms ago
</span></span><span class="line"><span class="ln">6</span><span class="cl">                      Replica: n3, current, seen 219ms ago</span></span></code></pre></div><p>Leader 是 Raft 選出的寫入協調者、其餘 replica 跟隨。<code>current</code> 代表該 replica 與 leader 同步；落後會顯示 <code>outdated</code> 加落後的 operation 數。失去 quorum 的行為見故障演練 Case 4。</p>
<h3 id="supercluster跨區-gateway-延展">Supercluster：跨區 gateway 延展</h3>
<p>Supercluster 用 gateway 連接多個 Cluster、形成跨 region / 跨雲的單一 NATS 邏輯網路。Gateway 之間是按需轉發、不是 full mesh：訊息只在有訂閱者的 region 之間流動、避免跨區頻寬被無謂的全量複製吃掉。Supercluster 讓 publisher 在任一 region 發訊息、訂閱者在另一 region 收到、同時讓每個 Cluster 維持自己的 JetStream Raft 群組與本地高可用。</p>
<blockquote>
<p>以下 Supercluster 行為依 <a href="https://docs.nats.io/running-a-nats-service/configuration/gateways">NATS 官方文件</a> 描述、未在本文實機環境驗證（gateway 多區拓樸需要跨 region 部署）。</p></blockquote>
<p><a href="/blog/backend/03-message-queue/cases/nats-form3-multi-cloud-payments/" data-link-title="3.C35 Form3：NATS JetStream 多雲低延遲支付" data-link-desc="Form3 服務 Tier-1 銀行、500ms SLA、SNS/SQS 吃 300ms 預算、改 NATS&#43;JetStream 跨雲 6x 延遲改善。">3.C35 Form3</a> 是 Leaf node 跨雲橋接的代表案例（Supercluster 為相應的一般拓樸選項、case 本身明確點到的是 Leaf node）：服務 Tier-1 銀行、要求 500ms 端到端 SLA、AWS SNS/SQS 約 300ms 延遲吃掉預算。Form3 用 JetStream 跨雲橋接、達到約 6× 延遲改善、並做到「AWS 整個 region 掛掉時不喪失處理能力」。這個案例揭露的判讀是：金融支付的硬 latency 預算逼出特定拓樸選型、不是把 Kafka / SQS 通用化套上去。</p>
<h3 id="leaf-node邊緣連中心">Leaf node：邊緣連中心</h3>
<p>Leaf node 是輕量 NATS server、跑在邊緣（工廠、店面、IoT gateway）、透過單一 leaf connection 連回中心 hub。它在邊緣本地提供完整的 NATS / JetStream 能力（本地 publish / subscribe / 本地持久化）、同時把需要的 subject 透過 leaf connection 雙向橋接到 hub。Leaf node 的價值在於：邊緣到中心的網路斷線時、邊緣端的本地 JetStream 持續收訊息、連線恢復後再同步、不丟資料。</p>
<blockquote>
<p>以下 Leaf node 行為依 <a href="https://docs.nats.io/running-a-nats-service/configuration/leafnodes">NATS 官方文件</a> 與下列 case 描述、未在本文實機環境驗證（leaf 拓樸需要 hub + edge 雙端部署）。</p></blockquote>
<p><a href="/blog/backend/03-message-queue/cases/nats-machinemetrics-edge-to-cloud/" data-link-title="3.C37 MachineMetrics：邊緣到雲端工廠資料管線" data-link-desc="MachineMetrics 跨數百工廠、數千機台、1000Hz 採樣、Kinesis 無法跑在 edge、改 NATS Leaf Node &#43; JetStream &#43; KV &#43; Object Store。">3.C37 MachineMetrics</a> 是 Leaf node 邊緣到雲端的完整案例：跨數百客戶廠區、數千機台、單機最高 1000Hz 採樣、工廠網路斷斷續續、Kinesis 等 cloud-only 工具無法跑在資源受限 edge。MachineMetrics 用 Leaf node 做 hub-and-spoke、edge 端用 JetStream 做本地持久化抵抗斷線。這個案例揭露的判讀是：broker 的功能集合（messaging + 本地持久化 + KV + Object Store + auth）決定它能不能取代邊緣的多套工具。</p>
<p><a href="/blog/backend/03-message-queue/cases/nats-iflow-ot-it-integration/" data-link-title="3.C41 i-flow：NATS 做 OT/IT 跨層整合 bus" data-link-desc="i-flow 每日 4 億筆 data operation、200&#43; OT/IT connector、客戶含 Bosch / Sto / Lenze、NATS 當邊緣到 central 整合 bus。">3.C41 i-flow</a> 是多工廠 leaf node 拓樸的另一證據：每日 4 億筆 data operation、200+ OT/IT connector、用 leaf node hub-and-spoke 把多工廠接到 central、而不是每工廠自管一套 cluster。判讀：多工廠場景的運維成本由「每個邊緣點是不是要獨立維運一套 cluster」決定、leaf node 把邊緣端壓到單一 server。</p>
<h2 id="subject-based-acl-與多租戶">Subject-based ACL 與多租戶</h2>
<p>NATS 多租戶的主機制是 account：account 是完全隔離的 subject 命名空間、不同 account 之間預設互不可見、即使 subject 名稱相同也不會互通。Account 之內再用 subject-level permission 控制每個 user 能 publish / subscribe 哪些 subject。這兩層組合起來：account 給租戶硬隔離、subject permission 給租戶內的角色細分權限。</p>
<p>跨 account 的受控互通用 import / export：一個 account 把特定 subject export 出來、另一個 account 顯式 import、才會打通那條 subject。預設不通、互通是顯式授權的結果、這讓多租戶的資料流動可審計。對應 MachineMetrics 案例用 decentralized auth 隔離不同客戶廠區的設計：每個客戶是一個 account、廠區設備在 account 內用 subject permission 限定只能發自己廠區的 subject。</p>
<p>多租戶設計的判讀訊號：租戶之間要完全隔離、用 account；同租戶內的不同服務 / 角色要限權、用 subject permission；少數需要跨租戶共享的 subject（例如全域控制信號）、用 import / export 顯式打通、不要為了方便把不同租戶塞進同 account。</p>
<h2 id="production-故障演練">Production 故障演練</h2>
<p>deep article 的差異化價值在故障演練。以下四個都是 JetStream stream / consumer / 拓樸層的典型事故、前兩個有本文實機驗證、後兩個結合實機（quorum）與 case 敘述。</p>
<h3 id="case-1ackwait-太短造成重複處理">Case 1：AckWait 太短造成重複處理</h3>
<p><strong>徵兆</strong>：consumer 正常運行、處理邏輯沒報錯、但下游出現大量重複副作用（重複扣款、重複寄信、重複寫入）。consumer info 的 <code>Redelivered Messages</code> 持續上升、即使處理端沒有任何 exception。</p>
<p><strong>根因</strong>：AckWait 設得比 consumer 處理一筆訊息的實際耗時短。訊息投遞後 consumer 還在處理、AckWait 就到期、server 判定投遞失敗、把同一筆訊息重投給（可能是另一個）consumer 實例、於是同一筆訊息被處理兩次。實機重現：建一個 AckWait 1s 的 consumer、拉出訊息不 ack、過 1s 後再拉、<code>tries</code> 從 1 變 2：</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">第一次拉：subj: orders.created.us-1 / tries: 1 / str seq: 1
</span></span><span class="line"><span class="ln">2</span><span class="cl">過 1s 後：subj: orders.created.us-1 / tries: 2 / str seq: 1
</span></span><span class="line"><span class="ln">3</span><span class="cl">consumer info → Redelivered Messages: 3</span></span></code></pre></div><p><strong>修法</strong>：</p>
<ol>
<li><strong>量測再設值</strong>：AckWait 設成 consumer 處理 p99 時間的 2-3 倍、而不是拍腦袋設 30s。處理一筆要 5s 的 worker 配 AckWait 30s、處理一筆要 45s 的 worker 配 AckWait 30s 就會持續誤判重投。</li>
<li><strong>長任務用 in-progress ack</strong>：處理時間本就偏長且方差大的任務、處理端在處理中定期送 <code>AckProgress</code>（working ack）延長 AckWait、而不是把 AckWait 設成一個無法涵蓋最壞情況的固定大值。</li>
<li><strong>處理端做冪等</strong>：at-least-once 投遞下重複是常態而非異常、副作用以業務 key 去重（對應 <a href="/blog/backend/knowledge-cards/processing-semantics/" data-link-title="Processing Semantics" data-link-desc="說明 consumer 處理事件後業務結果是否正確，與投遞成功分屬不同責任">Processing Semantics</a> 的冪等要求）。AckWait 只能降低重複頻率、不能消除重複。</li>
</ol>
<h3 id="case-2discard-policy-選錯靜默丟訊息">Case 2：discard policy 選錯靜默丟訊息</h3>
<p><strong>徵兆</strong>：上游 publisher 一切正常、沒收到任何 error、但下游 consumer 發現訊息有缺口（seq 跳號）、或最舊的歷史訊息神祕消失。對帳時帳目對不上、但日誌裡找不到任何失敗紀錄。</p>
<p><strong>根因</strong>：兩種情況。其一、stream 用 <code>discard old</code>、流量超過 MaxMsgs / MaxBytes、最舊的訊息被靜默丟棄騰空間——這在「事件 log 需要完整 replay」的場景是資料遺失。其二、stream 用 <code>discard new</code>、滿了之後新訊息被拒、但 publisher 用的是 <em>Core NATS publish</em>（不等 stream ack）、所以 publisher 端看到「發送成功」、訊息其實沒進 stream。實機重現後者的危險：對一個 discard new 已滿的 stream 用 Core pub 與 JetStream-aware pub、結果完全不同：</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">Core pub（不等 ack）：    Published 8 bytes to &#34;dnew.x&#34;        ← 看似成功、實際丟失
</span></span><span class="line"><span class="ln">2</span><span class="cl">JetStream pub（等 ack）： nats: error: maximum messages exceeded (10077)  ← 正確報錯</span></span></code></pre></div><p><strong>修法</strong>：</p>
<ol>
<li><strong>publisher 一律用 JetStream-aware publish</strong>：等 stream 的 PubAck 回來才算發送成功、才能在 stream 滿、quorum 失效、subject 不匹配時收到明確 error。用 Core pub 發進 JetStream subject 等於放棄所有投遞保證。</li>
<li><strong>discard policy 對齊業務語意</strong>：事件 log（需要完整歷史）配 limits + 充足 MaxAge、絕不靠 discard old 當容量控制；任務佇列配 discard new + 上游 backpressure、滿了就讓 producer 慢下來而不是擠掉排隊任務。</li>
<li><strong>監控 discard 計數</strong>：stream 的 discard 不是錯誤狀態、不會觸發 alert。要主動監控訊息 seq 連續性與 stream 的訊息移除速率、把「非預期的 discard」變成可觀測訊號。</li>
</ol>
<h3 id="case-3leaf-node-斷線重連">Case 3：Leaf node 斷線重連</h3>
<p><strong>徵兆</strong>：邊緣端（工廠 / 店面）到中心 hub 的網路抖動、leaf connection 反覆斷開重連、hub 端看到某些 subject 的訊息延遲尖刺、邊緣端 reconnect 計數持續累加。網路恢復後、邊緣累積的訊息一次湧入 hub、造成 hub 端短暫的處理尖峰。</p>
<p><strong>根因</strong>：邊緣到中心是廣域網、品質不如資料中心內網。Leaf connection 斷線期間、邊緣端的本地 JetStream 持續收訊息並本地持久化（這正是 leaf node 的設計目的）；連線恢復後、累積的 backlog 一次同步到 hub、形成尖峰。若邊緣端沒有本地 JetStream、斷線期間的訊息直接丟失。</p>
<blockquote>
<p>以下根因與修法依 NATS 官方 leaf node 文件與 <a href="/blog/backend/03-message-queue/cases/nats-machinemetrics-edge-to-cloud/" data-link-title="3.C37 MachineMetrics：邊緣到雲端工廠資料管線" data-link-desc="MachineMetrics 跨數百工廠、數千機台、1000Hz 採樣、Kinesis 無法跑在 edge、改 NATS Leaf Node &#43; JetStream &#43; KV &#43; Object Store。">MachineMetrics</a> / <a href="/blog/backend/03-message-queue/cases/nats-iflow-ot-it-integration/" data-link-title="3.C41 i-flow：NATS 做 OT/IT 跨層整合 bus" data-link-desc="i-flow 每日 4 億筆 data operation、200&#43; OT/IT connector、客戶含 Bosch / Sto / Lenze、NATS 當邊緣到 central 整合 bus。">i-flow</a> case 描述、未在本文實機環境驗證。</p></blockquote>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>邊緣端必開本地 JetStream</strong>：把斷線容忍從「依賴網路不斷」改成「斷線期間本地持久化、恢復後同步」。這是 MachineMetrics 用 edge JetStream 取代 SQLite 的核心理由——工廠網路斷斷續續是常態、不是異常。</li>
<li><strong>hub 端對同步尖峰做 flow control</strong>：恢復連線後的 backlog 同步用 consumer 端的 pull batch 限速、避免邊緣 backlog 一次打爆 hub 的處理能力。</li>
<li><strong>監控 reconnect 與 latency</strong>：leaf 連線的 reconnect 次數與 subject mapping latency 是邊緣網路品質的直接訊號（對應 overview 排錯段「leaf node 連線不穩」）。reconnect 頻繁代表網路或 hub 容量要處理、不是調 leaf 參數能解。</li>
</ol>
<h3 id="case-4stream-replica-失去-quorum">Case 4：Stream replica 失去 quorum</h3>
<p><strong>徵兆</strong>：R3 stream 突然無法寫入、publisher 的 JetStream publish 卡住後回 <code>no responders available</code>；stream info 顯示 <code>Leader:</code> 欄位空白、多數 replica 標 OFFLINE。讀取可能還能從存活節點拿到舊資料、但寫入完全停擺。</p>
<p><strong>根因</strong>：JetStream 的 stream 用 Raft 複製、寫入需要多數派確認。R3 stream 需要至少 2 節點健康才有 quorum；同時失去 2 節點就只剩 1 節點、達不到多數、Raft 無法選出 leader、stream 變成無法寫入。實機重現：3 節點 cluster 的 R3 stream、停掉 2 個節點、stream info 顯示無 leader、JetStream publish 報錯：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">停 2 節點後 stream info：
</span></span><span class="line"><span class="ln">2</span><span class="cl">                       Leader:
</span></span><span class="line"><span class="ln">3</span><span class="cl">                      Replica: n1, current, seen 3.35s ago
</span></span><span class="line"><span class="ln">4</span><span class="cl">                      Replica: n2, outdated, OFFLINE, not seen
</span></span><span class="line"><span class="ln">5</span><span class="cl">                      Replica: n3, outdated, OFFLINE, not seen
</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">此時 JetStream publish：
</span></span><span class="line"><span class="ln">8</span><span class="cl">                      nats: error: nats: no responders available for request</span></span></code></pre></div><p>恢復 1 個節點（回到 2/3 多數）後、Raft 立即重選 leader、stream 恢復可寫：</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">啟動 n2 後：
</span></span><span class="line"><span class="ln">2</span><span class="cl">                       Leader: n1 (506ms)
</span></span><span class="line"><span class="ln">3</span><span class="cl">                      Replica: n2, current, seen 499ms ago
</span></span><span class="line"><span class="ln">4</span><span class="cl">                      Replica: n3, outdated, OFFLINE, not seen, 4 operations behind</span></span></code></pre></div><p><strong>修法</strong>：</p>
<ol>
<li><strong>replica 數對齊容錯目標</strong>：要容忍 1 節點失效用 R3、容忍 2 節點用 R5；不要為了省資源把關鍵 stream 設 R1（單點、節點掛了 stream 直接不可用）。</li>
<li><strong>replica 跨 failure domain 散開</strong>：R3 的 3 個 replica 要落在不同 availability zone / rack、避免單一 AZ 故障同時帶走 2 個 replica 直接失去 quorum。</li>
<li><strong>監控 replica 健康而非只看 leader</strong>：stream info 的每個 replica 的 <code>current</code> / <code>outdated</code> / <code>OFFLINE</code> 狀態是 quorum 餘裕的直接訊號。R3 已經有 1 個 replica OFFLINE 時 quorum 餘裕只剩 0、要當成 P1 處理、不能等到第 2 個也掛才反應（對應 overview 排錯段「JetStream raft 不一致」）。</li>
</ol>
<h2 id="容量與規模判讀">容量與規模判讀</h2>
<p>JetStream 的配置在不同規模下適用性不同、超出範圍要換拓樸而非調參數。</p>
<table>
  <thead>
      <tr>
          <th>規模訊號</th>
          <th>適用拓樸</th>
          <th>換檔訊號</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>單區、中等吞吐、需要 HA</td>
          <td>單 Cluster R3</td>
          <td>單區頻寬 / 節點數撐不住 → 加節點 reshard 或拆 stream</td>
      </tr>
      <tr>
          <td>跨 region / 跨雲、訂閱者分散各區</td>
          <td>Supercluster（多 Cluster + gateway）</td>
          <td>需要邊緣本地持久化 → 疊加 Leaf node</td>
      </tr>
      <tr>
          <td>大量邊緣點、網路不穩、邊緣要本地能力</td>
          <td>Leaf node hub-and-spoke</td>
          <td>邊緣點 &gt; 數百、每點要獨立運維 → 評估 managed（Synadia）</td>
      </tr>
  </tbody>
</table>
<p><strong>單 Cluster R3</strong> 是多數中等規模服務的起點：單區內高可用、JetStream Raft 處理節點故障、運維只有一套 cluster。撞到天花板的訊號是單區頻寬或單節點 disk / CPU 到上限、此時先評估加節點重分配或把熱 stream 拆出去、而不是急著上 supercluster。</p>
<p><strong>Supercluster</strong> 在訂閱者地理分散、或要求單區整個掛掉仍能服務時才值得引入。它的成本是跨區 gateway 的運維複雜度與跨區頻寬、不該為了「以後可能要跨區」提前鋪。Form3 的判讀是硬 SLA（500ms、region 全掛仍可用）逼出來的、不是預設架構。</p>
<p><strong>Leaf node hub-and-spoke</strong> 在邊緣點多、邊緣網路不穩、邊緣要本地持久化 / KV / 計算能力時適用。當邊緣點數量大到每點獨立運維成本不可接受、評估走 managed NATS（Synadia Cloud）把運維外包、而不是自建更大的 hub。</p>
<h2 id="整合與下一步">整合與下一步</h2>
<p>本文聚焦 JetStream stream / consumer / 拓樸的 implementation；以下是往上下游的銜接。</p>
<h3 id="回-vendor-overview-與相鄰章節">回 vendor overview 與相鄰章節</h3>
<ul>
<li>上游 vendor 頁：<a href="/blog/backend/03-message-queue/vendors/nats/" data-link-title="NATS" data-link-desc="Lightweight messaging、JetStream 加持久化與 streams">NATS overview</a>——Core NATS vs JetStream 的選型判讀、排錯快速判讀、何時改走其他 broker</li>
<li>跨 vendor consumer 設計：<a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計</a>——本文的 pull/push、ack、重投放回語言無關的 consumer 設計框架</li>
<li>投遞與處理語意基礎：<a href="/blog/backend/knowledge-cards/delivery-semantics/" data-link-title="Delivery Semantics" data-link-desc="說明事件投遞語意如何定義遺失、重複、順序與補償策略">Delivery Semantics</a> / <a href="/blog/backend/knowledge-cards/processing-semantics/" data-link-title="Processing Semantics" data-link-desc="說明 consumer 處理事件後業務結果是否正確，與投遞成功分屬不同責任">Processing Semantics</a> / <a href="/blog/backend/knowledge-cards/redelivery-loop/" data-link-title="Redelivery Loop" data-link-desc="說明同一訊息反覆投遞失敗如何消耗 consumer 容量">Redelivery Loop</a> 知識卡</li>
</ul>
<h3 id="對應-case">對應 case</h3>
<ul>
<li><a href="/blog/backend/03-message-queue/cases/nats-form3-multi-cloud-payments/" data-link-title="3.C35 Form3：NATS JetStream 多雲低延遲支付" data-link-desc="Form3 服務 Tier-1 銀行、500ms SLA、SNS/SQS 吃 300ms 預算、改 NATS&#43;JetStream 跨雲 6x 延遲改善。">3.C35 Form3</a>——Supercluster + Leaf node 跨雲低延遲支付、硬 SLA 驅動拓樸</li>
<li><a href="/blog/backend/03-message-queue/cases/nats-machinemetrics-edge-to-cloud/" data-link-title="3.C37 MachineMetrics：邊緣到雲端工廠資料管線" data-link-desc="MachineMetrics 跨數百工廠、數千機台、1000Hz 採樣、Kinesis 無法跑在 edge、改 NATS Leaf Node &#43; JetStream &#43; KV &#43; Object Store。">3.C37 MachineMetrics</a>——Leaf node + edge JetStream + KV + Object Store + 多租戶 auth 的完整邊緣案例</li>
<li><a href="/blog/backend/03-message-queue/cases/nats-iflow-ot-it-integration/" data-link-title="3.C41 i-flow：NATS 做 OT/IT 跨層整合 bus" data-link-desc="i-flow 每日 4 億筆 data operation、200&#43; OT/IT connector、客戶含 Bosch / Sto / Lenze、NATS 當邊緣到 central 整合 bus。">3.C41 i-flow</a>——多工廠 leaf node hub-and-spoke、運維成本驅動拓樸選型</li>
</ul>
<h3 id="後續可深入的議題">後續可深入的議題</h3>
<ul>
<li><strong>JetStream KV / Object Store</strong>：基於 stream 的 key-value 與 blob 儲存、何時用 NATS KV vs 真的 KV 服務（Redis / etcd）、見 overview 進階主題段</li>
<li><strong>Leaf node 多節點實機驗證</strong>：本文 Supercluster / Leaf node 段以 case + 官方文件敘述；補一篇 hub + edge 雙端 compose 的實機演練（含斷線注入、backlog 同步觀測）是自然延伸</li>
<li><strong>Subject mapping 與 transform</strong>：leaf node 跨層的 subject 重映射、跨 account import / export 的細部配置</li>
</ul>
]]></content:encoded></item><item><title>Redis Streams XCLAIM / PEL 失敗接管與 Cluster 影響</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/redis-streams/xclaim-pel-recovery/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/redis-streams/xclaim-pel-recovery/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/redis-streams/" data-link-title="Redis Streams" data-link-desc="Redis 生態內的 streams、append-only log &amp;#43; consumer group">Redis Streams&lt;/a> overview 的 implementation-layer deep article。Overview 給選型與最短路徑、本文聚焦「consumer crash 之後、卡在 PEL 的訊息怎麼回到處理流程」這條 implementation flow。實機輸出來自 &lt;code>redis:7&lt;/code>（7.4.9）單節點。&lt;/p>&lt;/blockquote>
&lt;h2 id="consumer-crash-後訊息卡在哪裡">consumer crash 後、訊息卡在哪裡&lt;/h2>
&lt;p>Redis Streams 的 consumer group 設計是「先投遞、後 ack」：&lt;code>XREADGROUP&lt;/code> 把 entry 投給某個 consumer 的同時、entry 進入該 group 的 &lt;strong>PEL（Pending Entries List）&lt;/strong>、標記為「已投遞、未確認」。consumer 處理完才呼叫 &lt;code>XACK&lt;/code> 把 entry 移出 PEL。這一段「已投遞未 ack」的視窗、是 Redis Streams 提供 at-least-once 的全部依據。&lt;/p>
&lt;p>問題在於 consumer crash 時機落在這個視窗內。consumer 已經拿到訊息、PEL 已經記了它的名字、但它在 ack 之前就死了。Redis 沒有 broker 級的「重新投遞」背景程序——不像 RabbitMQ consumer 斷線後 unacked 訊息自動 requeue。Redis 把這筆訊息留在 PEL、owner 仍是那個死掉的 consumer、然後什麼都不做。要讓這筆訊息回到處理流程、只有 application 主動呼叫 &lt;code>XCLAIM&lt;/code> 或 &lt;code>XAUTOCLAIM&lt;/code> 改寫 owner。&lt;/p>
&lt;p>這就是 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/redis-streams-bitso-reliable-streams/" data-link-title="3.C42 Bitso：Reliable Redis Streams 抽象 &amp;#43; 自建 DLQ" data-link-desc="Bitso 加密交易所、千 msg/sec/stream &amp;#43; 亞毫秒延遲、自建 Reliable Streams 封裝 PEL &amp;#43; retry &amp;#43; DLQ、idempotent processing。">Bitso 自建 Reliable Streams 抽象&lt;/a> 揭露的核心事實：Redis Streams 是「資料結構」、不是「broker 系統」、可靠性責任在 application 層。本文展開的就是這個責任的具體形狀——PEL 怎麼累積、怎麼判讀、接管機制怎麼運作、以及哪些操作會讓接管失效。&lt;/p>
&lt;h2 id="pel-機制xreadgroup-進xack-出">PEL 機制：XREADGROUP 進、XACK 出&lt;/h2>
&lt;p>PEL 是 per-group 的結構、記錄每個 entry 的四個欄位：entry ID、目前 owner consumer、idle time（距上次投遞的毫秒數）、delivery count（被投遞過幾次）。先用實機輸出建立基礎。寫入 5 筆、建 group、兩個 consumer 各讀一部分：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">$ redis-cli XADD mystream &lt;span class="s1">&amp;#39;*&amp;#39;&lt;/span> event order_1 amount &lt;span class="m">100&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">1781584105202-0
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="c1"># ... order_2 ~ order_5、各得遞增 entry ID&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">$ redis-cli XGROUP CREATE mystream g1 &lt;span class="m">0&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">OK
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">$ redis-cli XREADGROUP GROUP g1 c1 COUNT &lt;span class="m">3&lt;/span> STREAMS mystream &lt;span class="s1">&amp;#39;&amp;gt;&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="c1"># c1 拿到 order_1 / order_2 / order_3&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">$ redis-cli XREADGROUP GROUP g1 c2 COUNT &lt;span class="m">10&lt;/span> STREAMS mystream &lt;span class="s1">&amp;#39;&amp;gt;&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="c1"># c2 拿到 order_4 / order_5&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>'&amp;gt;'&lt;/code> 代表「只取從未投遞給本 group 的新訊息」。投遞後這 5 筆全進 PEL。&lt;code>XPENDING&lt;/code> 的 summary 形式給總覽：&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/03-message-queue/vendors/redis-streams/" data-link-title="Redis Streams" data-link-desc="Redis 生態內的 streams、append-only log &#43; consumer group">Redis Streams</a> overview 的 implementation-layer deep article。Overview 給選型與最短路徑、本文聚焦「consumer crash 之後、卡在 PEL 的訊息怎麼回到處理流程」這條 implementation flow。實機輸出來自 <code>redis:7</code>（7.4.9）單節點。</p></blockquote>
<h2 id="consumer-crash-後訊息卡在哪裡">consumer crash 後、訊息卡在哪裡</h2>
<p>Redis Streams 的 consumer group 設計是「先投遞、後 ack」：<code>XREADGROUP</code> 把 entry 投給某個 consumer 的同時、entry 進入該 group 的 <strong>PEL（Pending Entries List）</strong>、標記為「已投遞、未確認」。consumer 處理完才呼叫 <code>XACK</code> 把 entry 移出 PEL。這一段「已投遞未 ack」的視窗、是 Redis Streams 提供 at-least-once 的全部依據。</p>
<p>問題在於 consumer crash 時機落在這個視窗內。consumer 已經拿到訊息、PEL 已經記了它的名字、但它在 ack 之前就死了。Redis 沒有 broker 級的「重新投遞」背景程序——不像 RabbitMQ consumer 斷線後 unacked 訊息自動 requeue。Redis 把這筆訊息留在 PEL、owner 仍是那個死掉的 consumer、然後什麼都不做。要讓這筆訊息回到處理流程、只有 application 主動呼叫 <code>XCLAIM</code> 或 <code>XAUTOCLAIM</code> 改寫 owner。</p>
<p>這就是 <a href="/blog/backend/03-message-queue/cases/redis-streams-bitso-reliable-streams/" data-link-title="3.C42 Bitso：Reliable Redis Streams 抽象 &#43; 自建 DLQ" data-link-desc="Bitso 加密交易所、千 msg/sec/stream &#43; 亞毫秒延遲、自建 Reliable Streams 封裝 PEL &#43; retry &#43; DLQ、idempotent processing。">Bitso 自建 Reliable Streams 抽象</a> 揭露的核心事實：Redis Streams 是「資料結構」、不是「broker 系統」、可靠性責任在 application 層。本文展開的就是這個責任的具體形狀——PEL 怎麼累積、怎麼判讀、接管機制怎麼運作、以及哪些操作會讓接管失效。</p>
<h2 id="pel-機制xreadgroup-進xack-出">PEL 機制：XREADGROUP 進、XACK 出</h2>
<p>PEL 是 per-group 的結構、記錄每個 entry 的四個欄位：entry ID、目前 owner consumer、idle time（距上次投遞的毫秒數）、delivery count（被投遞過幾次）。先用實機輸出建立基礎。寫入 5 筆、建 group、兩個 consumer 各讀一部分：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl">$ redis-cli XADD mystream <span class="s1">&#39;*&#39;</span> event order_1 amount <span class="m">100</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">1781584105202-0
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"># ... order_2 ~ order_5、各得遞增 entry ID</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">$ redis-cli XGROUP CREATE mystream g1 <span class="m">0</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">OK
</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">$ redis-cli XREADGROUP GROUP g1 c1 COUNT <span class="m">3</span> STREAMS mystream <span class="s1">&#39;&gt;&#39;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"># c1 拿到 order_1 / order_2 / order_3</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">$ redis-cli XREADGROUP GROUP g1 c2 COUNT <span class="m">10</span> STREAMS mystream <span class="s1">&#39;&gt;&#39;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"># c2 拿到 order_4 / order_5</span></span></span></code></pre></div><p><code>'&gt;'</code> 代表「只取從未投遞給本 group 的新訊息」。投遞後這 5 筆全進 PEL。<code>XPENDING</code> 的 summary 形式給總覽：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">$ redis-cli XPENDING mystream g1
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="m">5</span>                  <span class="c1"># PEL 總數</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">1781584105202-0    <span class="c1"># 最小 pending ID</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">1781584105578-0    <span class="c1"># 最大 pending ID</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">c1                 <span class="c1"># 各 consumer 的 pending 數</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="m">3</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">c2
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="m">2</span></span></span></code></pre></div><p>5 筆全在 PEL、c1 扛 3 筆、c2 扛 2 筆。展開形式 <code>XPENDING &lt;key&gt; &lt;group&gt; - + &lt;count&gt;</code> 給每筆細節：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">$ redis-cli XPENDING mystream g1 - + <span class="m">10</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">1781584105202-0  c1  <span class="m">6318</span>  <span class="m">1</span>    <span class="c1"># entry ID / owner / idle ms / delivery count</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">1781584105278-0  c1  <span class="m">6318</span>  <span class="m">1</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">1781584105373-0  c1  <span class="m">6318</span>  <span class="m">1</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">1781584105466-0  c2  <span class="m">6224</span>  <span class="m">1</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">1781584105578-0  c2  <span class="m">6224</span>  <span class="m">1</span></span></span></code></pre></div><p><code>idle</code> 是 6318ms（距投遞已過 6.3 秒）、<code>delivery count</code> 都是 1（只投過一次）。這兩個數字是後面接管決策的核心輸入：idle 判斷「owner 是不是死了」、delivery count 判斷「這筆是不是 <a href="/blog/backend/knowledge-cards/poison-message-quarantine/" data-link-title="Poison-Message Quarantine" data-link-desc="說明把毒訊息從主處理路徑隔離出來的機制，讓正常訊息繼續前進">poison message</a>」。</p>
<p><code>XACK</code> 把處理完的 entry 移出 PEL：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl">$ redis-cli XACK mystream g1 1781584105202-0
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="m">1</span>                  <span class="c1"># 成功移除 1 筆</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">$ redis-cli XPENDING mystream g1
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="m">4</span>                  <span class="c1"># PEL 剩 4 筆</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">1781584105278-0
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">1781584105578-0
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">c1
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="m">2</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">c2
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="m">2</span></span></span></code></pre></div><p>PEL 從 5 降到 4。判讀原則固定：<strong>PEL 持續成長就是 consumer 健康訊號異常</strong>——不是 crash 沒 ack、就是處理速度跟不上、再不然是 ACK 程式碼漏寫。三者用 idle time 區分：crash 的 entry idle 會單調成長、處理慢的 idle 在 timeout 附近震盪、漏 ACK 的 entry delivery count 停在 1 但 idle 無上限成長。</p>
<h2 id="xclaim-與-xautoclaim改寫-owner-的兩條路">XCLAIM 與 XAUTOCLAIM：改寫 owner 的兩條路</h2>
<p>接管的本質是把 PEL entry 的 owner 從死掉的 consumer 改成活著的 consumer。<code>XCLAIM</code> 是手動指定 entry ID 接管、<code>XAUTOCLAIM</code> 是自動掃 idle 超過門檻的 entry 批次接管。兩者都接受 min-idle-time 參數當安全閥。</p>
<p><code>XCLAIM &lt;key&gt; &lt;group&gt; &lt;new-consumer&gt; &lt;min-idle-time&gt; &lt;id...&gt;</code>：把指定 entry 改判給新 consumer、條件是該 entry 的 idle 已達 min-idle-time。下面用 min-idle-time 0（無條件接管）把 c1 的一筆轉給 c3：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl">$ redis-cli XCLAIM mystream g1 c3 <span class="m">0</span> 1781584105278-0
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">1781584105278-0
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">event
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">order_2
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">amount
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="m">200</span>               <span class="c1"># 回傳被接管 entry 的完整內容</span>
</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">$ redis-cli XPENDING mystream g1 - + <span class="m">10</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">1781584105278-0  c3  <span class="m">66</span>     <span class="m">2</span>    <span class="c1"># owner 變 c3、idle 歸零(66ms)、delivery count 升到 2</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">1781584105373-0  c1  <span class="m">14590</span>  <span class="m">1</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">1781584105466-0  c2  <span class="m">14496</span>  <span class="m">1</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">1781584105578-0  c2  <span class="m">14496</span>  <span class="m">1</span></span></span></code></pre></div><p>接管後三件事同時發生：owner 改成 c3、idle 重置（剛 claim、66ms）、<strong>delivery count 從 1 升到 2</strong>。delivery count 自增是接管機制留下的審計軌跡——一筆訊息 delivery count 累積到 5、10、代表它反覆被接管又反覆沒處理完、這就是 poison message 的訊號、該路由到隔離區（見 <a href="/blog/backend/knowledge-cards/recovery-semantics/" data-link-title="Recovery Semantics" data-link-desc="說明事件處理失敗後能否透過 replay、checkpoint 與補償重建正確狀態並驗證">recovery semantics</a> 與 <a href="/blog/backend/knowledge-cards/poison-message-quarantine/" data-link-title="Poison-Message Quarantine" data-link-desc="說明把毒訊息從主處理路徑隔離出來的機制，讓正常訊息繼續前進">poison message quarantine</a>）。</p>
<p><code>XAUTOCLAIM &lt;key&gt; &lt;group&gt; &lt;new-consumer&gt; &lt;min-idle-time&gt; &lt;start-id&gt;</code>（Redis 6.2+）省掉「先 XPENDING 找 ID、再逐筆 XCLAIM」兩步、一次掃描接管：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl">$ redis-cli XAUTOCLAIM mystream g1 c3 <span class="m">0</span> <span class="m">0</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">0-0                          <span class="c1"># 下次掃描的 cursor（0-0 代表掃完一輪）</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">1781584105278-0 ...          <span class="c1"># 接管的 entry 內容（order_2）</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">1781584105373-0 ...          <span class="c1"># order_3</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">1781584105466-0 ...          <span class="c1"># order_4</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">1781584105578-0 ...          <span class="c1"># order_5</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="o">(</span>empty array<span class="o">)</span>                <span class="c1"># 第三個回傳值：已從 stream 刪除的 entry ID 清單</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">$ redis-cli XPENDING mystream g1
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="m">4</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">1781584105278-0
</span></span><span class="line"><span class="ln">12</span><span class="cl">1781584105578-0
</span></span><span class="line"><span class="ln">13</span><span class="cl">c3                           <span class="c1"># 全部 4 筆 owner 變 c3</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="m">4</span></span></span></code></pre></div><p>一次呼叫把整個 group 的 idle 訊息全歸到 c3。<code>XAUTOCLAIM</code> 是 consumer crash 後接管的主力——consumer 在啟動或處理迴圈裡固定跑一輪 <code>XAUTOCLAIM</code>、把孤兒訊息撿回來。回傳的 cursor 支援分批（一次掃不完時帶 cursor 續掃）、第三個回傳值（被刪 entry 清單）對應後面 MAXLEN 修剪的故障。</p>
<h2 id="min-idle-time防止活-consumer-被搶單">min-idle-time：防止活 consumer 被搶單</h2>
<p>min-idle-time 不是裝飾參數、是接管機制的安全閥：它要求「只有 idle 超過門檻的 entry 才能被接管」。沒有這個門檻、兩個 consumer 會互相搶對方正在處理的訊息。</p>
<p>驗證搶單防護——剛被 c3 claim 的訊息 idle 很低、用 60 秒門檻去 claim 會落空：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">$ redis-cli XCLAIM mystream g1 c4 <span class="m">60000</span> 1781584105278-0
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="o">(</span>empty array<span class="o">)</span>               <span class="c1"># 回空：該 entry idle 未達 60000ms、c4 搶不到</span></span></span></code></pre></div><p>回空陣列代表 claim 失敗、owner 不變、訊息留在 c3 手上。這就是 min-idle-time 的作用：<strong>門檻 = 我願意相信 owner consumer 還活著的最長時間</strong>。</p>
<p>門檻設定是接管設計的核心取捨、沒有通用值、由訊息處理時間分佈決定。門檻設太短、正常處理中的訊息被當成孤兒搶走、變成多 consumer 重複處理同一筆。門檻設太長、真正 crash 的訊息要等很久才有人接管、recovery 延遲拉高。<a href="/blog/backend/03-message-queue/cases/redis-streams-harness-event-driven-state/" data-link-title="3.C44 Harness：CD 微服務 async state transfer" data-link-desc="Harness CD 平台用 Redis Streams 解 brittle HTTP、揭露監控缺口 / MAXLEN truncation / head-of-line blocking 三類問題。">Harness 的 event-driven 案例</a> 正是用 XAUTOCLAIM 重派來解 head-of-line blocking（慢訊息阻塞 consumer 進度）、並自設 redelivery 策略避免上述反覆搶單。實務基準是「門檻 &gt; p99 處理時間 + 安全係數」：若單筆處理 p99 是 2 秒、門檻設 30-60 秒、確保只有真的死掉（遠超正常處理時間）的 owner 才被接管。</p>
<p>接管後仍需 application 層去重。XCLAIM 改寫 owner、不代表原 consumer 真的沒處理完——它可能正在 ack 的瞬間被 claim、結果兩邊都處理一次。at-least-once 的去重責任永遠在 application、靠 <a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a> 兜底、這跟接管門檻設多準無關。</p>
<h2 id="memory-與-retentionmaxlen--xtrim-的取捨">Memory 與 retention：MAXLEN / XTRIM 的取捨</h2>
<p>Stream 是 append-only、不主動丟資料、佔用的 Redis 記憶體單調成長。retention 的唯一旋鈕是修剪：<code>MAXLEN</code>（保留最近 N 筆）或 <code>MINID</code>（保留 ID 大於某值的 entry）。可以在 <code>XADD</code> 寫入時順帶修剪、也可以用 <code>XTRIM</code> 獨立執行。</p>
<p>精確修剪 <code>MAXLEN =</code> 跟近似修剪 <code>MAXLEN ~</code> 的差別在性能。stream 內部是 radix tree of macro-nodes（每個 node 打包多筆 entry）。精確修剪要拆 node 才能剛好留 N 筆、近似修剪只刪「整個可以丟掉的 node」、留下的筆數會略多於 N、但省掉拆 node 的開銷。<code>~</code> 是 production 預設、<code>=</code> 只在需要嚴格上限時用：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">$ redis-cli XADD mystream MAXLEN <span class="s1">&#39;~&#39;</span> <span class="m">1000</span> <span class="s1">&#39;*&#39;</span> event order_6 amount <span class="m">600</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">1781584152570-0             <span class="c1"># 近似修剪：超過 ~1000 才整 node 刪</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">$ redis-cli XADD mystream MAXLEN <span class="s1">&#39;=&#39;</span> <span class="m">3</span> <span class="s1">&#39;*&#39;</span> event order_7 amount <span class="m">700</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">1781584152871-0
</span></span><span class="line"><span class="ln">5</span><span class="cl">$ redis-cli XLEN mystream
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="m">3</span>                           <span class="c1"># 精確修剪到剛好 3 筆</span></span></span></code></pre></div><p>stream 不受 <code>maxmemory-policy</code> eviction 管理——一般 key 在記憶體壓力下會被 evict、stream entry 不會。這代表 stream 是「只進不出、除非主動修剪」的記憶體成長源。<a href="/blog/backend/03-message-queue/cases/redis-streams-learning-com-event-source-retreat/" data-link-title="3.C46 Learning.com：Redis 事件源退場（反例）" data-link-desc="Learning.com 把 microservice event store 放 Redis、1 年累積 GB/週、AOF&#43;EBS 變 latency 痛點、退到 PostgreSQL。">Learning.com 把 Redis 當長期事件儲存、最終因成本與延遲退場</a> 就是沒設修剪上限的反例（該案例涵蓋 Redis 事件儲存整體、Stream 是其中一塊）：事件量每週以 GB 成長、AOF fsync 與 EBS I/O 變成 latency 痛點、最終退回 PostgreSQL。判讀訊號是 <code>MEMORY USAGE mystream</code> 對比實例 <code>maxmemory</code>、超過預算就調低 MAXLEN。</p>
<h2 id="故障演練">故障演練</h2>
<h3 id="case-1consumer-crash-後-pel-訊息卡死沒人接">Case 1：consumer crash 後 PEL 訊息卡死沒人接</h3>
<p><strong>徵兆</strong>：<code>XPENDING</code> 總數持續成長、某個 consumer 的 pending 數停在固定值不降、那些 entry 的 idle time 單調往上爬（幾分鐘、幾小時）、業務端對應的訊息「進了 stream 但沒被處理」。</p>
<p><strong>根因</strong>：consumer 進程 crash（OOM kill / 部署滾動 / panic）、留下的 PEL entry owner 仍是死掉的 consumer。Redis 不會自動重投——沒有任何背景程序會碰這些 entry。它們會永遠卡在 PEL、直到有人主動接管。新啟動的 consumer 用 <code>XREADGROUP ... '&gt;'</code> 只會拿到「從未投遞」的新訊息、不會碰到前任留下的孤兒。</p>
<p><strong>修法</strong>：consumer 啟動時跟處理迴圈裡固定跑 <code>XAUTOCLAIM</code>、把超過 idle 門檻的孤兒撿回來：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 每個 consumer 週期性執行、min-idle-time 設 60s</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">$ redis-cli XAUTOCLAIM mystream g1 self_consumer_id <span class="m">60000</span> <span class="m">0</span></span></span></code></pre></div><ol>
<li><strong>min-idle-time 設成 &gt; p99 處理時間 + 安全係數</strong>：避免把處理中的訊息誤判成孤兒（接 Case 2）。</li>
<li><strong>用回傳 cursor 分批掃</strong>：PEL 大時一次 <code>XAUTOCLAIM</code> 不掃完、帶 cursor 續掃、避免單次 block 太久。</li>
<li><strong>接管後檢查 delivery count</strong>：超過閾值（如 5）的 entry 不再處理、路由到 DLQ（Redis Streams 沒原生 DLQ、Bitso 自建一個 stream 當 DLQ）。</li>
<li><strong>監控 PEL 最大 idle</strong>：alert 設在「最老 pending entry 的 idle 超過 N 倍接管門檻」、代表接管機制本身停了。</li>
</ol>
<h3 id="case-2min-idle-time-設太短活-consumer-被搶單">Case 2：min-idle-time 設太短、活 consumer 被搶單</h3>
<p><strong>徵兆</strong>：同一筆訊息被多個 consumer 處理、下游出現重複副作用（重複扣款、重複發信）；<code>XPENDING</code> 展開看到某些 entry 的 delivery count 異常高（5、10+）但 stream 流量正常、沒有 consumer crash。</p>
<p><strong>根因</strong>：接管門檻低於正常處理時間。consumer A 拿到一筆要處理 10 秒的訊息、門檻設了 5 秒、consumer B 跑 <code>XAUTOCLAIM</code> 時這筆 idle 已過 5 秒、B 把還在 A 手上處理的訊息搶走、兩邊都處理一次。這是接管門檻設計的通用競態——一筆慢訊息被反覆搶、delivery count 暴衝、卻沒人真正完成。（<a href="/blog/backend/03-message-queue/cases/redis-streams-harness-event-driven-state/" data-link-title="3.C44 Harness：CD 微服務 async state transfer" data-link-desc="Harness CD 平台用 Redis Streams 解 brittle HTTP、揭露監控缺口 / MAXLEN truncation / head-of-line blocking 三類問題。">Harness 案例</a> 用 XAUTOCLAIM 重派解 head-of-line blocking 時、正是靠門檻與 redelivery 策略避開這種搶單。）</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>量測真實處理時間分佈、門檻設 &gt; p99</strong>：先用 metric 抓單筆處理 p50 / p99、門檻設 p99 的數倍。</li>
<li><strong>delivery count 當搶單偵測器</strong>：同一 entry delivery count 快速成長、代表它在被搶來搶去、調高門檻或隔離該訊息。</li>
<li><strong>idempotency 兜底</strong>：門檻再準也防不了「ack 瞬間被 claim」的競態、application 層去重是最後防線、不可省（見 <a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency 卡</a>）。</li>
</ol>
<h3 id="case-3maxlen-修剪掉-pel-內還沒-ack-的訊息">Case 3：MAXLEN 修剪掉 PEL 內還沒 ack 的訊息</h3>
<p><strong>徵兆</strong>：<code>XPENDING</code> 顯示某些 entry 仍 pending、但 <code>XCLAIM</code> 接管它時拿不到內容；consumer 接手後發現訊息 body 是空的、無法處理、又無法判斷該不該 ack。</p>
<p><strong>根因</strong>：<strong>修剪只看 entry ID 的新舊、不看它在不在 PEL</strong>。<code>XTRIM MAXLEN</code> 把最舊的 entry 從 stream 物理刪除、即使這些 entry 還在某個 group 的 PEL 裡等 ack。PEL 只記 entry ID、不存 body；body 存在 stream 本體。entry 被 trim 掉、PEL 還記得這個 ID、但 body 已經不存在了。實機驗證——4 筆全在 PEL、把 stream 修剪到剩 2 筆：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl">$ redis-cli XLEN mystream
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="m">5</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">$ redis-cli XPENDING mystream g1
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="m">4</span>                           <span class="c1"># 4 筆未 ack 在 PEL</span>
</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">$ redis-cli XTRIM mystream MAXLEN <span class="m">2</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="m">3</span>                           <span class="c1"># 刪掉 3 筆（含 PEL 內的未 ack entry）</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">$ redis-cli XLEN mystream
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="m">2</span>
</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">$ redis-cli XPENDING mystream g1 - + <span class="m">10</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">1781584105278-0  c3  <span class="m">19307</span>  <span class="m">3</span>   <span class="c1"># PEL 還記得這些 ID</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">1781584105373-0  c3  <span class="m">19307</span>  <span class="m">2</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">1781584105466-0  c3  <span class="m">19307</span>  <span class="m">2</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">1781584105578-0  c3  <span class="m">19307</span>  <span class="m">2</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">
</span></span><span class="line"><span class="ln">17</span><span class="cl">$ redis-cli XCLAIM mystream g1 c5 <span class="m">0</span> 1781584105278-0
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="o">(</span>empty array<span class="o">)</span>               <span class="c1"># 接管成功改 owner、但 entry body 已被 trim、拿不到內容</span></span></span></code></pre></div><p>PEL 還有 4 筆記錄、但對應的 body 已從 stream 消失。<code>XCLAIM</code> 接管這種 entry、改得了 owner、拿不到 body——這是訊息靜默遺失。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>修剪上限要 &gt; 處理 backlog 深度</strong>：MAXLEN / 流入速率 = 訊息在被修剪前的最長存活時間、這個時間要遠大於「最慢 consumer 清空 backlog 的時間」。</li>
<li><strong>修剪前檢查 PEL 最舊 ID</strong>：自動修剪前比對 <code>XPENDING</code> 的最小 pending ID、確保不會修到還在 PEL 的 entry。</li>
<li><strong>慢 consumer 監控優先於積極修剪</strong>：先解決 consumer 處理太慢導致 PEL 積壓的根因、再談用小 MAXLEN 壓記憶體；倒過來只會修掉未 ack 訊息。</li>
<li><strong>MINID 修剪比 MAXLEN 安全</strong>：MINID 用時間/業務邊界（如「保留 24 小時內」）、比 MAXLEN 的「保留 N 筆」更容易保證涵蓋未 ack 視窗。</li>
</ol>
<h3 id="case-4redis-cluster-對單-stream-的-shard-限制">Case 4：Redis Cluster 對單 stream 的 shard 限制</h3>
<p><strong>徵兆</strong>：stream 流量成長到單 node 容量上限、想像 Kafka 那樣「加 partition 分流」、卻發現 Redis Cluster 沒有這個機制；單一 stream key 的全部讀寫永遠打在同一個 node。</p>
<p><strong>根因</strong>：Redis Cluster 用 <code>CRC16(key) % 16384</code> 把 key 映射到 slot、slot 分佈在 node 上。<strong>一個 stream 是一個 key、永遠落在單一 slot、永遠在單一 shard</strong>。Streams 沒有 Kafka partition 那種「同一 topic 切多片、分散到多 broker」的概念。單 stream 的吞吐天花板就是單 node 的天花板。</p>
<p>實機驗證 keyslot 計算（cluster-enabled 節點）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">$ redis-cli CLUSTER KEYSLOT stream:orders
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="m">6139</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">$ redis-cli CLUSTER KEYSLOT stream:payments
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="m">3696</span>                        <span class="c1"># 不同 key 落不同 slot、可能在不同 shard</span></span></span></code></pre></div><p><strong>修法</strong>：要分流就在 application 層切多個 stream key（<code>stream:orders:0</code>、<code>stream:orders:1</code> &hellip;）、自己做 partition 路由。若需要某幾個 stream 保證落同一 shard（為了跨 stream 的原子操作或 co-located 處理）、用 hash tag——只有 <code>{}</code> 內的部分參與 CRC16：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">$ redis-cli CLUSTER KEYSLOT <span class="s1">&#39;{shard1}:stream:orders&#39;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="m">10271</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">$ redis-cli CLUSTER KEYSLOT <span class="s1">&#39;{shard1}:stream:payments&#39;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="m">10271</span>                       <span class="c1"># 同 hash tag、強制落同 slot</span></span></span></code></pre></div><p>兩個不同 key 因為共用 <code>{shard1}</code> hash tag、CRC16 算出同一個 slot 10271、保證在同一 shard。判讀邊界：需要真正的 partition + replication + 跨節點水平擴展、Redis Streams 不是答案、改走 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka</a>。Redis Streams 的定位是中等規模、單 shard 容量內、不跨節點分片。</p>
<blockquote>
<p>Cluster 多節點分片下的端到端行為（resharding 期間 stream key 隨 slot 搬移、client topology cache）需要多節點環境、本文未實機驗證；slot migration 機制與踩雷見 <a href="/blog/backend/02-cache-redis/vendors/redis/cluster-resharding/" data-link-title="Redis Cluster Re-sharding：source = target，但 topology 重劃的 5 段流程" data-link-desc="Redis cluster re-sharding 是 5 type migration 漏類實證 — source / target 同 cluster、無 schema / paradigm 差、但 16384 slot 重分配是核心；本文涵蓋 4 種 re-sharding driver、slot migration 機制、redis-cli --cluster rebalance / reshard 工具、5 個 production 踩雷（cluster busy / replica lag / client cache stale / cross-slot transaction / monitor gap）">Redis Cluster Re-sharding</a>。</p></blockquote>
<h3 id="case-5failover-後-pel-狀態不一致">Case 5：failover 後 PEL 狀態不一致</h3>
<p><strong>徵兆</strong>：Sentinel / Cluster failover 後（replica 升 primary）、原本在 PEL 的部分訊息「消失」或「重複投遞」；<code>XPENDING</code> 數字跟 failover 前對不上；consumer 接管邏輯撿到不該撿的訊息、或漏撿該撿的。</p>
<p><strong>根因</strong>：Redis 的 replication 是非同步的。primary 上的 <code>XADD</code> / <code>XACK</code> / <code>XCLAIM</code> 先在本地生效、再非同步傳給 replica。failover 那一刻、replica 的 PEL 狀態落後 primary 一個 replication lag 的視窗。新 primary 從它當下的（落後的）PEL 狀態接手：lag 視窗內已 ack 的訊息在新 primary 上仍 pending（重複投遞）、lag 視窗內剛 claim 的 owner 改寫可能丟失（接管邏輯錯亂）。AOF / RDB 持久化只保證單機重啟的恢復、不改變跨 replica 的非同步本質。</p>
<blockquote>
<p>failover 對 PEL 一致性的影響需要多節點 Sentinel / Cluster 環境跨節點觀測、本文未實機驗證；以下依官方 replication 語義與案例敘述判讀。</p></blockquote>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>接受 at-least-once、靠 idempotency 收斂</strong>：failover 造成的重複投遞跟正常的重複投遞同一性質、application 去重邏輯本來就要處理（見 <a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency 卡</a>）。</li>
<li><strong>failover 後主動全量 XAUTOCLAIM 對帳</strong>：failover 偵測到後、consumer 跑一輪低門檻 <code>XAUTOCLAIM</code> 重新接管、用 application 端的處理紀錄判斷哪些真的沒處理。</li>
<li><strong>降低 replication lag</strong>：lag 越小、failover 視窗的 PEL 偏差越小；監控 <code>master_repl_offset</code> 與 replica offset 差。</li>
<li><strong>語義誤配風險</strong>：把 Redis Streams 當「不丟訊息的 broker」用、在 failover 邊界會破功——這是 <a href="/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9 語義誤配</a> 的思路、選型時就要認清 Redis Streams 的一致性等級。</li>
</ol>
<h2 id="capacity-與判讀路由">Capacity 與判讀路由</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>判讀訊號</th>
          <th>邊界</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>PEL 深度</td>
          <td><code>XPENDING</code> 總數持續成長</td>
          <td>成長不停 = consumer 健康問題、不是調 MAXLEN 能解</td>
      </tr>
      <tr>
          <td>接管門檻</td>
          <td>delivery count 異常高（搶單）/ 最老 idle 不收斂</td>
          <td>門檻 &gt; p99 處理時間 + 安全係數</td>
      </tr>
      <tr>
          <td>Stream 記憶體</td>
          <td><code>MEMORY USAGE</code> 對比 <code>maxmemory</code></td>
          <td>stream 不被 eviction、唯一旋鈕是 MAXLEN / MINID 修剪</td>
      </tr>
      <tr>
          <td>修剪 vs 未 ack 視窗</td>
          <td>修剪上限 / 流入速率 &lt; backlog 清空時間</td>
          <td>違反就會修掉 PEL 內未 ack 訊息（Case 3）</td>
      </tr>
      <tr>
          <td>單 stream 吞吐</td>
          <td>單 node CPU / memory 打滿、無法加 partition</td>
          <td>達單 shard 天花板 = 該評估 Kafka</td>
      </tr>
  </tbody>
</table>
<p>判讀路由固定三層：先看 PEL 是「整 group 成長」（流入 &gt; 處理、擴 consumer）還是「單 consumer 卡住」（crash、要接管）；接管時先確認 min-idle-time 對得上處理時間分佈、再看 delivery count 篩 poison message；retention 調整前先確認修剪上限涵蓋 PEL 未 ack 視窗。</p>
<h2 id="整合與下一步">整合與下一步</h2>
<p>接管機制是 <a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">consumer 設計</a> 在 Redis Streams 上的具體落地——consumer 不只是讀訊息的迴圈、還要承擔「撿前任孤兒」的責任。設計 consumer 時把 <code>XAUTOCLAIM</code> 排進處理迴圈、跟 <code>XREADGROUP '&gt;'</code> 並列、不是事後補丁。</p>
<p>知識卡對位：delivery count 超閾值的訊息對應 <a href="/blog/backend/knowledge-cards/poison-message-quarantine/" data-link-title="Poison-Message Quarantine" data-link-desc="說明把毒訊息從主處理路徑隔離出來的機制，讓正常訊息繼續前進">poison message quarantine</a>（Redis Streams 沒原生 DLQ、自建一個 stream 當隔離區）；接管後的去重對應 <a href="/blog/backend/knowledge-cards/recovery-semantics/" data-link-title="Recovery Semantics" data-link-desc="說明事件處理失敗後能否透過 replay、checkpoint 與補償重建正確狀態並驗證">recovery semantics</a> 跟 <a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a>（at-least-once 的收斂責任在 application）。</p>
<p>案例延伸：<a href="/blog/backend/03-message-queue/cases/redis-streams-bitso-reliable-streams/" data-link-title="3.C42 Bitso：Reliable Redis Streams 抽象 &#43; 自建 DLQ" data-link-desc="Bitso 加密交易所、千 msg/sec/stream &#43; 亞毫秒延遲、自建 Reliable Streams 封裝 PEL &#43; retry &#43; DLQ、idempotent processing。">Bitso</a> 把本文這些機制封裝成 Reliable Streams 抽象層 + 自建 DLQ、是「application 層補可靠性」的完整實作參考；<a href="/blog/backend/03-message-queue/cases/redis-streams-klaxit-rust-log-pipeline/" data-link-title="3.C45 Klaxit：Rust &#43; Redis Streams 處理 Heroku Logplex" data-link-desc="Klaxit carpool 用 Redis Streams 處理 Heroku Logplex 匯流、自動偵測修復平台 perf 問題、6 個月 production Rust。">Klaxit Rust + Logplex</a> 是高吞吐 log ingestion 下 consumer group 分流長時間穩定運轉的範例；接管門檻搶單的反面教訓在 <a href="/blog/backend/03-message-queue/cases/redis-streams-harness-event-driven-state/" data-link-title="3.C44 Harness：CD 微服務 async state transfer" data-link-desc="Harness CD 平台用 Redis Streams 解 brittle HTTP、揭露監控缺口 / MAXLEN truncation / head-of-line blocking 三類問題。">Harness event-driven</a>。</p>
<p>選型回路：單 stream 撞到單 shard 天花板、或 failover 一致性要求超出 at-least-once、回 <a href="/blog/backend/03-message-queue/vendors/redis-streams/#%e4%bd%95%e6%99%82%e6%94%b9%e8%b5%b0%e5%85%b6%e4%bb%96%e6%9c%8d%e5%8b%99" data-link-title="Redis Streams" data-link-desc="Redis 生態內的 streams、append-only log &#43; consumer group">Redis Streams overview 的「何時改走其他服務」</a>、評估 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka</a>（partition + replication）。Cluster 層的 slot / topology 行為見 <a href="/blog/backend/02-cache-redis/vendors/redis/cluster-resharding/" data-link-title="Redis Cluster Re-sharding：source = target，但 topology 重劃的 5 段流程" data-link-desc="Redis cluster re-sharding 是 5 type migration 漏類實證 — source / target 同 cluster、無 schema / paradigm 差、但 16384 slot 重分配是核心；本文涵蓋 4 種 re-sharding driver、slot migration 機制、redis-cli --cluster rebalance / reshard 工具、5 個 production 踩雷（cluster busy / replica lag / client cache stale / cross-slot transaction / monitor gap）">Redis Cluster Re-sharding</a>。</p>
]]></content:encoded></item><item><title>MongoDB Shard Expansion + Multi-DC：Type F「不需要 parallel run」的 multi-region 例外</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/mongodb/shard-expansion-multi-dc/</link><pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/mongodb/shard-expansion-multi-dc/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/mongodb/" data-link-title="MongoDB" data-link-desc="Document database 代表、Atlas managed、跨雲可用、許多大規模平台從 MongoDB 起家">MongoDB&lt;/a> overview 的 implementation-layer deep article。對應 &lt;a href="https://tarrragon.github.io/blog/report/data-topology-as-audit-dimension/" data-link-title="Data topology 是 process content 的第 6 audit 維度" data-link-desc="Process content 的 diff dimension audit 原本 5 維（schema / operational / paradigm / components / application change）漏了 *data topology* — 資料在 cluster / partition / region 之間的分佈拓樸；topology 不在既有 5 維任一個、但決定 re-sharding / partition redesign / multi-region rollout 的結構；本卡擴 audit 到 6 維、新增 Type F「Topology re-layout」結構">#128 Type F「Topology re-layout」&lt;/a> 第 3 個 dogfood、特別驗證 self-aware limitation 第 3 點「不需要 parallel run」claim 的 &lt;em>multi-region rollout 例外&lt;/em> — 本文是反例的具體實證。&lt;/p>&lt;/blockquote>
&lt;h2 id="reviewer-d-的質疑type-f-一定不需要-parallel-run-嗎">Reviewer D 的質疑：Type F 一定不需要 parallel run 嗎&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/report/data-topology-as-audit-dimension/" data-link-title="Data topology 是 process content 的第 6 audit 維度" data-link-desc="Process content 的 diff dimension audit 原本 5 維（schema / operational / paradigm / components / application change）漏了 *data topology* — 資料在 cluster / partition / region 之間的分佈拓樸；topology 不在既有 5 維任一個、但決定 re-sharding / partition redesign / multi-region rollout 的結構；本卡擴 audit 到 6 維、新增 Type F「Topology re-layout」結構">#128 Self-aware limitation&lt;/a> 第 3 點承認：&lt;/p>
&lt;blockquote>
&lt;p>「不需要 parallel run」claim 部分不成立：multi-region rollout（#128 列為 Type F 情境）必須 parallel run — 兩 region 同時跑然後切流量、不然就是停機切換、跟 Type A phase 3 機制相同。&lt;/p>&lt;/blockquote>
&lt;p>本文是該 claim 的 &lt;em>正面實證&lt;/em> — MongoDB sharded cluster 從 single-DC 加 shard + 加 secondary DC、確實需要 parallel run + 流量切換、跟 Type A phased migration 局部同構：&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/mongodb/" data-link-title="MongoDB" data-link-desc="Document database 代表、Atlas managed、跨雲可用、許多大規模平台從 MongoDB 起家">MongoDB</a> overview 的 implementation-layer deep article。對應 <a href="/blog/report/data-topology-as-audit-dimension/" data-link-title="Data topology 是 process content 的第 6 audit 維度" data-link-desc="Process content 的 diff dimension audit 原本 5 維（schema / operational / paradigm / components / application change）漏了 *data topology* — 資料在 cluster / partition / region 之間的分佈拓樸；topology 不在既有 5 維任一個、但決定 re-sharding / partition redesign / multi-region rollout 的結構；本卡擴 audit 到 6 維、新增 Type F「Topology re-layout」結構">#128 Type F「Topology re-layout」</a> 第 3 個 dogfood、特別驗證 self-aware limitation 第 3 點「不需要 parallel run」claim 的 <em>multi-region rollout 例外</em> — 本文是反例的具體實證。</p></blockquote>
<h2 id="reviewer-d-的質疑type-f-一定不需要-parallel-run-嗎">Reviewer D 的質疑：Type F 一定不需要 parallel run 嗎</h2>
<p><a href="/blog/report/data-topology-as-audit-dimension/" data-link-title="Data topology 是 process content 的第 6 audit 維度" data-link-desc="Process content 的 diff dimension audit 原本 5 維（schema / operational / paradigm / components / application change）漏了 *data topology* — 資料在 cluster / partition / region 之間的分佈拓樸；topology 不在既有 5 維任一個、但決定 re-sharding / partition redesign / multi-region rollout 的結構；本卡擴 audit 到 6 維、新增 Type F「Topology re-layout」結構">#128 Self-aware limitation</a> 第 3 點承認：</p>
<blockquote>
<p>「不需要 parallel run」claim 部分不成立：multi-region rollout（#128 列為 Type F 情境）必須 parallel run — 兩 region 同時跑然後切流量、不然就是停機切換、跟 Type A phase 3 機制相同。</p></blockquote>
<p>本文是該 claim 的 <em>正面實證</em> — MongoDB sharded cluster 從 single-DC 加 shard + 加 secondary DC、確實需要 parallel run + 流量切換、跟 Type A phased migration 局部同構：</p>
<table>
  <thead>
      <tr>
          <th>Type F 假設</th>
          <th>Single-DC re-sharding（Redis case）</th>
          <th><strong>Multi-DC expansion（本文）</strong></th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>同 cluster 不同 state</td>
          <td>yes</td>
          <td>yes（同 MongoDB cluster）</td>
      </tr>
      <tr>
          <td>不需 schema translation</td>
          <td>yes</td>
          <td>yes</td>
      </tr>
      <tr>
          <td>不需 parallel run</td>
          <td>yes（slot migration 內部完成）</td>
          <td><strong>no — 兩 DC 同跑後切流量</strong></td>
      </tr>
      <tr>
          <td>不需 cleanup phase</td>
          <td>yes</td>
          <td>partial（舊 DC 角色降為 standby）</td>
      </tr>
      <tr>
          <td>Step-by-step + rollback boundary</td>
          <td>yes</td>
          <td>yes</td>
      </tr>
  </tbody>
</table>
<p>→ Type F anatomy 仍適用、但「不需 parallel run」是 <em>子情境條件</em>、不是 universal claim。</p>
<h2 id="兩個操作合併shard-加--dc-加">兩個操作合併：shard 加 + DC 加</h2>
<p>實務上中型公司常 <em>同時</em> 跑兩個 topology 變動：</p>
<ol>
<li><strong>Shard expansion</strong>：現有 3-shard cluster 加到 5-shard、chunk migration 平均分佈</li>
<li><strong>Multi-DC</strong>：從 single-DC（us-east-1）加到 multi-DC（us-east-1 + us-west-2）</li>
</ol>
<p>兩個操作的 <a href="/blog/report/content-structure-by-max-diff-dimension/" data-link-title="Process content 結構由最大差異維度決定、不是 universal phased" data-link-desc="跨 X process content（migration / upgrade / rollout / playbook）的結構由 source / target 之間 *差異維度組合* 決定、不存在 universal phased 模板；6 種 migration / process type 實證（schema 差 / drop-in / operational / multi-tool / paradigm / topology re-layout）跑出 6 種不同結構；寫作前必須做 *6 維 diff dimension audit* 才能決定結構、跳過會套錯模板">diff dimension audit</a>：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>Shard 加（單獨）</th>
          <th>Multi-DC（單獨）</th>
          <th>兩者同跑</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Schema / API</td>
          <td>Low</td>
          <td>Low</td>
          <td>Low</td>
      </tr>
      <tr>
          <td>Operational model</td>
          <td>Low</td>
          <td>Medium（跨 DC ops）</td>
          <td>Medium</td>
      </tr>
      <tr>
          <td>Paradigm</td>
          <td>Low</td>
          <td>Low</td>
          <td>Low</td>
      </tr>
      <tr>
          <td>Components</td>
          <td>Low（加 shard、同 cluster）</td>
          <td>Low</td>
          <td>Low</td>
      </tr>
      <tr>
          <td>Application change</td>
          <td>Low</td>
          <td>Low-Medium（cross-DC latency aware）</td>
          <td>Low-Medium</td>
      </tr>
      <tr>
          <td><strong>Data topology</strong></td>
          <td><strong>High</strong>（sharding strategy）</td>
          <td><strong>High</strong>（replication + region）</td>
          <td><strong>High</strong>（雙變、複合 topology）</td>
      </tr>
  </tbody>
</table>
<p>兩者主導維度都是 topology = High、組合走 Type F multi-axis 子情境。</p>
<h2 id="pre-layout-analysis當前--目標-topology">Pre-layout analysis：當前 + 目標 topology</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// 1. 當前 shard 分佈
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="nx">sh</span><span class="p">.</span><span class="nx">status</span><span class="p">({</span><span class="nx">verbose</span><span class="o">:</span> <span class="kc">false</span><span class="p">});</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1">// 期望輸出: 3 shard、每個 ~33% chunks、no migration in progress
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"></span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="nx">db</span><span class="p">.</span><span class="nx">printShardingStatus</span><span class="p">({</span><span class="nx">verbose</span><span class="o">:</span> <span class="kc">false</span><span class="p">});</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1">// 找 hot shard、imbalanced chunk distribution
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"></span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1">// 2. Replication topology
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span><span class="nx">rs</span><span class="p">.</span><span class="nx">status</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1">// 各 replica set primary/secondary 健康度、replication lag
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1">// 3. Cross-DC network baseline (在 add DC 前測)
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1">// us-east-1 → us-west-2 RTT、bandwidth
</span></span></span></code></pre></div><p>Pre-layout 階段 output：</p>
<ul>
<li><strong>當前</strong>：3 shard × 1 replica set per shard (3 member) = 9 node、全在 us-east-1</li>
<li><strong>目標</strong>：5 shard × 1 replica set per shard (5 member: 3 us-east + 2 us-west) = 25 node</li>
<li><strong>Migration scope</strong>：加 2 shard + 加 2 DC member 每 shard、共 +16 node</li>
<li><strong>Chunk migration estimate</strong>：30% chunk 需重分（從 33% × 3 變 20% × 5）</li>
</ul>
<h2 id="re-layout-機制">Re-layout 機制</h2>
<p>兩個 mechanism 平行進行：</p>
<h3 id="shard-expansion-mechanism">Shard expansion mechanism</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// 1. 新增 shard 到 cluster
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="nx">sh</span><span class="p">.</span><span class="nx">addShard</span><span class="p">(</span><span class="s2">&#34;rs-shard4/host10:27017,host11:27017,host12:27017&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="nx">sh</span><span class="p">.</span><span class="nx">addShard</span><span class="p">(</span><span class="s2">&#34;rs-shard5/host13:27017,host14:27017,host15:27017&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1">// 2. balancer 自動 chunk migration
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"></span><span class="nx">sh</span><span class="p">.</span><span class="nx">startBalancer</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1">// 觀察 progress: db.adminCommand({balancerStatus: 1})
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"></span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1">// 3. 完成後 verify shard distribution
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"></span><span class="nx">sh</span><span class="p">.</span><span class="nx">status</span><span class="p">();</span></span></span></code></pre></div><p>Chunk migration 是 <em>background</em> job、balancer 控制 throttle；不阻塞 production query、但 CPU / network 上升 30-50%。</p>
<h3 id="multi-dc-expansion-mechanism">Multi-DC expansion mechanism</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// 1. 對每 shard 的 replica set 加 us-west-2 member (priority 0)
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="nx">rs</span><span class="p">.</span><span class="nx">add</span><span class="p">({</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="nx">host</span><span class="o">:</span> <span class="s2">&#34;us-west-2-host:27017&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="nx">priority</span><span class="o">:</span> <span class="mi">0</span><span class="p">,</span>           <span class="c1">// 不能當 primary
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span>  <span class="nx">votes</span><span class="o">:</span> <span class="mi">1</span><span class="p">,</span>              <span class="c1">// 參與投票
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"></span>  <span class="nx">hidden</span><span class="o">:</span> <span class="kc">false</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="c1">// 2. 等 initial sync 完成（依資料量 1 小時 - 1 天）
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"></span><span class="nx">rs</span><span class="p">.</span><span class="nx">printReplicationInfo</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1">// 3. 確認 secondary 健康後、提升 priority 或 votes
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1">// 不要立刻設 priority 1、避免 unintended failover
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1"></span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="c1">// 4. Cross-DC routing 透過 readPreference 在 application 設
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="c1"></span><span class="kr">const</span> <span class="nx">client</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">MongoClient</span><span class="p">(</span><span class="nx">uri</span><span class="p">,</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">  <span class="nx">readPreference</span><span class="o">:</span> <span class="s1">&#39;secondaryPreferred&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">  <span class="nx">readPreferenceTags</span><span class="o">:</span> <span class="p">[{</span> <span class="nx">region</span><span class="o">:</span> <span class="s1">&#39;us-west-2&#39;</span> <span class="p">},</span> <span class="p">{}],</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>關鍵：multi-DC 是 <em>漸進加 member</em>、不是 atomic switch；每 shard 獨立加、整體耗時 = shard 數 × initial sync time。</p>
<h2 id="execution-flow含-parallel-run--流量切換">Execution flow（含 parallel run + 流量切換）</h2>
<p>8 step、包含 <em>parallel run + 切流量</em> 段——驗證 <a href="/blog/report/data-topology-as-audit-dimension/" data-link-title="Data topology 是 process content 的第 6 audit 維度" data-link-desc="Process content 的 diff dimension audit 原本 5 維（schema / operational / paradigm / components / application change）漏了 *data topology* — 資料在 cluster / partition / region 之間的分佈拓樸；topology 不在既有 5 維任一個、但決定 re-sharding / partition redesign / multi-region rollout 的結構；本卡擴 audit 到 6 維、新增 Type F「Topology re-layout」結構">#128 self-aware limitation</a> 第 3 點：</p>
<table>
  <thead>
      <tr>
          <th>Step</th>
          <th>動作</th>
          <th>Parallel run?</th>
          <th>Rollback boundary</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1 Pre-check</td>
          <td>量化當前 topology、確認 cluster 健康</td>
          <td>no</td>
          <td>-</td>
      </tr>
      <tr>
          <td>2 加 us-east shard</td>
          <td>sh.addShard、balancer migrate chunk</td>
          <td>no（cluster 內）</td>
          <td>removeShard、chunk migrate 回</td>
      </tr>
      <tr>
          <td>3 加 us-west member</td>
          <td>對每 shard rs.add 跨 DC member</td>
          <td>no</td>
          <td>rs.remove、initial sync 投入廢棄</td>
      </tr>
      <tr>
          <td>4 <strong>Initial sync wait</strong></td>
          <td>等所有 us-west member catch up</td>
          <td><strong>parallel run starts</strong>：兩 DC 同時 serve</td>
          <td>-</td>
      </tr>
      <tr>
          <td>5 <strong>Cross-DC dual-serve</strong></td>
          <td>兩 DC 都跑 read traffic（不切 write）</td>
          <td><strong>yes、parallel run</strong>：app 用 secondary preferred us-west</td>
          <td>readPref 切回 us-east primary</td>
      </tr>
      <tr>
          <td>6 <strong>流量切換</strong></td>
          <td>application us-west traffic 走 us-west read</td>
          <td><strong>yes</strong></td>
          <td>DNS / readPref 切回</td>
      </tr>
      <tr>
          <td>7 Promote us-west（optional）</td>
          <td>一個 shard 的 us-west member priority 提到 1</td>
          <td>post-cutover</td>
          <td>demote priority 回 0</td>
      </tr>
      <tr>
          <td>8 Cleanup</td>
          <td>Verify、archive log、document new topology</td>
          <td>no</td>
          <td>-</td>
      </tr>
  </tbody>
</table>
<p>Step 4-6 是 <em>parallel run + 切流量</em> — <strong>Type F 有此例外、跟 Type A phase 3 機制同構</strong>；anatomy 中「Execution flow per-step」段必須含 parallel run 子段。</p>
<h2 id="production-故障演練">Production 故障演練</h2>
<h3 id="case-1balancer-跑-chunk-migration-撞-production-peak">Case 1：Balancer 跑 chunk migration 撞 production peak</h3>
<p><strong>徵兆</strong>：加 shard 後 balancer 開始 migrate chunk、production write latency p99 從 10ms 跳到 100ms；application 端 timeout 大量。</p>
<p><strong>根因</strong>：MongoDB balancer 預設 24×7 跑、chunk migrate 是 <em>blocking</em> 操作（migration lock 期間阻塞 write 到該 chunk）；產線高峰時間 balancer 不會自動暫停。</p>
<p><strong>修法</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// 限 balancer 跑在 low-traffic window
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="nx">sh</span><span class="p">.</span><span class="nx">setBalancerState</span><span class="p">(</span><span class="kc">true</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nx">db</span><span class="p">.</span><span class="nx">settings</span><span class="p">.</span><span class="nx">update</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="p">{</span> <span class="nx">_id</span><span class="o">:</span> <span class="s2">&#34;balancer&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="p">{</span> <span class="nx">$set</span><span class="o">:</span> <span class="p">{</span> <span class="nx">activeWindow</span><span class="o">:</span> <span class="p">{</span> <span class="nx">start</span><span class="o">:</span> <span class="s2">&#34;02:00&#34;</span><span class="p">,</span> <span class="nx">stop</span><span class="o">:</span> <span class="s2">&#34;06:00&#34;</span> <span class="p">}</span> <span class="p">}</span> <span class="p">},</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="p">{</span> <span class="nx">upsert</span><span class="o">:</span> <span class="kc">true</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">);</span></span></span></code></pre></div><p>且設 <code>chunkSize</code> 較小（128MB → 64MB）讓 migration 步驟細、單次 lock 時間短。</p>
<h3 id="case-2cross-dc-initial-sync-期間-oplog-跑出窗口">Case 2：Cross-DC initial sync 期間 oplog 跑出窗口</h3>
<p><strong>徵兆</strong>：加 us-west member 後、initial sync 跑 4 小時、結束時 member 顯示「too stale to catch up」、需要 full re-sync。</p>
<p><strong>根因</strong>：MongoDB oplog 是 capped collection、預設 size 5% disk；4 小時 initial sync 期間 primary 寫入量超出 oplog 保留範圍、member 拿到的 oplog start point 已被覆蓋。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>預先擴 oplog size</strong>：<code>db.adminCommand({replSetResizeOplog: 1, size: 51200})</code> 加到 50GB、覆蓋 sync window</li>
<li><strong>Off-peak initial sync</strong>：跑在低流量時間、oplog 寫入較慢</li>
<li><strong>Manual initial sync via snapshot</strong>：用 <code>mongodump</code> 從 primary snapshot、restore 到 new member、跳過 oplog tail catch-up</li>
</ol>
<h3 id="case-3跨-dc-read-路由錯誤stale-data-影響業務">Case 3：跨 DC read 路由錯誤、stale data 影響業務</h3>
<p><strong>徵兆</strong>：切流量到 us-west 後、application 偶爾抓到 5-30 秒前的 stale data；customer 報告「明明剛改了 setting、refresh 又變回去」。</p>
<p><strong>根因</strong>：us-west member 是 secondary、replication lag 5-30 秒；application readPreference 設 <code>secondaryPreferred</code> 但沒 <code>maxStalenessSeconds</code>、可能讀到嚴重 stale member。</p>
<p><strong>修法</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kr">const</span> <span class="nx">client</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">MongoClient</span><span class="p">(</span><span class="nx">uri</span><span class="p">,</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="nx">readPreference</span><span class="o">:</span> <span class="s1">&#39;secondaryPreferred&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="nx">readPreferenceTags</span><span class="o">:</span> <span class="p">[{</span> <span class="nx">region</span><span class="o">:</span> <span class="s1">&#39;us-west-2&#39;</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">maxStalenessSeconds</span><span class="o">:</span> <span class="mi">90</span><span class="p">,</span>  <span class="c1">// 限 stale 不超過 90 秒
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span><span class="p">});</span>
</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 class="c1">// 對 strict consistency 場景強制 primary
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"></span><span class="kr">const</span> <span class="nx">client_strict</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">MongoClient</span><span class="p">(</span><span class="nx">uri</span><span class="p">,</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="nx">readPreference</span><span class="o">:</span> <span class="s1">&#39;primary&#39;</span><span class="p">,</span>  <span class="c1">// 強制讀 us-east primary
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"></span><span class="p">});</span></span></span></code></pre></div><p>Application-level read pattern 必須區分「accept stale read」vs「require fresh read」、不是 cluster-level 統一配置。</p>
<h3 id="case-4shard-tag-aware-routing-沒設cross-dc-traffic-爆-cost">Case 4：Shard tag-aware routing 沒設、cross-DC traffic 爆 cost</h3>
<p><strong>徵兆</strong>：multi-DC 跑了 1 個月、AWS egress cost 從 $500 / month 漲到 $8000 / month；99% 流量還是 us-east → us-west 跨 DC。</p>
<p><strong>根因</strong>：sharded cluster 沒設 <em>zone sharding</em>、application 不知道哪些 chunk 在哪個 DC、所有 query 預設打 us-east primary、跨 DC bandwidth 爆。</p>
<p><strong>修法</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// 注意: MongoDB 4.2+ API、舊版 sh.addShardTag / sh.addTagRange 已 deprecated
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1">// 對應改 sh.addShardToZone / sh.updateZoneKeyRange
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"></span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1">// 1. 給 shard 加 zone (MongoDB 4.2+)
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span><span class="nx">sh</span><span class="p">.</span><span class="nx">addShardToZone</span><span class="p">(</span><span class="s2">&#34;rs-shard1&#34;</span><span class="p">,</span> <span class="s2">&#34;us-east&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="nx">sh</span><span class="p">.</span><span class="nx">addShardToZone</span><span class="p">(</span><span class="s2">&#34;rs-shard2&#34;</span><span class="p">,</span> <span class="s2">&#34;us-east&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="nx">sh</span><span class="p">.</span><span class="nx">addShardToZone</span><span class="p">(</span><span class="s2">&#34;rs-shard3&#34;</span><span class="p">,</span> <span class="s2">&#34;us-east&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="nx">sh</span><span class="p">.</span><span class="nx">addShardToZone</span><span class="p">(</span><span class="s2">&#34;rs-shard4&#34;</span><span class="p">,</span> <span class="s2">&#34;us-west&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="nx">sh</span><span class="p">.</span><span class="nx">addShardToZone</span><span class="p">(</span><span class="s2">&#34;rs-shard5&#34;</span><span class="p">,</span> <span class="s2">&#34;us-west&#34;</span><span class="p">);</span>
</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 class="c1">// 2. 對 collection 加 zone range
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"></span><span class="nx">sh</span><span class="p">.</span><span class="nx">updateZoneKeyRange</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">  <span class="s2">&#34;myapp.events&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">  <span class="p">{</span> <span class="nx">region</span><span class="o">:</span> <span class="s2">&#34;us-east&#34;</span><span class="p">,</span> <span class="nx">_id</span><span class="o">:</span> <span class="nx">MinKey</span> <span class="p">},</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">  <span class="p">{</span> <span class="nx">region</span><span class="o">:</span> <span class="s2">&#34;us-east&#34;</span><span class="p">,</span> <span class="nx">_id</span><span class="o">:</span> <span class="nx">MaxKey</span> <span class="p">},</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">  <span class="s2">&#34;us-east&#34;</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="p">);</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="nx">sh</span><span class="p">.</span><span class="nx">updateZoneKeyRange</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">  <span class="s2">&#34;myapp.events&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">  <span class="p">{</span> <span class="nx">region</span><span class="o">:</span> <span class="s2">&#34;us-west&#34;</span><span class="p">,</span> <span class="nx">_id</span><span class="o">:</span> <span class="nx">MinKey</span> <span class="p">},</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">  <span class="p">{</span> <span class="nx">region</span><span class="o">:</span> <span class="s2">&#34;us-west&#34;</span><span class="p">,</span> <span class="nx">_id</span><span class="o">:</span> <span class="nx">MaxKey</span> <span class="p">},</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">  <span class="s2">&#34;us-west&#34;</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="p">);</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">
</span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="c1">// 3. balancer 重新分配 chunk 到對應 zone
</span></span></span></code></pre></div><p>Zone sharding 是 multi-DC 必要設計、不設等於白付 egress cost。</p>
<h3 id="case-5failover-後跨-dc-primary-切換application-連線中斷">Case 5：Failover 後跨 DC primary 切換、application 連線中斷</h3>
<p><strong>徵兆</strong>：production 跑 6 個月後、us-east-1 outage、某 shard primary 切到 us-west member；application 5-10 秒內大量 connection error。</p>
<p><strong>根因</strong>：MongoDB driver 預設 election timeout 10 秒、application 沒設 server selection retry；primary 切換期間 client 沒重連。</p>
<p><strong>修法</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">const</span> <span class="nx">client</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">MongoClient</span><span class="p">(</span><span class="nx">uri</span><span class="p">,</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nx">serverSelectionTimeoutMS</span><span class="o">:</span> <span class="mi">30000</span><span class="p">,</span>    <span class="c1">// 等 30 秒給 election
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span>  <span class="nx">retryWrites</span><span class="o">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nx">retryReads</span><span class="o">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="nx">heartbeatFrequencyMS</span><span class="o">:</span> <span class="mi">5000</span><span class="p">,</span>         <span class="c1">// 更頻繁 detect topology 變動
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span><span class="p">});</span></span></span></code></pre></div><p>且 multi-DC primary 應該設 <em>priority asymmetry</em>：us-east member priority 2、us-west priority 1；正常情況不切換、災難時自動切。</p>
<h2 id="capacity--cost">Capacity / cost</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>Single-DC 3-shard</th>
          <th>Multi-DC 5-shard</th>
          <th>Trade-off</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Node count</td>
          <td>9</td>
          <td>25</td>
          <td>~3x infrastructure cost</td>
      </tr>
      <tr>
          <td>Storage redundancy</td>
          <td>3 replica</td>
          <td>5 replica (3 east + 2 west)</td>
          <td>+2 copy、storage cost +66%</td>
      </tr>
      <tr>
          <td>Network egress</td>
          <td>內部 VPC、低</td>
          <td>Cross-DC、高（需 zone sharding）</td>
          <td>$500 → $8000 / month if no zone sharding</td>
      </tr>
      <tr>
          <td>Latency p99 (write)</td>
          <td>5-10ms</td>
          <td>5-15ms（primary 仍 us-east）</td>
          <td>略升</td>
      </tr>
      <tr>
          <td>Latency p99 (read)</td>
          <td>5-10ms</td>
          <td>2-5ms (local DC)</td>
          <td>Multi-DC 區域 read 加快</td>
      </tr>
      <tr>
          <td>Disaster recovery</td>
          <td>RTO 30 分鐘（rebuild）</td>
          <td>RTO &lt; 1 分鐘（auto failover）</td>
          <td>顯著改善</td>
      </tr>
      <tr>
          <td>Operational complexity</td>
          <td>低</td>
          <td>高（zone sharding / DR drill）</td>
          <td>+1 SRE FTE 維護</td>
      </tr>
  </tbody>
</table>
<p><strong>判讀</strong>：multi-DC 是 <em>DR 投資</em>、不是 cost optimization；只在 <em>availability SLA &gt; 99.9% 或合規要求</em> 場景值得。</p>
<h2 id="整合--下一步">整合 / 下一步</h2>
<h3 id="跟-mongodb--atlas-migration-對位">跟 <a href="/blog/backend/01-database/vendors/mongodb/migrate-to-atlas/" data-link-title="MongoDB → Atlas：Atlas 不是 MongoDB &#43; managed、是另一個 product" data-link-desc="Atlas 號稱「MongoDB managed」但 operational model 完全不同（auto-scaling / VPC peering / IAM-driven access / 內建 backup / billing 模型）；本文採用 Type C operational redesign hybrid 結構、4-phase operational migration &#43; drop-in cutover、5 個 production 踩雷（連線數限制 / IP whitelist / backup retention / IAM token 過期 / billing 暴漲）">MongoDB → Atlas migration</a> 對位</h3>
<p>Self-managed multi-DC 複雜度高、Atlas 把 multi-cluster + cross-region 簡化成 UI 配置；如果走 multi-DC、考慮直接遷 Atlas。</p>
<h3 id="跟-application-read-pattern-整合">跟 Application read pattern 整合</h3>
<p>zone sharding + readPreference 跟 application logic 緊密耦合；不能事後補、應在 multi-DC 設計階段就設計 application 端的 region-aware routing。</p>
<h3 id="跟-cassandra-keyspace-re-balance-對比">跟 <a href="https://cassandra.apache.org/">Cassandra keyspace re-balance</a> 對比</h3>
<p>Cassandra 是另一個 Type F multi-DC 典型 case；用 <em>NetworkTopologyStrategy + replication factor per DC</em>、跟 MongoDB zone sharding 概念對等但 mechanism 完全不同。Reviewer D 把 Cassandra 列為 Type F 反例 — 本文以 MongoDB 替代驗證。</p>
<h3 id="下一步議題">下一步議題</h3>
<ul>
<li><strong>Cross-region active-active</strong>：MongoDB 不支援 multi-primary、cross-region active-active 需要 application-level conflict resolution</li>
<li><strong>PostgreSQL Citus / CockroachDB multi-region</strong> 對比：distributed SQL 對 multi-region 有不同設計</li>
<li><strong>Cost optimization</strong>：跨 DC egress 是 long-term concern、zone sharding 設好後仍要 quarterly review</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li>上游 vendor 頁：<a href="/blog/backend/01-database/vendors/mongodb/" data-link-title="MongoDB" data-link-desc="Document database 代表、Atlas managed、跨雲可用、許多大規模平台從 MongoDB 起家">MongoDB</a></li>
<li>平行 migration playbook：<a href="/blog/backend/01-database/vendors/mongodb/migrate-to-atlas/" data-link-title="MongoDB → Atlas：Atlas 不是 MongoDB &#43; managed、是另一個 product" data-link-desc="Atlas 號稱「MongoDB managed」但 operational model 完全不同（auto-scaling / VPC peering / IAM-driven access / 內建 backup / billing 模型）；本文採用 Type C operational redesign hybrid 結構、4-phase operational migration &#43; drop-in cutover、5 個 production 踩雷（連線數限制 / IP whitelist / backup retention / IAM token 過期 / billing 暴漲）">MongoDB → Atlas</a></li>
<li>平行 Type F dogfood：<a href="/blog/backend/02-cache-redis/vendors/redis/cluster-resharding/" data-link-title="Redis Cluster Re-sharding：source = target，但 topology 重劃的 5 段流程" data-link-desc="Redis cluster re-sharding 是 5 type migration 漏類實證 — source / target 同 cluster、無 schema / paradigm 差、但 16384 slot 重分配是核心；本文涵蓋 4 種 re-sharding driver、slot migration 機制、redis-cli --cluster rebalance / reshard 工具、5 個 production 踩雷（cluster busy / replica lag / client cache stale / cross-slot transaction / monitor gap）">Redis Cluster Re-sharding</a>（dogfood #1）/ <a href="/blog/backend/01-database/vendors/postgresql/partition-redesign/" data-link-title="PostgreSQL Partition Redesign：當 monthly partition 越跑越慢" data-link-desc="PostgreSQL partition redesign 是 Type F「topology re-layout」第 2 個 dogfood — 從 monthly partition 改 daily / 從 range 改 list / 從單軸改 sub-partition；6 維 audit 皆 Low &#43; topology 軸 High；涵蓋 partition 不平衡偵測、ATTACH/DETACH 線上重劃、5 個 production 踩雷、跟 partition_pruning &#43; autovacuum 整合">PostgreSQL Partition Redesign</a>（dogfood #2）</li>
<li>Methodology：<a href="/blog/posts/migration-playbook-%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84stage-0-variant-%E8%A6%8F%E5%8A%83%E6%8A%8A-collapse-%E7%8E%87%E5%BE%9E-60-%E9%99%8D%E5%88%B0-0/" data-link-title="Migration Playbook 方法論的演化紀錄：Stage 0 variant 規劃把 collapse 率從 60% 降到 0%" data-link-desc="跨 vendor migration playbook 需要獨立寫作方法論的依據，以及這套方法論從三輪 batch dogfood 中演化出來的驗證證據。">Migration playbook methodology</a> / <a href="/blog/report/data-topology-as-audit-dimension/" data-link-title="Data topology 是 process content 的第 6 audit 維度" data-link-desc="Process content 的 diff dimension audit 原本 5 維（schema / operational / paradigm / components / application change）漏了 *data topology* — 資料在 cluster / partition / region 之間的分佈拓樸；topology 不在既有 5 維任一個、但決定 re-sharding / partition redesign / multi-region rollout 的結構；本卡擴 audit 到 6 維、新增 Type F「Topology re-layout」結構">#128 Data topology 是第 6 audit 維度</a>（本文驗證 self-aware limitation 第 3 點）</li>
</ul>
]]></content:encoded></item><item><title>MySQL Replication Topology：async / semi-sync / GTID 不是三選一、是三個 trade-off 軸的疊加</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/replication-topology/</link><pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/replication-topology/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/" data-link-title="MySQL" data-link-desc="高併發網路服務常用關聯式資料庫、Vitess / PlanetScale 分片生態、GitHub / Shopify / Facebook 規模驗證">MySQL&lt;/a> overview 的 implementation-layer deep article。Overview 已說明 MySQL 在 OLTP 譜系的定位、本文聚焦 &lt;em>replication topology&lt;/em> — 從 single primary 到 multi-replica 部署的 3 個 trade-off 軸跟 5 段配置。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="replication-的-3-個-trade-off-軸--mode-選擇">Replication 的 3 個 trade-off 軸 + mode 選擇&lt;/h2>
&lt;p>Replication mode 選擇看起來是「選 async 還是 semi-sync」、但決策實際是 3 個獨立 trade-off 軸的權衡、async / semi-sync 是這些軸的兩個常見組合 &lt;em>名稱&lt;/em>：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>軸&lt;/th>
 &lt;th>端 A&lt;/th>
 &lt;th>端 B&lt;/th>
 &lt;th>MySQL 旋鈕&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;strong>Durability&lt;/strong>&lt;/td>
 &lt;td>primary 寫完就 commit&lt;/td>
 &lt;td>至少一個 standby 收到才 commit&lt;/td>
 &lt;td>&lt;code>rpl_semi_sync_master_enabled&lt;/code> / sync ack count&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Latency&lt;/strong>&lt;/td>
 &lt;td>client 等 primary 寫完 OK&lt;/td>
 &lt;td>client 等 standby ack（額外 RTT）&lt;/td>
 &lt;td>&lt;code>rpl_semi_sync_master_timeout&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Consistency&lt;/strong>&lt;/td>
 &lt;td>replica 隨時可能 stale&lt;/td>
 &lt;td>replica 跟 primary 保證讀到一致&lt;/td>
 &lt;td>application read routing rule（不是 replication 旋鈕）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>「async vs semi-sync」實際上是 &lt;em>durability + latency 兩軸&lt;/em> 的選擇、不影響 &lt;em>consistency 軸&lt;/em>（consistency 在 read routing 層決定）。Group Replication / MySQL Cluster（synchronous multi-primary）會同時改三軸、是另一個故事、不在本文 scope。&lt;/p>
&lt;p>跟這三軸獨立的、是 &lt;em>replication 機制本身的可維護性&lt;/em>。binlog position-based replication 用 &lt;code>(file, position)&lt;/code> 標 replica 進度、failover 時要對齊 position 容易出錯；&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/gtid/" data-link-title="GTID" data-link-desc="說明全域交易識別碼如何讓複製進度與故障切換不依賴實體 log 位置">&lt;strong>GTID（Global Transaction Identifier）&lt;/strong>&lt;/a>用全域 transaction ID 標進度、failover / re-pointing 不必算 position。GTID 是 &lt;em>跨 mode 的 infrastructure&lt;/em>、不是第三種 mode。&lt;/p>
&lt;h2 id="async-replicationdefault--高-throughput-的代價">Async replication：default + 高 throughput 的代價&lt;/h2>
&lt;p>Async 是 MySQL 預設、行為：&lt;/p>
&lt;ol>
&lt;li>Primary 寫 binlog、立刻 commit、回應 client OK&lt;/li>
&lt;li>Replica 的 IO thread 從 primary pull binlog event 到 local relay log&lt;/li>
&lt;li>Replica 的 SQL thread apply relay log（單 thread 或 multi-thread parallel）&lt;/li>
&lt;/ol>
&lt;p>&lt;strong>Trade-off&lt;/strong>：&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/mysql/" data-link-title="MySQL" data-link-desc="高併發網路服務常用關聯式資料庫、Vitess / PlanetScale 分片生態、GitHub / Shopify / Facebook 規模驗證">MySQL</a> overview 的 implementation-layer deep article。Overview 已說明 MySQL 在 OLTP 譜系的定位、本文聚焦 <em>replication topology</em> — 從 single primary 到 multi-replica 部署的 3 個 trade-off 軸跟 5 段配置。</p></blockquote>
<hr>
<h2 id="replication-的-3-個-trade-off-軸--mode-選擇">Replication 的 3 個 trade-off 軸 + mode 選擇</h2>
<p>Replication mode 選擇看起來是「選 async 還是 semi-sync」、但決策實際是 3 個獨立 trade-off 軸的權衡、async / semi-sync 是這些軸的兩個常見組合 <em>名稱</em>：</p>
<table>
  <thead>
      <tr>
          <th>軸</th>
          <th>端 A</th>
          <th>端 B</th>
          <th>MySQL 旋鈕</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>Durability</strong></td>
          <td>primary 寫完就 commit</td>
          <td>至少一個 standby 收到才 commit</td>
          <td><code>rpl_semi_sync_master_enabled</code> / sync ack count</td>
      </tr>
      <tr>
          <td><strong>Latency</strong></td>
          <td>client 等 primary 寫完 OK</td>
          <td>client 等 standby ack（額外 RTT）</td>
          <td><code>rpl_semi_sync_master_timeout</code></td>
      </tr>
      <tr>
          <td><strong>Consistency</strong></td>
          <td>replica 隨時可能 stale</td>
          <td>replica 跟 primary 保證讀到一致</td>
          <td>application read routing rule（不是 replication 旋鈕）</td>
      </tr>
  </tbody>
</table>
<p>「async vs semi-sync」實際上是 <em>durability + latency 兩軸</em> 的選擇、不影響 <em>consistency 軸</em>（consistency 在 read routing 層決定）。Group Replication / MySQL Cluster（synchronous multi-primary）會同時改三軸、是另一個故事、不在本文 scope。</p>
<p>跟這三軸獨立的、是 <em>replication 機制本身的可維護性</em>。binlog position-based replication 用 <code>(file, position)</code> 標 replica 進度、failover 時要對齊 position 容易出錯；<a href="/blog/backend/knowledge-cards/gtid/" data-link-title="GTID" data-link-desc="說明全域交易識別碼如何讓複製進度與故障切換不依賴實體 log 位置"><strong>GTID（Global Transaction Identifier）</strong></a>用全域 transaction ID 標進度、failover / re-pointing 不必算 position。GTID 是 <em>跨 mode 的 infrastructure</em>、不是第三種 mode。</p>
<h2 id="async-replicationdefault--高-throughput-的代價">Async replication：default + 高 throughput 的代價</h2>
<p>Async 是 MySQL 預設、行為：</p>
<ol>
<li>Primary 寫 binlog、立刻 commit、回應 client OK</li>
<li>Replica 的 IO thread 從 primary pull binlog event 到 local relay log</li>
<li>Replica 的 SQL thread apply relay log（單 thread 或 multi-thread parallel）</li>
</ol>
<p><strong>Trade-off</strong>：</p>
<ul>
<li>Durability：primary 寫完 commit、replica 還沒 pull = primary 在這瞬間 crash + 永久故障 → <em>data loss</em>（已 commit 的 transaction 在 replica 不存在）</li>
<li>Latency：client 不等 replica、寫入延遲 = primary 自身寫 binlog 的時間（通常 &lt; 1ms with <code>innodb_flush_log_at_trx_commit=1</code>）</li>
<li>Consistency：replica 可能 lag、application 讀 replica 會 stale；用 <code>SHOW SLAVE STATUS</code> 看 <code>Seconds_Behind_Master</code></li>
</ul>
<p><strong>適用</strong>：</p>
<ul>
<li>主流選擇（90% 場景）</li>
<li>Failover loss 在容忍範圍（多數 web 應用容忍 1-2 秒 data loss）</li>
<li>Read scaling 為主要 driver、絕對 durability 非首要</li>
</ul>
<p><strong>不適用</strong>：</p>
<ul>
<li>金融交易 / 訂單系統、不允許 any data loss</li>
<li>Compliance 要求 zero data loss（PCI-DSS / 部分監管場景）</li>
</ul>
<h2 id="semi-sync-replication至少一個-standby-ack-才-commit">Semi-sync replication：至少一個 standby ack 才 commit</h2>
<p>Semi-sync 在 async 基礎上加 <em>primary 等至少 N 個 replica ack 才 commit</em> 的步驟：</p>
<ol>
<li>Primary 寫 binlog</li>
<li>Primary 發送 binlog event 到所有 replica</li>
<li><em>Primary 等至少 N 個 replica 回 ack</em>（N 是 <code>rpl_semi_sync_master_wait_for_slave_count</code>、預設 1）</li>
<li>Primary commit、回應 client</li>
</ol>
<p><strong>Trade-off</strong>：</p>
<ul>
<li>Durability：至少 N 個 replica 收到 binlog（不一定 apply）、primary crash 後 replica 還有 binlog 可 promote、保證 zero data loss（但是 <em>binlog-level</em>、不是 <em>applied-level</em>）</li>
<li>Latency：client 等 primary + 一輪 replica ack RTT；跨 AZ 通常 +1-3ms、跨 region 可能 +50-200ms</li>
<li>Consistency：跟 async 一樣、replica apply 仍 async、application 讀 replica 仍可能 stale</li>
</ul>
<p><strong>MySQL 5.7+ 區分 <em>standard</em> 跟 <em>Loss-Less</em> semi-sync</strong>：</p>
<ul>
<li>Standard semi-sync（5.5-5.6）：primary 先 commit 再等 ack、ack 超時 fallback 成 async — <em>仍可能 lose data</em></li>
<li>Loss-Less semi-sync（5.7+、<code>rpl_semi_sync_master_wait_point=AFTER_SYNC</code>）：primary 寫完 binlog 但 <em>先等 ack 再 commit</em>、ack 超時 fallback async 之前已寫 binlog 仍保證 durable</li>
</ul>
<p>Production 場景必須用 Loss-Less semi-sync、不是 standard。</p>
<p><strong>適用</strong>：</p>
<ul>
<li>金融交易 / 訂單 / payment ledger</li>
<li>不允許 data loss、可接受寫入延遲 +1-3ms</li>
<li>已有 multi-AZ / multi-region 部署、replica 物理上可靠</li>
</ul>
<p><strong>不適用</strong>：</p>
<ul>
<li>跨 region semi-sync（RTT 50-200ms）通常不划算 — 寫吞吐砍半、改用 <em>region-local sync replica + cross-region async chain</em></li>
<li>寫吞吐 &gt; 50K WPS 且容忍 sub-second loss — async 即可</li>
</ul>
<h2 id="gtid-based-replication機制升級跨-mode-都需要">GTID-based replication：機制升級、跨 mode 都需要</h2>
<p>GTID 把每個 transaction 標一個全域 ID：<code>&lt;server_uuid&gt;:&lt;transaction_id&gt;</code>。Replica 紀錄「已 apply 的 GTID set」、不再用 <code>(binlog_file, position)</code>。</p>
<p><strong>為什麼 GTID 比 binlog position 好</strong>：</p>
<ul>
<li><strong>Failover re-pointing 簡單</strong>：promote 新 primary 後、其他 replica 重新 attach 不必算 <code>MASTER_LOG_FILE</code> + <code>MASTER_LOG_POS</code>、用 <code>CHANGE MASTER TO MASTER_AUTO_POSITION=1</code> 即可</li>
<li><strong>Multi-source replication 可行</strong>：一個 replica 從多個 primary 拉、各 primary 的 GTID set 獨立 track</li>
<li><strong>Consistency check 容易</strong>：兩個 server 對 GTID set、就知道誰落後、有無 gap</li>
<li><strong>跟 group replication / MySQL Cluster 必需</strong>：5.7+ 多 primary 場景 GTID 是前提</li>
</ul>
<p><strong>設定流程</strong>（兩階段、不能直接開）：</p>
<ol>
<li>
<p><strong>Phase 1 (預備、所有 server 同 mode)</strong>：</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">gtid_mode</span> <span class="o">=</span> <span class="s">ON_PERMISSIVE  -- 接受 GTID 跟 non-GTID transaction</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="na">enforce_gtid_consistency</span> <span class="o">=</span> <span class="s">ON  -- 拒絕無法用 GTID 表達的 statement（CREATE TABLE...SELECT 等）</span></span></span></code></pre></div></li>
<li>
<p><strong>Phase 2 (rolling、全部 server 都 Phase 1 後)</strong>：</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">gtid_mode</span> <span class="o">=</span> <span class="s">ON  -- 只接受 GTID transaction</span></span></span></code></pre></div></li>
</ol>
<p>跳 phase 直接 <code>gtid_mode=ON</code> 會讓 replication break（既有 non-GTID transaction 無法處理）。Production 啟用 GTID 要排 maintenance window、跑完 phase 1 觀察 1-2 天再進 phase 2。</p>
<h2 id="配置-step-by-steploss-less-semi-sync--gtid-組合">配置 step-by-step（Loss-Less semi-sync + GTID 組合）</h2>
<p>實務最常見組合：Loss-Less semi-sync + GTID。配置順序：</p>
<h3 id="step-1primary--replica-都開-gtid兩-phase-跑完">Step 1：Primary + replica 都開 GTID（兩 phase 跑完）</h3>





<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="c1"># my.cnf on primary AND replica</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="na">gtid_mode</span> <span class="o">=</span> <span class="s">ON</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="na">enforce_gtid_consistency</span> <span class="o">=</span> <span class="s">ON</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="na">log_bin</span> <span class="o">=</span> <span class="s">mysql-bin</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="na">log_slave_updates</span> <span class="o">=</span> <span class="s">1  -- replica 也記 binlog (chained replication 需要)</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="na">binlog_format</span> <span class="o">=</span> <span class="s">ROW    -- ROW 比 STATEMENT 安全</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="na">sync_binlog</span> <span class="o">=</span> <span class="s">1        -- 每次 commit fsync binlog</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="na">innodb_flush_log_at_trx_commit</span> <span class="o">=</span> <span class="s">1  -- 每次 commit fsync InnoDB log</span></span></span></code></pre></div><h3 id="step-2primary-安裝-semi-sync-plugin">Step 2：Primary 安裝 semi-sync plugin</h3>





<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="n">INSTALL</span><span class="w"> </span><span class="n">PLUGIN</span><span class="w"> </span><span class="n">rpl_semi_sync_master</span><span class="w"> </span><span class="n">SONAME</span><span class="w"> </span><span class="s1">&#39;semisync_master.so&#39;</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="k">SET</span><span class="w"> </span><span class="k">GLOBAL</span><span class="w"> </span><span class="n">rpl_semi_sync_master_enabled</span><span class="w"> </span><span class="o">=</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">3</span><span class="cl"><span class="w"></span><span class="k">SET</span><span class="w"> </span><span class="k">GLOBAL</span><span class="w"> </span><span class="n">rpl_semi_sync_master_wait_for_slave_count</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">1</span><span class="p">;</span><span class="w">  </span><span class="c1">-- 至少 1 個 ack
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="k">SET</span><span class="w"> </span><span class="k">GLOBAL</span><span class="w"> </span><span class="n">rpl_semi_sync_master_wait_point</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">AFTER_SYNC</span><span class="p">;</span><span class="w">   </span><span class="c1">-- Loss-Less
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="k">SET</span><span class="w"> </span><span class="k">GLOBAL</span><span class="w"> </span><span class="n">rpl_semi_sync_master_timeout</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">10000</span><span class="p">;</span><span class="w">           </span><span class="c1">-- 10s timeout、超時 fallback async</span></span></span></code></pre></div><h3 id="step-3replica-安裝-semi-sync-plugin">Step 3：Replica 安裝 semi-sync plugin</h3>





<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="n">INSTALL</span><span class="w"> </span><span class="n">PLUGIN</span><span class="w"> </span><span class="n">rpl_semi_sync_slave</span><span class="w"> </span><span class="n">SONAME</span><span class="w"> </span><span class="s1">&#39;semisync_slave.so&#39;</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="k">SET</span><span class="w"> </span><span class="k">GLOBAL</span><span class="w"> </span><span class="n">rpl_semi_sync_slave_enabled</span><span class="w"> </span><span class="o">=</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">3</span><span class="cl"><span class="w"></span><span class="n">STOP</span><span class="w"> </span><span class="n">SLAVE</span><span class="w"> </span><span class="n">IO_THREAD</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">START</span><span class="w"> </span><span class="n">SLAVE</span><span class="w"> </span><span class="n">IO_THREAD</span><span class="p">;</span><span class="w">  </span><span class="c1">-- 重啟 IO thread 啟用 semi-sync</span></span></span></code></pre></div><h3 id="step-4replica-attach-primary">Step 4：Replica attach primary</h3>





<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="n">CHANGE</span><span class="w"> </span><span class="n">MASTER</span><span class="w"> </span><span class="k">TO</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">MASTER_HOST</span><span class="o">=</span><span class="s1">&#39;primary.example.com&#39;</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">MASTER_PORT</span><span class="o">=</span><span class="mi">3306</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="n">MASTER_USER</span><span class="o">=</span><span class="s1">&#39;repl&#39;</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">MASTER_PASSWORD</span><span class="o">=</span><span class="s1">&#39;...&#39;</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">MASTER_AUTO_POSITION</span><span class="o">=</span><span class="mi">1</span><span class="p">;</span><span class="w">  </span><span class="c1">-- 用 GTID auto-position
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"></span><span class="k">START</span><span class="w"> </span><span class="n">SLAVE</span><span class="p">;</span></span></span></code></pre></div><h3 id="step-5驗證">Step 5：驗證</h3>





<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">-- Primary: 確認 semi-sync 啟用 + 有 active client
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">SHOW</span><span class="w"> </span><span class="n">STATUS</span><span class="w"> </span><span class="k">LIKE</span><span class="w"> </span><span class="s1">&#39;Rpl_semi_sync_master_status&#39;</span><span class="p">;</span><span class="w">      </span><span class="c1">-- ON
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"></span><span class="k">SHOW</span><span class="w"> </span><span class="n">STATUS</span><span class="w"> </span><span class="k">LIKE</span><span class="w"> </span><span class="s1">&#39;Rpl_semi_sync_master_clients&#39;</span><span class="p">;</span><span class="w">     </span><span class="c1">-- ≥ 1
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"></span><span class="k">SHOW</span><span class="w"> </span><span class="n">STATUS</span><span class="w"> </span><span class="k">LIKE</span><span class="w"> </span><span class="s1">&#39;Rpl_semi_sync_master_yes_tx&#39;</span><span class="p">;</span><span class="w">      </span><span class="c1">-- &gt; 0 (有 transaction 走 semi-sync)
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span><span class="k">SHOW</span><span class="w"> </span><span class="n">STATUS</span><span class="w"> </span><span class="k">LIKE</span><span class="w"> </span><span class="s1">&#39;Rpl_semi_sync_master_no_tx&#39;</span><span class="p">;</span><span class="w">       </span><span class="c1">-- 應該 = 0 (沒有 fallback 成 async)
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w"></span><span class="c1">-- Replica: 確認 GTID + IO thread 正常
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"></span><span class="k">SHOW</span><span class="w"> </span><span class="n">SLAVE</span><span class="w"> </span><span class="n">STATUS</span><span class="err">\</span><span class="k">G</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w"></span><span class="c1">-- Slave_IO_Running: Yes
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1">-- Slave_SQL_Running: Yes
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1">-- Retrieved_Gtid_Set: 跟 primary Executed_Gtid_Set 接近
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1">-- Seconds_Behind_Master: 觀察 lag</span></span></span></code></pre></div><h2 id="5-個-production-踩雷">5 個 Production 踩雷</h2>
<h3 id="1-replication-lag-暴衝--單-sql-thread-bottleneck">1. Replication lag 暴衝 — 單 SQL thread bottleneck</h3>
<p>預設 replica 的 SQL thread 是 <em>單 thread</em> apply、primary 多 thread 寫入時 replica 跟不上、lag 從 &lt; 100ms 飆到分鐘級。常見觸發：批次 UPDATE / DELETE、大 transaction、index rebuild。</p>
<p>修法：</p>
<ul>
<li>啟用 <em>multi-thread replication</em>：<code>slave_parallel_workers = 8</code>（per database 或 per logical clock parallel）</li>
<li>5.7+ 用 <code>slave_parallel_type = LOGICAL_CLOCK</code>：依 primary 上的 group commit 並行度自動 parallel</li>
<li>8.0+ 的 <em>writeset-based parallel</em>：<code>binlog_transaction_dependency_tracking = WRITESET</code>、更細粒度並行</li>
</ul>
<p>監控：<code>Seconds_Behind_Master</code> 是 <em>表面指標</em>、實際看 <code>Executed_Gtid_Set</code> 跟 primary 對比的 GTID gap 更準。</p>
<h3 id="2-semi-sync-timeout-fallback-成-async沒監控就看不見">2. Semi-sync timeout fallback 成 async（沒監控就看不見）</h3>
<p><code>rpl_semi_sync_master_timeout</code> 預設 10000ms（10 秒）、超時後 <em>自動 fallback async</em>、直到 replica 重連。Application 視角看不到任何 error、但 <em>durability guarantee 已失效</em>。</p>
<p>修法：</p>
<ul>
<li>監控 <code>Rpl_semi_sync_master_status</code> — fallback 後變 OFF</li>
<li>監控 <code>Rpl_semi_sync_master_no_tx</code> — fallback 期間每個 transaction 都計數</li>
<li>Alert 規則：5 分鐘內 <code>no_tx</code> 增加 &gt; 0 即告警</li>
<li>Timeout 設太短（&lt; 5s）容易 false positive、設太長（&gt; 30s）crash 時 data loss 風險增</li>
</ul>
<h3 id="3-gtid-gap--replica-無法-attach">3. GTID gap — replica 無法 attach</h3>
<p>Replica 重新 attach primary 時報 <code>ERROR 1236: ... transactions you need from master are purged</code>、原因是 primary 的 <code>binlog_expire_logs_seconds</code> 過短、需要的 binlog 已被清掉。GTID 模式下這個錯誤更明顯（直接看 GTID gap）、但 binlog position 模式下也一樣。</p>
<p>修法：</p>
<ul>
<li><code>binlog_expire_logs_seconds = 604800</code>（7 天）作為 baseline</li>
<li>大流量 server 確認 disk 容量能撐 7 天 binlog（一個高峰小時 binlog 可能 GB 級）</li>
<li>真的 gap 太大時用 <em>base backup + replay binlog</em> 重建 replica、不要硬 reset GTID</li>
</ul>
<h3 id="4-loss-less-semi-sync-不一定真的-loss-less">4. Loss-Less semi-sync 不一定真的 loss-less</h3>
<p><code>AFTER_SYNC</code> 模式 <em>primary 寫 binlog → 等 ack → commit</em>、看起來 zero loss。但 <em>primary 寫完 binlog 還沒等 ack 時 crash</em> + replica <em>剛好沒收到那個 binlog event</em> + replica promote — 這個 binlog event 在新 primary 不存在、但舊 primary 的 binlog 仍紀錄為 <em>已寫 binlog 未 commit</em>。client 收到 <em>connection lost</em>、不知道 transaction 是否成功。</p>
<p>修法：</p>
<ul>
<li>接受這個 <em>edge case unknown state</em>、application 用 idempotency key + retry 處理</li>
<li>Loss-Less semi-sync 保證的是 <em>已 commit transaction 不會丟</em>、不是 <em>所有寫入都 ack-and-tell</em></li>
<li>真的 zero unknown state 需要 group replication / Galera Cluster / MySQL Cluster（synchronous multi-primary）</li>
</ul>
<h3 id="5-chained-replication-雪崩">5. Chained replication 雪崩</h3>
<p>Topology 是 <code>primary → replica1 → replica2 → ...</code>（hub-and-spoke 之外的選擇、節省 primary 出口頻寬）。Replica1 SQL thread 卡住、replica2 跟 replica3 都被 block、整條 chain 雪崩。</p>
<p>修法：</p>
<ul>
<li>避免超過 2 層 chain（primary → tier1 replica → tier2 replica 是上限）</li>
<li>用 <em>parallel binary log relay</em>（5.7+ <code>slave_pending_jobs_size_max</code> + parallel workers）讓 chain 中段不阻塞</li>
<li>規模真的大、改用 <em>binlog server</em>（如 Maxwell / MaxScale）解耦 chain dependency</li>
<li>跨 region 用 <em>region-local hub + cross-region async</em>、不是長 chain</li>
</ul>
<h2 id="容量--cost-對照">容量 / cost 對照</h2>
<table>
  <thead>
      <tr>
          <th>配置</th>
          <th>寫吞吐影響</th>
          <th>Replica overhead</th>
          <th>適合 workload</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Async + binlog position</td>
          <td>baseline</td>
          <td>低（IO + SQL thread）</td>
          <td>高吞吐、容忍 sub-second loss</td>
      </tr>
      <tr>
          <td>Async + GTID</td>
          <td>baseline</td>
          <td>同上、failover 容易</td>
          <td>大多數 production 預設</td>
      </tr>
      <tr>
          <td>Loss-Less semi-sync + GTID（1 ack）</td>
          <td>-10% ~ -20%</td>
          <td>同上 + ack RTT</td>
          <td>金融、訂單、不容忍 data loss</td>
      </tr>
      <tr>
          <td>Loss-Less semi-sync + GTID（2 ack）</td>
          <td>-15% ~ -30%</td>
          <td>同上、跨 AZ</td>
          <td>強 durability + multi-AZ HA</td>
      </tr>
      <tr>
          <td>Group Replication（synchronous）</td>
          <td>-30% ~ -50%</td>
          <td>高（每 transaction quorum）</td>
          <td>不允許 single-primary、multi-primary 寫入</td>
      </tr>
  </tbody>
</table>
<p>跨 AZ semi-sync 通常加 1-3ms、跨 region 加 50-200ms — 寫密集 workload 跨 region semi-sync 通常不划算、改用 <em>region-local sync + cross-region async chain</em>。</p>
<h2 id="整合--下一步">整合 / 下一步</h2>
<h3 id="aurora-mysql">Aurora MySQL</h3>
<p>Aurora MySQL 用 <em>AWS-managed storage layer</em>、storage 自動 replicate 6 份跨 3 AZ、不需要應用層配 semi-sync。從自管 MySQL 遷 Aurora 時、上方所有 semi-sync 配置 <em>消失</em>、改成 Aurora storage quorum（4 of 6 write、3 of 6 read）。</p>
<p>trade-off 軸的 <em>durability</em> 完全交給 Aurora、application 只關心 <em>latency</em> + <em>consistency</em>。詳見 <a href="/blog/backend/01-database/vendors/aurora/" data-link-title="AWS Aurora" data-link-desc="AWS managed PostgreSQL / MySQL、storage / compute 分離、&#43;75% 效能改善的 production 證據">Aurora vendor page</a>。</p>
<h3 id="vitesssharding-layer">Vitess（sharding layer）</h3>
<p>Vitess shard 內部仍用 MySQL replication（async or semi-sync）、Vitess 不取代 replication topology、是 <em>上層 routing</em>。Vitess <code>vttablet</code> 每個 shard 有自己的 primary + replica、跟本文 topology 設計一致。</p>
<p>Vitess 比較大議題在 <em>cross-shard transaction</em>（VReplication 跨 shard binlog stream）、不是 replication topology — 詳見 MySQL backlog 中 <em>Vitess sharding 設計</em> 篇（待寫）。</p>
<h3 id="proxysqlread-replica-routing">ProxySQL（read replica routing）</h3>
<p>ProxySQL 是 MySQL 生態的 <em>connection pool + query routing</em> 標準、按 query type（SELECT vs DML）跟 replica lag 自動 route。寫入路 primary、讀走 replica、replica lag &gt; N 秒時暫時退路 primary 維持 consistency。</p>
<p>ProxySQL 跟本文 replication topology 是 <em>互補不重疊</em> — replication 設定哪些 server 有什麼資料、ProxySQL 設定 query 怎麼分配。詳見 MySQL backlog 中 <em>ProxySQL 配置</em> 篇（待寫）。</p>
<h3 id="orchestratorha-failover">Orchestrator（HA failover）</h3>
<p>Orchestrator 是 MySQL HA topology 管理 + 自動 failover 工具、用 GTID 偵測 replica 進度、failover 時自動 promote 最新 replica。對比 PostgreSQL 的 Patroni（詳見 <a href="/blog/backend/01-database/vendors/postgresql/patroni-ha/" data-link-title="PostgreSQL Patroni HA：從 leader 失聯到 client 重連的 5 段 failover lifecycle" data-link-desc="Patroni 把 PostgreSQL HA 拆成 detection / election / promotion / reconfiguration / recovery 五段 lifecycle、每段都有獨立配置跟 failure mode；DCS quorum &#43; watchdog 防 split-brain、async/sync replication 取捨、5 個 production 踩雷、跟 PgBouncer / HAProxy / cert-manager 整合">Patroni HA</a>）— 兩者角色相同、Orchestrator 需要 GTID + 對 MySQL 行為熟、Patroni 需要 DCS（etcd / Consul）+ 對 PG 行為熟。</p>
<p>詳見 MySQL backlog 中 <em>Orchestrator failover 設計</em> 篇（待寫）。</p>
<h3 id="cdcmaxwell--debezium">CDC（Maxwell / Debezium）</h3>
<p>Maxwell（Zendesk 出品、MySQL-only）跟 Debezium（Red Hat、MySQL / PG / MongoDB 都支援）都讀 MySQL binlog 轉成 event stream（Kafka / Kinesis / Pulsar）。Binlog 必須 <code>ROW</code> format、GTID 啟用後 <em>exactly-once</em> delivery 更好維護（不需算 binlog position）。</p>
<p>跟 PG logical replication + Debezium 對比、MySQL 用 binlog（physical / row-level）不是 logical decoding、所以 schema change 時 <em>CDC consumer 要 schema-aware</em> 處理。詳見 MySQL backlog 中 <em>Binary log + Maxwell / Debezium CDC</em> 篇（待寫）。</p>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/mysql/" data-link-title="MySQL" data-link-desc="高併發網路服務常用關聯式資料庫、Vitess / PlanetScale 分片生態、GitHub / Shopify / Facebook 規模驗證">MySQL vendor overview</a></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 整合">PostgreSQL Replication Topology</a>（PG sibling、streaming + LSN + slot 機制 vs MySQL binlog 對位）</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/patroni-ha/" data-link-title="PostgreSQL Patroni HA：從 leader 失聯到 client 重連的 5 段 failover lifecycle" data-link-desc="Patroni 把 PostgreSQL HA 拆成 detection / election / promotion / reconfiguration / recovery 五段 lifecycle、每段都有獨立配置跟 failure mode；DCS quorum &#43; watchdog 防 split-brain、async/sync replication 取捨、5 個 production 踩雷、跟 PgBouncer / HAProxy / cert-manager 整合">PostgreSQL Patroni HA</a>（PG sibling、不同 HA 機制）</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/logical-replication-debezium/" data-link-title="PostgreSQL Logical Replication &#43; Debezium CDC：replication slot × failure × recovery 對照" data-link-desc="PostgreSQL logical replication slot 跟 Debezium CDC 的失效模式對照表：slot lag 撐爆 primary disk / schema change 斷流 / 初始 COPY 鎖表 / zombie slot 不釋放 / replay storm 後 offset reset；publication / subscription / pgoutput 配置、跟 Kafka outbox pattern 整合">PostgreSQL Logical Replication + Debezium</a>（PG CDC sibling、不同 replication 抽象層）</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/replication-slot-management/" data-link-title="PostgreSQL Replication Slot Management：Physical / Logical / Failover Slot 治理" data-link-desc="PG replication slot 是 *primary 端的 standby 進度紀錄*、防 WAL premature deletion。但 orphan slot 會吃 disk、failover 後 logical slot 不會自動跟新 primary、是 PG 操作的 hidden complexity。本文走 physical / logical slot 差異、slot lifecycle、failover slot synchronization（PG 17&#43; 新特性）、orphan slot 治理、5 production 踩雷（orphan slot disk 爆 / logical slot lag / failover 後 slot 丟 / wal_keep_size 跟 slot 衝突 / connection 同時打 slot 數量限制）">PostgreSQL Replication Slot Management</a>（PG slot 治理、MySQL 無對應概念）</li>
<li><a href="/blog/backend/01-database/vendors/aurora/" data-link-title="AWS Aurora" data-link-desc="AWS managed PostgreSQL / MySQL、storage / compute 分離、&#43;75% 效能改善的 production 證據">Aurora vendor page</a>（managed MySQL、replication 交給 storage layer）</li>
<li><a href="/blog/backend/01-database/transaction-boundary/" data-link-title="1.3 Transaction 與一致性邊界" data-link-desc="交易邊界、isolation level、retry 策略、distributed transaction（2PC、Saga）與跨 region 強一致取捨">1.3 Transaction Boundary</a>（transaction 行為跟 replication 互動）</li>
<li><a href="/blog/backend/01-database/kv-document-capacity-planning/" data-link-title="1.10 KV / Document DB 容量規劃" data-link-desc="DynamoDB / Cosmos DB / Bigtable / MongoDB 等 KV / Document DB 的容量設計、partition key 取捨、capacity mode 選擇">1.10 KV / Document DB 容量規劃</a> / <a href="/blog/backend/01-database/global-distributed-oltp/" data-link-title="1.11 全球分散式 OLTP" data-link-desc="Spanner / Aurora DSQL / Cosmos DB multi-region write / CockroachDB / TiDB 的全球一致性取捨">1.11 全球分散式 OLTP</a>（替代路徑）</li>
<li><a href="/blog/backend/knowledge-cards/quorum/" data-link-title="Quorum" data-link-desc="分散式系統以多數節點同意作為提交或讀取有效性的門檻">quorum 卡片</a> / <a href="/blog/backend/knowledge-cards/eventual-consistency/" data-link-title="Eventual Consistency" data-link-desc="允許短暫不一致、最終收斂到同一資料狀態的一致性語意">eventual consistency 卡片</a> / <a href="/blog/backend/knowledge-cards/stale-read/" data-link-title="Stale Read" data-link-desc="讀取到落後於最新寫入版本的舊資料">stale read 卡片</a></li>
<li>官方：<a href="https://dev.mysql.com/doc/refman/8.0/en/replication.html">MySQL Replication</a> / <a href="https://dev.mysql.com/doc/refman/8.0/en/replication-semisync.html">Semi-Sync</a> / <a href="https://dev.mysql.com/doc/refman/8.0/en/replication-gtids.html">GTID</a></li>
</ul>
]]></content:encoded></item><item><title>PostgreSQL Replication Topology：async / sync / quorum 三模式跟 LSN + replication slot 的三軸組合</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/replication-topology/</link><pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/replication-topology/</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>streaming replication topology&lt;/em> — 從 single primary 到 multi-standby 部署的 3 個 trade-off 軸 + LSN + replication slot 機制。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="replication-的-3-個-trade-off-軸--mode-選擇">Replication 的 3 個 trade-off 軸 + mode 選擇&lt;/h2>
&lt;p>PG streaming replication mode 選擇看起來是「async 還是 sync」、實際是 3 個獨立 trade-off 軸的組合、async / sync / quorum-based sync 是這些軸的常見組合 &lt;em>名稱&lt;/em>：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>軸&lt;/th>
 &lt;th>端 A&lt;/th>
 &lt;th>端 B&lt;/th>
 &lt;th>PG 旋鈕&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;strong>Durability&lt;/strong>&lt;/td>
 &lt;td>primary 寫完就 commit&lt;/td>
 &lt;td>至少一個 standby 收到才 commit&lt;/td>
 &lt;td>&lt;code>synchronous_commit&lt;/code> / &lt;code>synchronous_standby_names&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Latency&lt;/strong>&lt;/td>
 &lt;td>client 等 primary 寫完 OK&lt;/td>
 &lt;td>client 等 standby ack（額外 RTT）&lt;/td>
 &lt;td>同上&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Consistency&lt;/strong>&lt;/td>
 &lt;td>standby 隨時可能 stale&lt;/td>
 &lt;td>standby 跟 primary 保證讀到一致&lt;/td>
 &lt;td>application read routing rule（不是 replication 旋鈕）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>跟這三軸獨立的、是 &lt;em>replication 機制本身的可維護性&lt;/em>：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>LSN（Log Sequence Number）&lt;/strong>：PG 用全域 byte offset 標 WAL 進度、所有 standby 同步用 LSN 對齊、不像 MySQL 早期 binlog position + file 雙欄&lt;/li>
&lt;li>&lt;strong>Replication slot&lt;/strong>：primary 紀錄每個 standby 已接收的 LSN、防 standby 失聯期間 WAL 被清掉、是 streaming replication 的 &lt;em>持久化進度追蹤&lt;/em>&lt;/li>
&lt;/ul>
&lt;p>跟 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/replication-topology/" data-link-title="MySQL Replication Topology：async / semi-sync / GTID 不是三選一、是三個 trade-off 軸的疊加" data-link-desc="MySQL replication 不是「選 async 還是 semi-sync」、是 *durability / latency / consistency* 三個 trade-off 軸的疊加；GTID 是跨 mode 的 infrastructure layer、不是第三種 mode。本文走 3 軸取捨模型 → async / semi-sync 行為對比 → GTID 替代 binlog-position 的好處 → 配置 step-by-step → 5 production 踩雷（lag 暴衝 / semi-sync 退回 async / GTID gap / Loss-Less semi-sync 真的 loss-less / chained replication 雪崩）→ 跟 Aurora MySQL / Vitess / ProxySQL / Orchestrator 整合">MySQL Replication Topology&lt;/a> 對比、PG 的 LSN + replication slot 直接內建 &lt;em>standby 進度追蹤&lt;/em>、不像 MySQL 5.7- 要靠 binlog position + GTID 雙機制；但 slot 是 &lt;em>primary 紀錄&lt;/em>、orphan slot 是 PG-specific 議題（slot 留 WAL 直到 standby 重連、standby 永久失聯 → primary disk 爆）。&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>streaming replication topology</em> — 從 single primary 到 multi-standby 部署的 3 個 trade-off 軸 + LSN + replication slot 機制。</p></blockquote>
<hr>
<h2 id="replication-的-3-個-trade-off-軸--mode-選擇">Replication 的 3 個 trade-off 軸 + mode 選擇</h2>
<p>PG streaming replication mode 選擇看起來是「async 還是 sync」、實際是 3 個獨立 trade-off 軸的組合、async / sync / quorum-based sync 是這些軸的常見組合 <em>名稱</em>：</p>
<table>
  <thead>
      <tr>
          <th>軸</th>
          <th>端 A</th>
          <th>端 B</th>
          <th>PG 旋鈕</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>Durability</strong></td>
          <td>primary 寫完就 commit</td>
          <td>至少一個 standby 收到才 commit</td>
          <td><code>synchronous_commit</code> / <code>synchronous_standby_names</code></td>
      </tr>
      <tr>
          <td><strong>Latency</strong></td>
          <td>client 等 primary 寫完 OK</td>
          <td>client 等 standby ack（額外 RTT）</td>
          <td>同上</td>
      </tr>
      <tr>
          <td><strong>Consistency</strong></td>
          <td>standby 隨時可能 stale</td>
          <td>standby 跟 primary 保證讀到一致</td>
          <td>application read routing rule（不是 replication 旋鈕）</td>
      </tr>
  </tbody>
</table>
<p>跟這三軸獨立的、是 <em>replication 機制本身的可維護性</em>：</p>
<ul>
<li><strong>LSN（Log Sequence Number）</strong>：PG 用全域 byte offset 標 WAL 進度、所有 standby 同步用 LSN 對齊、不像 MySQL 早期 binlog position + file 雙欄</li>
<li><strong>Replication slot</strong>：primary 紀錄每個 standby 已接收的 LSN、防 standby 失聯期間 WAL 被清掉、是 streaming replication 的 <em>持久化進度追蹤</em></li>
</ul>
<p>跟 <a href="/blog/backend/01-database/vendors/mysql/replication-topology/" data-link-title="MySQL Replication Topology：async / semi-sync / GTID 不是三選一、是三個 trade-off 軸的疊加" data-link-desc="MySQL replication 不是「選 async 還是 semi-sync」、是 *durability / latency / consistency* 三個 trade-off 軸的疊加；GTID 是跨 mode 的 infrastructure layer、不是第三種 mode。本文走 3 軸取捨模型 → async / semi-sync 行為對比 → GTID 替代 binlog-position 的好處 → 配置 step-by-step → 5 production 踩雷（lag 暴衝 / semi-sync 退回 async / GTID gap / Loss-Less semi-sync 真的 loss-less / chained replication 雪崩）→ 跟 Aurora MySQL / Vitess / ProxySQL / Orchestrator 整合">MySQL Replication Topology</a> 對比、PG 的 LSN + replication slot 直接內建 <em>standby 進度追蹤</em>、不像 MySQL 5.7- 要靠 binlog position + GTID 雙機制；但 slot 是 <em>primary 紀錄</em>、orphan slot 是 PG-specific 議題（slot 留 WAL 直到 standby 重連、standby 永久失聯 → primary disk 爆）。</p>
<h2 id="async-streamingdefault--高-throughput-的代價">Async streaming：default + 高 throughput 的代價</h2>
<p>Async 是 PG 預設、行為：</p>
<ol>
<li>Primary 寫 WAL 進 <code>pg_wal/</code> 目錄、commit、回應 client OK</li>
<li>WAL sender process 把 WAL stream 給 standby</li>
<li>Standby WAL receiver 寫 standby 的 <code>pg_wal/</code>、startup 進程 redo 套用</li>
</ol>
<p><strong>Trade-off</strong>：</p>
<ul>
<li>Durability：primary commit 後 standby 還沒收 → primary 永久故障 → <em>data loss</em>（已 commit 的 transaction 在 standby 不存在）</li>
<li>Latency：client 寫入延遲 = primary 自身 fsync WAL 的時間（<code>fsync=on</code> + <code>synchronous_commit=on</code> 預設、通常 &lt; 1ms 在 SSD / NVMe）</li>
<li>Consistency：standby 可能 lag、application 讀 standby 會 stale；用 <code>pg_stat_replication.write_lag / flush_lag / replay_lag</code> 看</li>
</ul>
<p><strong>配置</strong>：</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="c1"># postgresql.conf on primary</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="na">wal_level</span> <span class="o">=</span> <span class="s">replica          # 至少 replica（logical 是 superset）</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="na">max_wal_senders</span> <span class="o">=</span> <span class="s">10         # 並行 WAL sender process 數（依 standby 數量）</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="na">wal_keep_size</span> <span class="o">=</span> <span class="s">1024MB       # WAL 保留量（slot 為主、但 backup buffer）</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="na">synchronous_commit</span> <span class="o">=</span> <span class="s">on      # 預設、primary 自己 fsync WAL</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"># synchronous_standby_names 留空 = async</span></span></span></code></pre></div><p><strong>適用</strong>：</p>
<ul>
<li>主流選擇（90% 場景）</li>
<li>Failover loss 在容忍範圍（多數 web 應用容忍 1-2 秒 data loss）</li>
<li>Read scaling 為主要 driver、絕對 durability 非首要</li>
</ul>
<h2 id="sync-streaming至少一個-standby-flush-wal-才-commit">Sync streaming：至少一個 standby flush WAL 才 commit</h2>
<p>Sync mode 在 async 基礎上加 <em>primary 等指定 standby flush WAL 才回 client</em>：</p>
<ol>
<li>Primary 寫 WAL、send to standby</li>
<li>Standby 收到 WAL、寫進 <code>pg_wal/</code>、fsync、回 ack</li>
<li><em>Primary 等 ack</em> → commit → 回 client</li>
</ol>
<p><code>synchronous_commit</code> 有 5 個 level、不是 binary：</p>
<table>
  <thead>
      <tr>
          <th>Level</th>
          <th>行為</th>
          <th>Latency 影響</th>
          <th>Crash data loss</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>off</code></td>
          <td>primary 不等自己 fsync、background flush</td>
          <td>+0</td>
          <td>primary crash 丟 0-1 秒</td>
      </tr>
      <tr>
          <td><code>local</code></td>
          <td>primary fsync own WAL（不等 standby）</td>
          <td>baseline</td>
          <td>primary crash 0、standby 丟</td>
      </tr>
      <tr>
          <td><code>remote_write</code></td>
          <td>primary fsync + standby 收到（不必 standby fsync）</td>
          <td>+1 RTT 大致</td>
          <td>OS crash on standby 丟</td>
      </tr>
      <tr>
          <td><code>on</code> (預設)</td>
          <td>primary fsync + standby fsync（standby 收進 disk）</td>
          <td>+1 RTT + fsync</td>
          <td>全 crash 都不丟</td>
      </tr>
      <tr>
          <td><code>remote_apply</code></td>
          <td>primary fsync + standby fsync + standby 已 <em>replay</em>（visible to read）</td>
          <td>+1 RTT + fsync + replay</td>
          <td>全 crash 都不丟 + replica 立刻可讀</td>
      </tr>
  </tbody>
</table>
<p><strong>配置（synchronous）</strong>：</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">synchronous_commit</span> <span class="o">=</span> <span class="s">on</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="na">synchronous_standby_names</span> <span class="o">=</span> <span class="s">&#39;FIRST 1 (standby1, standby2)&#39;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"># &#39;FIRST 1&#39; = 第一個 active standby ack 即可</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># &#39;ANY 2 (s1, s2, s3)&#39; = 任 2 個 ack 即可（quorum-based）</span></span></span></code></pre></div><p><strong>Quorum-based sync</strong>：用 <code>ANY N</code> 語法、達到 N 個 ack 就 commit、提高 latency stability（不依賴特定 standby）：</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">synchronous_standby_names</span> <span class="o">=</span> <span class="s">&#39;ANY 2 (standby1, standby2, standby3)&#39;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># 3 個 standby 中任 2 個 ack 即 commit</span></span></span></code></pre></div><p><strong>適用</strong>：</p>
<ul>
<li>金融交易 / 訂單 / payment ledger（不允許 data loss）</li>
<li>已有 multi-AZ deploy、replica 物理上可靠</li>
<li>可接受寫入延遲 +1-3ms (跨 AZ)</li>
</ul>
<p><strong>不適用</strong>：</p>
<ul>
<li>跨 region sync（RTT 50-200ms）— 寫吞吐砍半、改用 <em>region-local sync + cross-region async</em></li>
<li>寫吞吐 &gt; 50K WPS + 容忍 sub-second loss — async 即可</li>
</ul>
<h2 id="lsn--replication-slotpg-的進度追蹤機制">LSN + Replication Slot：PG 的進度追蹤機制</h2>
<p>PG 每個 WAL 寫入都標 <em>LSN</em>（64-bit byte offset）。Standby 紀錄 <em>已收到 / 已 flush / 已 replay</em> 的 LSN、primary 透過 streaming protocol 知道每個 standby 進度。</p>
<p><strong>Replication slot</strong> 是 <em>primary 端的 standby 進度紀錄</em>：</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">-- 建 physical replication slot（給 streaming replication 用）
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">pg_create_physical_replication_slot</span><span class="p">(</span><span class="s1">&#39;standby1_slot&#39;</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></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="c1">-- 查 slot 狀態
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">slot_name</span><span class="p">,</span><span class="w"> </span><span class="n">active</span><span class="p">,</span><span class="w"> </span><span class="n">restart_lsn</span><span class="p">,</span><span class="w"> </span><span class="n">confirmed_flush_lsn</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">pg_size_pretty</span><span class="p">(</span><span class="n">pg_wal_lsn_diff</span><span class="p">(</span><span class="n">pg_current_wal_lsn</span><span class="p">(),</span><span class="w"> </span><span class="n">restart_lsn</span><span class="p">))</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">lag</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">FROM</span><span class="w"> </span><span class="n">pg_replication_slots</span><span class="p">;</span></span></span></code></pre></div><p><strong>Slot 的核心責任</strong>：</p>
<ul>
<li><em>防 WAL premature deletion</em>：standby 失聯（restart / network blip）、primary 仍保留 slot 對應 LSN 之後的 WAL、standby 重連可繼續 stream</li>
<li><em>無需 base backup re-build</em>：跟沒 slot 的 standby 對比、有 slot 的 standby 失聯後重連、不用重建</li>
</ul>
<p><strong>Slot 跟 <code>wal_keep_size</code></strong>：</p>
<ul>
<li><code>wal_keep_size</code>（PG 13+）/ <code>wal_keep_segments</code>（&lt; 13）：minimum WAL 保留量、不依賴 slot</li>
<li>Slot 是 <em>動態保留</em>：直到 slot 的 standby 推進 LSN 才釋放對應 WAL</li>
<li>兩者組合：<code>wal_keep_size</code> 是底線、slot 是 standby-specific 動態保留</li>
</ul>
<p><strong>Standby 配置（用 slot）</strong>：</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="c1"># standby1 postgresql.conf</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="na">primary_conninfo</span> <span class="o">=</span> <span class="s">&#39;host=primary.example.com port=5432 user=replication password=...&#39;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="na">primary_slot_name</span> <span class="o">=</span> <span class="s">&#39;standby1_slot&#39;   # 用 primary 上預先建的 slot</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="na">hot_standby</span> <span class="o">=</span> <span class="s">on                       # 讓 standby 接受 read query</span></span></span></code></pre></div><p><code>standby.signal</code> 空檔案在 PG_DATA 內、告訴 PG 這是 standby、進入 recovery mode。</p>
<h2 id="配置-step-by-stepsync-streaming--slot">配置 step-by-step（sync streaming + slot）</h2>
<p>實務最常見組合：sync streaming + replication slot + cross-AZ replica。</p>
<h3 id="step-1primary-配置">Step 1：Primary 配置</h3>





<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="c1"># postgresql.conf</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="na">wal_level</span> <span class="o">=</span> <span class="s">replica</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="na">max_wal_senders</span> <span class="o">=</span> <span class="s">10</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="na">max_replication_slots</span> <span class="o">=</span> <span class="s">10</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="na">synchronous_commit</span> <span class="o">=</span> <span class="s">on</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="na">synchronous_standby_names</span> <span class="o">=</span> <span class="s">&#39;FIRST 1 (standby1, standby2)&#39;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="na">wal_keep_size</span> <span class="o">=</span> <span class="s">1024MB</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="c1"># pg_hba.conf — 允許 replication 連線</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="na">host replication replication 10.0.0.0/16 scram-sha-256</span></span></span></code></pre></div><p>Restart primary 套用。</p>
<h3 id="step-2建-replication-user--slot">Step 2：建 replication user + slot</h3>





<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">USER</span><span class="w"> </span><span class="n">replication</span><span class="w"> </span><span class="k">WITH</span><span class="w"> </span><span class="n">REPLICATION</span><span class="w"> </span><span class="n">PASSWORD</span><span class="w"> </span><span class="s1">&#39;...&#39;</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="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">pg_create_physical_replication_slot</span><span class="p">(</span><span class="s1">&#39;standby1_slot&#39;</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="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">pg_create_physical_replication_slot</span><span class="p">(</span><span class="s1">&#39;standby2_slot&#39;</span><span class="p">);</span></span></span></code></pre></div><h3 id="step-3standby-base-backup">Step 3：Standby base backup</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 在 standby 上跑</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">pg_basebackup -h primary.example.com -D /var/lib/postgresql/data <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  -U replication -P -X stream <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  -S standby1_slot -R
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># -R: 自動生成 standby.signal + primary_conninfo</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"># -X stream: 邊 backup 邊 stream 增量 WAL（避免 backup 期間 WAL gap）</span></span></span></code></pre></div><h3 id="step-4standby-啟動">Step 4：Standby 啟動</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># standby /var/lib/postgresql/data/postgresql.auto.conf 已有：</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># primary_conninfo = &#39;host=primary.example.com user=replication password=... application_name=standby1&#39;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"># primary_slot_name = &#39;standby1_slot&#39;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl">pg_ctl -D /var/lib/postgresql/data start</span></span></code></pre></div><h3 id="step-5驗證">Step 5：驗證</h3>





<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">-- Primary: 確認 standby 連上
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">application_name</span><span class="p">,</span><span class="w"> </span><span class="k">state</span><span class="p">,</span><span class="w"> </span><span class="n">sync_state</span><span class="p">,</span><span class="w"> </span><span class="n">write_lag</span><span class="p">,</span><span class="w"> </span><span class="n">flush_lag</span><span class="p">,</span><span class="w"> </span><span class="n">replay_lag</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="n">pg_stat_replication</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="c1">-- 應顯示 standby1 / streaming / sync / 各 lag
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w"></span><span class="c1">-- Standby: 確認在 recovery + 收到 WAL
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">pg_is_in_recovery</span><span class="p">(),</span><span class="w"> </span><span class="n">pg_last_wal_receive_lsn</span><span class="p">(),</span><span class="w"> </span><span class="n">pg_last_wal_replay_lsn</span><span class="p">();</span></span></span></code></pre></div><h2 id="5-個-production-踩雷">5 個 Production 踩雷</h2>
<h3 id="1-standby-lag-暴衝--single-replay-process-bottleneck">1. Standby lag 暴衝 — Single replay process bottleneck</h3>
<p>PG standby 是 <em>single startup process</em> 套用 WAL（不像 MySQL multi-thread replication）、primary 高並發寫入時 standby 跟不上、lag 從 &lt; 100ms 飆到分鐘級。常見觸發：批次 UPDATE / DELETE、大 transaction、index 建立、autovacuum 大量 dead tuple cleanup。</p>
<p>修法：</p>
<ul>
<li><em>Parallel WAL apply</em>（PG 14+）：<code>max_parallel_workers_per_gather</code> 增加 background worker、但仍受 startup process 主導</li>
<li>對 <em>read scaling</em> 場景接受 standby lag、application 用 <em>primary read 對 latency-critical query</em></li>
<li><em>Cascading replication</em> 對 high-fan-out 解決 sender CPU bottleneck、但 standby replay 仍 single-thread</li>
</ul>
<p>監控：<code>pg_stat_replication.replay_lag</code> 是 <em>最後一個 commit 到 standby replay 的時間差</em>、超過 threshold 即告警。</p>
<h3 id="2-sync-standby-失聯時-primary-commit-卡住">2. Sync standby 失聯時 primary commit 卡住</h3>
<p><code>synchronous_standby_names = 'FIRST 1 (standby1)'</code> + standby1 down → primary commit <em>等永遠</em>。Application 全部 timeout。</p>
<p>修法：</p>
<ul>
<li>用 <code>ANY N</code> quorum：<code>synchronous_standby_names = 'ANY 1 (standby1, standby2)'</code> — 任一 standby ack 即可</li>
<li>設多 standby、防單一失聯</li>
<li>監控 sync standby 健康、自動 failover 切 sync mode 到其他 standby（Patroni 自動做）</li>
<li>緊急情況：在 primary 跑 <code>ALTER SYSTEM SET synchronous_standby_names = ''; SELECT pg_reload_conf();</code> 暫時退 async（接受 data loss risk）</li>
</ul>
<h3 id="3-orphan-replication-slot--primary-disk-爆">3. Orphan replication slot — Primary disk 爆</h3>
<p>Standby 失聯（永久故障 / 重 decommission 但忘了 drop slot）、primary slot 持續保留 WAL、<code>pg_wal/</code> 累積到 disk 滿、primary 也掛。</p>
<p>修法：</p>
<ul>
<li>
<p>監控 <code>pg_replication_slots.active</code> — <code>false</code> 持續 &gt; N 小時是警訊</p>
</li>
<li>
<p>監控 slot lag：</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">SELECT</span><span class="w"> </span><span class="n">slot_name</span><span class="p">,</span><span class="w"> </span><span class="n">active</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">pg_size_pretty</span><span class="p">(</span><span class="n">pg_wal_lsn_diff</span><span class="p">(</span><span class="n">pg_current_wal_lsn</span><span class="p">(),</span><span class="w"> </span><span class="n">restart_lsn</span><span class="p">))</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">retained_wal</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="n">pg_replication_slots</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">retained_wal</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="mi">10</span><span class="n">GB</span><span class="p">;</span></span></span></code></pre></div></li>
<li>
<p>設 <code>max_slot_wal_keep_size</code>（PG 13+）— slot 對應 WAL 超過 limit 自動 invalidate slot（standby 之後要 base backup 重來）</p>
</li>
<li>
<p>DR runbook 紀錄 <em>standby 退役流程</em> 必須包含 <code>pg_drop_replication_slot('xxx')</code></p>
</li>
</ul>
<h3 id="4-cascading-replication-雪崩">4. Cascading replication 雪崩</h3>
<p>Topology <code>primary → standby1 → standby2 → ...</code>（每層遞迴 stream）。Standby1 startup process 卡住、後續 standby 都被 block、整條 chain 雪崩。</p>
<p>修法：</p>
<ul>
<li>避免超過 2 層 cascade（primary → tier1 → tier2 是上限）</li>
<li>跨 region 用 <em>region-local tier1 + cross-region tier2</em>、不是長 chain</li>
<li>真的大規模、改用 <em>binlog server</em> style：<a href="https://github.com/postgresml/PgCat">Citus / PgCat</a> 等中介、或 logical replication 解耦</li>
</ul>
<h3 id="5-failover-後-timeline-分歧">5. Failover 後 timeline 分歧</h3>
<p>Primary 失敗、standby1 promote 為新 primary、其他 standby（standby2 / 3）原本連舊 primary、必須重新連 standby1。但 PG 用 <em>timeline</em>（每次 promotion 增 1）標 WAL 分支、原 standby 的 timeline 跟新 primary 不同。重連時看到 timeline mismatch、報錯。</p>
<p>修法：</p>
<ul>
<li><em>pg_rewind</em> 工具：對比新 primary 跟舊 standby 的 timeline 分歧點、把舊 standby 上 <em>新 primary 沒有的 WAL</em> 倒退、然後從分歧點重新跟新 primary 同步</li>
<li><em>Base backup re-build</em>：對舊 standby 重建 — 慢但保證乾淨</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/patroni-ha/" data-link-title="PostgreSQL Patroni HA：從 leader 失聯到 client 重連的 5 段 failover lifecycle" data-link-desc="Patroni 把 PostgreSQL HA 拆成 detection / election / promotion / reconfiguration / recovery 五段 lifecycle、每段都有獨立配置跟 failure mode；DCS quorum &#43; watchdog 防 split-brain、async/sync replication 取捨、5 個 production 踩雷、跟 PgBouncer / HAProxy / cert-manager 整合">Patroni</a> 自動處理 pg_rewind / base backup 選擇</li>
</ul>
<h2 id="容量--cost-對照">容量 / cost 對照</h2>
<table>
  <thead>
      <tr>
          <th>配置</th>
          <th>寫吞吐影響</th>
          <th>Standby overhead</th>
          <th>適合 workload</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Async streaming + slot</td>
          <td>baseline</td>
          <td>低（WAL receive + startup）</td>
          <td>高吞吐、容忍 sub-second loss</td>
      </tr>
      <tr>
          <td>Sync <code>remote_write</code> + 1 standby</td>
          <td>-5% ~ -10%</td>
          <td>同上 + RTT</td>
          <td>一般 production、可接受 OS crash 丟</td>
      </tr>
      <tr>
          <td>Sync <code>on</code> + 1 standby</td>
          <td>-10% ~ -20%</td>
          <td>同上 + fsync</td>
          <td>金融、訂單、不容忍 data loss</td>
      </tr>
      <tr>
          <td>Sync <code>on</code> + ANY 2 quorum</td>
          <td>-15% ~ -30%</td>
          <td>同上、跨 AZ</td>
          <td>強 durability + multi-AZ HA</td>
      </tr>
      <tr>
          <td>Sync <code>remote_apply</code> + 1 standby</td>
          <td>-20% ~ -40%</td>
          <td>同上 + replay</td>
          <td>強一致 read on standby（少用、成本高）</td>
      </tr>
  </tbody>
</table>
<p>跨 AZ sync 通常加 1-3ms、跨 region 加 50-200ms — 寫密集 workload 跨 region sync 通常不划算、改用 <em>region-local sync + cross-region async chain</em>。</p>
<h2 id="整合--下一步">整合 / 下一步</h2>
<h3 id="patroni-ha">Patroni HA</h3>
<p><a href="/blog/backend/01-database/vendors/postgresql/patroni-ha/" data-link-title="PostgreSQL Patroni HA：從 leader 失聯到 client 重連的 5 段 failover lifecycle" data-link-desc="Patroni 把 PostgreSQL HA 拆成 detection / election / promotion / reconfiguration / recovery 五段 lifecycle、每段都有獨立配置跟 failure mode；DCS quorum &#43; watchdog 防 split-brain、async/sync replication 取捨、5 個 production 踩雷、跟 PgBouncer / HAProxy / cert-manager 整合">Patroni</a> 是 PG HA 自動 failover 標準、依賴 DCS（etcd / Consul）+ 本文 replication topology。Patroni 自動：</p>
<ul>
<li>偵測 primary 失聯、promote 適合 standby</li>
<li>處理 timeline 分歧（pg_rewind）</li>
<li>重配 sync standby（避免 sync standby 失聯卡 primary）</li>
</ul>
<h3 id="logical-replication--debezium">Logical Replication + Debezium</h3>
<p><a href="/blog/backend/01-database/vendors/postgresql/logical-replication-debezium/" data-link-title="PostgreSQL Logical Replication &#43; Debezium CDC：replication slot × failure × recovery 對照" data-link-desc="PostgreSQL logical replication slot 跟 Debezium CDC 的失效模式對照表：slot lag 撐爆 primary disk / schema change 斷流 / 初始 COPY 鎖表 / zombie slot 不釋放 / replay storm 後 offset reset；publication / subscription / pgoutput 配置、跟 Kafka outbox pattern 整合">Logical replication + Debezium</a> 是 <em>跟 streaming replication 共用 WAL</em> 但不同 abstraction — logical decoding output event、streaming replication output physical bytes。Logical replication slot 跟 physical slot 共存、各自獨立 retention。</p>
<h3 id="pitr--wal-archiving">PITR + WAL Archiving</h3>
<p><a href="/blog/backend/01-database/vendors/postgresql/pitr-wal-archiving/" data-link-title="PostgreSQL PITR &#43; WAL archiving：從 base backup 到 point-in-time recovery 的完整鏈" data-link-desc="Base backup &#43; WAL archive 構成 PITR 的雙軌資料、archive_command &#43; restore_command 配置、用 pgBackRest / WAL-G 替代手寫腳本、5 個 production 踩雷（archive 靜默失敗 / archive lag / 錯誤 target time / base backup 過期未清 / timeline 分歧 recovery 模糊）、跟 Patroni &#43; monitoring 整合">PITR + WAL Archiving</a> 用 <em>archive_command</em> 把 WAL ship 到 S3、跟 streaming replication 並行：</p>
<ul>
<li>Streaming：給 <em>活的 standby</em>（real-time read scaling / HA）</li>
<li>Archive：給 <em>PITR + 新 standby base backup source</em></li>
</ul>
<p>兩者使用同一 WAL stream、不衝突。</p>
<h3 id="connection-路由pgbouncer--readwrite-split">Connection 路由（PgBouncer + read/write split）</h3>
<p><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</a> 不做 read/write split（transaction pool 不看 SQL）。Read replica routing 通常用 <em>application-level</em> 或 <em>HAProxy 監控 standby health</em>。</p>
<h3 id="跟-mysql-replication-topology-對比">跟 MySQL Replication Topology 對比</h3>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>PG streaming replication</th>
          <th>MySQL replication</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>進度追蹤</td>
          <td>LSN（單一 byte offset）</td>
          <td>GTID 或 binlog (file, position)</td>
      </tr>
      <tr>
          <td>標準工具</td>
          <td>streaming replication（physical）+ logical</td>
          <td>binlog ROW format</td>
      </tr>
      <tr>
          <td>Sync 機制</td>
          <td><code>synchronous_commit</code> + standby names</td>
          <td>semi-sync plugin</td>
      </tr>
      <tr>
          <td>Quorum</td>
          <td><code>ANY N</code> syntax</td>
          <td><code>rpl_semi_sync_master_wait_for_slave_count</code></td>
      </tr>
      <tr>
          <td>Replay parallelism</td>
          <td>Single startup process</td>
          <td>Multi-thread (logical clock / writeset)</td>
      </tr>
      <tr>
          <td>Replica routing</td>
          <td>PgBouncer 不看 SQL、需外接</td>
          <td>ProxySQL 內建 query routing</td>
      </tr>
  </tbody>
</table>
<p>兩者 high-level 對等、低層機制有顯著差異。詳見 <a href="/blog/backend/01-database/vendors/mysql/replication-topology/" data-link-title="MySQL Replication Topology：async / semi-sync / GTID 不是三選一、是三個 trade-off 軸的疊加" data-link-desc="MySQL replication 不是「選 async 還是 semi-sync」、是 *durability / latency / consistency* 三個 trade-off 軸的疊加；GTID 是跨 mode 的 infrastructure layer、不是第三種 mode。本文走 3 軸取捨模型 → async / semi-sync 行為對比 → GTID 替代 binlog-position 的好處 → 配置 step-by-step → 5 production 踩雷（lag 暴衝 / semi-sync 退回 async / GTID gap / Loss-Less semi-sync 真的 loss-less / chained replication 雪崩）→ 跟 Aurora MySQL / Vitess / ProxySQL / Orchestrator 整合">MySQL Replication Topology</a>。</p>
<h2 id="相關連結">相關連結</h2>
<ul>
<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 vendor overview</a></li>
<li><a href="/blog/backend/01-database/vendors/postgresql/patroni-ha/" data-link-title="PostgreSQL Patroni HA：從 leader 失聯到 client 重連的 5 段 failover lifecycle" data-link-desc="Patroni 把 PostgreSQL HA 拆成 detection / election / promotion / reconfiguration / recovery 五段 lifecycle、每段都有獨立配置跟 failure mode；DCS quorum &#43; watchdog 防 split-brain、async/sync replication 取捨、5 個 production 踩雷、跟 PgBouncer / HAProxy / cert-manager 整合">PG Patroni HA</a>（HA failover、依賴本文 replication topology）</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/logical-replication-debezium/" data-link-title="PostgreSQL Logical Replication &#43; Debezium CDC：replication slot × failure × recovery 對照" data-link-desc="PostgreSQL logical replication slot 跟 Debezium CDC 的失效模式對照表：slot lag 撐爆 primary disk / schema change 斷流 / 初始 COPY 鎖表 / zombie slot 不釋放 / replay storm 後 offset reset；publication / subscription / pgoutput 配置、跟 Kafka outbox pattern 整合">PG Logical Replication + Debezium</a>（不同 abstraction、共用 WAL）</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/pitr-wal-archiving/" data-link-title="PostgreSQL PITR &#43; WAL archiving：從 base backup 到 point-in-time recovery 的完整鏈" data-link-desc="Base backup &#43; WAL archive 構成 PITR 的雙軌資料、archive_command &#43; restore_command 配置、用 pgBackRest / WAL-G 替代手寫腳本、5 個 production 踩雷（archive 靜默失敗 / archive lag / 錯誤 target time / base backup 過期未清 / timeline 分歧 recovery 模糊）、跟 Patroni &#43; monitoring 整合">PG PITR + WAL Archiving</a>（streaming + archive 並行）</li>
<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）跟容量規劃">PG PgBouncer</a>（connection pool、不做 read/write split）</li>
<li><a href="/blog/backend/01-database/vendors/mysql/replication-topology/" data-link-title="MySQL Replication Topology：async / semi-sync / GTID 不是三選一、是三個 trade-off 軸的疊加" data-link-desc="MySQL replication 不是「選 async 還是 semi-sync」、是 *durability / latency / consistency* 三個 trade-off 軸的疊加；GTID 是跨 mode 的 infrastructure layer、不是第三種 mode。本文走 3 軸取捨模型 → async / semi-sync 行為對比 → GTID 替代 binlog-position 的好處 → 配置 step-by-step → 5 production 踩雷（lag 暴衝 / semi-sync 退回 async / GTID gap / Loss-Less semi-sync 真的 loss-less / chained replication 雪崩）→ 跟 Aurora MySQL / Vitess / ProxySQL / Orchestrator 整合">MySQL Replication Topology</a>（sibling、不同機制）</li>
<li><a href="/blog/backend/knowledge-cards/quorum/" data-link-title="Quorum" data-link-desc="分散式系統以多數節點同意作為提交或讀取有效性的門檻">quorum 卡片</a> / <a href="/blog/backend/knowledge-cards/stale-read/" data-link-title="Stale Read" data-link-desc="讀取到落後於最新寫入版本的舊資料">stale-read 卡片</a> / <a href="/blog/backend/knowledge-cards/eventual-consistency/" data-link-title="Eventual Consistency" data-link-desc="允許短暫不一致、最終收斂到同一資料狀態的一致性語意">eventual-consistency 卡片</a></li>
<li>官方：<a href="https://www.postgresql.org/docs/current/warm-standby.html">PG Streaming Replication</a> / <a href="https://www.postgresql.org/docs/current/app-pgbasebackup.html">pg_basebackup</a></li>
</ul>
]]></content:encoded></item><item><title>Kafka Retention 與 Tiered Storage：保留策略、log compaction 與冷熱分層</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/retention-tiered-storage/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/retention-tiered-storage/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka&lt;/a> overview 的 implementation-layer deep article、聚焦保留與分層儲存。選型層的「該不該選 Kafka」「跟其他 broker 差在哪」見 overview；本文回答「保留策略怎麼設、log compaction 怎麼運作、冷熱分層怎麼讓容量跟保留期解耦、踩哪些坑」。配置段在 Apache Kafka KRaft 單節點實機驗證；tiered storage 段標註未實機驗證的範圍。&lt;/p>&lt;/blockquote>
&lt;h2 id="retention-是-replay-window-的物理邊界">Retention 是 replay window 的物理邊界&lt;/h2>
&lt;p>Retention 的核心責任是決定「一筆訊息在 broker 上能存活多久」、而這條邊界直接界定 consumer 能往回重播多遠。Kafka 的 log 是 append-only 的事件序列、訊息寫入後不會被原地修改；retention 是唯一會把舊訊息從磁碟移除的機制。設多久、用什麼條件刪、刪掉之後 consumer 還能不能讀到，全由保留策略決定。&lt;/p>
&lt;p>這條邊界之所以重要、是因為 Kafka 的多 consumer 模型讓「重播」變成一級能力。同一個 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic&lt;/a> 可以被多組 consumer 各自從任意 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/offset/" data-link-title="Offset" data-link-desc="說明 consumer 在事件流中的讀取位置與重放基準">offset&lt;/a> 開始讀、每組維護自己的進度；只要訊息還在 retention 範圍內、新加入的 consumer 或出事後要補算的 consumer 都能從頭重讀。一旦訊息超過 retention 被刪、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/replay-window/" data-link-title="Replay Window" data-link-desc="說明事件可重播的時間或 offset 範圍邊界，由 retention 與 checkpoint 決定">replay window&lt;/a> 就到此為止、補償只能改走資料庫或上游來源。&lt;/p>
&lt;p>Kafka 提供兩條獨立的保留軸、可單獨用也可同時用：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>配置&lt;/th>
 &lt;th>觸發條件&lt;/th>
 &lt;th>典型場景&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>retention.ms&lt;/code>&lt;/td>
 &lt;td>訊息寫入時間超過設定值（時間軸）&lt;/td>
 &lt;td>「保留 7 天事件供事故 replay」&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>retention.bytes&lt;/code>&lt;/td>
 &lt;td>該 partition log 總大小超過設定值（容量軸）&lt;/td>
 &lt;td>「每 partition 上限 50 GB、防止磁碟塞爆」&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>兩者同時設&lt;/td>
 &lt;td>任一條件先達到就刪（取交集、誰先到誰生效）&lt;/td>
 &lt;td>「保留 7 天、但單 partition 不超過 50 GB」&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>時間軸對齊的是 replay 需求：把 retention 設成「事故從發生到偵測到修復的最長時間」、確保發現要補算時事件還在。容量軸對齊的是成本與磁碟保護：避免某個突發高流量 topic 把 broker 磁碟寫滿、拖垮同 broker 上其他 partition。兩者同時設時是「誰先觸發誰生效」、所以容量軸常常會在高流量時段提前砍掉本來預期能保留 7 天的事件——這個交互是後面故障演練的重點之一。&lt;/p>
&lt;p>實機建立一個同時設兩軸的 topic、&lt;code>--describe&lt;/code> 會把保留配置直接列在 Configs：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># CLI 在容器內 /opt/kafka/bin/、bootstrap-server 指向 broker&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">kafka-topics.sh --create --topic ret-delete --partitions &lt;span class="m">1&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --config retention.ms&lt;span class="o">=&lt;/span>&lt;span class="m">60000&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --config retention.bytes&lt;span class="o">=&lt;/span>&lt;span class="m">10485760&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --config segment.ms&lt;span class="o">=&lt;/span>&lt;span class="m">10000&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --bootstrap-server localhost:9092
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">kafka-topics.sh --describe --topic ret-delete --bootstrap-server localhost:9092
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">&lt;span class="c1"># Configs: retention.ms=60000,retention.bytes=10485760,segment.ms=10000,...&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>retention 不是寫死在建 topic 當下、線上可以用 &lt;code>kafka-configs.sh --alter&lt;/code> 動態調整、立即生效不需重啟 broker：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">kafka-configs.sh --alter --entity-type topics --entity-name ret-delete &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --add-config retention.ms&lt;span class="o">=&lt;/span>&lt;span class="m">3600000&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --bootstrap-server localhost:9092
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1"># Completed updating config for topic ret-delete.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">kafka-configs.sh --describe --entity-type topics --entity-name ret-delete &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --bootstrap-server localhost:9092
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="c1"># retention.ms=3600000 sensitive=false synonyms={DYNAMIC_TOPIC_CONFIG:retention.ms=3600000}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>動態調整的 retention 屬於 &lt;code>DYNAMIC_TOPIC_CONFIG&lt;/code>、優先於 broker 層的 &lt;code>log.retention.*&lt;/code> 預設值；synonyms 欄位會把覆蓋關係列出來、排查時可確認當前生效的是哪一層。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka</a> overview 的 implementation-layer deep article、聚焦保留與分層儲存。選型層的「該不該選 Kafka」「跟其他 broker 差在哪」見 overview；本文回答「保留策略怎麼設、log compaction 怎麼運作、冷熱分層怎麼讓容量跟保留期解耦、踩哪些坑」。配置段在 Apache Kafka KRaft 單節點實機驗證；tiered storage 段標註未實機驗證的範圍。</p></blockquote>
<h2 id="retention-是-replay-window-的物理邊界">Retention 是 replay window 的物理邊界</h2>
<p>Retention 的核心責任是決定「一筆訊息在 broker 上能存活多久」、而這條邊界直接界定 consumer 能往回重播多遠。Kafka 的 log 是 append-only 的事件序列、訊息寫入後不會被原地修改；retention 是唯一會把舊訊息從磁碟移除的機制。設多久、用什麼條件刪、刪掉之後 consumer 還能不能讀到，全由保留策略決定。</p>
<p>這條邊界之所以重要、是因為 Kafka 的多 consumer 模型讓「重播」變成一級能力。同一個 <a href="/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic</a> 可以被多組 consumer 各自從任意 <a href="/blog/backend/knowledge-cards/offset/" data-link-title="Offset" data-link-desc="說明 consumer 在事件流中的讀取位置與重放基準">offset</a> 開始讀、每組維護自己的進度；只要訊息還在 retention 範圍內、新加入的 consumer 或出事後要補算的 consumer 都能從頭重讀。一旦訊息超過 retention 被刪、<a href="/blog/backend/knowledge-cards/replay-window/" data-link-title="Replay Window" data-link-desc="說明事件可重播的時間或 offset 範圍邊界，由 retention 與 checkpoint 決定">replay window</a> 就到此為止、補償只能改走資料庫或上游來源。</p>
<p>Kafka 提供兩條獨立的保留軸、可單獨用也可同時用：</p>
<table>
  <thead>
      <tr>
          <th>配置</th>
          <th>觸發條件</th>
          <th>典型場景</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>retention.ms</code></td>
          <td>訊息寫入時間超過設定值（時間軸）</td>
          <td>「保留 7 天事件供事故 replay」</td>
      </tr>
      <tr>
          <td><code>retention.bytes</code></td>
          <td>該 partition log 總大小超過設定值（容量軸）</td>
          <td>「每 partition 上限 50 GB、防止磁碟塞爆」</td>
      </tr>
      <tr>
          <td>兩者同時設</td>
          <td>任一條件先達到就刪（取交集、誰先到誰生效）</td>
          <td>「保留 7 天、但單 partition 不超過 50 GB」</td>
      </tr>
  </tbody>
</table>
<p>時間軸對齊的是 replay 需求：把 retention 設成「事故從發生到偵測到修復的最長時間」、確保發現要補算時事件還在。容量軸對齊的是成本與磁碟保護：避免某個突發高流量 topic 把 broker 磁碟寫滿、拖垮同 broker 上其他 partition。兩者同時設時是「誰先觸發誰生效」、所以容量軸常常會在高流量時段提前砍掉本來預期能保留 7 天的事件——這個交互是後面故障演練的重點之一。</p>
<p>實機建立一個同時設兩軸的 topic、<code>--describe</code> 會把保留配置直接列在 Configs：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># CLI 在容器內 /opt/kafka/bin/、bootstrap-server 指向 broker</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">kafka-topics.sh --create --topic ret-delete --partitions <span class="m">1</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --config retention.ms<span class="o">=</span><span class="m">60000</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --config retention.bytes<span class="o">=</span><span class="m">10485760</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  --config segment.ms<span class="o">=</span><span class="m">10000</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="se"></span>  --bootstrap-server localhost:9092
</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">kafka-topics.sh --describe --topic ret-delete --bootstrap-server localhost:9092
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="c1"># Configs: retention.ms=60000,retention.bytes=10485760,segment.ms=10000,...</span></span></span></code></pre></div><p>retention 不是寫死在建 topic 當下、線上可以用 <code>kafka-configs.sh --alter</code> 動態調整、立即生效不需重啟 broker：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">kafka-configs.sh --alter --entity-type topics --entity-name ret-delete <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --add-config retention.ms<span class="o">=</span><span class="m">3600000</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --bootstrap-server localhost:9092
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># Completed updating config for topic ret-delete.</span>
</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">kafka-configs.sh --describe --entity-type topics --entity-name ret-delete <span class="se">\
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="se"></span>  --bootstrap-server localhost:9092
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1"># retention.ms=3600000 sensitive=false synonyms={DYNAMIC_TOPIC_CONFIG:retention.ms=3600000}</span></span></span></code></pre></div><p>動態調整的 retention 屬於 <code>DYNAMIC_TOPIC_CONFIG</code>、優先於 broker 層的 <code>log.retention.*</code> 預設值；synonyms 欄位會把覆蓋關係列出來、排查時可確認當前生效的是哪一層。</p>
<h2 id="segment-是刪除的最小單位">Segment 是刪除的最小單位</h2>
<p>Retention 刪資料的最小單位是 log segment、不是單筆訊息。理解這一點才能解釋「為什麼設了 retention.ms 之後，過期的訊息有時還在」。每個 partition 的 log 在磁碟上被切成多個 segment 檔、只有 active segment（當前正在寫入的那一個）以外、已經 roll over 的 segment 才會被 retention 檢查並整段刪除。</p>
<p>Segment 何時 roll over 由兩個條件決定：<code>segment.bytes</code>（檔案大到上限、預設 1 GB、最小 1 MB）或 <code>segment.ms</code>（檔案存在時間超過設定）。實機寫入 ~6 MB 資料到一個 <code>segment.bytes=1048576</code>（1 MB）的 topic、磁碟上會看到 6 個 roll 過的 segment：</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">00000000000000000000.log   1045229   # 已 roll，可被 retention 刪
</span></span><span class="line"><span class="ln">2</span><span class="cl">00000000000000001024.log   1046336   # 已 roll
</span></span><span class="line"><span class="ln">3</span><span class="cl">00000000000000002048.log   1046336   # 已 roll
</span></span><span class="line"><span class="ln">4</span><span class="cl">00000000000000003072.log   1046336   # 已 roll
</span></span><span class="line"><span class="ln">5</span><span class="cl">00000000000000004096.log   1037748   # 已 roll
</span></span><span class="line"><span class="ln">6</span><span class="cl">00000000000000005112.log    904737   # active segment，不會被刪</span></span></code></pre></div><p>Retention 的實際刪除動作由背景執行緒週期性執行、頻率是 broker 層的 <code>log.retention.check.interval.ms</code>、預設 300000 毫秒（5 分鐘）。這代表「過期」跟「被刪」之間有最長一個檢查週期的延遲：訊息超過 retention.ms 的瞬間不會立刻消失、要等下一次檢查跑到、且該訊息所在的 segment 已經 roll over、整段才會被刪。實機把 retention.bytes 設成 2 MB、寫進 6 MB（6 個 segment）、在 5 分鐘檢查週期內查 earliest offset 仍是 0——超量的 segment 還沒被回收、因為檢查執行緒還沒跑到下一輪。</p>
<p>這個機制有兩個操作後果。其一、磁碟用量會在「超過 retention 上限」到「下一次檢查」之間短暫超標、容量規劃要把這段 overshoot 算進緩衝。其二、把 retention.ms 設得比 segment.ms 還短沒有意義：訊息要等所在 segment roll 才可能被刪、active segment 永遠刪不掉、所以實際最短保留時間是 <code>max(retention.ms, segment 尚未 roll 的時間)</code>。</p>
<h2 id="cleanuppolicydelete-與-compact-是兩種回收語意">cleanup.policy：delete 與 compact 是兩種回收語意</h2>
<p><code>cleanup.policy</code> 決定 retention 用哪種語意回收空間、是保留策略最關鍵的分岔。預設值 <code>delete</code> 是時間或容量到期就整段刪除、適合事件流（event stream）：訊息代表「發生過的事實」、過了 replay window 就沒有保留價值。另一個值 <code>compact</code> 是 log compaction、語意完全不同：它保留每個 key 的最新值、刪除同 key 的歷史版本、適合「狀態快照」型資料。</p>
<p>兩者的判準是這份 log 表達的是「事件序列」還是「最終狀態」。訂單建立、付款完成、商品瀏覽這類事件、每一筆都是獨立事實、用 <code>delete</code>；使用者個人設定、商品庫存當前值、CDC 同步出來的資料表鏡像這類「同一個 key 不斷被覆寫、只關心最新值」的資料、用 <code>compact</code>。Kafka 內部的 <code>__consumer_offsets</code> topic 就是 compact——它只需要每個 consumer group 的最新 offset、不需要歷史 commit 記錄。</p>
<p>兩者可以同時開（<code>cleanup.policy=compact,delete</code>）：先按 key 壓縮保留最新值、同時對壓縮後的結果再套時間 / 容量上限。用 <code>kafka-configs.sh</code> 切換時、逗號分隔的值要用中括號群組、否則會被解析成兩個獨立 config：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">kafka-configs.sh --alter --entity-type topics --entity-name ret-delete <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --add-config <span class="s1">&#39;cleanup.policy=[compact,delete]&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --bootstrap-server localhost:9092
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># Completed updating config for topic ret-delete.</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># describe: cleanup.policy=compact,delete</span></span></span></code></pre></div><h2 id="log-compaction-用最新值取代歷史">Log compaction 用最新值取代歷史</h2>
<p>Log compaction 的核心責任是讓一個 topic 收斂成「每個 key 的最新狀態」、同時保有 Kafka 的 log 重播能力。它的運作方式是背景的 log cleaner 執行緒掃描已 roll 的 segment、對每個 key 只保留 offset 最大的那筆、把同 key 的舊版本標記移除、再把存活的記錄重寫成新 segment。Compaction 後、新加入的 consumer 從頭讀一次、拿到的就是整個 keyspace 的最新快照、而非完整變更歷史。</p>
<p>實機驗證最直接：建一個 compact topic、對 3 個 key 各寫 2 個版本（舊值在前、新值在後）、等 compaction 跑完、從頭消費：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl">kafka-topics.sh --create --topic ret-compact --partitions <span class="m">1</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="se"></span>  --config cleanup.policy<span class="o">=</span>compact <span class="se">\
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="se"></span>  --config min.cleanable.dirty.ratio<span class="o">=</span>0.01 <span class="se">\
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="se"></span>  --config segment.ms<span class="o">=</span><span class="m">5000</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="se"></span>  --config delete.retention.ms<span class="o">=</span><span class="m">100</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="se"></span>  --bootstrap-server localhost:9092
</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 class="c1"># 寫 k1/k2/k3 各舊值一筆、再各新值一筆（key:value 用冒號分隔）</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="nb">printf</span> <span class="s1">&#39;k1:v1-old\nk2:v1-old\nk3:v1-old\nk1:v2-new\nk2:v2-new\nk3:v2-new\n&#39;</span> <span class="p">|</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="se"></span>  kafka-console-producer.sh --topic ret-compact <span class="se">\
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="se"></span>  --property parse.key<span class="o">=</span><span class="nb">true</span> --property key.separator<span class="o">=</span>: <span class="se">\
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="se"></span>  --bootstrap-server localhost:9092
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1"># 等 segment roll + compaction，再從頭消費</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">kafka-console-consumer.sh --topic ret-compact --from-beginning <span class="se">\
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="se"></span>  --property print.key<span class="o">=</span><span class="nb">true</span> --property print.offset<span class="o">=</span><span class="nb">true</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="se"></span>  --timeout-ms <span class="m">6000</span> --bootstrap-server localhost:9092
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="c1"># Offset:3  k1  v2-new</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="c1"># Offset:4  k2  v2-new</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="c1"># Offset:5  k3  v2-new</span></span></span></code></pre></div><p>寫進 6 筆、從頭只讀到 3 筆——k1/k2/k3 的 <code>v1-old</code>（offset 0-2）被壓縮移除、只留每個 key 的 <code>v2-new</code>。關鍵細節：offset 沒有重新編號、留存記錄保留原始 offset（3、4、5）、log 的位置語意不變、其他 consumer 的 offset 進度不會錯位。</p>
<p>Compaction 的觸發不是即時的、由幾個參數共同決定。<code>min.cleanable.dirty.ratio</code> 是「髒比例」門檻、髒記錄（已被新版本取代但還沒清掉的舊版本）佔 log 比例超過這個值、cleaner 才會處理該 partition、預設 0.5（驗證時調成 0.01 加速觸發）。<code>segment.ms</code> 控制 active segment 多久 roll、只有 roll 過的 segment 能被 compact。<code>delete.retention.ms</code> 控制 tombstone（value 為 null 的刪除標記）保留多久——compaction topic 用 null value 表示「這個 key 已刪除」、tombstone 要保留夠久讓所有 consumer 都讀到刪除事件、之後才清掉。</p>
<p>Tombstone 是 compaction 表達「刪除」的方式：寫一筆 key 存在、value 為 null 的記錄、compaction 會把該 key 的所有歷史連同這筆 tombstone 在 <code>delete.retention.ms</code> 之後一起清除。這讓 compact topic 能表達「key 從存在到被刪」的完整生命週期、而不只是「永遠累積最新值」。</p>
<h2 id="tiered-storage-讓容量與保留期解耦">Tiered Storage 讓容量與保留期解耦</h2>
<blockquote>
<p>以下 tiered storage 段落依 Apache Kafka 官方文件（KIP-405）與 Pinterest / LinkedIn 公開案例敘述、未在本文的 KRaft 單節點環境實機驗證。Apache Kafka 的原生 tiered storage（<code>remote.storage.enable</code>）在當前版本屬 early-access、需要額外的 RemoteStorageManager plugin 與 broker 設定；正式採用前以官方文件版本標註為準。</p></blockquote>
<p>Tiered storage 的核心責任是把 broker 的「儲存容量」跟「保留期長度」解耦。傳統 Kafka 的保留期受限於 broker 本機磁碟：想保留 30 天、就得讓每個 broker 的 local disk 容納 30 天的全量資料、retention 拉長等於 broker 數量或單機磁碟線性增長、而 broker 的 CPU / 記憶體 / 網路其實沒用到那麼多。Tiered storage 把 log 分成兩層：熱資料（近期、頻繁讀）留在 broker local disk（local tier）、冷資料（過期門檻之外、偶爾 replay）卸載到遠端物件儲存如 S3（remote tier）。Broker 只需放得下熱資料、保留期可以拉到數月甚至更久、成本變成 S3 的物件儲存費而非 broker 機群。</p>
<p>分層的觸發由 <code>local.retention.ms</code> / <code>local.retention.bytes</code>（本機保留多久 / 多大、超過就卸到 remote）跟整體的 <code>retention.ms</code> / <code>retention.bytes</code>（含 remote 的總保留邊界、超過才真正刪除）共同界定。一筆訊息的生命週期變成：寫入 local tier、超過 local retention 卸到 remote tier、超過整體 retention 從 remote 刪除。Replay window 因此可以遠大於 broker local disk 容量。</p>
<p>讀取路徑分熱冷兩條、效能特性不同。Consumer 讀近期 offset、資料在 local tier、走的是 Kafka 一向的 page cache + 順序讀路徑、低延遲高吞吐。Consumer 讀很舊的 offset（例如出事後從幾週前重播）、資料在 remote tier、broker 要先從 S3 把對應 segment 拉回來才能 serve、第一次讀的延遲明顯高於熱路徑、吞吐受 S3 頻寬與 broker 拉取並行度限制。這個熱冷讀差異是 tiered storage 的核心取捨——也是故障演練要處理的場景。</p>
<p>業界對 tiered storage 有兩條不同的工程路線、對應不同的 broker 角色定位：</p>
<table>
  <thead>
      <tr>
          <th>路線</th>
          <th>broker 角色</th>
          <th>代表案例</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Broker-coupled（KIP-405 原生）</td>
          <td>broker 仍是 remote 讀的熱路徑、代理拉取</td>
          <td>Apache Kafka 原生 tiered storage</td>
      </tr>
      <tr>
          <td>Broker-decoupled</td>
          <td>consumer 直接從 S3 拉、broker 不在熱路徑</td>
          <td><a href="/blog/backend/03-message-queue/cases/kafka-pinterest-tiered-storage/" data-link-title="3.C11 Pinterest：Kafka tiered storage broker-decoupled" data-link-desc="Pinterest 採 broker-decoupled tiered storage、把 ~200 TB/day 熱資料卸到 S3、broker 不再是熱路徑。">3.C11 Pinterest Tiered Storage</a></td>
      </tr>
  </tbody>
</table>
<p><a href="/blog/backend/03-message-queue/cases/kafka-pinterest-tiered-storage/" data-link-title="3.C11 Pinterest：Kafka tiered storage broker-decoupled" data-link-desc="Pinterest 採 broker-decoupled tiered storage、把 ~200 TB/day 熱資料卸到 S3、broker 不再是熱路徑。">Pinterest 的 broker-decoupled 做法</a>把 ~200 TB/day 熱資料卸到 S3、讓 consumer 直接從 S3 拉冷資料、broker 不再是冷讀的熱路徑。它揭露的設計判讀是「broker 運算資源」跟「跨 AZ 網路成本」其實該分開治理、而不是綁在 broker 容量擴張上——保留期變長不該等於 broker 機群變大。</p>
<p><a href="/blog/backend/03-message-queue/cases/linkedin-kafka-tiered-clusters/" data-link-title="3.C4 LinkedIn：Kafka 分層叢集治理" data-link-desc="Kafka 從單叢集走向 tiered clusters 的轉換案例。">LinkedIn 的分層叢集策略</a>是另一個層次的「分層」：把不同業務特性與可靠性需求的 workload 拆到不同叢集（依關鍵程度分群、例如關鍵 / 一般 / 實驗性，分層名稱為示意而非案例原文用詞）、避免混在同一叢集時故障與資源競爭互相放大。這裡的「分層」指叢集隔離、不是儲存的冷熱分層。兩種「分層」常被混談、但解的是不同問題：tiered storage 解單一 topic 的儲存成本、tiered clusters 解多 workload 的隔離治理。</p>
<h2 id="故障演練">故障演練</h2>
<h3 id="retention-太短replay-window-不夠補事故">Retention 太短、replay window 不夠補事故</h3>
<p><strong>徵兆</strong>：下游 consumer 出 bug、產出錯誤的衍生資料、幾天後才被對帳發現；要從原始事件重播修復時、發現最舊的事件已經被刪、replay 從某個時間點之後才有資料、之前的修不回來。</p>
<p><strong>根因</strong>：retention.ms 設得比「事故從發生到偵測到開始修復的最長時間」短。Replay window 由 broker retention 與 consumer checkpoint 共同界定、retention 是其物理上限；偵測延遲一旦超過 retention、要補算時原始事件已過期。常見的隱性誘因是把 retention 按「正常 consumer 跟得上的進度」來設（例如 consumer 通常落後幾分鐘、就設 1 天保險）、卻沒按「最壞情況下多久才會發現問題」來設。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>把 retention.ms 對齊事故偵測到修復的最長時間、而非 consumer 正常落後量；對帳 / 審計類 pipeline 的偵測週期常以天計、retention 要跟著拉到對應天數。</li>
<li>對「偵測延遲可能很長」的關鍵 topic、在下游另留可重算的來源（資料庫快照、上游 source of truth）、不把 Kafka retention 當唯一補償依據。</li>
<li>用 <code>kafka-configs.sh --alter</code> 動態延長 retention 是即時生效的、但只對「還沒被刪」的訊息有用——已刪的救不回來；所以調整要趁事故升級前、發現偵測週期被低估的當下就改、不是等出事才改。</li>
<li>Replay 邊界對齊見 <a href="/blog/backend/03-message-queue/event-contract-replay-boundary/" data-link-title="3.7 Event Contract 與 Replay Boundary" data-link-desc="說明 event schema、idempotency key、replay window 與補償如何先於 broker 選型。">3.7 Event Contract 與 Replay Boundary</a>：replay 要能指定 time range、超出 retention 的 time range 直接無效。</li>
</ol>
<h3 id="compaction-開了磁碟卻沒回收">Compaction 開了、磁碟卻沒回收</h3>
<p><strong>徵兆</strong>：topic 設了 <code>cleanup.policy=compact</code>、預期同 key 舊版本會被清掉、磁碟用量卻持續上漲、<code>--describe</code> 看 partition log 一直變大；從頭消費仍讀到大量同 key 的歷史版本。</p>
<p><strong>根因</strong>：compaction 觸發條件沒滿足。log cleaner 只處理已 roll 的 segment、active segment 永遠不壓縮；<code>min.cleanable.dirty.ratio</code> 預設 0.5、髒比例沒到一半 cleaner 不動手；如果寫入集中在少數 key、active segment 遲遲不 roll（segment.bytes / segment.ms 都沒到）、髒記錄全積在 active segment 裡、compaction 看不到它們。另一個常見原因是 broker 的 log cleaner 執行緒數（<code>log.cleaner.threads</code>）不足以跟上高寫入量、cleaner backlog 累積。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>確認 active segment 會適時 roll：對寫入量不大但需要及時壓縮的 topic、設 <code>segment.ms</code>（例如數小時）強制 roll、讓髒記錄離開 active segment 進入可壓縮範圍。</li>
<li>視壓縮急迫度調 <code>min.cleanable.dirty.ratio</code>：要更積極壓縮就調低（驗證時用 0.01）、但調太低會讓 cleaner 頻繁重寫 segment、增加 I/O——這是壓縮及時性跟 cleaner 開銷的取捨。</li>
<li>監控 cleaner backlog：看 broker 的 <code>log-cleaner</code> 相關 metric、backlog 持續成長代表 cleaner 執行緒不夠、加 <code>log.cleaner.threads</code>。</li>
<li>確認沒有把 compact 用在「其實該 delete」的事件流上——事件流每筆 key 多半唯一、compaction 沒有舊版本可壓、磁碟自然不會降；那種情況該用 <code>delete</code> 加 retention。</li>
</ol>
<h3 id="cold-tier-讀延遲拖垮-replay">Cold tier 讀延遲拖垮 replay</h3>
<p><strong>徵兆</strong>：開了 tiered storage、平時讀近期資料正常、一旦發起從幾週前的舊 offset 大規模 replay、consumer 的吞吐驟降、p99 拉取延遲飆高、broker S3 拉取頻寬打滿、同 broker 上其他正常 consumer 也跟著受影響。</p>
<p><strong>根因</strong>：舊 offset 的資料在 remote tier、每次讀要先從 S3 把 segment 拉回 broker、第一次冷讀延遲遠高於 local tier 的順序讀。大規模 replay 等於一次要從 S3 拉大量冷 segment、S3 頻寬與 broker 拉取並行成為瓶頸；broker-coupled 架構下這些拉取流量全經過 broker、會排擠到熱路徑的正常服務。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>把大規模冷 replay 排到低流量時段、避免跟線上熱路徑爭 broker 資源與 S3 頻寬。</li>
<li>控制 replay 的並行度與範圍：依 <a href="/blog/backend/03-message-queue/event-contract-replay-boundary/" data-link-title="3.7 Event Contract 與 Replay Boundary" data-link-desc="說明 event schema、idempotency key、replay window 與補償如何先於 broker 選型。">replay boundary</a> 指定 time range / tenant / partition、分批拉冷資料、不要一次全量回放整個保留期。</li>
<li>評估 broker-decoupled 架構（如 <a href="/blog/backend/03-message-queue/cases/kafka-pinterest-tiered-storage/" data-link-title="3.C11 Pinterest：Kafka tiered storage broker-decoupled" data-link-desc="Pinterest 採 broker-decoupled tiered storage、把 ~200 TB/day 熱資料卸到 S3、broker 不再是熱路徑。">Pinterest 做法</a>）：consumer 直接從 S3 拉冷資料、把冷讀流量從 broker 熱路徑移開、保護線上服務。</li>
<li>容量規劃把「冷讀延遲」算進 RTO：replay window 拉很長能補很久以前的事故、但補的速度受 cold tier 吞吐限制、事故修復時間估算要把這段拉取時間算進去。</li>
</ol>
<h3 id="retentionbytes-在高流量時段提早刪">retention.bytes 在高流量時段提早刪</h3>
<p><strong>徵兆</strong>：retention.ms 明明設了 7 天、某次流量突增後、consumer 卻發現幾小時前的事件就已經被刪、replay 拿不到本該還在的資料；earliest offset 在沒人預期的時候大幅前移。</p>
<p><strong>根因</strong>：retention.ms 與 retention.bytes 同時設時是「誰先觸發誰生效」。流量突增讓 partition log 在遠不到 7 天時就撞到 retention.bytes 容量上限、容量軸先觸發、舊 segment 被提前刪除——時間軸的 7 天承諾在高流量下失效。常見於「按平均流量估容量上限、卻遇到尖峰流量」、或多個 topic 共享磁碟時為了保護磁碟把每 topic 容量上限壓得偏低。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>釐清這個 topic 的保留承諾是時間還是容量主導：以 replay window 為準的關鍵 topic、容量上限要按「尖峰流量 × 保留天數」估、而非平均流量、否則尖峰時容量軸會偷走時間承諾。</li>
<li>監控 earliest offset 與 log 大小的變化率：earliest offset 在非預期時間前移、就是 retention.bytes 提前觸發的訊號、加進告警。</li>
<li>要硬保證時間保留、就把 retention.bytes 設成 -1（不限容量、純時間軸）、改用獨立的磁碟告警與容量規劃來防磁碟塞爆、而不是用 retention.bytes 兼做兩件事。</li>
<li>評估 tiered storage：把保留壓力從 broker local disk 移到 remote tier、local 只留熱資料、就不必為了保護 broker 磁碟而把 retention.bytes 壓低、時間承諾不再被容量上限侵蝕。</li>
</ol>
<h2 id="容量與成本">容量與成本</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>估算與判讀</th>
          <th>警戒</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Local disk 用量</td>
          <td>partition 數 × 單 partition log 大小 × replication factor</td>
          <td>接近磁碟上限時 retention.bytes 會提前砍時間承諾</td>
      </tr>
      <tr>
          <td>保留期 vs 成本</td>
          <td>純 local 時 retention 線性推高 broker 磁碟成本</td>
          <td>數月保留 + 純 local = broker 機群為冷資料買單</td>
      </tr>
      <tr>
          <td>Tiered remote 成本</td>
          <td>S3 物件儲存費 + 冷讀時的拉取 / egress 流量費</td>
          <td>跨 AZ / 跨 region 冷讀 egress 成本易被低估</td>
      </tr>
      <tr>
          <td>Retention 檢查延遲</td>
          <td>過期到實際刪除最長一個 <code>log.retention.check.interval.ms</code>（預設 5 分）</td>
          <td>容量規劃要預留 overshoot 緩衝</td>
      </tr>
      <tr>
          <td>Compaction 開銷</td>
          <td>cleaner 重寫 segment 的 I/O、隨 dirty.ratio 調低而上升</td>
          <td>dirty.ratio 過低 = cleaner 頻繁重寫、I/O 壓力升</td>
      </tr>
      <tr>
          <td>Cold replay 吞吐</td>
          <td>受 remote tier（S3）頻寬與 broker 拉取並行度限制</td>
          <td>大規模 cold replay 排低流量時段、分批進行</td>
      </tr>
  </tbody>
</table>
<p>實務 default：</p>
<ul>
<li>事件流 topic 用 <code>delete</code>、retention.ms 對齊事故偵測到修復的最長時間、retention.bytes 設 -1 或按尖峰流量估、不讓容量軸偷走時間承諾。</li>
<li>狀態快照 / CDC 鏡像 topic 用 <code>compact</code>、確認 active segment 會適時 roll、監控 cleaner backlog。</li>
<li>需要長保留期（數月以上）且 broker 磁碟成本敏感時、評估 tiered storage、把冷資料移到 S3、broker 只放熱資料。</li>
<li>任何 retention 調整前先確認當前生效層級（<code>kafka-configs.sh --describe</code> 看 synonyms）、避免 broker 預設與 topic 動態配置混淆。</li>
</ul>
<h2 id="整合與下一步">整合與下一步</h2>
<h3 id="跟-replay-邊界對齊">跟 replay 邊界對齊</h3>
<p>Retention 是 <a href="/blog/backend/knowledge-cards/replay-window/" data-link-title="Replay Window" data-link-desc="說明事件可重播的時間或 offset 範圍邊界，由 retention 與 checkpoint 決定">replay window</a> 的物理上限、但 replay 能不能正確執行還要看 <a href="/blog/backend/03-message-queue/event-contract-replay-boundary/" data-link-title="3.7 Event Contract 與 Replay Boundary" data-link-desc="說明 event schema、idempotency key、replay window 與補償如何先於 broker 選型。">event contract</a> 是否齊備（event id / schema version / occurred time / dedup key）。保留策略設計要跟 <a href="/blog/backend/03-message-queue/event-contract-replay-boundary/" data-link-title="3.7 Event Contract 與 Replay Boundary" data-link-desc="說明 event schema、idempotency key、replay window 與補償如何先於 broker 選型。">3.7 Event Contract 與 Replay Boundary</a> 一起看：retention 決定「能不能讀到」、event contract 決定「讀到了能不能正確重播」、兩者缺一 replay 都不成立。相關概念見 <a href="/blog/backend/knowledge-cards/retention/" data-link-title="Retention" data-link-desc="說明資料或事件保留多久，以及保留期限如何影響重放與成本">retention</a> 與 <a href="/blog/backend/knowledge-cards/offset/" data-link-title="Offset" data-link-desc="說明 consumer 在事件流中的讀取位置與重放基準">offset</a> 知識卡。</p>
<h3 id="跟分層叢集治理對位">跟分層叢集治理對位</h3>
<p>本文的 tiered storage 解的是單一 topic 的儲存成本；<a href="/blog/backend/03-message-queue/cases/linkedin-kafka-tiered-clusters/" data-link-title="3.C4 LinkedIn：Kafka 分層叢集治理" data-link-desc="Kafka 從單叢集走向 tiered clusters 的轉換案例。">3.C4 LinkedIn 分層叢集</a>解的是多 workload 的隔離——把不同可靠性需求的 topic 拆到不同叢集、避免資源競爭互相放大。保留策略在分層叢集裡會按層差異化：critical 叢集拉長 retention 保 replay、experimental 叢集縮短 retention 控成本。</p>
<h3 id="跟-broker-decoupled-架構的取捨">跟 broker-decoupled 架構的取捨</h3>
<p><a href="/blog/backend/03-message-queue/cases/kafka-pinterest-tiered-storage/" data-link-title="3.C11 Pinterest：Kafka tiered storage broker-decoupled" data-link-desc="Pinterest 採 broker-decoupled tiered storage、把 ~200 TB/day 熱資料卸到 S3、broker 不再是熱路徑。">3.C11 Pinterest broker-decoupled tiered storage</a> 把冷讀流量從 broker 熱路徑移開、是「cold tier 讀延遲拖垮 replay」故障演練的架構級解法；它跟 <a href="/blog/backend/03-message-queue/cases/kafka-pinterest-shallow-mirror/" data-link-title="3.C12 Pinterest：Shallow Mirror 優化 MirrorMaker" data-link-desc="Pinterest 跨 3 region MirrorMaker、原版解壓&#43;重壓造成 CPU/memory 2-10x spike、改 RecordBatch 層淺迭代。">3.C12 Pinterest Shallow Mirror</a> 揭露的「跨區同步是 CPU + memory + 網路三維壓力」一起、構成 Pinterest 在儲存與複製兩條路徑上的成本治理。</p>
<h3 id="回上游">回上游</h3>
<ul>
<li>上游 vendor 頁：<a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Apache Kafka</a>（「Tiered storage」與「Cross-region 與分層叢集」段）</li>
<li>平行 deep article：<a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">consumer rebalance 與 lag 診斷</a> / <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">replication、ISR 與 exactly-once</a>（同 vendor 其他實作層議題）</li>
<li>下游能力：<a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計</a> / <a href="/blog/backend/06-reliability/idempotency-replay/" data-link-title="6.12 Idempotency 與 Replay 驗證" data-link-desc="把重試、重播與冪等性從口頭約定變成可驗證屬性">6.12 idempotency / replay</a></li>
</ul>
]]></content:encoded></item><item><title>RabbitMQ Queue Type 選型：Classic、Quorum、Stream 的責任邊界與容量取捨</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/rabbitmq/queue-types-classic-quorum-stream/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/rabbitmq/queue-types-classic-quorum-stream/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/rabbitmq/" data-link-title="RabbitMQ" data-link-desc="Classic message broker、AMQP routing 為主">RabbitMQ&lt;/a> overview 的 implementation-layer deep article、對應 overview「Classic queue vs Quorum queue vs Stream」段。Overview 回答「RabbitMQ 該不該選、跟 Kafka / SQS 差在哪」、本文回答「選了 RabbitMQ 之後、同一個 broker 內三種 queue type 怎麼挑、各自的容量與故障形狀」。&lt;/p>&lt;/blockquote>
&lt;h2 id="同一個-broker三套儲存引擎">同一個 broker、三套儲存引擎&lt;/h2>
&lt;p>RabbitMQ 的 queue 由三種 &lt;em>儲存引擎&lt;/em> 構成、共用同一套 AMQP 協議與 management 介面。Queue type 決定訊息怎麼持久化、怎麼跨節點複製、消費後是否保留 — 這些差異在宣告 queue 的那一刻就鎖定、之後無法原地切換。選錯 queue type 的代價不是參數調整、是 &lt;em>重建 queue + 遷移 in-flight 訊息&lt;/em>。&lt;/p>
&lt;p>三種 type 各自承擔不同責任：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Classic queue&lt;/strong>：單節點的 durable / transient queue、訊息消費即刪除、是 RabbitMQ 最原始的工作隊列模型。跨節點高可用曾靠 &lt;em>mirrored queue&lt;/em>（鏡像複製）達成、但該機制在 3.x 已標記 deprecated、4.0 移除。&lt;/li>
&lt;li>&lt;strong>Quorum queue&lt;/strong>：Raft 共識協議實作的 replicated queue、跨節點維持強一致的訊息狀態、設計目標是 &lt;em>取代 mirrored queue&lt;/em> 提供可靠的工作隊列高可用。訊息仍是消費即刪除的隊列語意。&lt;/li>
&lt;li>&lt;strong>Stream&lt;/strong>：3.9 引入的 append-only log、訊息寫入後 &lt;em>不因消費而刪除&lt;/em>、多個 consumer 可從各自的 offset 重複讀取、retention 由時間 / 大小上限控制。語意接近 Kafka 的 partition log、但跑在 RabbitMQ 體系內、共用 AMQP 與專屬 stream protocol。&lt;/li>
&lt;/ul>
&lt;p>判讀起點是一個問題：訊息被消費後該不該保留。需要 replay、多 consumer 各自進度、長期事件流 → stream；訊息是一次性任務、處理完即丟、要跨節點不丟 → quorum；單節點夠用、可接受節點故障時該 queue 暫時不可用 → classic。&lt;/p>
&lt;p>本文用 RabbitMQ 3.13.7（OrbStack 單節點）實機驗證宣告語意差異。生產的跨節點行為（Raft 選舉、replica lag）單節點環境無法重現、相關段落標注來源。&lt;/p>
&lt;h2 id="三種-queue-type-的宣告語意差異實機驗證">三種 queue type 的宣告語意差異（實機驗證）&lt;/h2>
&lt;p>Queue type 由宣告時的 &lt;code>x-queue-type&lt;/code> argument 決定。三種 type 在同一 broker 宣告後、&lt;code>type&lt;/code> 欄位區分清楚：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">rabbitmqadmin &lt;span class="nb">declare&lt;/span> queue &lt;span class="nv">name&lt;/span>&lt;span class="o">=&lt;/span>q-classic &lt;span class="nv">durable&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">rabbitmqadmin &lt;span class="nb">declare&lt;/span> queue &lt;span class="nv">name&lt;/span>&lt;span class="o">=&lt;/span>q-quorum &lt;span class="nv">durable&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span> &lt;span class="nv">arguments&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s1">&amp;#39;{&amp;#34;x-queue-type&amp;#34;:&amp;#34;quorum&amp;#34;}&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">rabbitmqadmin &lt;span class="nb">declare&lt;/span> queue &lt;span class="nv">name&lt;/span>&lt;span class="o">=&lt;/span>q-stream &lt;span class="nv">durable&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span> &lt;span class="nv">arguments&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s1">&amp;#39;{&amp;#34;x-queue-type&amp;#34;:&amp;#34;stream&amp;#34;}&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">rabbitmqctl list_queues name &lt;span class="nb">type&lt;/span> durable leader members&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>實機輸出（節錄、單節點所以 leader / members 都是同一節點）：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">name type durable leader members
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">q-classic classic true
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">q-quorum quorum true rabbit@&amp;lt;node&amp;gt; [rabbit@&amp;lt;node&amp;gt;]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">q-stream stream true rabbit@&amp;lt;node&amp;gt; [rabbit@&amp;lt;node&amp;gt;]&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>兩個關鍵差異在這裡浮現。&lt;/p>
&lt;p>第一、&lt;strong>quorum 與 stream 強制 durable&lt;/strong>。Classic queue 可宣告為 transient（&lt;code>durable=false&lt;/code>、broker 重啟後消失、適合臨時 RPC reply queue）；quorum 與 stream 不允許 transient — 嘗試宣告會直接被拒：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">*** invalid property &amp;#39;non-durable&amp;#39; for queue &amp;#39;q-quorum-nondur&amp;#39; in vhost &amp;#39;/&amp;#39;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">*** invalid property &amp;#39;non-durable&amp;#39; for queue &amp;#39;q-stream-nondur&amp;#39; in vhost &amp;#39;/&amp;#39;&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個限制反映設計意圖：quorum 與 stream 存在的理由是 &lt;em>資料安全&lt;/em>、transient 模式與該目標矛盾、所以從宣告層就封死。Classic queue 保留 transient 選項、是因為它要同時服務「臨時隊列」與「持久隊列」兩種場景。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/03-message-queue/vendors/rabbitmq/" data-link-title="RabbitMQ" data-link-desc="Classic message broker、AMQP routing 為主">RabbitMQ</a> overview 的 implementation-layer deep article、對應 overview「Classic queue vs Quorum queue vs Stream」段。Overview 回答「RabbitMQ 該不該選、跟 Kafka / SQS 差在哪」、本文回答「選了 RabbitMQ 之後、同一個 broker 內三種 queue type 怎麼挑、各自的容量與故障形狀」。</p></blockquote>
<h2 id="同一個-broker三套儲存引擎">同一個 broker、三套儲存引擎</h2>
<p>RabbitMQ 的 queue 由三種 <em>儲存引擎</em> 構成、共用同一套 AMQP 協議與 management 介面。Queue type 決定訊息怎麼持久化、怎麼跨節點複製、消費後是否保留 — 這些差異在宣告 queue 的那一刻就鎖定、之後無法原地切換。選錯 queue type 的代價不是參數調整、是 <em>重建 queue + 遷移 in-flight 訊息</em>。</p>
<p>三種 type 各自承擔不同責任：</p>
<ul>
<li><strong>Classic queue</strong>：單節點的 durable / transient queue、訊息消費即刪除、是 RabbitMQ 最原始的工作隊列模型。跨節點高可用曾靠 <em>mirrored queue</em>（鏡像複製）達成、但該機制在 3.x 已標記 deprecated、4.0 移除。</li>
<li><strong>Quorum queue</strong>：Raft 共識協議實作的 replicated queue、跨節點維持強一致的訊息狀態、設計目標是 <em>取代 mirrored queue</em> 提供可靠的工作隊列高可用。訊息仍是消費即刪除的隊列語意。</li>
<li><strong>Stream</strong>：3.9 引入的 append-only log、訊息寫入後 <em>不因消費而刪除</em>、多個 consumer 可從各自的 offset 重複讀取、retention 由時間 / 大小上限控制。語意接近 Kafka 的 partition log、但跑在 RabbitMQ 體系內、共用 AMQP 與專屬 stream protocol。</li>
</ul>
<p>判讀起點是一個問題：訊息被消費後該不該保留。需要 replay、多 consumer 各自進度、長期事件流 → stream；訊息是一次性任務、處理完即丟、要跨節點不丟 → quorum；單節點夠用、可接受節點故障時該 queue 暫時不可用 → classic。</p>
<p>本文用 RabbitMQ 3.13.7（OrbStack 單節點）實機驗證宣告語意差異。生產的跨節點行為（Raft 選舉、replica lag）單節點環境無法重現、相關段落標注來源。</p>
<h2 id="三種-queue-type-的宣告語意差異實機驗證">三種 queue type 的宣告語意差異（實機驗證）</h2>
<p>Queue type 由宣告時的 <code>x-queue-type</code> argument 決定。三種 type 在同一 broker 宣告後、<code>type</code> 欄位區分清楚：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">rabbitmqadmin <span class="nb">declare</span> queue <span class="nv">name</span><span class="o">=</span>q-classic <span class="nv">durable</span><span class="o">=</span><span class="nb">true</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">rabbitmqadmin <span class="nb">declare</span> queue <span class="nv">name</span><span class="o">=</span>q-quorum  <span class="nv">durable</span><span class="o">=</span><span class="nb">true</span> <span class="nv">arguments</span><span class="o">=</span><span class="s1">&#39;{&#34;x-queue-type&#34;:&#34;quorum&#34;}&#39;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">rabbitmqadmin <span class="nb">declare</span> queue <span class="nv">name</span><span class="o">=</span>q-stream  <span class="nv">durable</span><span class="o">=</span><span class="nb">true</span> <span class="nv">arguments</span><span class="o">=</span><span class="s1">&#39;{&#34;x-queue-type&#34;:&#34;stream&#34;}&#39;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl">rabbitmqctl list_queues name <span class="nb">type</span> durable leader members</span></span></code></pre></div><p>實機輸出（節錄、單節點所以 leader / members 都是同一節點）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">name       type     durable  leader              members
</span></span><span class="line"><span class="ln">2</span><span class="cl">q-classic  classic  true
</span></span><span class="line"><span class="ln">3</span><span class="cl">q-quorum   quorum   true     rabbit@&lt;node&gt;       [rabbit@&lt;node&gt;]
</span></span><span class="line"><span class="ln">4</span><span class="cl">q-stream   stream   true     rabbit@&lt;node&gt;       [rabbit@&lt;node&gt;]</span></span></code></pre></div><p>兩個關鍵差異在這裡浮現。</p>
<p>第一、<strong>quorum 與 stream 強制 durable</strong>。Classic queue 可宣告為 transient（<code>durable=false</code>、broker 重啟後消失、適合臨時 RPC reply queue）；quorum 與 stream 不允許 transient — 嘗試宣告會直接被拒：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">*** invalid property &#39;non-durable&#39; for queue &#39;q-quorum-nondur&#39; in vhost &#39;/&#39;
</span></span><span class="line"><span class="ln">2</span><span class="cl">*** invalid property &#39;non-durable&#39; for queue &#39;q-stream-nondur&#39; in vhost &#39;/&#39;</span></span></code></pre></div><p>這個限制反映設計意圖：quorum 與 stream 存在的理由是 <em>資料安全</em>、transient 模式與該目標矛盾、所以從宣告層就封死。Classic queue 保留 transient 選項、是因為它要同時服務「臨時隊列」與「持久隊列」兩種場景。</p>
<p>第二、<strong>quorum 與 stream 有 leader / members、classic 沒有</strong>。Classic queue 的訊息只存在宣告它的節點上（mirrored policy 另算）；quorum 與 stream 在設計上就是 <em>cluster-aware</em> 的 replicated 結構、leader 處理讀寫、members 列出 replica 所在節點。單節點環境下 members 只有一個、但欄位本身揭露了複製拓樸的存在。</p>
<p>Stream 的 retention 與 segment 參數在宣告時設定、宣告後可查：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">rabbitmqadmin <span class="nb">declare</span> queue <span class="nv">name</span><span class="o">=</span>q-stream-ret <span class="nv">durable</span><span class="o">=</span><span class="nb">true</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  <span class="nv">arguments</span><span class="o">=</span><span class="s1">&#39;{&#34;x-queue-type&#34;:&#34;stream&#34;,&#34;x-max-length-bytes&#34;:20000000000,&#34;x-max-age&#34;:&#34;7D&#34;,&#34;x-stream-max-segment-size-bytes&#34;:100000000}&#39;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl">rabbitmqctl list_queues name <span class="nb">type</span> arguments</span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">q-stream-ret  stream  [{&#34;x-max-age&#34;,&#34;7D&#34;},{&#34;x-max-length-bytes&#34;,20000000000},
</span></span><span class="line"><span class="ln">2</span><span class="cl">                       {&#34;x-queue-type&#34;,&#34;stream&#34;},{&#34;x-stream-max-segment-size-bytes&#34;,100000000}]</span></span></code></pre></div><p><code>x-max-age</code>（保留 7 天）與 <code>x-max-length-bytes</code>（保留 20GB）是 stream 獨有的 retention 控制 — classic 與 quorum 沒有這個概念、因為它們消費即刪除、不存在「保留多久」的問題。Quorum queue 對應的是 <code>x-delivery-limit</code>（投遞次數上限、超過進 dead-letter）這類 <em>重試治理</em> 參數、而非 retention：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">q-quorum-dl  quorum  [{&#34;x-delivery-limit&#34;,5},{&#34;x-queue-type&#34;,&#34;quorum&#34;}]</span></span></code></pre></div><p>宣告參數的差異就是責任邊界的縮影：stream 的參數圍繞「保留多少歷史」、quorum 的參數圍繞「重試到第幾次放棄」、classic 兩者都精簡。</p>
<h2 id="三軸選型判讀">三軸選型判讀</h2>
<p>Queue type 的選擇由三個軸決定：消費後是否保留（retention / replay）、跨節點一致性需求、記憶體與 throughput 成本。</p>
<table>
  <thead>
      <tr>
          <th>判讀軸</th>
          <th>Classic</th>
          <th>Quorum</th>
          <th>Stream</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>消費語意</td>
          <td>消費即刪除</td>
          <td>消費即刪除</td>
          <td>消費不刪除、offset 各自獨立</td>
      </tr>
      <tr>
          <td>Replay</td>
          <td>不支援</td>
          <td>不支援</td>
          <td>支援、consumer 可重設 offset 重讀</td>
      </tr>
      <tr>
          <td>跨節點一致性</td>
          <td>無（mirrored deprecated）</td>
          <td>Raft 強一致、majority 寫入才 ack</td>
          <td>Leader-follower 複製、append-only</td>
      </tr>
      <tr>
          <td>高 throughput</td>
          <td>中（單節點 fsync 上限）</td>
          <td>中（Raft majority round-trip 成本）</td>
          <td>高（順序寫 log、批次讀）</td>
      </tr>
      <tr>
          <td>記憶體成本</td>
          <td>高（訊息常駐記憶體、lazy 例外）</td>
          <td>中（on-disk 為主、index 在記憶體）</td>
          <td>低（log 在磁碟、讀靠 page cache）</td>
      </tr>
      <tr>
          <td>典型場景</td>
          <td>單節點任務隊列、臨時 RPC reply</td>
          <td>跨節點不可丟的工作隊列</td>
          <td>事件流、多 consumer、需要 replay 的審計</td>
      </tr>
  </tbody>
</table>
<h3 id="消費後是否保留retention-與-replay">消費後是否保留：retention 與 replay</h3>
<p>Stream 與 classic / quorum 的根本分界是訊息生命週期。Classic 與 quorum 是 <em>隊列</em>：訊息被 ack 後從 queue 移除、後到的 consumer 看不到歷史。Stream 是 <em>log</em>：訊息寫入後常駐到 retention 上限為止、consumer 各自維護 offset、可以從 offset 0 重讀整段歷史、也可以從 timestamp 起讀。</p>
<p>實機可觀察到 stream 的訊息在 publish 後保留在 queue 內：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">rabbitmqadmin publish <span class="nv">exchange</span><span class="o">=</span>amq.default <span class="nv">routing_key</span><span class="o">=</span>q-stream <span class="nv">payload</span><span class="o">=</span><span class="s2">&#34;msg1&#34;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">rabbitmqadmin publish <span class="nv">exchange</span><span class="o">=</span>amq.default <span class="nv">routing_key</span><span class="o">=</span>q-stream <span class="nv">payload</span><span class="o">=</span><span class="s2">&#34;msg2&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">rabbitmqadmin publish <span class="nv">exchange</span><span class="o">=</span>amq.default <span class="nv">routing_key</span><span class="o">=</span>q-stream <span class="nv">payload</span><span class="o">=</span><span class="s2">&#34;msg3&#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">rabbitmqctl list_queues name <span class="nb">type</span> messages messages_ready</span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">q-stream   stream  3  3</span></span></code></pre></div><p>對 classic queue、同樣 publish 後 consumer ack 一次、訊息歸零；對 stream、即使一個 consumer 讀完、<code>messages</code> 仍維持 3、因為訊息保留供其他 consumer 與未來 replay。這個差異決定了選型：需要「新上線的 consumer 補讀歷史事件」「同一份事件流餵給多個下游」「審計與重算」→ stream 是唯一選項；只要「一個任務交給一個 worker 處理一次」→ classic 或 quorum、不要用 stream（log 保留會吃磁碟、且隊列語意更貼合任務分派）。</p>
<p>需要在 RabbitMQ 體系外做大規模事件流（跨團隊 schema 治理、tiered storage、生態工具）時、stream 不是終點、改評估 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka</a>。Stream 的定位是「已經在用 RabbitMQ、需要 replay 但不想引入第二套 broker」。</p>
<h3 id="跨節點一致性mirrored-的退場與-quorum-的接手">跨節點一致性：mirrored 的退場與 quorum 的接手</h3>
<p>Classic queue 在單節點上沒有複製。早期要跨節點高可用、靠 <em>mirrored queue</em> — 一個 master、多個 mirror、master 寫入同步到所有 mirror。這個機制的問題在 <a href="/blog/backend/03-message-queue/cases/rabbitmq-runtastic-mirrored-queue-bottleneck/" data-link-title="3.C30 Runtastic：Mirrored queue 網路負載瓶頸" data-link-desc="Runtastic 2020 lockdown 流量暴增、performance test 揭露 mirroring 邏輯把網路元件壓垮、調整 mirroring 配置消除瓶頸。">3.C30 Runtastic</a> 揭露：mirror 數越多、每筆訊息的網路放大越大、規模化時網路元件先被壓垮。RabbitMQ 3.x 已將 mirrored queue 標記 deprecated、4.0 移除。</p>
<p>Quorum queue 用 Raft 共識取代 mirroring。差異在「同步多少 replica 才算寫成功」：mirrored queue 要求 <em>所有</em> mirror 同步（全量放大）；Raft 只要求 <em>majority</em>（多數派）寫入即 ack，少數派慢或暫時離線不阻塞寫入。majority 機制讓 quorum queue 在「容忍少數節點故障」與「寫入延遲」之間取得 mirrored 做不到的平衡。</p>
<p>代價是 Raft 的 round-trip 成本：每筆訊息要等多數派落盤、單筆延遲高於 classic 單節點 fsync。所以 quorum queue 適合「不可丟、可接受中等延遲」的工作隊列、不適合追求極致低延遲的場景。</p>
<h3 id="記憶體與-throughput-成本">記憶體與 throughput 成本</h3>
<p>Classic queue 的歷史包袱是訊息傾向常駐記憶體、queue 堆積時記憶體壓力大（lazy queue 模式可緩解、但仍是 classic 的調校負擔）。Quorum queue 預設 on-disk 為主、記憶體只放 index 與近期訊息、堆積時記憶體曲線比 classic 平緩。Stream 是 append-only log、寫入是順序磁碟 I/O、讀取靠 OS page cache、是三者中記憶體效率最高、throughput 最高的 — 順序寫與批次讀讓它在高吞吐事件流場景接近 Kafka 的量級。</p>
<p>throughput 排序大致是 stream &gt; classic ≈ quorum（quorum 因 Raft round-trip 略低於單節點 classic、但換得一致性）。選型時 throughput 不該是唯一軸：stream throughput 高但語意是 log、用它跑任務隊列會錯配；quorum throughput 中但提供 classic 給不了的高可用。</p>
<h2 id="故障演練">故障演練</h2>
<p>三種 queue type 的故障形狀完全不同。以下四個場景對應實際遷移與運維會踩的坑。</p>
<h3 id="mirrored-queue-的網路放大成本">Mirrored queue 的網路放大成本</h3>
<p><strong>徵兆</strong>：流量暴增期間、RabbitMQ cluster 出現高延遲與間歇中斷、但 CPU 與磁碟未飽和；performance test 指向網路元件被壓垮。這正是 <a href="/blog/backend/03-message-queue/cases/rabbitmq-runtastic-mirrored-queue-bottleneck/" data-link-title="3.C30 Runtastic：Mirrored queue 網路負載瓶頸" data-link-desc="Runtastic 2020 lockdown 流量暴增、performance test 揭露 mirroring 邏輯把網路元件壓垮、調整 mirroring 配置消除瓶頸。">3.C30 Runtastic</a> 2020 lockdown 期間的情況。</p>
<p><strong>根因</strong>：mirrored queue 把每筆訊息同步到 <em>所有</em> mirror。一個 master + 2 mirror 的 queue、每筆 publish 產生 2 份額外的跨節點複製流量；mirror 數與訊息量相乘、網路頻寬隨規模線性放大。可靠性看似免費（多一個 mirror 就多一份備援）、實際成本藏在網路層、平時不顯、流量尖峰才爆。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>量化 mirror 的網路成本</strong>：mirror 數不是越多越安全、每個 mirror 都是固定的複製流量稅。生產上 mirror 數很少需要超過總節點的 majority。</li>
<li><strong>遷移到 quorum queue</strong>：Raft 的 majority 寫入取代全量同步、把網路放大從「mirror 數」降到「majority round-trip」。Runtastic case 是「為何該遷 quorum」的典型動機。</li>
<li><strong>監控網路而非只看 CPU / 磁碟</strong>：mirrored queue 的瓶頸常在網路、用 Prometheus integration 把跨節點複製流量納入告警基線。</li>
</ol>
<h3 id="quorum-queue-的-quorum-loss">Quorum queue 的 quorum loss</h3>
<p><strong>徵兆</strong>：cluster 有節點故障後、某些 quorum queue 變成不可寫、publisher confirm 卡住超時、<code>rabbitmq-diagnostics check_if_node_is_quorum_critical</code> 報警。</p>
<blockquote>
<p>以下跨節點行為依官方文件、單節點環境未實機驗證。</p></blockquote>
<p><strong>根因</strong>：quorum queue 靠 Raft majority 運作。一個 3-replica 的 queue 容忍 1 個節點故障（剩 2 個構成 majority）；故障 2 個節點時、剩 1 個無法構成多數派、queue 進入 <em>無 leader</em> 狀態、拒絕寫入以保證一致性。這是 Raft 的設計選擇：寧可不可用、不可不一致。replica 數設成偶數（如 2 或 4）更糟 — 偶數的 majority 門檻不會提升容錯、反而浪費資源。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>replica 數設奇數</strong>：3 replica 容忍 1 故障、5 replica 容忍 2 故障。奇數讓 majority 計算最有效率。</li>
<li><strong>監控 quorum critical 狀態</strong>：<code>rabbitmq-diagnostics check_if_node_is_quorum_critical</code> 在「再掛一個節點就會失去 quorum」時提前告警、在維護重啟前先確認不會打破 majority。</li>
<li><strong>跨故障域分佈 replica</strong>：把 3 個 replica 放在不同 AZ / 機架、避免單一故障域同時帶走多數派。</li>
<li><strong>理解不可用是預期行為</strong>：quorum loss 時 queue 拒寫是 <em>正確</em> 的、不是 bug。恢復路徑是把故障節點拉回 cluster 重組 majority、不是強制覆寫。</li>
</ol>
<h3 id="stream-retention-超量">Stream retention 超量</h3>
<p><strong>徵兆</strong>：stream queue 所在節點磁碟使用率持續上升、最終觸發 disk alarm、broker 暫停所有 publisher；或 consumer 嘗試讀取較舊的 offset 時拿到「offset 不存在」、發現歷史訊息已被截斷。</p>
<p><strong>根因</strong>：stream 是 append-only log、訊息 <em>不因消費而刪除</em>、只靠 retention 上限（<code>x-max-age</code> 時間 / <code>x-max-length-bytes</code> 大小）回收。retention 設太寬、或寫入速率超過預估、log 持續長大直到塞滿磁碟。反過來 retention 設太緊、consumer 還沒讀到的舊訊息就被截斷、replay 場景拿不到完整歷史。Stream 的容量管理是「設定 retention」、不是「靠消費清空」 — 這跟隊列直覺相反。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>retention 雙保險</strong>：同時設 <code>x-max-age</code>（時間上限、對齊業務 replay 窗口、如 7 天）與 <code>x-max-length-bytes</code>（大小上限、對齊磁碟容量）。先到的條件先觸發截斷、避免單一維度失控。</li>
<li><strong>segment 大小對齊回收粒度</strong>：<code>x-stream-max-segment-size-bytes</code> 決定 log 分段大小、retention 以 segment 為單位回收。segment 太大、retention 觸發後一次釋放大量空間、磁碟曲線鋸齒；太小、segment 檔案數量爆炸。</li>
<li><strong>容量公式先算再設</strong>：預估 <code>寫入速率 × 訊息平均大小 × retention 時間</code>、確認低於節點磁碟可用空間的安全水位（如 70%）、再上線。</li>
<li><strong>monitor disk_free_limit</strong>：stream 節點的磁碟告警閾值要比一般節點更早、因為 stream 是磁碟密集型、disk alarm 觸發會凍結整個 broker 的 publisher。</li>
</ol>
<h3 id="classic--quorum-遷移的-in-flight-message">Classic → Quorum 遷移的 in-flight message</h3>
<p><strong>徵兆</strong>：把工作隊列從 classic（或 deprecated mirrored）遷到 quorum 時、切換瞬間有訊息遺失、或重複處理 — queue 重建期間 publisher 已經在發、consumer 還沒接上新 queue。</p>
<p><strong>根因</strong>：queue type 無法原地變更、遷移本質是 <em>建新 queue + 切流量 + 排空舊 queue</em>。最大的坑是 in-flight 訊息：舊 classic queue 裡還有未消費的訊息、若直接刪除舊 queue、這些訊息就丟了；若 publisher 提前切到新 queue、舊 queue 的 consumer 還在處理、就出現新舊兩條路徑並存的一致性窗口。<a href="/blog/backend/03-message-queue/cases/rabbitmq-zalando-aws-master-selection/" data-link-title="3.C27 Zalando：RabbitMQ on AWS 自動化 master selection" data-link-desc="Zalando 用 sidekick 服務查 AWS API 動態識別 cluster、指定最老 instance 當 master、跨版本升級用 federation 過渡。">3.C27 Zalando</a> 跨版本升級用 federation 過渡、正是為了平滑搬移而非硬切。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>新 queue 先建、binding 並存</strong>：用新 routing key 或新 queue 名建立 quorum queue、舊 classic queue 暫不刪。</li>
<li><strong>consumer 先切、publisher 後切</strong>：先讓 consumer 同時消費新舊兩個 queue、確認新 queue 路徑正常、再把 publisher 切到只發新 queue。順序顛倒（publisher 先切）會讓舊 queue 的 in-flight 訊息沒人消費。</li>
<li><strong>排空舊 queue 再刪</strong>：publisher 切換後、等舊 classic queue <code>messages</code> 歸零（用 <code>list_queues name messages</code> 確認）、才刪除舊 queue。</li>
<li><strong>依賴 idempotency 兜底</strong>：遷移窗口內訊息可能重複投遞、consumer 端的 <a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a> 是最後一道防線（語義誤配的後果見 <a href="/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9</a>）、不要假設遷移零重複。</li>
<li><strong>用 federation / shovel 做大規模搬移</strong>：跨 cluster 或跨版本場景、用 federation upstream 把舊 cluster 訊息引流到新 cluster、避免一次性硬切（Zalando case 的做法）。</li>
</ol>
<h2 id="容量與成本規劃">容量與成本規劃</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>Classic</th>
          <th>Quorum</th>
          <th>Stream</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>單筆寫入延遲</td>
          <td>低（單節點 fsync）</td>
          <td>中（Raft majority round-trip）</td>
          <td>低（順序 append、批次 ack）</td>
      </tr>
      <tr>
          <td>記憶體 / 訊息</td>
          <td>高（常駐、lazy 緩解）</td>
          <td>中（on-disk 為主 + index）</td>
          <td>低（log 在磁碟、靠 page cache）</td>
      </tr>
      <tr>
          <td>磁碟成長</td>
          <td>隨未消費堆積</td>
          <td>隨未消費堆積</td>
          <td>隨 retention 上限、消費不回收</td>
      </tr>
      <tr>
          <td>節點故障容忍</td>
          <td>無（該 queue 不可用）</td>
          <td>容忍少數派故障（3 replica 容 1）</td>
          <td>Leader 故障可切 follower</td>
      </tr>
      <tr>
          <td>適用規模上限訊號</td>
          <td>堆積導致記憶體壓力 / 需要跨節點 HA</td>
          <td>Raft 延遲成為瓶頸 / 超高吞吐</td>
          <td>事件流規模需要跨團隊 schema 治理</td>
      </tr>
      <tr>
          <td>超出後改走</td>
          <td>Quorum（要 HA）/ Stream（要 replay）</td>
          <td>Stream（要 replay）/ Kafka（要生態）</td>
          <td><a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka</a>（跨團隊事件平台）</td>
      </tr>
  </tbody>
</table>
<p>實務 default：</p>
<ul>
<li><strong>單節點開發 / 臨時隊列</strong>：classic、最簡單、transient 模式適合 RPC reply。</li>
<li><strong>生產工作隊列、不可丟訊息</strong>：quorum、3 replica 跨 AZ、replica 數設奇數。</li>
<li><strong>事件流 / 多 consumer / 需要 replay</strong>：stream、retention 雙保險、磁碟容量先算。</li>
<li><strong>判斷該不該升級到 Kafka</strong>：當 stream 場景開始需要跨團隊 schema registry、tiered storage、或成熟的 streaming 生態工具時、stream 是過渡、Kafka 是終點。</li>
</ul>
<h2 id="整合與下一步">整合與下一步</h2>
<p>Queue type 的選擇與 RabbitMQ 其他能力交織：</p>
<ul>
<li><strong>回 vendor overview</strong>：三種 queue type 的取捨在 <a href="/blog/backend/03-message-queue/vendors/rabbitmq/" data-link-title="RabbitMQ" data-link-desc="Classic message broker、AMQP routing 為主">RabbitMQ overview</a>「Classic queue vs Quorum queue vs Stream」段有 vendor-level 定位；本文是其 implementation 展開。</li>
<li><strong>durable queue 能力層</strong>：queue type 的持久化語意建立在 <a href="/blog/backend/03-message-queue/durable-queue/" data-link-title="3.2 durable queue 與重試策略" data-link-desc="整理持久化佇列、DLQ 與重試流程">3.2 durable queue</a> 的概念上 — quorum 與 stream 強制 durable、正是把「處理即承諾」的可靠性從單節點延伸到跨節點。</li>
<li><strong>durable queue 知識卡</strong>：訊息持久化的概念基礎見 <a href="/blog/backend/knowledge-cards/durable-queue/" data-link-title="Durable Queue" data-link-desc="說明可持久化的 queue 如何在重啟與失敗後保留待處理工作">durable queue 知識卡</a>。</li>
<li><strong>mirrored → quorum 的遷移動機</strong>：<a href="/blog/backend/03-message-queue/cases/rabbitmq-runtastic-mirrored-queue-bottleneck/" data-link-title="3.C30 Runtastic：Mirrored queue 網路負載瓶頸" data-link-desc="Runtastic 2020 lockdown 流量暴增、performance test 揭露 mirroring 邏輯把網路元件壓垮、調整 mirroring 配置消除瓶頸。">3.C30 Runtastic</a> 量化 mirrored 網路成本、是遷 quorum 的證據。</li>
<li><strong>跨版本 / 跨 cluster 平滑遷移</strong>：<a href="/blog/backend/03-message-queue/cases/rabbitmq-zalando-aws-master-selection/" data-link-title="3.C27 Zalando：RabbitMQ on AWS 自動化 master selection" data-link-desc="Zalando 用 sidekick 服務查 AWS API 動態識別 cluster、指定最老 instance 當 master、跨版本升級用 federation 過渡。">3.C27 Zalando</a> 用 federation 過渡、是 in-flight message 安全搬移的範本。</li>
</ul>
<p>何時 revisit queue type 選擇：classic queue 開始出現記憶體壓力或需要跨節點 HA 時、評估 quorum；任何 queue 場景開始需要「補讀歷史」「多 consumer 各自進度」「replay 重算」時、評估 stream；stream 場景開始需要跨團隊事件治理時、評估遷 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka</a>。</p>
]]></content:encoded></item><item><title>MySQL Online Schema Change：gh-ost 跟 pt-online-schema-change 兩條完全不同的 ghost table 路徑</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/online-schema-change-tools/</link><pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/online-schema-change-tools/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/" data-link-title="MySQL" data-link-desc="高併發網路服務常用關聯式資料庫、Vitess / PlanetScale 分片生態、GitHub / Shopify / Facebook 規模驗證">MySQL&lt;/a> overview 的 implementation-layer deep article。Overview 已說明 MySQL 在 OLTP 譜系的定位、本文聚焦 &lt;em>online schema change&lt;/em> — gh-ost 跟 pt-online-schema-change 兩條工具路徑的機制對比。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>機制&lt;/th>
 &lt;th>pt-online-schema-change（Percona）&lt;/th>
 &lt;th>gh-ost（GitHub）&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>同步機制&lt;/td>
 &lt;td>&lt;strong>MySQL trigger&lt;/strong>（原表 INSERT/UPDATE/DELETE 觸發寫 ghost）&lt;/td>
 &lt;td>&lt;strong>Binlog stream&lt;/strong>（讀 primary binlog 寫 ghost）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Primary 寫入 overhead&lt;/td>
 &lt;td>trigger 觸發成本（同 transaction 內）&lt;/td>
 &lt;td>0（binlog 已存在）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Replica lag 影響&lt;/td>
 &lt;td>trigger 在 primary 跑、replica 自然 lag&lt;/td>
 &lt;td>從 replica 讀 binlog、可主動 throttle&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Foreign key&lt;/td>
 &lt;td>部分支援（drop/recreate strategy）&lt;/td>
 &lt;td>不支援（必須先 drop FK）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Roll back（過程中）&lt;/td>
 &lt;td>困難（trigger 已建、要清乾淨）&lt;/td>
 &lt;td>容易（drop ghost table 即可）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>暫停 / resume&lt;/td>
 &lt;td>不支援&lt;/td>
 &lt;td>支援（gh-ost interactive command）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>切換時 lock 持續&lt;/td>
 &lt;td>rename 期間 metadata lock（毫秒級）&lt;/td>
 &lt;td>rename 期間 metadata lock（毫秒級）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>工具 binary&lt;/td>
 &lt;td>Perl 腳本（Percona Toolkit）&lt;/td>
 &lt;td>Go binary（單一可執行檔）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>推出年份&lt;/td>
 &lt;td>2011&lt;/td>
 &lt;td>2016&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>兩工具最終結果一樣（ghost table 取代原表）、但 &lt;em>過程中對 production 的影響非常不同&lt;/em>。選哪個取決於：trigger overhead 可不可接受、是否有 foreign key、是否需要 resume/throttle 能力、團隊熟悉哪條工具鏈。&lt;/p>
&lt;h2 id="為什麼-alter-table-需要-online-path">為什麼 ALTER TABLE 需要 online path&lt;/h2>
&lt;p>MySQL 8.0 之前的 &lt;code>ALTER TABLE&lt;/code> 多數情況下 &lt;em>rebuild 整張表&lt;/em> — 過程中 &lt;em>primary key 之外的 read/write 都 block&lt;/em>。100 GB 表 ALTER 跑 hours、production write 全部失敗。&lt;/p>
&lt;p>MySQL 8.0 加 &lt;em>Instant DDL&lt;/em>（部分 ALTER 不 rebuild、只改 metadata、毫秒級完成）、但 &lt;em>能用 instant 的 ALTER 是 subset&lt;/em>：&lt;/p>
&lt;ul>
&lt;li>支援：ADD COLUMN（末尾）、DROP COLUMN（部分情境）、RENAME COLUMN&lt;/li>
&lt;li>不支援：ADD INDEX、CHANGE COLUMN type、ADD/DROP PRIMARY KEY、ADD FOREIGN KEY&lt;/li>
&lt;/ul>
&lt;p>不支援 instant 的場景仍要走 ghost table。Percona 跟 GitHub 各自從 production 痛點出發、產出 pt-osc（2011）跟 gh-ost（2016）。&lt;/p>
&lt;h2 id="pt-online-schema-change用-trigger-同步寫入">pt-online-schema-change：用 trigger 同步寫入&lt;/h2>
&lt;p>pt-osc 流程：&lt;/p>
&lt;ol>
&lt;li>CREATE ghost table（跟原表同 schema + 你要的 ALTER）&lt;/li>
&lt;li>在原表上 &lt;em>建 3 個 trigger&lt;/em>：INSERT / UPDATE / DELETE&lt;/li>
&lt;li>任何寫入原表的 transaction &lt;em>同時觸發 trigger&lt;/em> 寫對應 ghost&lt;/li>
&lt;li>背景 chunk-by-chunk copy 既有 row 到 ghost&lt;/li>
&lt;li>全部 copy 完後 &lt;code>RENAME TABLE&lt;/code>：原表 → archive、ghost → 原表名（atomic、metadata lock 毫秒級）&lt;/li>
&lt;li>Drop trigger、drop archive&lt;/li>
&lt;/ol>
&lt;p>&lt;strong>Trade-off&lt;/strong>：&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/mysql/" data-link-title="MySQL" data-link-desc="高併發網路服務常用關聯式資料庫、Vitess / PlanetScale 分片生態、GitHub / Shopify / Facebook 規模驗證">MySQL</a> overview 的 implementation-layer deep article。Overview 已說明 MySQL 在 OLTP 譜系的定位、本文聚焦 <em>online schema change</em> — gh-ost 跟 pt-online-schema-change 兩條工具路徑的機制對比。</p></blockquote>
<hr>
<table>
  <thead>
      <tr>
          <th>機制</th>
          <th>pt-online-schema-change（Percona）</th>
          <th>gh-ost（GitHub）</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>同步機制</td>
          <td><strong>MySQL trigger</strong>（原表 INSERT/UPDATE/DELETE 觸發寫 ghost）</td>
          <td><strong>Binlog stream</strong>（讀 primary binlog 寫 ghost）</td>
      </tr>
      <tr>
          <td>Primary 寫入 overhead</td>
          <td>trigger 觸發成本（同 transaction 內）</td>
          <td>0（binlog 已存在）</td>
      </tr>
      <tr>
          <td>Replica lag 影響</td>
          <td>trigger 在 primary 跑、replica 自然 lag</td>
          <td>從 replica 讀 binlog、可主動 throttle</td>
      </tr>
      <tr>
          <td>Foreign key</td>
          <td>部分支援（drop/recreate strategy）</td>
          <td>不支援（必須先 drop FK）</td>
      </tr>
      <tr>
          <td>Roll back（過程中）</td>
          <td>困難（trigger 已建、要清乾淨）</td>
          <td>容易（drop ghost table 即可）</td>
      </tr>
      <tr>
          <td>暫停 / resume</td>
          <td>不支援</td>
          <td>支援（gh-ost interactive command）</td>
      </tr>
      <tr>
          <td>切換時 lock 持續</td>
          <td>rename 期間 metadata lock（毫秒級）</td>
          <td>rename 期間 metadata lock（毫秒級）</td>
      </tr>
      <tr>
          <td>工具 binary</td>
          <td>Perl 腳本（Percona Toolkit）</td>
          <td>Go binary（單一可執行檔）</td>
      </tr>
      <tr>
          <td>推出年份</td>
          <td>2011</td>
          <td>2016</td>
      </tr>
  </tbody>
</table>
<p>兩工具最終結果一樣（ghost table 取代原表）、但 <em>過程中對 production 的影響非常不同</em>。選哪個取決於：trigger overhead 可不可接受、是否有 foreign key、是否需要 resume/throttle 能力、團隊熟悉哪條工具鏈。</p>
<h2 id="為什麼-alter-table-需要-online-path">為什麼 ALTER TABLE 需要 online path</h2>
<p>MySQL 8.0 之前的 <code>ALTER TABLE</code> 多數情況下 <em>rebuild 整張表</em> — 過程中 <em>primary key 之外的 read/write 都 block</em>。100 GB 表 ALTER 跑 hours、production write 全部失敗。</p>
<p>MySQL 8.0 加 <em>Instant DDL</em>（部分 ALTER 不 rebuild、只改 metadata、毫秒級完成）、但 <em>能用 instant 的 ALTER 是 subset</em>：</p>
<ul>
<li>支援：ADD COLUMN（末尾）、DROP COLUMN（部分情境）、RENAME COLUMN</li>
<li>不支援：ADD INDEX、CHANGE COLUMN type、ADD/DROP PRIMARY KEY、ADD FOREIGN KEY</li>
</ul>
<p>不支援 instant 的場景仍要走 ghost table。Percona 跟 GitHub 各自從 production 痛點出發、產出 pt-osc（2011）跟 gh-ost（2016）。</p>
<h2 id="pt-online-schema-change用-trigger-同步寫入">pt-online-schema-change：用 trigger 同步寫入</h2>
<p>pt-osc 流程：</p>
<ol>
<li>CREATE ghost table（跟原表同 schema + 你要的 ALTER）</li>
<li>在原表上 <em>建 3 個 trigger</em>：INSERT / UPDATE / DELETE</li>
<li>任何寫入原表的 transaction <em>同時觸發 trigger</em> 寫對應 ghost</li>
<li>背景 chunk-by-chunk copy 既有 row 到 ghost</li>
<li>全部 copy 完後 <code>RENAME TABLE</code>：原表 → archive、ghost → 原表名（atomic、metadata lock 毫秒級）</li>
<li>Drop trigger、drop archive</li>
</ol>
<p><strong>Trade-off</strong>：</p>
<ul>
<li><em>寫入 overhead</em>：每個 primary 寫入 transaction 都多一次 trigger 執行、寫吞吐降 10-30%</li>
<li><em>Replica lag</em>：trigger 跟原寫入同 transaction、replica 上每個 row 也跑 trigger、replica lag 可能暴增（缺少主動 throttle）</li>
<li><em>Roll back 困難</em>：tool 跑到一半失敗、trigger 已建、要手動清掉才能 retry</li>
<li><em>FK 處理</em>：原表有 FK 指向時、ghost table 要先 drop FK 再 recreate、操作複雜</li>
</ul>
<p><strong>適用</strong>：</p>
<ul>
<li>寫吞吐 &lt; 50% capacity（有 buffer 撐 trigger overhead）</li>
<li>無 FK 或 FK 簡單</li>
<li>沒有 replica lag 敏感的 read（trigger 在 replica 也跑）</li>
</ul>
<p><strong>不適用</strong>：</p>
<ul>
<li>高寫吞吐（&gt; 80% capacity）— trigger overhead 直接 saturate</li>
<li>大量 FK 結構</li>
<li>需要 throttle / pause / resume</li>
</ul>
<h2 id="gh-ost用-binlog-stream-同步寫入">gh-ost：用 binlog stream 同步寫入</h2>
<p>gh-ost 流程：</p>
<ol>
<li>CREATE ghost table</li>
<li><em>從 replica 讀 binlog</em>（不在 primary 加 trigger）</li>
<li>同步 <em>primary 上的寫入</em> 透過 binlog event 寫到 ghost</li>
<li>背景 chunk-by-chunk copy 既有 row 到 ghost</li>
<li>全部 copy 完後 swap：<code>RENAME TABLE</code></li>
<li>Drop archive</li>
</ol>
<p><strong>Trade-off</strong>：</p>
<ul>
<li><em>寫入 overhead</em>：0（binlog 已經寫了、gh-ost 只是 consumer）</li>
<li><em>Replica lag 影響</em>：gh-ost 可監測 replica lag、超過 threshold 自動 throttle copy（不影響 primary 寫入）</li>
<li><em>Roll back 容易</em>：取消時直接 drop ghost table、原表完全沒被改動</li>
<li><em>FK 不支援</em>：gh-ost 設計上不處理 FK、有 FK 必須先 drop / restructure</li>
</ul>
<p><strong>適用</strong>：</p>
<ul>
<li>高寫吞吐 production（trigger overhead 不可接受）</li>
<li>需要 throttle / pause / resume（gh-ost interactive command 可動態調 chunk size、cut-over 時點）</li>
<li>已用 GitHub-flavored MySQL operations workflow</li>
</ul>
<p><strong>不適用</strong>：</p>
<ul>
<li>有複雜 FK 結構、不想動 schema</li>
<li>Replica 跑不了 binlog（極少數場景）</li>
</ul>
<h2 id="配置-step-by-stepgh-ost">配置 step-by-step（gh-ost）</h2>
<p>實務 production 多用 gh-ost（GitHub / Slack / Booking.com 等）。pt-osc 用於有 FK 或舊系統。</p>
<h3 id="gh-ost-一個-alter-命令">gh-ost 一個 ALTER 命令</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl">gh-ost <span class="se">\
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="se"></span>  --host<span class="o">=</span>replica.example.com <span class="se">\ </span>          <span class="c1"># 從 replica 讀 binlog</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  --user<span class="o">=</span>ghost <span class="se">\
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="se"></span>  --password<span class="o">=</span>... <span class="se">\
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="se"></span>  --database<span class="o">=</span>production <span class="se">\
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="se"></span>  --table<span class="o">=</span>orders <span class="se">\
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="se"></span>  --alter<span class="o">=</span><span class="s1">&#39;ADD COLUMN status VARCHAR(20) DEFAULT NULL, ADD INDEX idx_status (status)&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="se"></span>  --allow-on-master<span class="o">=</span><span class="nb">false</span> <span class="se">\ </span>             <span class="c1"># 不直接連 primary 讀 binlog</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  --chunk-size<span class="o">=</span><span class="m">1000</span> <span class="se">\ </span>                   <span class="c1"># 每批 copy 1000 row</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  --max-load<span class="o">=</span><span class="s1">&#39;Threads_running=50&#39;</span> <span class="se">\ </span>     <span class="c1"># primary load 限制</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  --critical-load<span class="o">=</span><span class="s1">&#39;Threads_running=200&#39;</span> <span class="se">\ </span><span class="c1"># 超過直接 abort</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">  --max-lag-millis<span class="o">=</span><span class="m">1500</span> <span class="se">\ </span>               <span class="c1"># replica lag 限制</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">  --throttle-additional-flag-file<span class="o">=</span>/tmp/throttle <span class="se">\ </span> <span class="c1"># touch 此檔 throttle</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">  --postpone-cut-over-flag-file<span class="o">=</span>/tmp/postpone <span class="se">\ </span>   <span class="c1"># touch 此檔延後 cut-over</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">  --execute                              <span class="c1"># 真的執行（沒這個只 dry-run）</span></span></span></code></pre></div><h3 id="interactive-commandgh-ost-跑起來後">Interactive command（gh-ost 跑起來後）</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 連 gh-ost socket（同 directory）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;status&#34;</span> <span class="p">|</span> nc -U /tmp/gh-ost.production.orders.sock
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"># 動態調 chunk size</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;chunk-size=500&#34;</span> <span class="p">|</span> nc -U /tmp/gh-ost.production.orders.sock
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># 立即觸發 cut-over（不再等）</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;unpostpone&#34;</span> <span class="p">|</span> nc -U /tmp/gh-ost.production.orders.sock
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"># Abort 並 drop ghost</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;panic&#34;</span> <span class="p">|</span> nc -U /tmp/gh-ost.production.orders.sock</span></span></code></pre></div><h2 id="配置-step-by-steppt-osc">配置 step-by-step（pt-osc）</h2>
<p>對比 gh-ost 的 binlog reader、pt-osc 命令更短但配置義務同樣多：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl">pt-online-schema-change <span class="se">\
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="se"></span>  --host<span class="o">=</span>primary.example.com <span class="se">\
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="se"></span>  --user<span class="o">=</span>ghost <span class="se">\
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="se"></span>  --password<span class="o">=</span>... <span class="se">\
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="se"></span>  --alter<span class="o">=</span><span class="s1">&#39;ADD COLUMN status VARCHAR(20) DEFAULT NULL, ADD INDEX idx_status (status)&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="se"></span>  <span class="nv">D</span><span class="o">=</span>production,t<span class="o">=</span>orders <span class="se">\
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="se"></span>  --chunk-size<span class="o">=</span><span class="m">1000</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="se"></span>  --max-load<span class="o">=</span><span class="s1">&#39;Threads_running=50&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="se"></span>  --critical-load<span class="o">=</span><span class="s1">&#39;Threads_running=200&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="se"></span>  --max-lag<span class="o">=</span>1.5 <span class="se">\
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="se"></span>  --check-replication-filters <span class="se">\ </span>          <span class="c1"># 防 binlog filter 漏 trigger</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">  --alter-foreign-keys-method<span class="o">=</span>auto <span class="se">\ </span>     <span class="c1"># auto / rebuild_constraints / drop_swap / none</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">  --execute</span></span></code></pre></div><p><code>--alter-foreign-keys-method</code> 是 pt-osc 對 FK 處理的策略選項、四種選擇對 production 影響非常不同（rebuild 重建 FK / drop_swap 用更快但少了 atomic、none 是不處理）。</p>
<h2 id="5-個-production-踩雷">5 個 Production 踩雷</h2>
<h3 id="1-pt-osc-trigger-overhead-不可預期">1. pt-osc trigger overhead 不可預期</h3>
<p><code>--max-load='Threads_running=50'</code> 看起來保護了 server、但 trigger 在 transaction 內、production 的 <em>每個寫入</em> 都加 trigger 開銷。<code>Threads_running</code> 是 <em>當下</em> 數字、看不到 trigger 累積 latency。常見場景：高峰時段下 pt-osc、預期 30% overhead、實際 60%、p99 飆 5x。</p>
<p>修法：</p>
<ul>
<li>高峰時段不跑 pt-osc、排 off-peak window</li>
<li>用 <em>staging environment</em> 跑 production-like load 預估 trigger overhead</li>
<li>對寫吞吐 &gt; 50% capacity 的 server 改用 gh-ost</li>
</ul>
<h3 id="2-gh-ost-binlog-lag-跟-primary-寫入率追不上">2. gh-ost binlog lag 跟 primary 寫入率追不上</h3>
<p>gh-ost 從 replica 讀 binlog、binlog event 進來速度有上限。如果 <em>primary 寫入率超過 gh-ost binlog consume 速度</em>（每秒幾千 transaction 對某些 server 已是 ceiling）、gh-ost 永遠追不上、cut-over 會長時間卡住。</p>
<p>修法：</p>
<ul>
<li>gh-ost 預設用 <em>replica binlog</em>、改用 <code>--allow-on-master</code> 直接從 primary 讀（如果 primary 容量夠）</li>
<li>提高 <code>--chunk-size</code> 加快 copy（同時用 <code>--max-load</code> 防過載）</li>
<li>真的追不上、考慮 <em>暫停部分寫入流量</em>（throttle traffic，而非 throttle tool）</li>
</ul>
<h3 id="3-foreign-key-constraint--兩工具都尷尬">3. Foreign key constraint — 兩工具都尷尬</h3>
<p>原表有 FK 指向（其他 table FK references 這張表）、ghost table 切換時 <em>新 ghost 沒有那些 FK 指向</em>。Cut-over 一瞬間、FK 從指向「原表」變成指向「archive 表」、外部 constraint 失效。</p>
<p>修法（pt-osc）：</p>
<ul>
<li>用 <code>--alter-foreign-keys-method=rebuild_constraints</code>：先 ALTER 外部 table FK 指向 ghost、再 cut-over</li>
<li>或 <code>drop_swap</code>：cut-over 前 drop FK、cut-over 後 recreate（更快但 cut-over 期間 FK 失效）</li>
</ul>
<p>修法（gh-ost）：</p>
<ul>
<li>gh-ost 不支援 — 手動 drop FK / 重 setup FK</li>
<li>或維護 schema 改 FK 結構（FK 改在 application 層 enforce）</li>
</ul>
<h3 id="4-pt-osc-trigger-跟-application-既有-trigger-衝突">4. pt-osc trigger 跟 application 既有 trigger 衝突</h3>
<p>原表上已經有 application 自建 trigger、pt-osc 在原表 <em>再加 3 個 trigger</em>、新舊 trigger 執行順序 MySQL 不保證（多 trigger 同事件按 <em>未定義順序</em>）。Application 行為可能 subtly broken。</p>
<p>修法：</p>
<ul>
<li>跑 pt-osc 前 audit 原表 trigger（<code>SHOW TRIGGERS FROM production LIKE 'orders'</code>）</li>
<li>如果有 application trigger、考慮 <em>暫時 disable 再 ALTER</em> 或改 gh-ost</li>
<li>gh-ost 不在原表加 trigger、不會碰到這個問題</li>
</ul>
<h3 id="5-cut-over-瞬間-deadlock--兩工具都有但表現不同">5. Cut-over 瞬間 deadlock — 兩工具都有但表現不同</h3>
<p>Cut-over 用 <code>RENAME TABLE original TO archive, ghost TO original</code>（atomic operation）。但 cut-over 瞬間需要 <em>metadata lock</em>、跟 <em>進行中的 long-running transaction</em> 衝突會 wait。Long-running transaction 持續、cut-over 永遠 wait、最後 timeout 失敗。</p>
<p>修法（gh-ost）：</p>
<ul>
<li><code>--cut-over-lock-timeout-seconds=3</code>、超時 abort、稍後 retry</li>
<li><code>--postpone-cut-over-flag-file</code>：先把 copy 跑完、等流量空檔再觸發 cut-over</li>
</ul>
<p>修法（pt-osc）：</p>
<ul>
<li><code>--set-vars=&quot;lock_wait_timeout=60&quot;</code>、cut-over 等更久（風險：long transaction 撐住更久 server 更多 lock wait）</li>
<li>或排在 long transaction 已知不會跑的時段（nightly backup 後）</li>
</ul>
<h2 id="容量--時間估算">容量 / 時間估算</h2>
<p>對 100 GB 表、ALTER 加 column + 加 index 為例：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>pt-osc</th>
          <th>gh-ost</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>估算總時間</td>
          <td>6-12 小時（依 chunk size + load）</td>
          <td>5-10 小時（同上、可動態調整）</td>
      </tr>
      <tr>
          <td>寫吞吐影響</td>
          <td>-10% ~ -30%（trigger overhead）</td>
          <td>&lt; 5%（binlog 已存在）</td>
      </tr>
      <tr>
          <td>Replica lag</td>
          <td>1-10 秒（trigger 在 replica 跑）</td>
          <td>自動 throttle 在 threshold 內</td>
      </tr>
      <tr>
          <td>Disk 額外需求</td>
          <td>~原表大小 + index（ghost 用）</td>
          <td>同左</td>
      </tr>
      <tr>
          <td>Rollback 成本</td>
          <td>中（清 trigger）</td>
          <td>低（drop ghost）</td>
      </tr>
  </tbody>
</table>
<p>兩工具總時間接近、<em>影響 production 的差異大</em>。</p>
<h2 id="跟其他模組整合">跟其他模組整合</h2>
<h3 id="跟-gtid--replication-topology">跟 GTID / Replication topology</h3>
<p>兩工具都 <em>依賴 replication</em> — pt-osc 透過 trigger 確保 replica 同步、gh-ost 直接從 replica 讀 binlog。Pre-requisite：</p>
<ul>
<li>Binlog <code>ROW</code> format（兩工具都要）</li>
<li>GTID 啟用（gh-ost 更需要、binlog re-pointing 容易）</li>
<li>詳見 <a href="/blog/backend/01-database/vendors/mysql/replication-topology/" data-link-title="MySQL Replication Topology：async / semi-sync / GTID 不是三選一、是三個 trade-off 軸的疊加" data-link-desc="MySQL replication 不是「選 async 還是 semi-sync」、是 *durability / latency / consistency* 三個 trade-off 軸的疊加；GTID 是跨 mode 的 infrastructure layer、不是第三種 mode。本文走 3 軸取捨模型 → async / semi-sync 行為對比 → GTID 替代 binlog-position 的好處 → 配置 step-by-step → 5 production 踩雷（lag 暴衝 / semi-sync 退回 async / GTID gap / Loss-Less semi-sync 真的 loss-less / chained replication 雪崩）→ 跟 Aurora MySQL / Vitess / ProxySQL / Orchestrator 整合">Replication Topology</a></li>
</ul>
<h3 id="跟-vitess">跟 Vitess</h3>
<p>Vitess 有自己的 <em>VReplication-based online DDL</em>、不用 gh-ost 或 pt-osc。Vitess online DDL 在 shard 內部用類似 gh-ost 的 binlog stream 機制、但有 Vitess-aware schema management。詳見 <em>Vitess sharding 設計</em> 篇（待寫）。</p>
<h3 id="跟-aurora-mysql">跟 Aurora MySQL</h3>
<p>Aurora MySQL 仍支援 gh-ost / pt-osc、但 <em>Aurora 自己的 fast DDL</em>（部分 ALTER） 比 8.0 Instant DDL 更廣。先檢查 Aurora 文件、能用 native fast DDL 就不用 ghost table tool。詳見 <a href="/blog/backend/01-database/vendors/aurora/" data-link-title="AWS Aurora" data-link-desc="AWS managed PostgreSQL / MySQL、storage / compute 分離、&#43;75% 效能改善的 production 證據">Aurora vendor page</a>。</p>
<h3 id="跟-planetscale">跟 PlanetScale</h3>
<p>PlanetScale（managed Vitess）走 <em>branch-based schema migration</em> — 建 schema branch、跑 schema change、deploy 時 atomic merge。schema change 由 PlanetScale 內建流程承擔。詳見 <a href="/blog/backend/01-database/vendors/mysql/migrate-to-planetscale/" data-link-title="MySQL → PlanetScale：managed Vitess &#43; branch-based schema workflow 的 hybrid shift" data-link-desc="自管 MySQL → PlanetScale 加上 Vitess sharding 跟 branch-based schema workflow。本文走 6 維 audit（Paradigm &#43; Operational &#43; Schema 多軸）、4-phase migration、5 production 踩雷、何時不要遷。">PlanetScale migration playbook</a>。</p>
<h2 id="production-casegh-ost-operation-workflow">Production case：gh-ost operation workflow</h2>
<p>Online schema change 的 production 責任是把大表 DDL 拆成可暫停、可節流、可切換的資料搬移流程。gh-ost 作為 GitHub 開源工具，把 schema change 轉成 ghost table copy、binlog tailing 與 controlled cutover；這讓 operator 可以在 replica lag、application load 或部署窗口變化時調整速度。</p>
<p>這個案例要回收到三個操作判準。第一，throttle 指標要接 production SLO，例如 replica lag、thread running、application latency 或錯誤率，而非只看 copy rows/sec。第二，pause / resume 是變更治理能力，代表 schema change 可以配合 incident response、deploy freeze 與商業尖峰窗口。第三，cutover 要設 rollback window 與 owner，因為 rename table 的瞬間仍是高風險控制點。</p>
<p>gh-ost workflow 的 sibling 路由是 <a href="/blog/backend/01-database/vendors/postgresql/online-schema-change/" data-link-title="PostgreSQL Online Schema Change：先用 ALTER 內建特性、不能解才 pg_repack / pg-osc" data-link-desc="PostgreSQL ALTER TABLE 對多數變更已是 *fast catalog-only*（add column nullable / drop column / 改 default），不必走 ghost table tool。本文走 PG 內建 fast DDL 行為、何時必須走 pg_repack / pg-osc、兩工具機制對比（trigger-based vs WAL-shipping）、配置 step-by-step、5 production 踩雷（lock 升級 / VACUUM FULL 誤用 / pg_repack version mismatch / concurrent index 失敗清理 / generated stored column 不能 online）、跟 MySQL gh-ost / pt-osc sibling 對比">PostgreSQL Online Schema Change</a>。PostgreSQL 常靠 fast ALTER、MVCC 與 extension 工具解決同類需求；MySQL 的 ghost table tool 更常成為標準路徑，主因是大表 DDL、metadata lock 與 replication event 的組合壓力不同。</p>
<h2 id="何時用哪一個">何時用哪一個</h2>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>選擇</th>
          <th>原因</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>標準 production write &lt; 50% capacity</td>
          <td>gh-ost（預設）</td>
          <td>寫入 overhead 0、控制更細</td>
      </tr>
      <tr>
          <td>高寫吞吐 (&gt; 80% capacity)</td>
          <td>gh-ost（必須）</td>
          <td>pt-osc trigger overhead 直接 OOM</td>
      </tr>
      <tr>
          <td>有 FK constraint 需要保留</td>
          <td>pt-osc</td>
          <td>gh-ost 不處理 FK</td>
      </tr>
      <tr>
          <td>有 application-side trigger 在原表</td>
          <td>gh-ost</td>
          <td>pt-osc trigger 跟既有 trigger 不可預期</td>
      </tr>
      <tr>
          <td>需要 pause / resume 能力</td>
          <td>gh-ost</td>
          <td>pt-osc 不支援</td>
      </tr>
      <tr>
          <td>已用 Percona Toolkit 整套（pt-table-checksum / pt-archiver）</td>
          <td>pt-osc</td>
          <td>工具鏈一致</td>
      </tr>
      <tr>
          <td>已用 Vitess</td>
          <td>Vitess online DDL</td>
          <td>維持 Vitess schema workflow</td>
      </tr>
      <tr>
          <td>已用 PlanetScale</td>
          <td>branch-based</td>
          <td>維持 PlanetScale schema workflow</td>
      </tr>
      <tr>
          <td>已用 Aurora MySQL + native fast DDL OK</td>
          <td>不用 ghost table</td>
          <td>直接 ALTER</td>
      </tr>
  </tbody>
</table>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/mysql/" data-link-title="MySQL" data-link-desc="高併發網路服務常用關聯式資料庫、Vitess / PlanetScale 分片生態、GitHub / Shopify / Facebook 規模驗證">MySQL vendor overview</a></li>
<li><a href="/blog/backend/01-database/vendors/mysql/replication-topology/" data-link-title="MySQL Replication Topology：async / semi-sync / GTID 不是三選一、是三個 trade-off 軸的疊加" data-link-desc="MySQL replication 不是「選 async 還是 semi-sync」、是 *durability / latency / consistency* 三個 trade-off 軸的疊加；GTID 是跨 mode 的 infrastructure layer、不是第三種 mode。本文走 3 軸取捨模型 → async / semi-sync 行為對比 → GTID 替代 binlog-position 的好處 → 配置 step-by-step → 5 production 踩雷（lag 暴衝 / semi-sync 退回 async / GTID gap / Loss-Less semi-sync 真的 loss-less / chained replication 雪崩）→ 跟 Aurora MySQL / Vitess / ProxySQL / Orchestrator 整合">MySQL Replication Topology</a>（binlog ROW format + GTID 是 pre-requisite）</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/online-schema-change/" data-link-title="PostgreSQL Online Schema Change：先用 ALTER 內建特性、不能解才 pg_repack / pg-osc" data-link-desc="PostgreSQL ALTER TABLE 對多數變更已是 *fast catalog-only*（add column nullable / drop column / 改 default），不必走 ghost table tool。本文走 PG 內建 fast DDL 行為、何時必須走 pg_repack / pg-osc、兩工具機制對比（trigger-based vs WAL-shipping）、配置 step-by-step、5 production 踩雷（lock 升級 / VACUUM FULL 誤用 / pg_repack version mismatch / concurrent index 失敗清理 / generated stored column 不能 online）、跟 MySQL gh-ost / pt-osc sibling 對比">PostgreSQL Online Schema Change</a>（PG sibling、為什麼 PG 比 MySQL 少用 ghost table — fast ALTER 覆蓋多數變更）</li>
<li><a href="/blog/backend/01-database/vendors/aurora/" data-link-title="AWS Aurora" data-link-desc="AWS managed PostgreSQL / MySQL、storage / compute 分離、&#43;75% 效能改善的 production 證據">Aurora vendor page</a>（managed MySQL fast DDL）</li>
<li><a href="https://planetscale.com/">PlanetScale</a>（branch-based 不用 ghost table）</li>
<li><a href="/blog/backend/01-database/database-migration-playbook/" data-link-title="1.6 資料庫轉換實作：雙寫、回填、切流與回滾" data-link-desc="同 DB 內 schema 演進與資料變更的可分段驗證流程、跟 1.12 cross-DB migration 分工">1.6 Database Migration Playbook</a>（schema migration 治理）</li>
<li><a href="/blog/backend/knowledge-cards/expand-contract/" data-link-title="Expand / Contract" data-link-desc="說明先擴充相容面、再收斂舊路徑的遷移做法">Expand / Contract 卡片</a>（schema migration 設計原則）</li>
<li>官方：<a href="https://github.com/github/gh-ost">gh-ost</a> / <a href="https://docs.percona.com/percona-toolkit/pt-online-schema-change.html">pt-online-schema-change</a></li>
</ul>
]]></content:encoded></item><item><title>PostgreSQL Online Schema Change：先用 ALTER 內建特性、不能解才 pg_repack / pg-osc</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/online-schema-change/</link><pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/online-schema-change/</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>online schema change&lt;/em> — 先看 PG ALTER 哪些已 fast catalog-only、再看 pg_repack / pg-osc 何時必要。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;p>跟 MySQL 不同：PG 大量 schema change &lt;em>內建&lt;/em> fast catalog-only 行為、不必走 ghost table tool。MySQL 對應的 gh-ost / pt-online-schema-change 之於 PG 是 &lt;em>少數場景才需要的 escape hatch&lt;/em>、不是 standard practice。&lt;/p>
&lt;p>寫作 OSC 時必須 &lt;em>先看 PG 自身 ALTER 行為&lt;/em>、確認真的需要再上 pg_repack / pg-osc — 否則徒增複雜度。&lt;/p>
&lt;h2 id="pg-alter-table-的-fast--slow-分類">PG ALTER TABLE 的 fast / slow 分類&lt;/h2>





&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="c1">-- ALTER TABLE 的操作大致三類&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="類-afast-catalog-only-1-秒metadata-改">類 A：Fast catalog-only（&amp;lt; 1 秒、metadata 改）&lt;/h3>
&lt;p>PG 9.4+ / 11+ 多數 ALTER 已 catalog-only：&lt;/p>
&lt;ul>
&lt;li>&lt;code>ADD COLUMN col TYPE NULL DEFAULT NULL&lt;/code> — 直接 metadata、不 rewrite&lt;/li>
&lt;li>&lt;code>ADD COLUMN col TYPE NOT NULL DEFAULT &amp;lt;constant&amp;gt;&lt;/code>（PG 11+）— optimizer 把 default 存在 metadata、舊 row read 時動態返回 default、不 rewrite&lt;/li>
&lt;li>&lt;code>DROP COLUMN&lt;/code> — metadata 標 dropped、實際 row 不 rewrite（VACUUM 之後逐步清理）&lt;/li>
&lt;li>&lt;code>ALTER COLUMN ... SET DEFAULT &amp;lt;constant&amp;gt;&lt;/code> — metadata&lt;/li>
&lt;li>&lt;code>RENAME COLUMN&lt;/code> / &lt;code>RENAME TABLE&lt;/code> — metadata&lt;/li>
&lt;li>&lt;code>ADD CONSTRAINT ... NOT VALID&lt;/code> — 標記 constraint 不 validate、之後 &lt;code>VALIDATE CONSTRAINT&lt;/code> 才 scan&lt;/li>
&lt;li>&lt;code>ALTER COLUMN ... TYPE&lt;/code> 同 binary-compat 類型（&lt;code>VARCHAR(10) → VARCHAR(20)&lt;/code>、&lt;code>TEXT → VARCHAR&lt;/code> 等）— catalog-only&lt;/li>
&lt;/ul>
&lt;p>這類 ALTER &lt;em>直接跑、不必任何工具&lt;/em>。&lt;/p>
&lt;h3 id="類-block-heavyrewrites-tableproduction-慎用">類 B：Lock heavy（rewrites table、production 慎用）&lt;/h3>
&lt;p>需要 &lt;em>rewrite 整張 table&lt;/em>、ACCESS EXCLUSIVE lock 整個 ALTER 期間：&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>online schema change</em> — 先看 PG ALTER 哪些已 fast catalog-only、再看 pg_repack / pg-osc 何時必要。</p></blockquote>
<hr>
<p>跟 MySQL 不同：PG 大量 schema change <em>內建</em> fast catalog-only 行為、不必走 ghost table tool。MySQL 對應的 gh-ost / pt-online-schema-change 之於 PG 是 <em>少數場景才需要的 escape hatch</em>、不是 standard practice。</p>
<p>寫作 OSC 時必須 <em>先看 PG 自身 ALTER 行為</em>、確認真的需要再上 pg_repack / pg-osc — 否則徒增複雜度。</p>
<h2 id="pg-alter-table-的-fast--slow-分類">PG ALTER TABLE 的 fast / slow 分類</h2>





<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">-- ALTER TABLE 的操作大致三類</span></span></span></code></pre></div><h3 id="類-afast-catalog-only-1-秒metadata-改">類 A：Fast catalog-only（&lt; 1 秒、metadata 改）</h3>
<p>PG 9.4+ / 11+ 多數 ALTER 已 catalog-only：</p>
<ul>
<li><code>ADD COLUMN col TYPE NULL DEFAULT NULL</code> — 直接 metadata、不 rewrite</li>
<li><code>ADD COLUMN col TYPE NOT NULL DEFAULT &lt;constant&gt;</code>（PG 11+）— optimizer 把 default 存在 metadata、舊 row read 時動態返回 default、不 rewrite</li>
<li><code>DROP COLUMN</code> — metadata 標 dropped、實際 row 不 rewrite（VACUUM 之後逐步清理）</li>
<li><code>ALTER COLUMN ... SET DEFAULT &lt;constant&gt;</code> — metadata</li>
<li><code>RENAME COLUMN</code> / <code>RENAME TABLE</code> — metadata</li>
<li><code>ADD CONSTRAINT ... NOT VALID</code> — 標記 constraint 不 validate、之後 <code>VALIDATE CONSTRAINT</code> 才 scan</li>
<li><code>ALTER COLUMN ... TYPE</code> 同 binary-compat 類型（<code>VARCHAR(10) → VARCHAR(20)</code>、<code>TEXT → VARCHAR</code> 等）— catalog-only</li>
</ul>
<p>這類 ALTER <em>直接跑、不必任何工具</em>。</p>
<h3 id="類-block-heavyrewrites-tableproduction-慎用">類 B：Lock heavy（rewrites table、production 慎用）</h3>
<p>需要 <em>rewrite 整張 table</em>、ACCESS EXCLUSIVE lock 整個 ALTER 期間：</p>
<ul>
<li><code>ALTER COLUMN ... TYPE</code> binary 不相容類型（<code>INT → BIGINT</code> 永遠 rewrite、<code>TEXT → INT</code> 也是）— 雖然語意「擴大」、底層 4-byte 跟 8-byte storage 不同、全表 rewrite + ACCESS EXCLUSIVE 不可省</li>
<li><code>ALTER COLUMN ... SET NOT NULL</code> 對既有 nullable column（要 scan 整 table）</li>
<li><code>ALTER COLUMN ... DROP IDENTITY</code></li>
<li><code>ALTER TABLE ... SET TABLESPACE</code></li>
</ul>
<p>這類 ALTER 對大表 <em>production 不能直接跑</em>、要 ghost table tool。</p>
<h3 id="類-cconcurrent-index--online-operation無-table-lock">類 C：Concurrent index / online operation（無 table lock）</h3>
<ul>
<li><code>CREATE INDEX CONCURRENTLY</code> — 不 lock 寫入、background build、慢但安全</li>
<li><code>REINDEX INDEX CONCURRENTLY</code>（PG 12+） — 同上</li>
<li><code>DROP INDEX CONCURRENTLY</code> — 短 ACCESS EXCLUSIVE lock 只在最後 swap</li>
</ul>
<h2 id="何時需要-ghost-table-tool">何時需要 ghost table tool</h2>
<p>只在以下場景才需要 pg_repack / pg-osc：</p>
<ol>
<li><strong>Rewrite-required type change</strong>（類 B <code>ALTER COLUMN TYPE</code>）對大表</li>
<li><strong>VACUUM FULL 替代</strong>：pg_repack 比 VACUUM FULL 安全（不 lock 整表）</li>
<li><strong>Bloat 重組</strong>：大表 dead tuple 累積、想完整 rewrite</li>
</ol>
<p>對「add column」「drop column」「create index」等場景 <em>PG 內建 fast 已夠</em>、不必 ghost table tool。</p>
<h2 id="tool-1pg_repack--trigger-based--雙-table-swap">Tool 1：pg_repack — Trigger-based + 雙 table swap</h2>
<p>pg_repack 是 PG community 標準 online table rewrite 工具：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">pg_repack -h primary.example.com -p <span class="m">5432</span> -d production -U postgres <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --table<span class="o">=</span>orders --no-superuser-check</span></span></code></pre></div><p><strong>Mechanism</strong>：</p>
<ol>
<li>CREATE <code>repack.table_&lt;oid&gt;</code> 跟原表同 schema</li>
<li>在原表加 3 個 trigger：INSERT / UPDATE / DELETE → 寫入 log table <code>repack.log_&lt;oid&gt;</code></li>
<li>從原表 <code>INSERT INTO repack.table_&lt;oid&gt; SELECT * FROM original</code> 複製 row</li>
<li>邊複製邊 apply log table 紀錄的變更</li>
<li>切換：rename 原表 → original_old、rename repack.table_<oid> → original（atomic）</li>
<li>Drop 舊原表跟 trigger / log</li>
</ol>
<p><strong>Trade-off</strong>：</p>
<ul>
<li><em>Trigger overhead</em>：每個 primary 寫入加 trigger 執行（10-30% 寫吞吐降）</li>
<li><em>FK 處理</em>：需要 drop &amp; re-create FK referencing original table（pg_repack 自動處理但有 lock window）</li>
<li>適用 <em>PG-version 綁定</em> — pg_repack 13 不能對 PG 14 cluster 跑</li>
</ul>
<p><strong>配置</strong>：</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">-- Primary 安裝
</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="n">EXTENSION</span><span class="w"> </span><span class="n">pg_repack</span><span class="p">;</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># Repack orders</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">pg_repack -d production --table<span class="o">=</span>orders
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"># 監控 lock：另一 session 跑 SELECT * FROM pg_stat_activity</span></span></span></code></pre></div><h2 id="tool-2pg-osc--pg-online-schema-change--wal-shipping-style">Tool 2：pg-osc / pg-online-schema-change — WAL-shipping style</h2>
<p><a href="https://github.com/shayonj/pg-osc">pg-osc</a>（Shayon Mukherjee、2023）是較新的工具、模仿 gh-ost mechanism：</p>
<p><strong>Mechanism</strong>：</p>
<ol>
<li>用 logical replication slot 從 primary WAL stream 變更</li>
<li>CREATE shadow table + 套 ALTER 變更</li>
<li>Stream WAL event 同步 shadow table（不靠 trigger）</li>
<li>完成後 swap</li>
</ol>
<p><strong>Trade-off</strong>：</p>
<ul>
<li><em>Primary 寫入 overhead</em>：0（WAL 已存在）</li>
<li>比 pg_repack 較新（社群驗證度低）</li>
<li>適合 <em>trigger overhead 不可接受</em> 的高吞吐 production</li>
</ul>
<p><strong>配置</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 用 gem install</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">gem install pg_online_schema_change
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># Run</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">pg-online-schema-change perform <span class="se">\
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="se"></span>  --alter-statement<span class="o">=</span><span class="s2">&#34;ALTER TABLE orders ADD COLUMN status VARCHAR(20)&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="se"></span>  --schema<span class="o">=</span>public <span class="se">\
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="se"></span>  --dbname<span class="o">=</span>production <span class="se">\
</span></span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="se"></span>  --host<span class="o">=</span>primary.example.com</span></span></code></pre></div><h2 id="配置-step-by-steppg_repack-為主">配置 step-by-step（pg_repack 為主）</h2>
<p>實務多數 PG OSC 用 pg_repack。pg-osc 是 high-write-throughput escape hatch。</p>
<h3 id="step-1安裝--確認版本">Step 1：安裝 + 確認版本</h3>





<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">-- 安裝 pg_repack（versioned）
</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="n">EXTENSION</span><span class="w"> </span><span class="n">pg_repack</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="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">pg_available_extensions</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">name</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;pg_repack&#39;</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="c1">-- 確認 installed_version 跟 PG major version 對齊</span></span></span></code></pre></div><h3 id="step-2跑-pg_repack">Step 2：跑 pg_repack</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">pg_repack -h primary -d production -U postgres <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --table<span class="o">=</span>orders <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --jobs<span class="o">=</span><span class="m">4</span> <span class="se">\ </span>                      <span class="c1"># 並行 worker</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  --wait-timeout<span class="o">=</span><span class="m">60</span> <span class="se">\ </span>             <span class="c1"># 等 lock 超時（秒）</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  --no-kill-backend                <span class="c1"># 不主動 kill 卡 lock 的 query</span></span></span></code></pre></div><h3 id="step-3監控">Step 3：監控</h3>





<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">-- 看 pg_repack 進度
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">pid</span><span class="p">,</span><span class="w"> </span><span class="n">query</span><span class="p">,</span><span class="w"> </span><span class="k">state</span><span class="p">,</span><span class="w"> </span><span class="n">wait_event_type</span><span class="p">,</span><span class="w"> </span><span class="n">wait_event</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="n">pg_stat_activity</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">WHERE</span><span class="w"> </span><span class="n">query</span><span class="w"> </span><span class="k">LIKE</span><span class="w"> </span><span class="s1">&#39;%repack%&#39;</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></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w"></span><span class="c1">-- 看 lock 狀態
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">pg_locks</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">relation</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">8</span><span class="cl"><span class="w">  </span><span class="k">SELECT</span><span class="w"> </span><span class="n">oid</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">pg_class</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">relname</span><span class="w"> </span><span class="k">IN</span><span class="w"> </span><span class="p">(</span><span class="s1">&#39;orders&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;repack.table_xxx&#39;</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="p">);</span></span></span></code></pre></div><h3 id="step-4驗證">Step 4：驗證</h3>





<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">-- 跑完後對比 row count + 抽樣 query
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><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">FROM</span><span class="w"> </span><span class="n">orders</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="c1">-- 跟 pg_repack 之前 count 對比</span></span></span></code></pre></div><h2 id="5-個-production-踩雷">5 個 Production 踩雷</h2>
<h3 id="1-alter-直接跑沒看是不是-fast-變-lock-heavy">1. ALTER 直接跑沒看是不是 fast 變 lock heavy</h3>
<p><code>ALTER TABLE orders ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'pending'</code> — 預期 catalog-only（PG 11+）、但若 PG 10 跑這個就會 rewrite 整表、ACCESS EXCLUSIVE lock 幾小時。</p>
<p>修法：</p>
<ul>
<li>寫 schema migration 前 <em>確認 PG version</em></li>
<li>看 <a href="https://www.postgresql.org/docs/current/sql-altertable.html">PG ALTER doc</a>、each subcommand 標 <em>Note</em> 段是否 fast</li>
<li>Production 跑前 staging 測 + 監控 <code>pg_stat_activity</code> lock wait</li>
</ul>
<h3 id="2-vacuum-full-誤用--production-downtime">2. VACUUM FULL 誤用 — Production downtime</h3>
<p><code>VACUUM FULL</code> 等於「rewrite 整表 + ACCESS EXCLUSIVE lock」。Production 跑 = 表變 unavailable 幾分鐘到幾小時。</p>
<p>修法：</p>
<ul>
<li><em>永遠用 pg_repack</em> 取代 VACUUM FULL（除非 maintenance window）</li>
<li>對 bloat 議題、定期跑 pg_repack</li>
<li>autovacuum tuning 第一優先（<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> 詳細）</li>
</ul>
<h3 id="3-pg_repack-version-mismatch">3. pg_repack version mismatch</h3>
<p>PG cluster 升 14、但 <code>pg_repack</code> extension 還是 13 版本。試 ALTER 跑 <code>pg_repack</code> 命令、ERROR: <code>program &quot;pg_repack 14.x&quot; does not match installed extension &quot;pg_repack 13.x&quot;</code>。</p>
<p>修法：</p>
<ul>
<li>升 PG cluster 後 <em>立即 ALTER EXTENSION pg_repack UPDATE</em></li>
<li>若 pg_repack 還沒釋出對應 PG 版本（早期升級）、暫時用 pg-osc 替代或等待</li>
<li>升級 runbook 紀錄 pg_repack 是 <em>必同步升級的 extension</em></li>
</ul>
<h3 id="4-create-index-concurrently-失敗清理">4. CREATE INDEX CONCURRENTLY 失敗清理</h3>
<p><code>CREATE INDEX CONCURRENTLY</code> 跑到一半被 cancel（用戶 Ctrl-C / connection drop）、產生 <em>invalid index</em>：</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">SELECT</span><span class="w"> </span><span class="n">indexrelid</span><span class="p">::</span><span class="n">regclass</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">pg_index</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="n">indisvalid</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="c1">-- 顯示一個 idx_orders_status_invalid</span></span></span></code></pre></div><p>Invalid index 仍佔 disk、但 optimizer 不會用。</p>
<p>修法：</p>
<ul>
<li>跑 <code>DROP INDEX CONCURRENTLY idx_orders_status_invalid</code></li>
<li>之後重新 <code>CREATE INDEX CONCURRENTLY</code></li>
<li>避免在 connection 不穩的 session 跑長時間 CREATE INDEX CONCURRENTLY、改用 cron 或 deploy pipeline</li>
</ul>
<h3 id="5-generated-stored-column-不能-online-add">5. Generated stored column 不能 online ADD</h3>
<p><code>ADD COLUMN total NUMERIC GENERATED ALWAYS AS (price * qty) STORED</code> — <em>stored</em> generated column 必須 rewrite 整表計算 column value、不是 catalog-only。</p>
<p>修法：</p>
<ul>
<li>
<p>用 <code>GENERATED ALWAYS AS (...) VIRTUAL</code>（PG 18+）— 不存實際 value、catalog-only</p>
</li>
<li>
<p>或 <em>先加 nullable column + backfill + 加 NOT NULL constraint</em>：</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">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="k">ADD</span><span class="w"> </span><span class="k">COLUMN</span><span class="w"> </span><span class="n">total</span><span class="w"> </span><span class="nb">NUMERIC</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="k">UPDATE</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="n">total</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">price</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="n">qty</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="k">BETWEEN</span><span class="w"> </span><span class="p">...;</span><span class="w">  </span><span class="c1">-- chunked
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span><span class="k">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="k">ALTER</span><span class="w"> </span><span class="k">COLUMN</span><span class="w"> </span><span class="n">total</span><span class="w"> </span><span class="k">SET</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">4</span><span class="cl"><span class="w"></span><span class="c1">-- 之後加 trigger 或 application 層維護 total</span></span></span></code></pre></div></li>
<li>
<p>或用 pg_repack 跑 rewrite ADD GENERATED STORED</p>
</li>
</ul>
<h2 id="容量--時間估算">容量 / 時間估算</h2>
<p>對 100 GB 表、ADD COLUMN 加 index 為例：</p>
<table>
  <thead>
      <tr>
          <th>操作</th>
          <th>時間</th>
          <th>Lock 影響</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>ADD COLUMN col TYPE NULL</code> (PG 11+)</td>
          <td>&lt; 1 秒</td>
          <td>ACCESS EXCLUSIVE（毫秒級）</td>
      </tr>
      <tr>
          <td><code>ADD COLUMN col TYPE NOT NULL DEFAULT 0</code> (PG 11+)</td>
          <td>&lt; 1 秒</td>
          <td>ACCESS EXCLUSIVE（毫秒級）</td>
      </tr>
      <tr>
          <td><code>CREATE INDEX CONCURRENTLY</code></td>
          <td>2-6 小時</td>
          <td>無 table lock</td>
      </tr>
      <tr>
          <td><code>pg_repack table</code></td>
          <td>4-8 小時</td>
          <td>短 ACCESS EXCLUSIVE（swap）</td>
      </tr>
      <tr>
          <td><code>ALTER COLUMN TYPE</code> rewrite</td>
          <td>4-8 小時</td>
          <td>ACCESS EXCLUSIVE 全程</td>
      </tr>
      <tr>
          <td><code>VACUUM FULL</code></td>
          <td>同 pg_repack</td>
          <td>ACCESS EXCLUSIVE 全程（不要跑）</td>
      </tr>
  </tbody>
</table>
<h2 id="跟-mysql-gh-ost--pt-osc-對照">跟 MySQL gh-ost / pt-osc 對照</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>PG pg_repack</th>
          <th>PG pg-osc</th>
          <th>MySQL gh-ost</th>
          <th>MySQL pt-osc</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>機制</td>
          <td>Trigger + log table</td>
          <td>WAL logical stream</td>
          <td>Binlog stream</td>
          <td>Trigger + log table</td>
      </tr>
      <tr>
          <td>Primary 寫 overhead</td>
          <td>中（trigger）</td>
          <td>0（WAL 已存在）</td>
          <td>0（binlog 已存在）</td>
          <td>中（trigger）</td>
      </tr>
      <tr>
          <td>Throttle 支援</td>
          <td>部分</td>
          <td>支援</td>
          <td>強</td>
          <td>部分</td>
      </tr>
      <tr>
          <td>Pause / Resume</td>
          <td>不支援</td>
          <td>不支援</td>
          <td>支援</td>
          <td>不支援</td>
      </tr>
      <tr>
          <td>工具成熟度</td>
          <td>高</td>
          <td>中（2023+）</td>
          <td>高</td>
          <td>高</td>
      </tr>
      <tr>
          <td>Use case 比例</td>
          <td>PG 主流（90% case）</td>
          <td>高吞吐 escape hatch</td>
          <td>MySQL 主流（dev）</td>
          <td>MySQL legacy + FK</td>
      </tr>
  </tbody>
</table>
<p>PG OSC tool 使用頻率比 MySQL 低 — 因為 PG 內建 fast ALTER 已 cover 90% schema change、ghost table tool 只對 <em>少數 rewrite-required</em> 場景。</p>
<p>詳見 <a href="/blog/backend/01-database/vendors/mysql/online-schema-change-tools/" data-link-title="MySQL Online Schema Change：gh-ost 跟 pt-online-schema-change 兩條完全不同的 ghost table 路徑" data-link-desc="MySQL ALTER TABLE 可能鎖整張表，production 需要 online schema change 流程。gh-ost（GitHub）跟 pt-online-schema-change（Percona）都用 ghost table 解決、但底層機制完全不同：pt-osc 用 trigger 同步、gh-ost 用 binlog stream 同步。本文走兩工具機制對照表 → trigger vs binlog 各自取捨 → 配置 step-by-step → 5 production 踩雷（trigger overhead / binlog 延遲 / FK constraint / hot trigger lock / 切換瞬間 deadlock）→ 何時用哪一個">MySQL Online Schema Change Tools</a> — sibling、不同 use case mix。</p>
<h2 id="跟其他模組整合">跟其他模組整合</h2>
<h3 id="跟-replication-topology">跟 Replication topology</h3>
<p>ALTER TABLE / pg_repack / pg-osc 都產生 WAL、會 replicate 到 standby。Standby 上的 long-running query 可能跟 ALTER 衝突、被 <code>hot_standby_feedback</code> 影響 primary autovacuum。詳見 <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>。</p>
<h3 id="跟-autovacuum-tuning">跟 Autovacuum Tuning</h3>
<p>Schema change 後常產生 dead tuple、autovacuum 需要重新 cover。詳見 <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>。</p>
<h3 id="跟-logical-replication">跟 Logical Replication</h3>
<p>logical replication 透過 publication / subscription 同步 — DDL <em>不會</em> logical replicate（PG 16 之前）、必須 <em>在 publisher / subscriber 各自跑 DDL</em>。詳見 <a href="/blog/backend/01-database/vendors/postgresql/logical-replication-debezium/" data-link-title="PostgreSQL Logical Replication &#43; Debezium CDC：replication slot × failure × recovery 對照" data-link-desc="PostgreSQL logical replication slot 跟 Debezium CDC 的失效模式對照表：slot lag 撐爆 primary disk / schema change 斷流 / 初始 COPY 鎖表 / zombie slot 不釋放 / replay storm 後 offset reset；publication / subscription / pgoutput 配置、跟 Kafka outbox pattern 整合">Logical Replication + Debezium</a>。</p>
<h3 id="跟-patroni-ha">跟 Patroni HA</h3>
<p>Patroni promote 新 primary 後、pg_repack extension state（slot / catalog）跟著走、新 primary 仍可繼續 pg_repack。詳見 <a href="/blog/backend/01-database/vendors/postgresql/patroni-ha/" data-link-title="PostgreSQL Patroni HA：從 leader 失聯到 client 重連的 5 段 failover lifecycle" data-link-desc="Patroni 把 PostgreSQL HA 拆成 detection / election / promotion / reconfiguration / recovery 五段 lifecycle、每段都有獨立配置跟 failure mode；DCS quorum &#43; watchdog 防 split-brain、async/sync replication 取捨、5 個 production 踩雷、跟 PgBouncer / HAProxy / cert-manager 整合">Patroni HA</a>。</p>
<h2 id="何時用哪個">何時用哪個</h2>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>選擇</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>ADD COLUMN nullable / DROP COLUMN / RENAME 等</td>
          <td>直接 ALTER（fast catalog-only）</td>
      </tr>
      <tr>
          <td>CREATE INDEX 大表</td>
          <td><code>CREATE INDEX CONCURRENTLY</code></td>
      </tr>
      <tr>
          <td>ALTER COLUMN TYPE rewrite（大表）</td>
          <td>pg_repack</td>
      </tr>
      <tr>
          <td>Bloat 重組</td>
          <td>pg_repack</td>
      </tr>
      <tr>
          <td>高吞吐 + trigger overhead 不可接受</td>
          <td>pg-osc</td>
      </tr>
      <tr>
          <td>ADD GENERATED STORED column</td>
          <td>nullable + backfill + constraint</td>
      </tr>
      <tr>
          <td>Cluster on Cloud（RDS / Aurora）</td>
          <td>RDS / Aurora 內建 fast DDL 多數已 cover、pg_repack 視 vendor 支援</td>
      </tr>
  </tbody>
</table>
<h2 id="相關連結">相關連結</h2>
<ul>
<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 vendor overview</a></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 整合">PG Replication Topology</a>（ALTER 跟 streaming replication 互動）</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 整合">PG Autovacuum Tuning</a>（schema change 後 vacuum 議題）</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/logical-replication-debezium/" data-link-title="PostgreSQL Logical Replication &#43; Debezium CDC：replication slot × failure × recovery 對照" data-link-desc="PostgreSQL logical replication slot 跟 Debezium CDC 的失效模式對照表：slot lag 撐爆 primary disk / schema change 斷流 / 初始 COPY 鎖表 / zombie slot 不釋放 / replay storm 後 offset reset；publication / subscription / pgoutput 配置、跟 Kafka outbox pattern 整合">PG Logical Replication + Debezium</a>（DDL 不 replicate 議題）</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/patroni-ha/" data-link-title="PostgreSQL Patroni HA：從 leader 失聯到 client 重連的 5 段 failover lifecycle" data-link-desc="Patroni 把 PostgreSQL HA 拆成 detection / election / promotion / reconfiguration / recovery 五段 lifecycle、每段都有獨立配置跟 failure mode；DCS quorum &#43; watchdog 防 split-brain、async/sync replication 取捨、5 個 production 踩雷、跟 PgBouncer / HAProxy / cert-manager 整合">PG Patroni HA</a>（HA 跟 pg_repack 整合）</li>
<li><a href="/blog/backend/01-database/vendors/mysql/online-schema-change-tools/" data-link-title="MySQL Online Schema Change：gh-ost 跟 pt-online-schema-change 兩條完全不同的 ghost table 路徑" data-link-desc="MySQL ALTER TABLE 可能鎖整張表，production 需要 online schema change 流程。gh-ost（GitHub）跟 pt-online-schema-change（Percona）都用 ghost table 解決、但底層機制完全不同：pt-osc 用 trigger 同步、gh-ost 用 binlog stream 同步。本文走兩工具機制對照表 → trigger vs binlog 各自取捨 → 配置 step-by-step → 5 production 踩雷（trigger overhead / binlog 延遲 / FK constraint / hot trigger lock / 切換瞬間 deadlock）→ 何時用哪一個">MySQL Online Schema Change Tools</a>（sibling、tool ecosystem 不同）</li>
<li><a href="/blog/backend/knowledge-cards/expand-contract/" data-link-title="Expand / Contract" data-link-desc="說明先擴充相容面、再收斂舊路徑的遷移做法">Expand / Contract 卡片</a>（schema migration 設計原則）</li>
<li>官方：<a href="https://www.postgresql.org/docs/current/sql-altertable.html">ALTER TABLE</a> / <a href="https://github.com/reorg/pg_repack">pg_repack GitHub</a> / <a href="https://github.com/shayonj/pg-osc">pg-osc GitHub</a></li>
</ul>
]]></content:encoded></item><item><title>Redis Cluster Re-sharding：source = target，但 topology 重劃的 5 段流程</title><link>https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/redis/cluster-resharding/</link><pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/redis/cluster-resharding/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/redis/" data-link-title="Redis" data-link-desc="OSS in-memory data structure store、cache 主流">Redis&lt;/a> overview 的 implementation-layer deep article。本文是 &lt;a href="https://tarrragon.github.io/blog/posts/migration-playbook-%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84stage-0-variant-%E8%A6%8F%E5%8A%83%E6%8A%8A-collapse-%E7%8E%87%E5%BE%9E-60-%E9%99%8D%E5%88%B0-0/" data-link-title="Migration Playbook 方法論的演化紀錄：Stage 0 variant 規劃把 collapse 率從 60% 降到 0%" data-link-desc="跨 vendor migration playbook 需要獨立寫作方法論的依據，以及這套方法論從三輪 batch dogfood 中演化出來的驗證證據。">Migration playbook methodology&lt;/a> 「何時不該套」段的第 3 項實證（容量重新規劃 / re-sharding）— source / target 同 vendor 同 cluster、但 &lt;em>data topology 重劃&lt;/em>、不在 5 type 內。&lt;/p>&lt;/blockquote>
&lt;h2 id="source--target但-topology-重劃">Source = Target，但 topology 重劃&lt;/h2>
&lt;p>Migration 通常假設 &lt;em>source 跟 target 是不同 cluster / vendor&lt;/em>；re-sharding 是 &lt;em>同 cluster 內的 slot 重分配&lt;/em>、source 跟 target 是 &lt;em>同一個 Redis Cluster 的不同 state&lt;/em>：&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">Before re-shard:
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> Cluster A: [node1: slots 0-5460] [node2: slots 5461-10921] [node3: slots 10922-16383]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> ~ 33% load ~ 50% load ~ 17% load (heavy imbalance)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">After re-shard:
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> Cluster A: [node1: slots 0-4095] [node2: slots 4096-8191] [node3: slots 8192-12287] [node4: slots 12288-16383]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> ~ 25% load ~ 25% load ~ 25% load ~ 25% load&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>source 跟 target 是 &lt;em>同 cluster&lt;/em>、區別在 &lt;em>slot 對 node 的 mapping&lt;/em>。Application connection string 不變、cluster API 不變、data model 不變。但 &lt;em>slot migration 期間&lt;/em> application 行為跟 &lt;em>normal operation&lt;/em> 差很多 — 這是 re-sharding 主要工作。&lt;/p>
&lt;p>跑 &lt;a href="https://tarrragon.github.io/blog/report/content-structure-by-max-diff-dimension/" data-link-title="Process content 結構由最大差異維度決定、不是 universal phased" data-link-desc="跨 X process content（migration / upgrade / rollout / playbook）的結構由 source / target 之間 *差異維度組合* 決定、不存在 universal phased 模板；6 種 migration / process type 實證（schema 差 / drop-in / operational / multi-tool / paradigm / topology re-layout）跑出 6 種不同結構；寫作前必須做 *6 維 diff dimension audit* 才能決定結構、跳過會套錯模板">diff dimension audit&lt;/a> 對 Redis cluster re-sharding：&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/02-cache-redis/vendors/redis/" data-link-title="Redis" data-link-desc="OSS in-memory data structure store、cache 主流">Redis</a> overview 的 implementation-layer deep article。本文是 <a href="/blog/posts/migration-playbook-%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84stage-0-variant-%E8%A6%8F%E5%8A%83%E6%8A%8A-collapse-%E7%8E%87%E5%BE%9E-60-%E9%99%8D%E5%88%B0-0/" data-link-title="Migration Playbook 方法論的演化紀錄：Stage 0 variant 規劃把 collapse 率從 60% 降到 0%" data-link-desc="跨 vendor migration playbook 需要獨立寫作方法論的依據，以及這套方法論從三輪 batch dogfood 中演化出來的驗證證據。">Migration playbook methodology</a> 「何時不該套」段的第 3 項實證（容量重新規劃 / re-sharding）— source / target 同 vendor 同 cluster、但 <em>data topology 重劃</em>、不在 5 type 內。</p></blockquote>
<h2 id="source--target但-topology-重劃">Source = Target，但 topology 重劃</h2>
<p>Migration 通常假設 <em>source 跟 target 是不同 cluster / vendor</em>；re-sharding 是 <em>同 cluster 內的 slot 重分配</em>、source 跟 target 是 <em>同一個 Redis Cluster 的不同 state</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">Before re-shard:
</span></span><span class="line"><span class="ln">2</span><span class="cl">  Cluster A: [node1: slots 0-5460] [node2: slots 5461-10921] [node3: slots 10922-16383]
</span></span><span class="line"><span class="ln">3</span><span class="cl">              ~ 33% load           ~ 50% load              ~ 17% load (heavy imbalance)
</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">After re-shard:
</span></span><span class="line"><span class="ln">6</span><span class="cl">  Cluster A: [node1: slots 0-4095] [node2: slots 4096-8191] [node3: slots 8192-12287] [node4: slots 12288-16383]
</span></span><span class="line"><span class="ln">7</span><span class="cl">              ~ 25% load           ~ 25% load              ~ 25% load              ~ 25% load</span></span></code></pre></div><p>source 跟 target 是 <em>同 cluster</em>、區別在 <em>slot 對 node 的 mapping</em>。Application connection string 不變、cluster API 不變、data model 不變。但 <em>slot migration 期間</em> application 行為跟 <em>normal operation</em> 差很多 — 這是 re-sharding 主要工作。</p>
<p>跑 <a href="/blog/report/content-structure-by-max-diff-dimension/" data-link-title="Process content 結構由最大差異維度決定、不是 universal phased" data-link-desc="跨 X process content（migration / upgrade / rollout / playbook）的結構由 source / target 之間 *差異維度組合* 決定、不存在 universal phased 模板；6 種 migration / process type 實證（schema 差 / drop-in / operational / multi-tool / paradigm / topology re-layout）跑出 6 種不同結構；寫作前必須做 *6 維 diff dimension audit* 才能決定結構、跳過會套錯模板">diff dimension audit</a> 對 Redis cluster re-sharding：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>評估</th>
          <th>等級</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Schema / API</td>
          <td>同 Redis、無變</td>
          <td>Low</td>
      </tr>
      <tr>
          <td>Operational model</td>
          <td>同 Redis Cluster、operational 不變</td>
          <td>Low</td>
      </tr>
      <tr>
          <td>Abstraction / paradigm</td>
          <td>同 Redis Cluster、無 paradigm 差</td>
          <td>Low</td>
      </tr>
      <tr>
          <td>Number of components</td>
          <td>同 1 個（cluster）</td>
          <td>Low</td>
      </tr>
      <tr>
          <td>Application change</td>
          <td>多數不改、client cluster mode 自處理</td>
          <td>Low</td>
      </tr>
      <tr>
          <td><strong>Data topology</strong></td>
          <td><strong>重劃</strong> — slot mapping 跟 node 數</td>
          <td><strong>New axis</strong></td>
      </tr>
  </tbody>
</table>
<p>5 維皆 Low、對映 Type B drop-in；但 <em>data topology</em> 是 5 type 沒有的 <em>第 6 維度</em>。本文採用 <em>re-sharding-specific 結構</em>、不是 5 type 任一個。</p>
<h2 id="4-種-re-sharding-driver">4 種 re-sharding driver</h2>
<p>不同 driver 對應不同 re-sharding 策略：</p>
<table>
  <thead>
      <tr>
          <th>Driver</th>
          <th>觸發場景</th>
          <th>對應 re-sharding 操作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Slot imbalance</td>
          <td>業務熱點打到部分 slot、單 node CPU / memory 80%+</td>
          <td>Rebalance（slot 重分配、不加 node）</td>
      </tr>
      <tr>
          <td>Capacity expansion</td>
          <td>整 cluster memory / throughput 上限快到、要加 node</td>
          <td>Add node + slot migration（從現有 node 搬部分 slot 過去）</td>
      </tr>
      <tr>
          <td>Node decommission</td>
          <td>老 node 硬體淘汰 / cloud instance 換代</td>
          <td>Drain（該 node 的 slot 全搬走）+ remove</td>
      </tr>
      <tr>
          <td>Hash tag refactor</td>
          <td>業務 access pattern 變、需要 co-located key 群重分組</td>
          <td>Application-side migration（不是 cluster-level）</td>
      </tr>
  </tbody>
</table>
<p>前 3 種是 cluster-internal、用 <code>redis-cli --cluster</code> 工具完成；第 4 種需要 application 端 dual-write + migration、本文不展開。</p>
<h2 id="slot-migration-機制">Slot migration 機制</h2>
<p>Redis Cluster 16384 個 slot、每個 key 經 <code>CRC16(key) % 16384</code> 對應 slot。Slot migration 過程：</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">Source node:     [slot N: MIGRATING to dest]
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">Dest node:       [slot N: IMPORTING from source]
</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">Source node:     SCAN slot N → for each key:
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">                 1. DUMP key (serialize value)
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">                 2. send to dest via MIGRATE command
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">                 3. dest RESTORE key
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">                 4. source DEL key
</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">Source node:     [slot N: OWNED by dest]
</span></span><span class="line"><span class="ln">11</span><span class="cl">Dest node:       [slot N: OWNED]
</span></span><span class="line"><span class="ln">12</span><span class="cl">                 ↓
</span></span><span class="line"><span class="ln">13</span><span class="cl">跨 cluster broadcast: slot N 屬於 dest</span></span></code></pre></div><p>期間 client 行為：</p>
<ul>
<li>Key 在 source 端（未 migrate）：source 直接 serve</li>
<li>Key 在 dest 端（已 migrate）：source 回 <code>-ASK</code> redirect、client 重發到 dest</li>
<li>寫入 MIGRATING slot 的新 key：source serve、之後也會 migrate</li>
<li>Application 不需要改 code、cluster-aware client 自動處理 <code>-ASK</code> redirect</li>
</ul>
<h2 id="redis-cli-cluster-工具">redis-cli &ndash;cluster 工具</h2>
<p>production 用 official tool、不要手寫 slot migration：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 1. Rebalance（slot 重分配、適合 imbalance）</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">redis-cli --cluster rebalance 10.0.0.1:6379 <span class="se">\
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="se"></span>  --cluster-use-empty-masters <span class="se">\
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="se"></span>  --cluster-threshold <span class="m">5</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"># 2. Reshard（指定來源 → 目標、適合 capacity expansion）</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">redis-cli --cluster reshard 10.0.0.1:6379 <span class="se">\
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="se"></span>  --cluster-from &lt;source-node-id&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="se"></span>  --cluster-to &lt;dest-node-id&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="se"></span>  --cluster-slots <span class="m">4096</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="se"></span>  --cluster-yes
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1"># 3. Add-node（加新 node 進 cluster）</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">redis-cli --cluster add-node 10.0.0.4:6379 10.0.0.1:6379 <span class="se">\
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="se"></span>  --cluster-master-id &lt;existing-master-id&gt;
</span></span><span class="line"><span class="ln">16</span><span class="cl">
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="c1"># 4. Del-node（移除 node、需先 drain slot）</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">redis-cli --cluster del-node 10.0.0.1:6379 &lt;node-to-remove&gt;</span></span></code></pre></div><p>關鍵：</p>
<ul>
<li><code>--cluster-threshold 5</code>：load 差異超過 5% 才 rebalance、避免反覆觸發</li>
<li><code>--cluster-slots</code>：一次 migrate 多少 slot；太大 lock 久、太小步驟多</li>
<li>Rebalance / reshard 過程 cluster 仍 serve traffic、但 <em>latency 升高</em>（migration overhead）</li>
</ul>
<h2 id="5-段執行流程">5 段執行流程</h2>





<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">1. Pre-resharding analysis
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">   - 當前 slot 分佈跟 load
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">   - Hot key 識別（CLUSTER COUNTKEYSINSLOT）
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">   - 預估 migration 時間
</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">2. Backup checkpoint
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">   - BGSAVE on all master
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">   - 確認 replica 跟得上（replication offset diff &lt; 10MB）
</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">3. Execute re-sharding
</span></span><span class="line"><span class="ln">11</span><span class="cl">   - 用 redis-cli --cluster 工具
</span></span><span class="line"><span class="ln">12</span><span class="cl">   - Monitor cluster health（CLUSTER INFO + CLUSTER NODES）
</span></span><span class="line"><span class="ln">13</span><span class="cl">   - Migration 期間 application 端 latency baseline 比對
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl">4. Verify
</span></span><span class="line"><span class="ln">16</span><span class="cl">   - Slot distribution 對 expected mapping
</span></span><span class="line"><span class="ln">17</span><span class="cl">   - Application traffic pattern 對 baseline
</span></span><span class="line"><span class="ln">18</span><span class="cl">   - 跑 cross-node sanity check
</span></span><span class="line"><span class="ln">19</span><span class="cl">
</span></span><span class="line"><span class="ln">20</span><span class="cl">5. Cleanup
</span></span><span class="line"><span class="ln">21</span><span class="cl">   - 舊 node（若 decommission）reset / 釋放
</span></span><span class="line"><span class="ln">22</span><span class="cl">   - Monitoring dashboard 更新 (Prometheus target / Grafana panel)
</span></span><span class="line"><span class="ln">23</span><span class="cl">   - Document new topology</span></span></code></pre></div><p>整體 1-7 天、依 cluster 大小（10GB ~ 1 小時、TB 級 1-3 天）。</p>
<h2 id="production-故障演練">Production 故障演練</h2>
<h3 id="case-1cluster-busy-期間-application-timeout">Case 1：Cluster busy 期間 application timeout</h3>
<p><strong>徵兆</strong>：re-sharding 跑到一半、application 端開始大量 <code>CLUSTER BUSY</code> error / <code>OOM</code> warning / latency p99 從 5ms 跳到 200-2000ms；某些 batch operation 完全失敗。</p>
<p><strong>根因</strong>：MIGRATE command 對單 key 是 <em>blocking</em>（DUMP + send + RESTORE + DEL atomic）— 大 value（HASH / SORTED SET / LIST 含 100K+ entry）migration 可能 lock node 數秒；同期間其他 query 阻塞。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>Pre-resharding audit</strong>：<code>MEMORY USAGE</code> 跑 sample key、找 &gt; 1MB 的 <em>fat key</em>、列出單獨處理</li>
<li><strong>MIGRATE timeout 調</strong>：<code>redis.conf</code> 設 <code>cluster-migration-timeout 10000</code>（10s）、避免單 key migration 卡爆 cluster</li>
<li><strong>降低並行</strong>：<code>--cluster-pipeline 1</code> 一次只搬一個 slot（預設 10）、減少 CPU 壓力</li>
<li><strong>Fat key refactor</strong>：production 不該有 1M+ entry 的 collection、refactor 拆分</li>
</ol>
<h3 id="case-2replica-lag-during-re-sharding">Case 2：Replica lag during re-sharding</h3>
<p><strong>徵兆</strong>：reshard 完成後、replica 顯示 stale data 數分鐘、application 端 read from replica 拿到舊值。</p>
<p><strong>根因</strong>：master 端 slot migration 產生大量 <code>DEL</code> + <code>RESTORE</code> 命令、replication stream 量爆、replica 跟不上、accumulated lag。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>Pre-resharding 確認 replica lag &lt; 5MB</strong>、否則先 fix replica issue 再開始</li>
<li><strong>Throttle migration</strong>：用 <code>--cluster-replace</code> + lower pipeline、放慢 master 寫入速度</li>
<li><strong>Application 端 read-write split policy</strong>：reshard 期間強制 read from master、暫時放棄 replica read</li>
<li><strong>預備計畫</strong>：若 lag &gt; 30s 撐了 5+ 分鐘、考慮暫停 reshard、wait replica catch up</li>
</ol>
<h3 id="case-3client-side-topology-cache-stale">Case 3：Client-side topology cache stale</h3>
<p><strong>徵兆</strong>：reshard 完、application 端持續報 <code>MOVED &lt;slot&gt; &lt;new-node&gt;</code> redirect、但隔 30s 又 redirect 一次；某些 client 直接 connection refused（連到已 decommission node）。</p>
<p><strong>根因</strong>：cluster-aware client（lettuce / Jedis cluster mode）有 <em>topology cache</em>、reshard 後不主動 refresh；遇 MOVED 後 refresh 一次、但 cache TTL 內可能繼續用舊 mapping。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>Client config</strong>：lettuce <code>clusterTopologyRefreshOptions(...)</code> 設較短 refresh interval（60s）+ <code>enablePeriodicRefresh()</code></li>
<li><strong>Reshard 完後 trigger refresh</strong>：application 端可主動發 <code>CLUSTER NODES</code> 拿最新 topology、不依賴 client lib 自動 refresh</li>
<li><strong>Graceful client shutdown / restart</strong>：對 latency-sensitive 服務、reshard 完 rolling restart application pod、避免 stale cache</li>
<li><strong>Decommissioned node 保留 5 分鐘</strong>：不立刻 stop node、給 stale client 自然 retry 機會</li>
</ol>
<h3 id="case-4cross-slot-transaction-失敗">Case 4：Cross-slot transaction 失敗</h3>
<p><strong>徵兆</strong>：application 用 <code>MULTI/EXEC</code> 跨多 key、reshard 期間部分 transaction 報 <code>MOVED</code> error、整個 transaction 失敗、business logic 不一致。</p>
<p><strong>根因</strong>：Redis Cluster transaction 要求 <em>所有 key 在同 slot</em>（用 hash tag <code>{user:123}</code>）；reshard 期間如果 transaction 內某 key migrate 到 dest、cluster topology 暫時 inconsistent、transaction 拒絕。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>Pre-resharding audit</strong>：grep application code 找 MULTI / pipeline 使用、確認所有都用 hash tag co-locate</li>
<li><strong>Reshard 期間 application 端加 retry</strong>：transaction failure 後 backoff retry、cluster stabilize 後成功</li>
<li><strong>架構</strong>：transaction-heavy 場景考慮不用 Redis Cluster、用 Redis Sentinel single master（無 slot 概念）</li>
</ol>
<h3 id="case-5monitor-visibility-gap-during-reshard">Case 5：Monitor visibility gap during reshard</h3>
<p><strong>徵兆</strong>：reshard 期間 Prometheus dashboard 對某 node 的 metric 突然顯示 <em>錯位</em> — load = 95% 但 slot count 顯示 6% slot；SOC 不知道 node 健康狀況。</p>
<p><strong>根因</strong>：Prometheus exporter 對 <em>slot count</em> 跟 <em>traffic load</em> 分開計算；reshard 期間 slot count 已 migrate 但流量仍打 source node（client cache stale）— metric 看似矛盾。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>Reshard 期間關 alert</strong>：knownmaintenance window、Prometheus silence alert</li>
<li><strong>加 reshard-aware metric</strong>：用 <code>redis_cluster_migration_slots</code> 量化 in-flight migration</li>
<li><strong>Dashboard 加註解</strong>：reshard 期間 SOC 看 dashboard 知道是 <em>normal anomaly</em></li>
</ol>
<h2 id="capacity--cost">Capacity / cost</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>估算</th>
          <th>警戒</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Slot migration 速度</td>
          <td>1-10K key / sec（依 key size + network）</td>
          <td>TB 級 10K key / sec → 1 天</td>
      </tr>
      <tr>
          <td>Application latency impact</td>
          <td>p99 +50-200% during migration</td>
          <td>設 latency budget、超出暫停</td>
      </tr>
      <tr>
          <td>Memory / node</td>
          <td>不變、但 temporary 雙寫期間 +5-15%</td>
          <td>不能在 memory 90%+ 時 reshard</td>
      </tr>
      <tr>
          <td>Network bandwidth</td>
          <td>跨 node 大流量、~100-500 Mbps per migration stream</td>
          <td>跨 AZ reshard egress cost 注意</td>
      </tr>
      <tr>
          <td>Recovery time</td>
          <td>Reshard 失敗回退 = 反向 reshard（時間相同）</td>
          <td>不能在 incident 期間 reshard</td>
      </tr>
  </tbody>
</table>
<p>實務 default：</p>
<ul>
<li>跑在 <em>低流量時段</em>（夜間 / 週末）</li>
<li>Throughput 容忍度 &lt; 50% 再 reshard、不要 80%+ 時操作</li>
<li>預留 <em>回退 window</em> — reshard 卡住時能 abort + 恢復原狀</li>
</ul>
<h2 id="整合--下一步">整合 / 下一步</h2>
<h3 id="跟-redis--dragonflydb-migration-對位">跟 <a href="/blog/backend/02-cache-redis/vendors/redis/migrate-to-dragonflydb/" data-link-title="Redis → DragonflyDB：drop-in 相容下的容量躍升 &#43; 5 個踩雷" data-link-desc="DragonflyDB 號稱 Redis drop-in 替代、單機 throughput 25x、記憶體效率 30% 提升；遷移流程簡單但有 5 個 production 踩雷（RDB 版本差 / Lua 腳本不全支援 / Pub-Sub fanout 行為差異 / Cluster mode 兼容度 / Modules 不支援）、跟 Sentinel / Cluster 模式對位">Redis → DragonflyDB migration</a> 對位</h3>
<p>DragonflyDB 設計上 <em>單機效能取代 cluster</em>、re-sharding 議題消失；如果 cluster re-sharding 頻繁觸發、評估直接遷 DragonflyDB 是否更便宜。</p>
<h3 id="跟-sentinel-ha-對比">跟 <a href="/blog/backend/02-cache-redis/vendors/redis/" data-link-title="Redis" data-link-desc="OSS in-memory data structure store、cache 主流">Sentinel HA</a> 對比</h3>
<p>Sentinel 模式無 slot 概念、re-sharding 不適用；但 <em>manual sharding by application</em> 場景仍可能需要類似 topology re-layout、application 端要自己處理。</p>
<h3 id="跟-redis-7-function--cluster-v2">跟 Redis 7+ Function / Cluster v2</h3>
<p>Redis 7 推 Cluster v2 跟 Functions、slot migration 機制部分升級；keyspace migration 仍是核心議題、但 API 跟 monitoring 改進。</p>
<h3 id="下一步議題">下一步議題</h3>
<ul>
<li><strong>Auto-rebalance via operator</strong>：Redis Enterprise / Aiven 等 managed Redis 提供自動 rebalance、不需手動觸發</li>
<li><strong>Cross-DC slot migration</strong>：跨 region cluster slot migration 對 latency / cost 影響大、通常用 <em>application-level sharding</em> 取代 cluster-level</li>
<li><strong>Hash tag 治理</strong>：application code grep / lint 強制 hash tag、避免 cross-slot transaction 反模式</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li>上游 vendor 頁：<a href="/blog/backend/02-cache-redis/vendors/redis/" data-link-title="Redis" data-link-desc="OSS in-memory data structure store、cache 主流">Redis</a></li>
<li>平行 migration playbook：<a href="/blog/backend/02-cache-redis/vendors/redis/migrate-to-dragonflydb/" data-link-title="Redis → DragonflyDB：drop-in 相容下的容量躍升 &#43; 5 個踩雷" data-link-desc="DragonflyDB 號稱 Redis drop-in 替代、單機 throughput 25x、記憶體效率 30% 提升；遷移流程簡單但有 5 個 production 踩雷（RDB 版本差 / Lua 腳本不全支援 / Pub-Sub fanout 行為差異 / Cluster mode 兼容度 / Modules 不支援）、跟 Sentinel / Cluster 模式對位">Redis → DragonflyDB</a></li>
<li>對位 deep article：<a href="/blog/backend/01-database/vendors/postgresql/major-version-upgrade/" data-link-title="PostgreSQL major version upgrade (14 → 17)：為什麼這篇不套 5 type migration" data-link-desc="PostgreSQL major version upgrade 是 *5 type 漏類* 的實證 — source/target 同 vendor、5 維度都 Low 但 *upgrade-specific audit* 是核心；本文結構接近 deep article methodology 的 6-section &#43; 額外 upgrade audit 段；涵蓋 pg_upgrade / logical replication / blue-green 三方法、extension 相容性、5 production 踩雷">PostgreSQL major version upgrade</a>（另一個 5 type 漏類驗證）</li>
<li>Methodology：<a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章的寫作方法論</a> / <a href="/blog/posts/migration-playbook-%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84stage-0-variant-%E8%A6%8F%E5%8A%83%E6%8A%8A-collapse-%E7%8E%87%E5%BE%9E-60-%E9%99%8D%E5%88%B0-0/" data-link-title="Migration Playbook 方法論的演化紀錄：Stage 0 variant 規劃把 collapse 率從 60% 降到 0%" data-link-desc="跨 vendor migration playbook 需要獨立寫作方法論的依據，以及這套方法論從三輪 batch dogfood 中演化出來的驗證證據。">Migration playbook methodology</a>（本文驗證 <em>容量重劃漏類</em>）</li>
</ul>
]]></content:encoded></item><item><title>Kafka Schema Registry 與 schema 演進：wire format、compatibility level 與安全演進規則</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/schema-registry-evolution/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/schema-registry-evolution/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Apache Kafka&lt;/a> overview「KRaft 與 Schema Registry」段的 implementation-layer deep article。Overview 已交代 Schema Registry 在事件總線中的定位；本文聚焦 &lt;em>怎麼設 compatibility、wire format 長什麼樣、schema 怎麼安全演進、演進設錯會打掛什麼&lt;/em>。對應 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/event-schema-compatibility/" data-link-title="Event Schema Compatibility" data-link-desc="說明 event schema 演進時，新舊 producer 與 consumer 能否互通的相容性等級">Event Schema Compatibility&lt;/a> 知識卡的 implementation 展開。&lt;/p>&lt;/blockquote>
&lt;h2 id="為什麼事件總線需要一個獨立的-schema-治理元件">為什麼事件總線需要一個獨立的 schema 治理元件&lt;/h2>
&lt;p>Schema Registry 是把「event 的結構契約」從 producer 與 consumer 的程式碼裡抽出來、集中存放並強制版本相容性的元件。它承擔的責任是讓不同 service、不同部署節奏的 producer 與 consumer 在 schema 改版時仍能互通，而不需要全體同時上線。Kafka broker 本身只存 bytes、不理解 payload 結構；一旦多個團隊往同一個 topic 寫事件、又各自獨立發版，schema 漂移就會在 consumer 端炸開。&lt;/p>
&lt;p>這個責任在單一 service 內部不存在。一個 service 自己 produce、自己 consume，schema 改版同一個 deploy 就同步了，序列化用什麼格式都行。Schema Registry 解的是 &lt;em>跨 service、跨團隊、跨部署時間&lt;/em> 的契約問題：A 團隊升級了訂單事件加一個欄位，B 團隊的對帳服務還跑舊版 consumer，C 團隊的風控服務跑更舊版——三方不同步演進，靠的就是 registry 在 producer 註冊新 schema 時先擋下破壞相容性的改動。&lt;/p>
&lt;p>Yelp 的 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/kafka-yelp-schematizer/" data-link-title="3.C14 Yelp：Schematizer 自建 Schema Registry" data-link-desc="Yelp data pipeline 強制所有 message 走 Avro、自建 Schematizer 做 schema evolution 與 topic 自動分配。">Schematizer 案例&lt;/a> 把這個責任拉到極端：一天數十億訊息、數百個 service、數千個 schema，自建 registry 強制所有 message 走 Avro、訊息只帶 schema ID。它揭露 schema 治理是 data pipeline 的核心責任、不是 add-on——當規模到了數千 schema，沒有集中強制的相容性檢查，跨服務事件契約會在某次發版後悄悄斷掉，而 broker 不會報任何錯。&lt;/p>
&lt;p>Confluent Schema Registry 是業界事實標準的實作；Apicurio 是 CNCF 生態的開源替代，額外支援 OpenAPI / AsyncAPI artifact、且提供 Confluent-compatible API endpoint，遷移成本低。兩者都把 schema 存進一個 Kafka topic（Confluent 用 &lt;code>_schemas&lt;/code>，single-partition、compacted），registry 自己是無狀態的，掛掉重啟後從該 topic rebuild。&lt;/p>
&lt;h2 id="schema-id-嵌進訊息的-wire-format">Schema ID 嵌進訊息的 wire format&lt;/h2>
&lt;p>Confluent wire format 在每筆訊息的 value（或 key）前面加 5 個 byte：1 個 magic byte（固定 &lt;code>0x00&lt;/code>）加 4 個 big-endian byte 的 schema ID，後面才接序列化後的 payload。Consumer 拿到訊息先讀這 5 個 byte，用 schema ID 去 registry 查對應 schema，再用該 schema 反序列化。這是「訊息只帶 schema ID、不帶 schema 本體」的機制——schema 本體只在 registry 存一份，訊息裡放的是指標。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Apache Kafka</a> overview「KRaft 與 Schema Registry」段的 implementation-layer deep article。Overview 已交代 Schema Registry 在事件總線中的定位；本文聚焦 <em>怎麼設 compatibility、wire format 長什麼樣、schema 怎麼安全演進、演進設錯會打掛什麼</em>。對應 <a href="/blog/backend/knowledge-cards/event-schema-compatibility/" data-link-title="Event Schema Compatibility" data-link-desc="說明 event schema 演進時，新舊 producer 與 consumer 能否互通的相容性等級">Event Schema Compatibility</a> 知識卡的 implementation 展開。</p></blockquote>
<h2 id="為什麼事件總線需要一個獨立的-schema-治理元件">為什麼事件總線需要一個獨立的 schema 治理元件</h2>
<p>Schema Registry 是把「event 的結構契約」從 producer 與 consumer 的程式碼裡抽出來、集中存放並強制版本相容性的元件。它承擔的責任是讓不同 service、不同部署節奏的 producer 與 consumer 在 schema 改版時仍能互通，而不需要全體同時上線。Kafka broker 本身只存 bytes、不理解 payload 結構；一旦多個團隊往同一個 topic 寫事件、又各自獨立發版，schema 漂移就會在 consumer 端炸開。</p>
<p>這個責任在單一 service 內部不存在。一個 service 自己 produce、自己 consume，schema 改版同一個 deploy 就同步了，序列化用什麼格式都行。Schema Registry 解的是 <em>跨 service、跨團隊、跨部署時間</em> 的契約問題：A 團隊升級了訂單事件加一個欄位，B 團隊的對帳服務還跑舊版 consumer，C 團隊的風控服務跑更舊版——三方不同步演進，靠的就是 registry 在 producer 註冊新 schema 時先擋下破壞相容性的改動。</p>
<p>Yelp 的 <a href="/blog/backend/03-message-queue/cases/kafka-yelp-schematizer/" data-link-title="3.C14 Yelp：Schematizer 自建 Schema Registry" data-link-desc="Yelp data pipeline 強制所有 message 走 Avro、自建 Schematizer 做 schema evolution 與 topic 自動分配。">Schematizer 案例</a> 把這個責任拉到極端：一天數十億訊息、數百個 service、數千個 schema，自建 registry 強制所有 message 走 Avro、訊息只帶 schema ID。它揭露 schema 治理是 data pipeline 的核心責任、不是 add-on——當規模到了數千 schema，沒有集中強制的相容性檢查，跨服務事件契約會在某次發版後悄悄斷掉，而 broker 不會報任何錯。</p>
<p>Confluent Schema Registry 是業界事實標準的實作；Apicurio 是 CNCF 生態的開源替代，額外支援 OpenAPI / AsyncAPI artifact、且提供 Confluent-compatible API endpoint，遷移成本低。兩者都把 schema 存進一個 Kafka topic（Confluent 用 <code>_schemas</code>，single-partition、compacted），registry 自己是無狀態的，掛掉重啟後從該 topic rebuild。</p>
<h2 id="schema-id-嵌進訊息的-wire-format">Schema ID 嵌進訊息的 wire format</h2>
<p>Confluent wire format 在每筆訊息的 value（或 key）前面加 5 個 byte：1 個 magic byte（固定 <code>0x00</code>）加 4 個 big-endian byte 的 schema ID，後面才接序列化後的 payload。Consumer 拿到訊息先讀這 5 個 byte，用 schema ID 去 registry 查對應 schema，再用該 schema 反序列化。這是「訊息只帶 schema ID、不帶 schema 本體」的機制——schema 本體只在 registry 存一份，訊息裡放的是指標。</p>
<p>本文用 OrbStack 起 <code>confluentinc/cp-kafka</code> + <code>confluentinc/cp-schema-registry</code>，用 Avro console producer 寫一筆 <code>{&quot;id&quot;:1,&quot;name&quot;:&quot;alice&quot;}</code>，再 dump 出 raw bytes 驗證 wire format：</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">000000 00 00 00 00 01 02 0a 61 6c 69 63 65 0a   &gt;.......alice.&lt;</span></span></code></pre></div><p>逐 byte 拆解：</p>
<ul>
<li><code>00</code>：magic byte，標識這是 Confluent wire format</li>
<li><code>00 00 00 01</code>：4-byte big-endian schema ID = 1，consumer 拿這個去 registry 查 schema</li>
<li><code>02</code>：Avro 把 <code>id</code>（long）以 zigzag varint 編碼，<code>1</code> 編成 <code>0x02</code></li>
<li><code>0a 61 6c 69 63 65</code>：<code>name</code>（string）長度 5（zigzag <code>0x0a</code>）加 UTF-8 的 <code>alice</code></li>
</ul>
<p>這個格式有兩個工程後果。第一，consumer 反序列化任何訊息前都要能連到 registry——registry 掛掉，已 cache schema ID 的 consumer 還能跑，但遇到沒見過的 schema ID 就卡住。第二，schema ID 是全域單調遞增的整數、跨 subject 共用：同一份 schema 被多個 topic 註冊只會有一個 ID。實機驗證可以看到，先註冊到 <code>user-value</code> 的 schema 拿到 <code>id:1</code>，之後用同樣結構寫 <code>users-demo</code> topic 時，registry 認出是同一份 schema、複用 <code>id:1</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">{</span><span class="nt">&#34;subject&#34;</span><span class="p">:</span><span class="s2">&#34;users-demo-value&#34;</span><span class="p">,</span><span class="nt">&#34;version&#34;</span><span class="p">:</span><span class="mi">1</span><span class="p">,</span><span class="nt">&#34;id&#34;</span><span class="p">:</span><span class="mi">1</span><span class="p">,</span><span class="nt">&#34;schemaType&#34;</span><span class="p">:</span><span class="s2">&#34;AVRO&#34;</span><span class="p">,</span> <span class="err">...</span><span class="p">}</span></span></span></code></pre></div><p><code>version</code> 是 subject 內的序號（每個 subject 從 1 開始）、<code>id</code> 是全域的。除錯時看到某筆訊息反序列化失敗，第一步就是讀那 4-byte schema ID、去 registry 撈出它指向哪個 schema、跟 consumer 預期的對不對。</p>
<h2 id="序列化格式取捨avroprotobufjson-schema">序列化格式取捨：Avro、Protobuf、JSON Schema</h2>
<p>Schema Registry 支援三種格式，差異不只是語法、而是演進規則與生態的取捨。</p>
<table>
  <thead>
      <tr>
          <th>格式</th>
          <th>演進機制</th>
          <th>適合場景</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Avro</td>
          <td>reader / writer schema resolution</td>
          <td>data pipeline、強 schema 演進需求、JVM 生態</td>
      </tr>
      <tr>
          <td>Protobuf</td>
          <td>field number 標記</td>
          <td>已用 gRPC、跨語言 RPC + 事件共用 schema</td>
      </tr>
      <tr>
          <td>JSON Schema</td>
          <td>結構 + validation keyword</td>
          <td>已大量 JSON、要人類可讀、容忍較弱的型別保證</td>
      </tr>
  </tbody>
</table>
<p>Avro 的演進靠 <em>reader schema 與 writer schema 分離</em>：訊息用 writer schema（寫入時的版本）序列化，consumer 用自己的 reader schema（讀取時的版本）反序列化，registry 提供兩者做 schema resolution。這是 Avro 在 data pipeline 場景的核心優勢——欄位帶 default 時，舊資料用新 schema 讀會自動填 default，新資料用舊 schema 讀會自動忽略多出來的欄位。Yelp、多數 Kafka-native data platform 都選 Avro，正是因為它的演進語意最完整。</p>
<p>Protobuf 用 field number 而非欄位名做 wire 識別：欄位改名不破壞相容性（number 沒變即可），刪欄位要 reserve 掉 number 避免重用。已經用 gRPC 的團隊讓 RPC 與事件共用同一份 <code>.proto</code>，省一套 schema 維護。代價是 Protobuf 的 default 語意較弱（proto3 沒有 explicit presence 的 scalar 一律有 zero value），某些演進判斷不如 Avro 直觀。</p>
<p>JSON Schema 適合既有系統已經大量用 JSON、且看重人類可讀與 validation keyword（<code>required</code>、<code>minimum</code>、<code>pattern</code>）的場景。代價是 payload 較大（欄位名重複出現在每筆訊息）、型別保證弱於前兩者。當吞吐量大、payload size 敏感時，JSON Schema 的頻寬成本會顯著高於 Avro 的 binary 編碼。</p>
<p>選型判準：data pipeline 為主、重演進安全 → Avro；已有 gRPC、RPC 與事件共用 → Protobuf；既有 JSON 生態、重可讀性而吞吐量不極端 → JSON Schema。三者可在同一個 registry 並存（每個 subject 各自標 schemaType），但同一個 subject 內不能混用格式。</p>
<h2 id="subject-naming-strategy-決定相容性檢查的邊界">Subject naming strategy 決定相容性檢查的邊界</h2>
<p>Subject 是 registry 裡做版本管理與相容性檢查的基本單位；naming strategy 決定「哪些 schema 被歸進同一個 subject、因而要互相相容」。選錯 strategy 會讓相容性檢查管太寬或太窄，是後面故障演練的根源之一。</p>
<table>
  <thead>
      <tr>
          <th>Strategy</th>
          <th>Subject 名</th>
          <th>相容性檢查邊界</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>TopicNameStrategy</td>
          <td><code>&lt;topic&gt;-value</code> / <code>&lt;topic&gt;-key</code></td>
          <td>整個 topic 只能有一種 value schema 演進</td>
      </tr>
      <tr>
          <td>RecordNameStrategy</td>
          <td><code>&lt;record 全名&gt;</code></td>
          <td>同名 record 跨所有 topic 一起演進</td>
      </tr>
      <tr>
          <td>TopicRecordNameStrategy</td>
          <td><code>&lt;topic&gt;-&lt;record 全名&gt;</code></td>
          <td>同 topic 內可放多種 record、各自演進</td>
      </tr>
  </tbody>
</table>
<p>TopicNameStrategy 是預設，subject 名就是 <code>&lt;topic&gt;-value</code>。實機驗證可以看到，用 Avro producer 寫 <code>users-demo</code> topic 時，registry 自動建立 <code>users-demo-value</code> subject：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">[</span><span class="s2">&#34;user-value&#34;</span><span class="p">,</span><span class="s2">&#34;users-demo-value&#34;</span><span class="p">]</span></span></span></code></pre></div><p>預設策略的隱含假設是「一個 topic 只承載一種事件型別」。這對多數 topic 成立，但當業務要把多種相關事件（例如 <code>OrderCreated</code> 與 <code>OrderCancelled</code>）放進同一個 topic 以保證跨事件 ordering 時，TopicNameStrategy 會把兩種 record 當成同一個 subject 的版本演進、互相做相容性檢查——這幾乎一定失敗，因為兩種事件結構本來就不同。</p>
<p>這時要改 RecordNameStrategy（subject = record 全名，跨 topic 同名 record 共用一份演進歷史）或 TopicRecordNameStrategy（subject = topic + record 名，同 topic 多型別各自獨立演進）。判準：一個 topic 一種事件 → 預設即可；一個 topic 多種事件且要保 ordering → TopicRecordNameStrategy；同一種 record 散在多個 topic 要強制全域一致 → RecordNameStrategy。Producer 與 consumer 必須設成同一個 strategy，否則 consumer 會用錯 subject 去查 schema。</p>
<h2 id="compatibility-level四種基礎--transitive">Compatibility level：四種基礎 × transitive</h2>
<p>Compatibility level 是 registry 在 producer 註冊新 schema 時套用的相容性規則，決定哪些 schema 改動會被擋下。它回答的問題是「新 schema 跟既有 schema 比，誰應該能讀誰寫的資料」。設定可以是全域預設、也可以 per-subject 覆寫。</p>
<table>
  <thead>
      <tr>
          <th>Level</th>
          <th>規則</th>
          <th>保護對象</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>BACKWARD</td>
          <td>新 schema 能讀舊 schema 寫的資料</td>
          <td>consumer 先升級、producer 後升級</td>
      </tr>
      <tr>
          <td>FORWARD</td>
          <td>舊 schema 能讀新 schema 寫的資料</td>
          <td>producer 先升級、consumer 後升級</td>
      </tr>
      <tr>
          <td>FULL</td>
          <td>同時滿足 BACKWARD 與 FORWARD</td>
          <td>雙向都能不同步演進</td>
      </tr>
      <tr>
          <td>NONE</td>
          <td>不檢查</td>
          <td>不保護（演進風險全交給人）</td>
      </tr>
  </tbody>
</table>
<p>BACKWARD 是 Confluent 預設，實機驗證可以確認：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">{</span><span class="nt">&#34;compatibilityLevel&#34;</span><span class="p">:</span><span class="s2">&#34;BACKWARD&#34;</span><span class="p">}</span></span></span></code></pre></div><p>BACKWARD 保護的是「consumer 先升級」的演進順序——新版 consumer 必須能讀舊版 producer 還在寫的舊資料。它允許的安全改動是「加帶 default 的欄位」與「刪欄位」：新 schema 讀舊資料時，舊資料缺的新欄位用 default 補；新 schema 不要的欄位讀舊資料時忽略。它擋下的是「加沒有 default 的必填欄位」——舊資料沒這欄位、新 consumer 又要求它存在，就讀不出來。</p>
<p>FORWARD 反過來保護「producer 先升級」：舊版 consumer 要能讀新版 producer 寫的資料。它允許「刪帶 default 的欄位」與「加欄位」。當演進順序是 producer 先上、consumer 慢慢跟（例如先讓 producer 開始寫新欄位、consumer 之後才用）時選 FORWARD。</p>
<p>FULL 同時滿足兩者，代價是只能做「加帶 default 的欄位」與「刪帶 default 的欄位」這類雙向安全的改動，演進自由度最低但最安全。當 producer 與 consumer 的升級順序無法協調（大型組織、多團隊各自排程）時，FULL 把演進約束到怎麼改都不會斷。</p>
<p>四種各有一個 transitive 變體（<code>BACKWARD_TRANSITIVE</code> 等）。非 transitive 只檢查新 schema 對 <em>最近一版</em>；transitive 檢查新 schema 對 <em>該 subject 所有歷史版本</em>。差別在這個場景：v1 → v2 相容、v2 → v3 相容，但 v3 對 v1 不相容。非 transitive 會放行 v3（因為只比 v2）；transitive 會擋下。當 consumer 可能 replay 很舊的歷史資料（Kafka 的長期保留 + replay 正是常態），transitive 才能保證任何歷史版本都讀得出來。<a href="/blog/backend/03-message-queue/event-contract-replay-boundary/" data-link-title="3.7 Event Contract 與 Replay Boundary" data-link-desc="說明 event schema、idempotency key、replay window 與補償如何先於 broker 選型。">3.7 event contract / replay boundary</a> 講的 replay 邊界，在 schema 層的對應就是 transitive compatibility。</p>
<h2 id="安全演進規則實機驗證註冊與拒絕">安全演進規則：實機驗證註冊與拒絕</h2>
<p>把上面的規則落到實際操作。在預設 BACKWARD 下，註冊 v1（<code>id</code> + <code>name</code>）後，加一個帶 default 的 <code>email</code> 欄位是安全的，registry 接受並記為 v2：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">{</span><span class="nt">&#34;id&#34;</span><span class="p">:</span><span class="mi">2</span><span class="p">,</span><span class="nt">&#34;version&#34;</span><span class="p">:</span><span class="mi">2</span><span class="p">,</span><span class="nt">&#34;schemaType&#34;</span><span class="p">:</span><span class="s2">&#34;AVRO&#34;</span><span class="p">,</span> <span class="err">...</span><span class="p">}</span></span></span></code></pre></div><p><code>user-value</code> 的版本列表確認累積成兩版：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">[</span><span class="mi">1</span><span class="p">,</span><span class="mi">2</span><span class="p">]</span></span></span></code></pre></div><p>接著嘗試加一個 <em>沒有 default</em> 的 <code>age</code>（int）必填欄位——這破壞 BACKWARD，因為新 consumer 讀舊資料時 <code>age</code> 沒值也沒 default。registry 回 HTTP 409 並指出確切原因：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">{</span><span class="nt">&#34;error_code&#34;</span><span class="p">:</span><span class="mi">40901</span><span class="p">,</span><span class="nt">&#34;message&#34;</span><span class="p">:</span><span class="s2">&#34;Schema being registered is incompatible with an earlier schema for subject \&#34;user-value\&#34;</span><span class="p">,</span> <span class="err">details:</span> <span class="err">[{errorType:&#39;READER_FIELD_MISSING_DEFAULT_VALUE&#39;,</span> <span class="err">description:&#39;The</span> <span class="err">field</span> <span class="err">&#39;age&#39;</span> <span class="err">at</span> <span class="err">path</span> <span class="err">&#39;/fields/3&#39;</span> <span class="err">in</span> <span class="err">the</span> <span class="err">new</span> <span class="err">schema</span> <span class="err">has</span> <span class="err">no</span> <span class="err">default</span> <span class="err">value</span> <span class="err">and</span> <span class="err">is</span> <span class="err">missing</span> <span class="err">in</span> <span class="err">the</span> <span class="err">old</span> <span class="err">schema&#39;,</span> <span class="err">...</span><span class="p">}</span><span class="err">],</span> <span class="err">compatibility:</span> <span class="err">&#39;BACKWARD&#39;}</span></span></span></code></pre></div><p><code>READER_FIELD_MISSING_DEFAULT_VALUE</code> 精確命中規則：reader（新 schema）多了一個舊資料沒有、又無 default 的欄位。registry 另外提供 compatibility check API，可以在不真正註冊的前提下先問「相不相容」，給 CI pipeline 在 PR 階段擋下破壞性改動：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">{</span><span class="nt">&#34;is_compatible&#34;</span><span class="p">:</span><span class="kc">false</span><span class="p">}</span></span></span></code></pre></div><p>由此導出兩條安全演進的操作規則。<strong>加欄位</strong>：一律帶 default（BACKWARD / FULL 都要），舊資料才能用新 schema 讀出。沒有合理 default 的「必填新欄位」不能直接加——要嘛在 producer 端先全部開始寫該欄位、確認資料齊全後再 promote，要嘛走新 topic / 新 record 而非原地演進。<strong>刪欄位</strong>：分步做。先讓所有 consumer 停止依賴該欄位（部署一輪），確認沒人讀之後，下一輪才從 schema 拿掉。一步到位刪掉還在被讀的欄位，會在 FORWARD / FULL 下被擋、在 BACKWARD 下放行但打掛還沒升級的 consumer。</p>
<h2 id="production-故障演練">Production 故障演練</h2>
<h3 id="case-1producer-加必填欄位無-default打掛舊-consumer">Case 1：producer 加必填欄位無 default，打掛舊 consumer</h3>
<p><strong>徵兆</strong>：某團隊 producer 發版後，另一團隊的舊 consumer 開始大量反序列化失敗、<code>SerializationException</code> 或 <code>AvroTypeException: Found X, expecting Y</code>，consumer lag 暴衝、訊息卡在 poll 階段。producer 端與 broker 端完全沒報錯——訊息照寫成功。</p>
<p><strong>根因</strong>：subject 的 compatibility level 被設成 NONE（或該欄位走了 FORWARD 不檢查 reader 缺欄位的路徑）。producer 加了一個沒有 default 的必填欄位、registry 沒擋，新訊息帶新 schema ID 寫進 topic。舊 consumer 用自己的舊 reader schema 去反序列化新 writer schema 的資料，遇到自己不認識又無從補值的結構就炸。問題不在 producer 也不在 broker，在 <em>registry 沒在註冊時擋下這次演進</em>。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>把 compatibility level 改回至少 BACKWARD</strong>：實機驗證過 NONE 會直接放行破壞性 schema——把 <code>compatibility</code> 設成 NONE 後，前面被 409 拒絕的破壞性 schema 立刻被接受成 v3。NONE 等於把演進安全完全交給人，多團隊場景幾乎一定出事。</li>
<li><strong>回退 producer</strong>：先讓 producer 退回舊 schema 止血，恢復舊 consumer 可讀。</li>
<li><strong>重新演進</strong>：欄位帶 default 重發，或若該欄位語意上必填、走「先讓 producer 寫、consumer 升級、再 promote」的分步路徑。</li>
<li><strong>CI 防線</strong>：把 compatibility check API（<code>/compatibility/subjects/&lt;s&gt;/versions/latest</code>）接進 producer repo 的 CI，PR 階段就用 <code>is_compatible:false</code> 擋掉，不等到 production 註冊時才發現。</li>
</ol>
<h3 id="case-2compatibility-level-設錯放行破壞性變更">Case 2：compatibility level 設錯，放行破壞性變更</h3>
<p><strong>徵兆</strong>：team 以為有 registry 把關所以放心演進，某次刪掉一個還在被下游讀的欄位、registry 接受了，下游服務隔天開始拿到 null / 缺欄位、business logic 走錯分支，但沒有任何 exception——資料「看起來正常」只是少了東西。</p>
<p><strong>根因</strong>：compatibility level 設成了 FORWARD 而需求其實是 BACKWARD，或設成 NONE。實機驗證可以看到 per-subject 覆寫的行為——對 <code>user-value</code> 單獨 PUT <code>FORWARD</code> 後查 config 回 <code>{&quot;compatibilityLevel&quot;:&quot;FORWARD&quot;}</code>，這個 subject 的檢查方向就跟全域預設不同了。FORWARD 允許刪帶 default 的欄位（保護 producer 先升級的順序），但團隊實際的演進順序是 consumer 後升級——方向錯配，registry 放行的正是會打掛 consumer 的那類改動。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>依演進順序選 level，不是隨手設</strong>：consumer 先升級選 BACKWARD；producer 先升級選 FORWARD；順序無法協調選 FULL。把這個決策寫進 topic ownership 文件、不是留給註冊當下的人臨時判斷。</li>
<li><strong>可能 replay 歷史就用 transitive</strong>：Kafka 長期保留 + replay 是常態，非 transitive 只擋最近一版、replay 舊資料時舊 schema 仍可能讀不出。長期保留的 topic 預設用 <code>*_TRANSITIVE</code>。</li>
<li><strong>per-subject 覆寫要留審計</strong>：全域預設外的每一個 per-subject 覆寫都是一個風險點，要能查出「誰、何時、為什麼把這個 subject 改成跟預設不同」。</li>
</ol>
<h3 id="case-3schema-id-對不上consumer-反序列化失敗">Case 3：schema ID 對不上，consumer 反序列化失敗</h3>
<p><strong>徵兆</strong>：consumer 報 <code>Schema not found; error code: 40403</code> 或反序列化拿到亂碼、欄位錯位。某些訊息正常、某些失敗，跟特定 producer 或特定時間段相關。</p>
<p><strong>根因</strong>有幾種，靠讀訊息前 5 byte 的 schema ID 定位：</p>
<ul>
<li><strong>registry 換過、ID 不一致</strong>：跨環境（dev / staging / prod）各自一套 registry，schema ID 全域遞增的順序不同，同一份 schema 在不同環境是不同 ID。如果有人把 prod 的訊息 mirror 到 staging 而沒搬 schema，staging consumer 拿 prod 的 schema ID 去 staging registry 查就 404。</li>
<li><strong>訊息根本不是 Confluent wire format</strong>：有 producer 沒走 schema-aware serializer、直接寫 raw bytes，前 5 byte 不是 magic + ID。consumer 把第一個 byte 當 magic、後 4 byte 當 ID 去查，撈到不存在或錯誤的 schema。</li>
<li><strong>registry 不可達或 cache 失效</strong>：consumer 端 schema cache 沒命中、又連不上 registry。</li>
</ul>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>讀 wire format 確認</strong>：dump 訊息 raw bytes，確認第一個 byte 是 <code>00</code>、接下來 4 byte 解出來的 ID 在目標 registry 查得到。本文驗證過 <code>00 00 00 00 01</code> 對應 schema id 1，這是除錯的第一手證據。</li>
<li><strong>跨環境 schema 搬遷</strong>：mirror 訊息時用 registry 的 import / export，或 MirrorMaker 搭配 schema 同步，不要只搬資料不搬 schema。</li>
<li><strong>隔離非 schema-aware producer</strong>：用 ACL 或 topic 命名規範強制所有 producer 走 schema serializer，避免 raw bytes 混進 schema-managed topic。</li>
</ol>
<h3 id="case-4subject-naming-strategy-衝突">Case 4：subject naming strategy 衝突</h3>
<p><strong>徵兆</strong>：把第二種事件型別寫進既有 topic 時，producer 直接註冊失敗報 incompatible，或多 producer 寫同 topic 互相把對方的 schema 判成不相容、彼此發版互相擋。</p>
<p><strong>根因</strong>：用 TopicNameStrategy（預設）卻往同一個 topic 放多種 record。subject 是 <code>&lt;topic&gt;-value</code>、整個 topic 共用一條演進線，registry 拿 <code>OrderCancelled</code> 去跟既有的 <code>OrderCreated</code> 做相容性檢查——兩種結構不同的事件當然不相容。strategy 的隱含假設（一 topic 一事件型別）跟實際用法（一 topic 多事件保 ordering）衝突。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>改 strategy 配合用法</strong>：一 topic 多事件 → TopicRecordNameStrategy，subject 變成 <code>&lt;topic&gt;-&lt;record 全名&gt;</code>，每種 record 各自一條演進線、不互相檢查。</li>
<li><strong>producer 與 consumer 設同一個 strategy</strong>：strategy 不一致時 consumer 會用錯 subject 查 schema，拿到 null 或錯 schema。這是部署層的硬約束，要在共用 config 統一。</li>
<li><strong>若只是不小心寫錯 topic</strong>：那不是 strategy 問題、是路由問題，修 producer 的 topic 選擇邏輯，別為了繞過檢查改成 RecordNameStrategy。</li>
</ol>
<h2 id="容量與運維邊界">容量與運維邊界</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>估算 / 邊界</th>
          <th>警戒</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Schema 數量</td>
          <td>數千 schema registry 仍可運作（Yelp 等級）</td>
          <td><code>_schemas</code> topic 是 single-partition</td>
      </tr>
      <tr>
          <td>Wire format overhead</td>
          <td>每筆訊息固定 +5 byte</td>
          <td>高頻小訊息時相對 overhead 不可忽略</td>
      </tr>
      <tr>
          <td>Registry 可用性</td>
          <td>consumer cache 命中時可短暫容忍 registry 不可達</td>
          <td>冷 consumer / 新 schema ID 時硬依賴</td>
      </tr>
      <tr>
          <td>Compatibility 檢查</td>
          <td>註冊時做、非 hot path</td>
          <td>transitive 對長歷史 subject 檢查較慢</td>
      </tr>
      <tr>
          <td>環境隔離</td>
          <td>每環境一套 registry、schema ID 不跨環境一致</td>
          <td>跨環境 mirror 要同步搬 schema</td>
      </tr>
  </tbody>
</table>
<p>實務 default：data pipeline 場景選 Avro + 至少 BACKWARD；長期保留 + replay 的 topic 用 transitive；compatibility check 接進 CI 在 PR 階段擋破壞性改動，不依賴註冊當下把關；一 topic 一事件型別當預設、要多型別才動 naming strategy。Schema Registry 自己也是個要 HA 的元件——production 跑多副本、<code>_schemas</code> topic 的 replication factor 拉高，registry 是事件總線的單點時要當關鍵基礎設施對待。</p>
<h2 id="整合與下一步">整合與下一步</h2>
<h3 id="跟-cdc-pipeline-的銜接">跟 CDC pipeline 的銜接</h3>
<p><a href="/blog/backend/03-message-queue/cases/kafka-shopify-debezium-cdc/" data-link-title="3.C13 Shopify：Debezium CDC over sharded MySQL" data-link-desc="Shopify 100&#43; MySQL shard、150 Debezium connector、Black Friday 100K records/sec P99 &lt; 10s。">Shopify Debezium CDC 案例</a> 跑在 100+ MySQL shard、150 個 Debezium connector 的規模（該案例記載的重點是 lock-free snapshot 與 oversized record 處理）。CDC pipeline 有一個一般性的 schema 演進壓力，以下依 CDC 機制推導、非該案例的結論：上游 DDL 一改，Debezium 產生的 Kafka record schema 跟著變，下游 consumer 受影響。Schema Registry 的 compatibility 檢查就是把這道衝擊在進 Kafka 時攔下的關卡——選錯 compatibility level，一次 ALTER TABLE 就可能透過 CDC 打穿整條 pipeline。Debezium 與 Kafka Connect 原生整合 Schema Registry，connector 設定裡指定 registry URL 與 naming strategy。</p>
<h3 id="跟-replay-邊界與事件契約">跟 replay 邊界與事件契約</h3>
<p><a href="/blog/backend/03-message-queue/event-contract-replay-boundary/" data-link-title="3.7 Event Contract 與 Replay Boundary" data-link-desc="說明 event schema、idempotency key、replay window 與補償如何先於 broker 選型。">3.7 event contract / replay boundary</a> 講的是事件契約能 replay 多遠；schema 層的對應就是本文的 transitive compatibility。Replay 跨越多個 schema 版本時，只有 transitive 能保證任何歷史版本都讀得出來。兩者一起界定「這條事件流的契約能安全回放到多久以前」。</p>
<h3 id="下游能力">下游能力</h3>
<ul>
<li>概念索引：<a href="/blog/backend/knowledge-cards/event-schema-compatibility/" data-link-title="Event Schema Compatibility" data-link-desc="說明 event schema 演進時，新舊 producer 與 consumer 能否互通的相容性等級">Event Schema Compatibility</a> 知識卡（本文的 implementation 來源）</li>
<li>上游 vendor 頁：<a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Apache Kafka</a>（KRaft 與 Schema Registry 段）</li>
<li>對應案例：<a href="/blog/backend/03-message-queue/cases/kafka-yelp-schematizer/" data-link-title="3.C14 Yelp：Schematizer 自建 Schema Registry" data-link-desc="Yelp data pipeline 強制所有 message 走 Avro、自建 Schematizer 做 schema evolution 與 topic 自動分配。">3.C14 Yelp Schematizer</a>（schema 治理拉到平台層）、<a href="/blog/backend/03-message-queue/cases/kafka-shopify-debezium-cdc/" data-link-title="3.C13 Shopify：Debezium CDC over sharded MySQL" data-link-desc="Shopify 100&#43; MySQL shard、150 Debezium connector、Black Friday 100K records/sec P99 &lt; 10s。">3.C13 Shopify Debezium CDC</a>（CDC 場景的 schema evolution）</li>
<li>方法論：<a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章的寫作方法論</a></li>
</ul>
]]></content:encoded></item><item><title>RabbitMQ Network Partition 與 Cluster 一致性：腦裂下要保誰</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/rabbitmq/network-partition-clustering/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/rabbitmq/network-partition-clustering/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/rabbitmq/" data-link-title="RabbitMQ" data-link-desc="Classic message broker、AMQP routing 為主">RabbitMQ&lt;/a> overview「Erlang clustering 與 network partition」段的 implementation-layer deep article。Overview 回答「RabbitMQ cluster 是什麼、跟同類 broker 差在哪」；本文回答「partition 發生時 broker 怎麼決策、各策略保住什麼、丟掉什麼」。&lt;/p>&lt;/blockquote>
&lt;p>Network partition 是 cluster 節點之間的網路連線中斷、雙方各自仍存活但互相不可達的狀態。RabbitMQ cluster 建立在 Erlang distribution 之上、節點靠固定心跳（net_tick）互相確認存活；心跳連續數次收不到、Erlang 就判定對方失聯、把單一 cluster 切成兩個互不知道對方狀態的子群。此時的核心問題不是「怎麼避免 partition」——跨機房、跨 AZ、雲端 VPC 路由抖動都會造成短暫不可達、partition 在分散式系統是必然會遇到的物理事件——而是「分裂的瞬間、broker 要犧牲可用性保一致性、還是犧牲一致性保可用性」。&lt;code>cluster_partition_handling&lt;/code> 設定就是這個取捨的開關。&lt;/p>
&lt;h2 id="問題情境兩邊都覺得自己是對的">問題情境：兩邊都覺得自己是對的&lt;/h2>
&lt;p>腦裂（split-brain）的破壞性在於分裂的兩個子群各自繼續服務、各自接受寫入、各自認為對方已死。等到網路恢復、兩邊的狀態已經分歧：同一個 queue 在 A 子群被消費掉的訊息、在 B 子群還在；同一個 exchange 的 binding 在兩邊被改成不同樣子；同一筆業務在兩邊各被處理一次。&lt;/p>
&lt;p>RabbitMQ 的 classic queue 沒有內建的衝突解決機制。當兩個子群在 partition 期間各自修改了 cluster metadata（queue / exchange / binding 的定義）、恢復連線後 RabbitMQ 無法自動合併這些分歧、預設行為是 &lt;em>拒絕自動重新加入&lt;/em>、把節點停在 partition 狀態等人工處置。這就是為什麼 partition handling 策略的選擇、本質是「願意在分裂瞬間付出什麼代價、來換取恢復時的可預測性」。&lt;/p>
&lt;p>這個取捨跟 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/processing-recovery-semantics/" data-link-title="3.6 Processing Semantics 與 Recovery Semantics" data-link-desc="說明投遞成功、處理成功與恢復成功為何是三個不同判斷。">3.6 processing semantics 與 recovery semantics&lt;/a> 的判斷同源：投遞成功、處理成功、恢復成功是三件事。Partition 期間「broker 還在收訊息」（投遞層可用）不代表「訊息會被正確處理一次」（處理層一致）、更不代表「partition 結束後狀態能無損合併」（恢復層一致）。&lt;/p>
&lt;h2 id="核心概念一disc-node-與-ram-node">核心概念一：disc node 與 ram node&lt;/h2>
&lt;p>RabbitMQ cluster 的每個節點承擔一種角色、決定它存哪些資料。Cluster metadata（vhost、user、exchange、queue 定義、binding）在所有節點間複製、但 &lt;em>持久化到磁碟&lt;/em> 與否分兩種：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>節點類型&lt;/th>
 &lt;th>metadata 存放&lt;/th>
 &lt;th>適用場景&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Disc node&lt;/td>
 &lt;td>記憶體 + 磁碟&lt;/td>
 &lt;td>預設、cluster 必須至少有一個&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Ram node&lt;/td>
 &lt;td>僅記憶體&lt;/td>
 &lt;td>metadata 變更極頻繁的特殊場景、現代極少使用&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Disc node 把 cluster metadata 寫到磁碟、整個 cluster 重啟後能從磁碟恢復拓樸定義。Ram node 只把 metadata 放記憶體、metadata 操作（宣告大量 queue / binding）較快、但 cluster 若全部節點同時掛掉就會遺失定義。&lt;/p>
&lt;p>Ram node 是早期為了加速高頻 metadata 變更而設計的角色。實務上現代 RabbitMQ 部署幾乎都用全 disc node：metadata 操作的效能瓶頸在現代硬體上不再顯著、而全 disc 換來的「任意節點重啟都能恢復拓樸」的可預測性、價值遠高於那點 metadata 寫入速度。官方文件也建議 cluster 內 disc node 至少兩個、避免唯一的 disc node 掛掉時整個 cluster 的 metadata 無法持久化。&lt;/p>
&lt;p>本文實機演練的 3-node cluster 全部是 disc node、這也是 &lt;code>rabbitmqctl cluster_status&lt;/code> 在 OrbStack 上的實際輸出：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">Disk Nodes
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">rabbit@rmq1
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">rabbit@rmq2
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">rabbit@rmq3&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>要特別區分的是：disc / ram 講的是 &lt;em>cluster metadata&lt;/em> 的持久化、跟 &lt;em>訊息本身&lt;/em> 是否持久化（durable queue + persistent message）是兩個獨立軸。Disc node 不會讓 transient queue 的訊息變持久、ram node 也不會讓 durable queue 的訊息變揮發。訊息持久化的判讀見 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/durable-queue/" data-link-title="3.2 durable queue 與重試策略" data-link-desc="整理持久化佇列、DLQ 與重試流程">3.2 durable queue&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/03-message-queue/vendors/rabbitmq/" data-link-title="RabbitMQ" data-link-desc="Classic message broker、AMQP routing 為主">RabbitMQ</a> overview「Erlang clustering 與 network partition」段的 implementation-layer deep article。Overview 回答「RabbitMQ cluster 是什麼、跟同類 broker 差在哪」；本文回答「partition 發生時 broker 怎麼決策、各策略保住什麼、丟掉什麼」。</p></blockquote>
<p>Network partition 是 cluster 節點之間的網路連線中斷、雙方各自仍存活但互相不可達的狀態。RabbitMQ cluster 建立在 Erlang distribution 之上、節點靠固定心跳（net_tick）互相確認存活；心跳連續數次收不到、Erlang 就判定對方失聯、把單一 cluster 切成兩個互不知道對方狀態的子群。此時的核心問題不是「怎麼避免 partition」——跨機房、跨 AZ、雲端 VPC 路由抖動都會造成短暫不可達、partition 在分散式系統是必然會遇到的物理事件——而是「分裂的瞬間、broker 要犧牲可用性保一致性、還是犧牲一致性保可用性」。<code>cluster_partition_handling</code> 設定就是這個取捨的開關。</p>
<h2 id="問題情境兩邊都覺得自己是對的">問題情境：兩邊都覺得自己是對的</h2>
<p>腦裂（split-brain）的破壞性在於分裂的兩個子群各自繼續服務、各自接受寫入、各自認為對方已死。等到網路恢復、兩邊的狀態已經分歧：同一個 queue 在 A 子群被消費掉的訊息、在 B 子群還在；同一個 exchange 的 binding 在兩邊被改成不同樣子；同一筆業務在兩邊各被處理一次。</p>
<p>RabbitMQ 的 classic queue 沒有內建的衝突解決機制。當兩個子群在 partition 期間各自修改了 cluster metadata（queue / exchange / binding 的定義）、恢復連線後 RabbitMQ 無法自動合併這些分歧、預設行為是 <em>拒絕自動重新加入</em>、把節點停在 partition 狀態等人工處置。這就是為什麼 partition handling 策略的選擇、本質是「願意在分裂瞬間付出什麼代價、來換取恢復時的可預測性」。</p>
<p>這個取捨跟 <a href="/blog/backend/03-message-queue/processing-recovery-semantics/" data-link-title="3.6 Processing Semantics 與 Recovery Semantics" data-link-desc="說明投遞成功、處理成功與恢復成功為何是三個不同判斷。">3.6 processing semantics 與 recovery semantics</a> 的判斷同源：投遞成功、處理成功、恢復成功是三件事。Partition 期間「broker 還在收訊息」（投遞層可用）不代表「訊息會被正確處理一次」（處理層一致）、更不代表「partition 結束後狀態能無損合併」（恢復層一致）。</p>
<h2 id="核心概念一disc-node-與-ram-node">核心概念一：disc node 與 ram node</h2>
<p>RabbitMQ cluster 的每個節點承擔一種角色、決定它存哪些資料。Cluster metadata（vhost、user、exchange、queue 定義、binding）在所有節點間複製、但 <em>持久化到磁碟</em> 與否分兩種：</p>
<table>
  <thead>
      <tr>
          <th>節點類型</th>
          <th>metadata 存放</th>
          <th>適用場景</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Disc node</td>
          <td>記憶體 + 磁碟</td>
          <td>預設、cluster 必須至少有一個</td>
      </tr>
      <tr>
          <td>Ram node</td>
          <td>僅記憶體</td>
          <td>metadata 變更極頻繁的特殊場景、現代極少使用</td>
      </tr>
  </tbody>
</table>
<p>Disc node 把 cluster metadata 寫到磁碟、整個 cluster 重啟後能從磁碟恢復拓樸定義。Ram node 只把 metadata 放記憶體、metadata 操作（宣告大量 queue / binding）較快、但 cluster 若全部節點同時掛掉就會遺失定義。</p>
<p>Ram node 是早期為了加速高頻 metadata 變更而設計的角色。實務上現代 RabbitMQ 部署幾乎都用全 disc node：metadata 操作的效能瓶頸在現代硬體上不再顯著、而全 disc 換來的「任意節點重啟都能恢復拓樸」的可預測性、價值遠高於那點 metadata 寫入速度。官方文件也建議 cluster 內 disc node 至少兩個、避免唯一的 disc node 掛掉時整個 cluster 的 metadata 無法持久化。</p>
<p>本文實機演練的 3-node cluster 全部是 disc node、這也是 <code>rabbitmqctl cluster_status</code> 在 OrbStack 上的實際輸出：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">Disk Nodes
</span></span><span class="line"><span class="ln">2</span><span class="cl">rabbit@rmq1
</span></span><span class="line"><span class="ln">3</span><span class="cl">rabbit@rmq2
</span></span><span class="line"><span class="ln">4</span><span class="cl">rabbit@rmq3</span></span></code></pre></div><p>要特別區分的是：disc / ram 講的是 <em>cluster metadata</em> 的持久化、跟 <em>訊息本身</em> 是否持久化（durable queue + persistent message）是兩個獨立軸。Disc node 不會讓 transient queue 的訊息變持久、ram node 也不會讓 durable queue 的訊息變揮發。訊息持久化的判讀見 <a href="/blog/backend/03-message-queue/durable-queue/" data-link-title="3.2 durable queue 與重試策略" data-link-desc="整理持久化佇列、DLQ 與重試流程">3.2 durable queue</a>。</p>
<h2 id="核心概念二partition-偵測機制">核心概念二：partition 偵測機制</h2>
<p>RabbitMQ 不自己實作節點存活偵測、而是直接用 Erlang distribution 的 net_tick 機制。每個節點對 cluster 內其他節點定期送 tick、<code>net_ticktime</code> 預設 60 秒；連續數個 tick interval（預設約 4 個、即 net_ticktime 區間內）收不到對方回應、Erlang 就判定該節點 <code>nodedown</code>、向上層的 RabbitMQ partition handler 報告。</p>
<p>這個機制有兩個實務後果。第一、partition 偵測有 <em>延遲</em>：短於 net_ticktime 的網路抖動（幾秒的 GC pause、瞬間封包遺失）不會觸發 partition、避免把暫時性抖動誤判成永久分裂。第二、偵測延遲是雙刃：net_ticktime 設太長、真的 partition 了也要等很久才反應、期間腦裂持續擴大；設太短、雲端環境正常的網路抖動會頻繁誤觸發 partition handler、造成不必要的節點暫停。</p>
<p>本文實機演練用 <code>docker network disconnect</code> 切斷一個節點的網路、實測偵測延遲：disconnect 後約 60 秒（吻合 net_ticktime 預設值）、多數派側的 <code>cluster_status</code> 的 Running Nodes 才從三個掉到兩個：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">disconnect 後立即查 → Running Nodes 仍顯示 3 個（尚未偵測）
</span></span><span class="line"><span class="ln">2</span><span class="cl">等待約 60 秒 → Running Nodes 掉到 2 個（partition 已偵測）</span></span></code></pre></div><p>偵測到 partition 之後、broker 怎麼處置、完全取決於 <code>cluster_partition_handling</code> 設定。</p>
<h2 id="核心概念三cluster_partition_handling-三策略">核心概念三：cluster_partition_handling 三策略</h2>
<p>這個設定決定 broker 在偵測到 partition 後的行為、是整個 cluster 一致性與可用性取捨的單一開關。三種策略對應三種不同的 CAP 立場。</p>
<table>
  <thead>
      <tr>
          <th>策略</th>
          <th>partition 時行為</th>
          <th>保住</th>
          <th>犧牲</th>
          <th>適用</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>ignore</code></td>
          <td>兩邊都繼續服務、不做任何處置</td>
          <td>可用性</td>
          <td>一致性（會腦裂）</td>
          <td>單機 / 不在乎一致性的場景</td>
      </tr>
      <tr>
          <td><code>pause_minority</code></td>
          <td>少數派節點暫停 broker、多數派繼續</td>
          <td>一致性</td>
          <td>少數派可用性</td>
          <td>奇數節點 cluster（推薦）</td>
      </tr>
      <tr>
          <td><code>autoheal</code></td>
          <td>partition 結束後自動選贏家、輸家重啟丟狀態</td>
          <td>自動恢復</td>
          <td>輸家側的訊息</td>
          <td>可容忍少量訊息遺失的場景</td>
      </tr>
  </tbody>
</table>
<p>設定方式在 <code>rabbitmq.conf</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="ln">1</span><span class="cl"><span class="na">cluster_partition_handling</span> <span class="o">=</span> <span class="s">pause_minority</span></span></span></code></pre></div><p>或在舊版 advanced config（Erlang term 格式）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-erlang" data-lang="erlang"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">[</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="p">{</span><span class="n">rabbit</span><span class="p">,</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="p">{</span><span class="n">cluster_partition_handling</span><span class="p">,</span> <span class="n">pause_minority</span><span class="p">}</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="p">]}</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">].</span></span></span></code></pre></div><p>三個策略的差異不在「哪個比較好」、而在「分裂瞬間願意讓誰停下來」。下面三段把每個策略在真實服務裡長什麼樣展開。</p>
<h3 id="ignore兩邊都活恢復時等人來">ignore：兩邊都活、恢復時等人來</h3>
<p><code>ignore</code> 是預設值（OrbStack 起的 cluster <code>rabbitmqctl environment</code> 實測輸出 <code>{cluster_partition_handling, ignore}</code>）。它的行為是 partition 偵測到了、但 broker 什麼都不做、兩個子群繼續各自服務。</p>
<p>這在單節點部署完全沒問題——沒有 cluster 就沒有 partition。問題出在多節點 cluster：兩個子群會各自接受 publish、各自讓 consumer 消費、各自修改 metadata。網路恢復後、RabbitMQ 偵測到兩邊狀態分歧、會把節點停在 partition 狀態、不自動重新加入、在 log 留下 partition 警告等人工介入。此時 metadata 已經分歧、需要人工決定保留哪一邊、reset 另一邊重新 join。</p>
<p><code>ignore</code> 適合的場景很窄：單機部署、或刻意接受腦裂並在應用層做衝突解決的特殊架構。多數需要 cluster 的場景不該用 <code>ignore</code>——它把一致性的責任完全推給人工處置、而人工處置在凌晨三點的 incident 現場是最不可靠的環節。</p>
<h3 id="pause_minority少數派主動停下">pause_minority：少數派主動停下</h3>
<p><code>pause_minority</code> 是奇數節點 cluster 的推薦策略、它的設計直接對應 quorum 的數學：partition 把 cluster 切成兩半時、節點數較少的那一側（少數派）主動 <em>暫停自己的 broker</em>、停止接受任何 client 連線；節點數較多的那一側（多數派）繼續服務。</p>
<p>這保證了任何時刻最多只有一個子群在服務、從根本上杜絕腦裂。代價是少數派側的所有 client 在 partition 期間完全失去服務。</p>
<p>3-node cluster 是這個策略的最小有效配置。實機演練：把 rmq3 從 network disconnect、製造「rmq1 + rmq2 多數派 vs rmq3 少數派」的分裂、約 60 秒後查少數派 rmq3 的狀態：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">$ rabbitmqctl cluster_status   # 在被孤立的 rmq3 上執行
</span></span><span class="line"><span class="ln">2</span><span class="cl">Error: this command requires the &#39;rabbit&#39; app to be running on the target node.
</span></span><span class="line"><span class="ln">3</span><span class="cl">       Start it with &#39;rabbitmqctl start_app&#39;.</span></span></code></pre></div><p>少數派 rmq3 的 rabbit 應用被 partition handler 主動停止——這正是 pause_minority 的預期行為。同時多數派側 rmq1 的 cluster_status 顯示 Running Nodes 只剩 rmq1 + rmq2、繼續正常服務。</p>
<p>恢復也是自動的。把 rmq3 重新 network connect、約 15 秒後它自動重啟 rabbit 應用、重新加入 cluster、Running Nodes 回到三個、Network Partitions 顯示 <code>(none)</code>、無殘留 partition 需要人工處置。這是 pause_minority 相對 ignore 的關鍵優勢：恢復路徑自動化、不依賴凌晨的人工判斷。</p>
<p>pause_minority 有一個硬性前提：cluster 必須是奇數節點、且要能形成明確的多數。2-node cluster 用 pause_minority 是反模式——partition 時兩邊各 1 個、都不是多數、結果兩邊都暫停、整個 cluster 完全不可用。4-node cluster 切成 2:2 也同樣兩邊都停。要用 pause_minority、節點數必須是 3、5、7 這種能在最常見的 1-node 失聯情境下仍形成多數的奇數。</p>
<h3 id="autoheal分裂時都活恢復時選贏家丟輸家">autoheal：分裂時都活、恢復時選贏家丟輸家</h3>
<p><code>autoheal</code> 走另一條路：partition 期間 <em>兩個子群都繼續服務</em>（跟 ignore 一樣）、但在 partition <em>結束</em> 的瞬間、broker 自動裁決——選出一個「贏家」子群、強制「輸家」子群的節點重啟、丟棄輸家在 partition 期間累積的狀態、然後重新加入贏家。</p>
<p>贏家的選擇規則是：先比 client 連線數（連線多的贏）、連線數相同比節點數、再相同比節點名稱。</p>
<p>autoheal 的取捨點跟 pause_minority 相反。pause_minority 在分裂瞬間就讓少數派停止、犧牲的是少數派 partition 期間的 <em>可用性</em>；autoheal 讓兩邊都活、犧牲的是輸家 partition 期間累積的 <em>訊息與狀態</em>。輸家側在 partition 期間被消費掉的訊息、被接受的新 publish、被修改的 binding、在 autoheal 重啟輸家後全部丟失。</p>
<p>這讓 autoheal 適合一種特定場景：可用性比訊息完整性重要、且訊息本身是冪等或可重送的。例如純粹的快取失效通知、可重算的衍生事件——丟幾條重新觸發即可。對「丟一條訊息等於丟一筆訂單」的場景、autoheal 的自動丟棄是不可接受的。</p>
<h2 id="quorum-queue-在-partition-下的行為">quorum queue 在 partition 下的行為</h2>
<p>前面三個 <code>cluster_partition_handling</code> 策略管的是 <em>classic queue 與 cluster metadata</em> 的 partition 行為。Quorum queue 是另一套機制——它不依賴 <code>cluster_partition_handling</code>、而是用 Raft 共識協議自己決定 partition 下的行為。這是 RabbitMQ 對腦裂問題的根本性改寫。</p>
<p>Quorum queue 把每個 queue 實作成一個獨立的 Raft 複製群組：一個 leader 加數個 follower、預設複製到奇數個節點（3-node cluster 通常 3 副本）。每筆 publish 必須被 <em>多數副本</em> 確認寫入、leader 才回 publisher confirm。實機驗證 3-node cluster 上 quorum queue 的 Raft 拓樸：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">$ rabbitmq-queues quorum_status qq.test
</span></span><span class="line"><span class="ln">2</span><span class="cl">Node Name      Raft State   Membership
</span></span><span class="line"><span class="ln">3</span><span class="cl">rabbit@rmq1    leader       voter
</span></span><span class="line"><span class="ln">4</span><span class="cl">rabbit@rmq2    follower     voter
</span></span><span class="line"><span class="ln">5</span><span class="cl">rabbit@rmq3    follower     voter</span></span></code></pre></div><p>Partition 切斷 Raft 群組時、行為完全由 Raft 的 majority 規則決定、不需要 <code>cluster_partition_handling</code> 介入：</p>
<p>含 majority 副本的那一側選出（或維持）leader、繼續接受讀寫；不含 majority 的那一側無法 commit 任何寫入、自動進入唯讀或拒絕狀態。因為 commit 需要 majority 確認、少數派永遠湊不到 majority、所以少數派 <em>物理上不可能</em> 接受新寫入並確認——腦裂在協議層被排除、不靠運維設定。</p>
<p>實機演練最關鍵的一段：把 rmq2 與 rmq3 <em>同時</em> disconnect、讓 quorum queue 的 leader（在 rmq1）只剩自己一個副本、3 副本只剩 1 副本、失去 majority（1/3 &lt; 2/3）。此時 <code>quorum_status</code> 顯示其他兩個節點變 <code>timeout</code> 狀態：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">Node Name      Raft State   Membership
</span></span><span class="line"><span class="ln">2</span><span class="cl">rabbit@rmq1    leader       voter
</span></span><span class="line"><span class="ln">3</span><span class="cl">rabbit@rmq2    timeout
</span></span><span class="line"><span class="ln">4</span><span class="cl">rabbit@rmq3    timeout</span></span></code></pre></div><p>然後對這個失去 quorum 的 queue 嘗試 publish：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">$ rabbitmqadmin publish routing_key=qq.test payload=&#34;during-quorum-loss&#34;
</span></span><span class="line"><span class="ln">2</span><span class="cl">[實測：publish 阻塞、12 秒後仍未返回——Raft 無 majority 可 commit]</span></span></code></pre></div><p>Publish 被阻塞、不返回 publisher confirm。因為 leader 拿不到任何 follower 的確認、無法達成 majority、寫入永遠 commit 不了。這是 quorum queue 用 <em>阻塞</em> 換 <em>一致性</em>：寧可不接受寫入、也不接受一筆無法被多數副本保證的寫入。</p>
<p>同一個 partition 情境下、對 classic queue 做同樣的 publish 作為對照：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">$ rabbitmqadmin publish routing_key=cq.test payload=&#34;classic-during-partition&#34;
</span></span><span class="line"><span class="ln">2</span><span class="cl">Message published   # 立即成功</span></span></code></pre></div><p>Classic queue 立即接受寫入。它沒有 Raft、leader 節點獨自決定、可用性優先——但這也正是它在腦裂下會分歧的根源：rmq1 接受的這筆、partition 結束後可能跟另一側的狀態衝突。</p>
<p>把兩邊 disconnect 的節點重新 connect、quorum 恢復、<code>quorum_status</code> 三個節點回到 leader + 2 follower、原本被阻塞的 publish 路徑恢復、新 publish 立即成功。Quorum queue 的恢復是協議自動完成的、不需要人工 reset 任何節點。</p>
<p>這就是 classic queue 加 <code>cluster_partition_handling</code> 與 quorum queue 的根本差異：前者是 <em>用運維策略事後補救</em> 一個本身會腦裂的資料結構、後者是 <em>用共識協議從設計上排除</em> 腦裂。現代 RabbitMQ 對需要跨節點一致性的 queue、官方建議直接用 quorum queue、把 partition 一致性交給 Raft、而不是依賴 <code>cluster_partition_handling</code> 的 classic queue 補救。Classic / quorum / stream 的完整選型判讀見 <a href="/blog/backend/03-message-queue/vendors/rabbitmq/queue-types-classic-quorum-stream/" data-link-title="RabbitMQ Queue Type 選型：Classic、Quorum、Stream 的責任邊界與容量取捨" data-link-desc="RabbitMQ 3.x 三種 queue type 的選型 deep article — classic queue（mirrored 已 deprecated）、quorum queue（Raft 一致性、取代 mirrored）、stream（3.9&#43; append-only log、可重複消費）。涵蓋三種模型在 throughput / retention / replay / 記憶體成本的判讀、宣告語意差異（實機驗證）、4 個 production 故障演練（mirrored 網路放大 / quorum loss / stream retention 超量 / classic→quorum in-flight message），與容量規劃。">Queue Type 選型</a>。</p>
<h2 id="真實-cluster-治理以-zalando-為例">真實 cluster 治理：以 Zalando 為例</h2>
<p><a href="/blog/backend/03-message-queue/cases/rabbitmq-zalando-aws-master-selection/" data-link-title="3.C27 Zalando：RabbitMQ on AWS 自動化 master selection" data-link-desc="Zalando 用 sidekick 服務查 AWS API 動態識別 cluster、指定最老 instance 當 master、跨版本升級用 federation 過渡。">3.C27 Zalando RabbitMQ on AWS</a> 案例揭露了 K8s 普及之前、雲端 RabbitMQ cluster 治理的工程模式（master selection 與成員協調），跟 cluster 拓樸治理相關。</p>
<p>Zalando 的 communication platform 把 RabbitMQ cluster 跑在 EC2 上、自建 sidekick 服務查 AWS API 動態識別 cluster 成員、指定「最老的 instance」當 master、master 死後晉升下一個最老的節點。這套機制本質是在 RabbitMQ 內建的 partition handling 之外、額外加一層 <em>外部協調者</em> 來決定 cluster 拓樸（case 記載的直接動機是用 AWS API 動態識別成員、配合每 region 5 個 Elastic IP 的限制處理 master 角色）。把它讀作「早期雲端 RabbitMQ 在節點角色確定性上需要外部補強」是本文的判讀、非 case 明述的結論。</p>
<p>這個案例對映到本文的判讀是：早期 RabbitMQ cluster 的 partition 一致性需要大量外部工程（sidekick + AWS API + 自訂 master selection）來補足。Quorum queue 用 Raft 把這套外部協調內化進 broker——Raft 的 leader election 與 majority commit 取代了 Zalando 手寫的「最老 instance 當 master」邏輯。現代部署若用 quorum queue + pause_minority、不再需要外部 sidekick 來決定誰是 master。</p>
<p>語義誤配的風險在 partition 場景同樣存在。<a href="/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9 Queue 語義切換誤配</a> 指出 broker 行為改變時、「表面上訊息仍被送達、但業務資料開始出現重複或遺漏」。Partition 恢復正是這種高風險時刻：autoheal 丟棄輸家狀態、或人工從 ignore 的腦裂中合併、都可能讓同一批事件被處理零次或兩次。Partition 恢復後的 reconciliation、要對照 <a href="/blog/backend/03-message-queue/processing-recovery-semantics/" data-link-title="3.6 Processing Semantics 與 Recovery Semantics" data-link-desc="說明投遞成功、處理成功與恢復成功為何是三個不同判斷。">3.6 recovery semantics</a> 確認哪一段資料已被哪一側處理過、而不是假設「broker 恢復了 = 狀態正確了」。</p>
<h2 id="容量與規模判讀">容量與規模判讀</h2>
<p>Partition 處理策略的選擇隨 cluster 規模與一致性需求變化、不存在單一最佳解。</p>
<table>
  <thead>
      <tr>
          <th>規模 / 場景</th>
          <th>建議策略</th>
          <th>判讀</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>單節點</td>
          <td><code>ignore</code>（無 partition 可言）</td>
          <td>沒有 cluster、不需要 partition 處理</td>
      </tr>
      <tr>
          <td>3 / 5 / 7 奇數節點、需一致性</td>
          <td><code>pause_minority</code> + quorum queue</td>
          <td>少數派暫停、quorum queue 用 Raft 保一致</td>
      </tr>
      <tr>
          <td>偶數節點</td>
          <td>加一個節點變奇數、再用 pause_minority</td>
          <td>偶數節點對 pause_minority 是反模式</td>
      </tr>
      <tr>
          <td>可容忍訊息遺失、可用性優先</td>
          <td><code>autoheal</code> + classic queue</td>
          <td>接受輸家丟狀態、換 partition 期間雙邊可用</td>
      </tr>
      <tr>
          <td>跨 AZ / 跨 region</td>
          <td>重新評估是否該用單一 cluster</td>
          <td>partition 機率高、考慮 federation 拆成獨立 cluster</td>
      </tr>
  </tbody>
</table>
<p>幾個容量相關的硬性邊界：</p>
<p>跨 region 拉一個 RabbitMQ cluster 是高風險配置。跨 region 網路延遲與抖動讓 partition 從「偶發事件」變成「常態」——net_tick 頻繁逾時、pause_minority 頻繁暫停節點、cluster 實質不穩定。跨 region 的正確做法是每個 region 一個獨立 cluster、用 federation 或 shovel 做 region 間的訊息搬運、partition 限制在單一 region 內。</p>
<p>quorum queue 的副本數要對齊 cluster 規模。3-node cluster 配 3 副本能容忍 1 節點失聯（仍有 2/3 majority）；5-node 配 5 副本能容忍 2 節點失聯。副本數越多、容錯越高、但每筆寫入要等的確認也越多、寫入延遲上升。多數場景 3 副本是延遲與容錯的平衡點。</p>
<p>net_ticktime 的調整要保守。把它調短以加速 partition 偵測、會讓雲端正常抖動頻繁誤觸發 partition handler——pause_minority 下就是節點被頻繁暫停、可用性反而下降。除非有明確證據顯示偵測延遲是問題、否則保留 60 秒預設值。</p>
<h2 id="整合與下一步">整合與下一步</h2>
<p>Partition 處理是 RabbitMQ cluster 可靠性的一環、跟以下能力環環相扣：</p>
<p>queue 類型的選擇直接決定 partition 行為。Classic queue 靠 <code>cluster_partition_handling</code> 事後補救、quorum queue 靠 Raft 從設計排除腦裂、stream 又是另一套複製模型。三者在 partition、throughput、retention 上的完整取捨、見 <a href="/blog/backend/03-message-queue/vendors/rabbitmq/queue-types-classic-quorum-stream/" data-link-title="RabbitMQ Queue Type 選型：Classic、Quorum、Stream 的責任邊界與容量取捨" data-link-desc="RabbitMQ 3.x 三種 queue type 的選型 deep article — classic queue（mirrored 已 deprecated）、quorum queue（Raft 一致性、取代 mirrored）、stream（3.9&#43; append-only log、可重複消費）。涵蓋三種模型在 throughput / retention / replay / 記憶體成本的判讀、宣告語意差異（實機驗證）、4 個 production 故障演練（mirrored 網路放大 / quorum loss / stream retention 超量 / classic→quorum in-flight message），與容量規劃。">Queue Type 選型</a>。</p>
<p>partition 恢復的核心是恢復語義、不是連線恢復。Broker 重新連上不等於狀態一致——這正是 <a href="/blog/backend/03-message-queue/processing-recovery-semantics/" data-link-title="3.6 Processing Semantics 與 Recovery Semantics" data-link-desc="說明投遞成功、處理成功與恢復成功為何是三個不同判斷。">3.6 processing semantics 與 recovery semantics</a> 區分投遞、處理、恢復三層的價值。Partition 後的 reconciliation 要對照這三層判斷。</p>
<p>雲端 cluster 治理的歷史脈絡見 <a href="/blog/backend/03-message-queue/cases/rabbitmq-zalando-aws-master-selection/" data-link-title="3.C27 Zalando：RabbitMQ on AWS 自動化 master selection" data-link-desc="Zalando 用 sidekick 服務查 AWS API 動態識別 cluster、指定最老 instance 當 master、跨版本升級用 federation 過渡。">3.C27 Zalando AWS master selection</a>——理解外部協調者怎麼被 Raft 內化、有助於判斷現代部署該把多少責任交給 broker、多少留給運維。</p>
<p>語義誤配在 partition 恢復時的具體告警條件見 <a href="/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9 Queue 語義切換誤配</a>——下游同時出現重複與遺漏、是 partition 恢復處置出錯的典型訊號。</p>
<p>回到上游：<a href="/blog/backend/03-message-queue/vendors/rabbitmq/" data-link-title="RabbitMQ" data-link-desc="Classic message broker、AMQP routing 為主">RabbitMQ overview</a> 的進階主題段列了 Erlang clustering 之外的 federation / shovel / Cluster Operator 議題；<a href="/blog/backend/03-message-queue/broker-basics/" data-link-title="3.1 broker 基礎與投遞模型" data-link-desc="先理解 broker、queue、consumer 與 delivery semantics">3.1 broker basics</a> 是 broker 通用概念的起點。</p>
]]></content:encoded></item><item><title>Redis 記憶體與淘汰調校：maxmemory-policy、LFU 與碎片化的實戰判讀</title><link>https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/redis/memory-eviction-tuning/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/redis/memory-eviction-tuning/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/redis/" data-link-title="Redis" data-link-desc="OSS in-memory data structure store、cache 主流">Redis&lt;/a> overview 的 implementation-layer deep article。選型層（Redis vs Valkey vs Memcached）見 overview；本文只處理「已經選了 Redis、記憶體怎麼配才不會在尖峰爆掉」。配置以 &lt;a href="https://redis.io/docs/latest/operate/oss_and_stack/management/optimization/memory-optimization/">Redis 官方 memory optimization 文件&lt;/a> 為準、最後檢查日 2026-06-16。&lt;/p>&lt;/blockquote>
&lt;h2 id="你的-redis-會在凌晨三點-oom">你的 Redis 會在凌晨三點 OOM&lt;/h2>
&lt;p>Redis 的記憶體問題很少在有人盯著儀表板時發生。它發生在流量爬升、某個 key 集合悄悄長大、AOF rewrite 剛好撞上 RDB save 的那個瞬間——通常是凌晨三點，沒人盯著。徵兆是 application 端突然一片 &lt;code>OOM command not allowed when used memory &amp;gt; 'maxmemory'&lt;/code>，所有寫入失敗，但讀取還活著，於是監控的「Redis 還在回應」綠燈騙過了 on-call。&lt;/p>
&lt;p>這類事故的根因幾乎都不是「Redis 不夠快」，而是三個記憶體旋鈕在設計時被當成預設值放著沒動：&lt;code>maxmemory&lt;/code> 設多少、&lt;code>maxmemory-policy&lt;/code> 選哪個、以及沒人注意到的記憶體碎片化。這三個旋鈕決定了 Redis 在記憶體壓力下是「優雅地淘汰冷資料繼續服務」還是「拒絕所有寫入直到有人重啟」。本文處理這三者的會計模型、選型判讀，以及它們怎麼被寫成事故。&lt;/p>
&lt;p>對延遲就是業務 KPI 的服務，這個旋鈕的代價更直接。&lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/tinder-elasticache-valkey-matching/" data-link-title="9.C6 Tinder：ElastiCache for Valkey 撐 4700 萬月活的配對引擎" data-link-desc="Tinder 用 Amazon ElastiCache for Valkey 提供配對引擎所需的次毫秒延遲快取層">Tinder 的配對引擎&lt;/a>每次滑動要查多個快取（profile、距離、偏好、推薦池），4700 萬月活下 cache 不是 DB 的補救、是主要服務面，cache miss 是邊緣案例。eviction policy 選錯，淘汰掉的若是熱資料，下一次滑動就打回 origin，sub-millisecond 的延遲預算瞬間破表。&lt;/p>
&lt;h2 id="核心概念redis-記憶體的會計模型">核心概念：Redis 記憶體的會計模型&lt;/h2>
&lt;p>要調校記憶體，先要分清楚 &lt;code>used_memory&lt;/code> 這個數字到底由什麼組成。&lt;code>INFO memory&lt;/code> 回報的是幾層疊加的記憶體會計，每一層去處不同：&lt;/p>
&lt;p>&lt;strong>&lt;code>used_memory&lt;/code>&lt;/strong> 是 Redis allocator（預設 jemalloc）配給資料、結構與 buffer 的總量。&lt;strong>&lt;code>used_memory_rss&lt;/code>&lt;/strong> 是作業系統視角看到的 Redis 進程實體記憶體（resident set size），它通常大於 &lt;code>used_memory&lt;/code>——兩者的比值就是 &lt;code>mem_fragmentation_ratio&lt;/code>。&lt;strong>&lt;code>used_memory_dataset&lt;/code>&lt;/strong> 才是純資料的部分，扣掉了 Redis 自身的 overhead。&lt;/p>
&lt;p>理解三個跟 OOM 直接相關的記憶體去處：&lt;/p>
&lt;p>&lt;strong>資料本身的編碼會放大或縮小記憶體&lt;/strong>。一個小 hash（field 數少於 &lt;code>hash-max-listpack-entries&lt;/code>、value 短於 &lt;code>hash-max-listpack-value&lt;/code>）用 listpack 緊湊編碼，記憶體可能只有大 hash 用 hashtable 編碼的幾分之一。同樣的邏輯套用在 list、set、sorted set。一個欄位設計的小決定（把 user object 拆成 200 個獨立 key 還是壓成一個 hash）會讓記憶體差好幾倍。&lt;/p>
&lt;p>&lt;strong>client output buffer 不計入 dataset 但會吃光記憶體&lt;/strong>。慢速 consumer、&lt;code>MONITOR&lt;/code>、大量 pub/sub 訂閱者都會讓 Redis 在 server 端堆積 reply buffer。&lt;code>client-output-buffer-limit&lt;/code> 沒設好，一個讀很慢的 replica 或一個掛著的 &lt;code>MONITOR&lt;/code> 連線就能把記憶體推到 maxmemory。&lt;/p>
&lt;p>&lt;strong>fork 期間記憶體會短暫翻倍&lt;/strong>。RDB save 與 AOF rewrite 都靠 &lt;code>fork()&lt;/code> + copy-on-write，父進程在 fork 後若持續寫入，被改動的 page 會被複製，最壞情況記憶體接近翻倍。這是 maxmemory 必須留 headroom 的核心原因，細節見 &lt;a href="https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/redis/persistence-fork-latency/" data-link-title="Redis 持久化與 fork latency：AOF、RDB 與那一次卡住整個 cluster 的 fork" data-link-desc="Redis 的 RDB save 與 AOF rewrite 都靠一次 fork()，而 fork 在大記憶體實例上會凍結主執行緒數百毫秒、複製分頁讓記憶體逼近翻倍。本文展開 AOF / RDB 的機制與 fsync 取捨、copy-on-write 的記憶體放大、5 個把持久化寫成延遲尖峰與資料遺失的 production 踩坑，以及 cache 場景到底要不要持久化的邊界">persistence 與 fork latency deep article&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/02-cache-redis/vendors/redis/" data-link-title="Redis" data-link-desc="OSS in-memory data structure store、cache 主流">Redis</a> overview 的 implementation-layer deep article。選型層（Redis vs Valkey vs Memcached）見 overview；本文只處理「已經選了 Redis、記憶體怎麼配才不會在尖峰爆掉」。配置以 <a href="https://redis.io/docs/latest/operate/oss_and_stack/management/optimization/memory-optimization/">Redis 官方 memory optimization 文件</a> 為準、最後檢查日 2026-06-16。</p></blockquote>
<h2 id="你的-redis-會在凌晨三點-oom">你的 Redis 會在凌晨三點 OOM</h2>
<p>Redis 的記憶體問題很少在有人盯著儀表板時發生。它發生在流量爬升、某個 key 集合悄悄長大、AOF rewrite 剛好撞上 RDB save 的那個瞬間——通常是凌晨三點，沒人盯著。徵兆是 application 端突然一片 <code>OOM command not allowed when used memory &gt; 'maxmemory'</code>，所有寫入失敗，但讀取還活著，於是監控的「Redis 還在回應」綠燈騙過了 on-call。</p>
<p>這類事故的根因幾乎都不是「Redis 不夠快」，而是三個記憶體旋鈕在設計時被當成預設值放著沒動：<code>maxmemory</code> 設多少、<code>maxmemory-policy</code> 選哪個、以及沒人注意到的記憶體碎片化。這三個旋鈕決定了 Redis 在記憶體壓力下是「優雅地淘汰冷資料繼續服務」還是「拒絕所有寫入直到有人重啟」。本文處理這三者的會計模型、選型判讀，以及它們怎麼被寫成事故。</p>
<p>對延遲就是業務 KPI 的服務，這個旋鈕的代價更直接。<a href="/blog/backend/09-performance-capacity/cases/tinder-elasticache-valkey-matching/" data-link-title="9.C6 Tinder：ElastiCache for Valkey 撐 4700 萬月活的配對引擎" data-link-desc="Tinder 用 Amazon ElastiCache for Valkey 提供配對引擎所需的次毫秒延遲快取層">Tinder 的配對引擎</a>每次滑動要查多個快取（profile、距離、偏好、推薦池），4700 萬月活下 cache 不是 DB 的補救、是主要服務面，cache miss 是邊緣案例。eviction policy 選錯，淘汰掉的若是熱資料，下一次滑動就打回 origin，sub-millisecond 的延遲預算瞬間破表。</p>
<h2 id="核心概念redis-記憶體的會計模型">核心概念：Redis 記憶體的會計模型</h2>
<p>要調校記憶體，先要分清楚 <code>used_memory</code> 這個數字到底由什麼組成。<code>INFO memory</code> 回報的是幾層疊加的記憶體會計，每一層去處不同：</p>
<p><strong><code>used_memory</code></strong> 是 Redis allocator（預設 jemalloc）配給資料、結構與 buffer 的總量。<strong><code>used_memory_rss</code></strong> 是作業系統視角看到的 Redis 進程實體記憶體（resident set size），它通常大於 <code>used_memory</code>——兩者的比值就是 <code>mem_fragmentation_ratio</code>。<strong><code>used_memory_dataset</code></strong> 才是純資料的部分，扣掉了 Redis 自身的 overhead。</p>
<p>理解三個跟 OOM 直接相關的記憶體去處：</p>
<p><strong>資料本身的編碼會放大或縮小記憶體</strong>。一個小 hash（field 數少於 <code>hash-max-listpack-entries</code>、value 短於 <code>hash-max-listpack-value</code>）用 listpack 緊湊編碼，記憶體可能只有大 hash 用 hashtable 編碼的幾分之一。同樣的邏輯套用在 list、set、sorted set。一個欄位設計的小決定（把 user object 拆成 200 個獨立 key 還是壓成一個 hash）會讓記憶體差好幾倍。</p>
<p><strong>client output buffer 不計入 dataset 但會吃光記憶體</strong>。慢速 consumer、<code>MONITOR</code>、大量 pub/sub 訂閱者都會讓 Redis 在 server 端堆積 reply buffer。<code>client-output-buffer-limit</code> 沒設好，一個讀很慢的 replica 或一個掛著的 <code>MONITOR</code> 連線就能把記憶體推到 maxmemory。</p>
<p><strong>fork 期間記憶體會短暫翻倍</strong>。RDB save 與 AOF rewrite 都靠 <code>fork()</code> + copy-on-write，父進程在 fork 後若持續寫入，被改動的 page 會被複製，最壞情況記憶體接近翻倍。這是 maxmemory 必須留 headroom 的核心原因，細節見 <a href="/blog/backend/02-cache-redis/vendors/redis/persistence-fork-latency/" data-link-title="Redis 持久化與 fork latency：AOF、RDB 與那一次卡住整個 cluster 的 fork" data-link-desc="Redis 的 RDB save 與 AOF rewrite 都靠一次 fork()，而 fork 在大記憶體實例上會凍結主執行緒數百毫秒、複製分頁讓記憶體逼近翻倍。本文展開 AOF / RDB 的機制與 fsync 取捨、copy-on-write 的記憶體放大、5 個把持久化寫成延遲尖峰與資料遺失的 production 踩坑，以及 cache 場景到底要不要持久化的邊界">persistence 與 fork latency deep article</a>。</p>
<p><code>maxmemory</code> 框住的是 <code>used_memory</code>，不是 <code>used_memory_rss</code>。所以 maxmemory 設成機器 RAM 的 100% 是錯的——碎片化、fork copy-on-write、client buffer 都在 maxmemory 之外，會把 RSS 推爆系統，觸發 Linux OOM killer 直接砍掉 Redis 進程（比 Redis 自己的 noeviction 更糟，因為是無預警 SIGKILL）。</p>
<h2 id="配置maxmemory-與-policy-的設定路徑">配置：maxmemory 與 policy 的設定路徑</h2>
<p>設定分兩步：先框住記憶體上限，再決定撞到上限時的行為。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 1. 設定記憶體上限（留 headroom 給 fork / fragmentation / client buffer）</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"># 機器 RAM 8GB → maxmemory 設 ~5-6GB、留 25-35% headroom</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">redis-cli CONFIG SET maxmemory 6gb
</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"><span class="c1"># 2. 設定撞到上限時的淘汰行為</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">redis-cli CONFIG SET maxmemory-policy allkeys-lfu
</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 class="c1"># 3. 永久化到 redis.conf（CONFIG SET 重啟後失效）</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"># redis.conf:</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1">#   maxmemory 6gb</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1">#   maxmemory-policy allkeys-lfu</span></span></span></code></pre></div><p>八個 <code>maxmemory-policy</code> 選項分三類，選型靠「資料是不是全部都能淘汰」與「淘汰要靠存取頻率還是 TTL」兩個問題：</p>
<table>
  <thead>
      <tr>
          <th>policy</th>
          <th>淘汰範圍</th>
          <th>淘汰依據</th>
          <th>適用場景</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>noeviction</code></td>
          <td>不淘汰</td>
          <td>寫入直接報錯</td>
          <td>資料是 source-of-truth、不能丟（少見）</td>
      </tr>
      <tr>
          <td><code>allkeys-lru</code></td>
          <td>所有 key</td>
          <td>最近最少使用</td>
          <td>純 cache、無法預判哪些該留</td>
      </tr>
      <tr>
          <td><code>allkeys-lfu</code></td>
          <td>所有 key</td>
          <td>最少使用頻率</td>
          <td>純 cache、有明顯熱資料（多數 cache 場景）</td>
      </tr>
      <tr>
          <td><code>allkeys-random</code></td>
          <td>所有 key</td>
          <td>隨機</td>
          <td>key 存取均勻、省 LRU/LFU 計算</td>
      </tr>
      <tr>
          <td><code>volatile-lru</code></td>
          <td>有 TTL 的 key</td>
          <td>最近最少使用</td>
          <td>cache 與持久資料混存、只淘汰可過期的</td>
      </tr>
      <tr>
          <td><code>volatile-lfu</code></td>
          <td>有 TTL 的 key</td>
          <td>最少使用頻率</td>
          <td>同上、有熱資料</td>
      </tr>
      <tr>
          <td><code>volatile-random</code></td>
          <td>有 TTL 的 key</td>
          <td>隨機</td>
          <td>同上、省計算</td>
      </tr>
      <tr>
          <td><code>volatile-ttl</code></td>
          <td>有 TTL 的 key</td>
          <td>最接近過期的先淘汰</td>
          <td>想讓近期過期的提早讓位</td>
      </tr>
  </tbody>
</table>
<h3 id="lru-跟-lfu-的真實差異">LRU 跟 LFU 的真實差異</h3>
<p><code>allkeys-lru</code> 跟 <code>allkeys-lfu</code> 看起來像同一件事的兩種寫法，但選錯會在特定 workload 下讓 hit rate 掉一截。LRU 看「最後一次被存取是多久以前」，LFU 看「被存取的頻率」。差別在一次性掃描（scan pollution）：某個批次任務一次讀過大量冷 key，LRU 會把這些剛被碰過的冷 key 排到淘汰隊伍最後面，反而把真正的熱 key 擠出去。LFU 因為看頻率，一次性的存取不會讓冷 key 假裝成熱 key。</p>
<p>Redis 4.0 後的 LFU 用的是 probabilistic counter（Morris counter）加 decay，不是精確計數，靠兩個參數調：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># lfu-log-factor：counter 增長的對數速度、越大越能區分高頻 key</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">redis-cli CONFIG SET lfu-log-factor <span class="m">10</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"># lfu-decay-time：counter 衰減的分鐘數、越小越快遺忘舊熱度</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">redis-cli CONFIG SET lfu-decay-time <span class="m">1</span></span></span></code></pre></div><p>對 <a href="/blog/backend/09-performance-capacity/cases/tinder-elasticache-valkey-matching/" data-link-title="9.C6 Tinder：ElastiCache for Valkey 撐 4700 萬月活的配對引擎" data-link-desc="Tinder 用 Amazon ElastiCache for Valkey 提供配對引擎所需的次毫秒延遲快取層">Tinder 這類有明顯熱資料</a>（熱門 profile、熱區域推薦池）的服務，<code>allkeys-lfu</code> 比 <code>allkeys-lru</code> 更能保護熱 key 不被批次掃描或冷流量擠出。</p>
<h3 id="approximate-eviction-的取樣">approximate eviction 的取樣</h3>
<p>Redis 的 LRU/LFU 都是近似演算法，不掃全 keyspace，而是每次取樣 <code>maxmemory-samples</code> 個 key（預設 5）挑最該淘汰的。樣本數越大越接近精確 LRU/LFU，但越吃 CPU。記憶體壓力大、淘汰頻繁時，預設 5 已夠；要更精準可調到 10，代價是淘汰路徑的 CPU 上升。</p>
<h2 id="production-故障演練">Production 故障演練</h2>
<h3 id="case-1noeviction-讓寫入全滅讀取假裝健康">Case 1：noeviction 讓寫入全滅、讀取假裝健康</h3>
<p><strong>徵兆</strong>：application 寫入路徑大量 <code>OOM command not allowed when used memory &gt; 'maxmemory'</code>，但 <code>GET</code> 仍正常、health check（通常打 <code>PING</code> 或 <code>GET</code>）綠燈，on-call 收到的是 application 層的 500、不是 Redis 告警。</p>
<p><strong>根因</strong>：<code>maxmemory-policy</code> 預設是 <code>noeviction</code>。當 Redis 把 cache 當 cache 用，但 policy 留在 <code>noeviction</code>，記憶體一滿，所有會增加記憶體的命令（<code>SET</code>、<code>LPUSH</code>、<code>HSET</code>）直接報錯，唯讀命令照常。health check 若只測讀取，完全偵測不到。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>純 cache 場景把 policy 改成 <code>allkeys-lru</code> 或 <code>allkeys-lfu</code>，讓記憶體壓力自動透過淘汰釋放</li>
<li>health check 加一個寫入探針（<code>SET healthcheck:probe &lt;ts&gt; EX 10</code>），讓 OOM 寫入失敗能被偵測</li>
<li>告警掛在 <code>used_memory / maxmemory &gt; 0.85</code>，不要等 OOM 才反應</li>
<li>若資料真的不能淘汰（誤把 Redis 當 source-of-truth），那不該用 cache 配置，見本文 Capacity / cost 邊界段的路由判斷</li>
</ol>
<h3 id="case-2碎片化吃掉-30-記憶體">Case 2：碎片化吃掉 30% 記憶體</h3>
<p><strong>徵兆</strong>：<code>used_memory</code> 顯示 4GB、但 <code>used_memory_rss</code> 是 5.5GB，<code>mem_fragmentation_ratio</code> 是 1.37，機器 RAM 開始吃緊但資料量沒漲。重啟 Redis 後 RSS 掉回 4GB 出頭。</p>
<p><strong>根因</strong>：大量寫入後刪除、或 value 大小頻繁變動（例如 list 一直 push/pop），jemalloc 的記憶體頁出現空洞——配出去的 page 還佔著 RSS，但裡面只有零星資料。<code>mem_fragmentation_ratio</code> 持續 &gt; 1.5 是明確訊號。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>
<p>開 active defrag 讓 Redis 在背景整理（4.0+）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">redis-cli CONFIG SET activedefrag yes
</span></span><span class="line"><span class="ln">2</span><span class="cl">redis-cli CONFIG SET active-defrag-ignore-bytes 100mb
</span></span><span class="line"><span class="ln">3</span><span class="cl">redis-cli CONFIG SET active-defrag-threshold-lower <span class="m">10</span></span></span></code></pre></div></li>
<li>
<p>fragmentation_ratio &lt; 1.0 是另一種警訊——代表 Redis 在用 swap，比碎片化更危險，要立刻降記憶體壓力</p>
</li>
<li>
<p>結構選擇上避免大幅波動的 collection；穩態大小的資料碎片化天然較低</p>
</li>
<li>
<p>計算 maxmemory headroom 時把 1.2-1.4 的 fragmentation 算進去</p>
</li>
</ol>
<h3 id="case-3一個-monitor-連線把記憶體推爆">Case 3：一個 MONITOR 連線把記憶體推爆</h3>
<p><strong>徵兆</strong>：某次 debug 後記憶體莫名持續上升，<code>used_memory_dataset</code> 沒變但 <code>used_memory</code> 一直漲，<code>CLIENT LIST</code> 看到一個連線的 <code>omem</code>（output buffer memory）有幾百 MB。</p>
<p><strong>根因</strong>：有人開了 <code>MONITOR</code> 去看即時命令流、然後忘了關（或 client crash 但連線沒斷）。<code>MONITOR</code> 把每一條命令都推給該連線，高 QPS 下 server 端 output buffer 爆量堆積，計入 <code>used_memory</code> 但不在 dataset。慢速 replica 或大量 pub/sub 訂閱者也會觸發同類問題。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>
<p>設定 client output buffer 上限，超過就斷線：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># normal client / replica / pubsub 分開設</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">redis-cli CONFIG SET client-output-buffer-limit <span class="s2">&#34;normal 256mb 64mb 60&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">redis-cli CONFIG SET client-output-buffer-limit <span class="s2">&#34;pubsub 32mb 8mb 60&#34;</span></span></span></code></pre></div></li>
<li>
<p><code>MONITOR</code> 在 production 嚴格禁用或限時，它本身也拖慢整個 server</p>
</li>
<li>
<p>監控加 <code>CLIENT LIST</code> 的 <code>omem</code> 巡檢，找出異常 buffer 的連線</p>
</li>
<li>
<p>replica lag 過大時 output buffer 會堆，對應 <a href="/blog/backend/02-cache-redis/vendors/redis/sentinel-ha-failover/" data-link-title="Redis Sentinel 與 failover 時序：從 master 死掉到 client 重連的每一段" data-link-desc="Redis Sentinel 的 failover 不是一個瞬間動作，是 down 偵測 → quorum 確認 → 選主 → 提升 → 配置廣播 → client 重連的一條時序鏈，每一段都有自己的延遲與失敗模式。本文展開 Sentinel 的判定模型與這條時序、5 個讓 failover 卡住或丟資料的 production 踩坑，以及 Sentinel 撐不住該往 Cluster 或 managed 走的邊界">replication / failover deep article</a></p>
</li>
</ol>
<h3 id="case-4欄位設計讓記憶體多用三倍">Case 4：欄位設計讓記憶體多用三倍</h3>
<p><strong>徵兆</strong>：資料筆數跟預估一致，但 <code>used_memory</code> 是試算的 3 倍。<code>MEMORY USAGE &lt;key&gt;</code> 抽樣發現單筆 object 的記憶體遠超 value 本身的 byte 數。</p>
<p><strong>根因</strong>：把一個有 10 個欄位的 user object 拆成 10 個獨立 string key（<code>user:123:name</code>、<code>user:123:age</code>&hellip;），每個 key 都帶 Redis 的 key overhead（dict entry、expire dict entry、key 字串本身）。10 個 key 的 overhead 是一個 hash 的好幾倍。反過來，超過 <code>hash-max-listpack-entries</code> 的大 hash 從緊湊的 listpack 退化成 hashtable 編碼，也會放大記憶體。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>
<p>同一 entity 的欄位用一個 hash 存，共享 key overhead</p>
</li>
<li>
<p>保持 hash 在 listpack 閾值內以用緊湊編碼：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">redis-cli CONFIG GET hash-max-listpack-entries  <span class="c1"># 預設 128</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">redis-cli CONFIG GET hash-max-listpack-value    <span class="c1"># 預設 64</span></span></span></code></pre></div></li>
<li>
<p>用 <code>MEMORY USAGE &lt;key&gt;</code> 跟 <code>redis-cli --bigkeys</code> 抽樣驗證實際記憶體，不靠試算</p>
</li>
<li>
<p><a href="/blog/backend/02-cache-redis/cases/shopify-cache-serialization-migration/" data-link-title="2.C3 Shopify：快取序列化格式遷移" data-link-desc="快取 payload 從 Marshal 轉 MessagePack 的遷移策略。">Shopify 的 serialization 遷移</a>（Marshal → MessagePack）正是用更省的編碼壓 payload，欄位編碼決策對記憶體與頻寬同時有效</p>
</li>
</ol>
<h3 id="case-5淘汰把熱-key-一起帶走hit-rate-崩">Case 5：淘汰把熱 key 一起帶走、hit rate 崩</h3>
<p><strong>徵兆</strong>：記憶體壓力下開始 eviction（<code>evicted_keys</code> 持續上升），同時 <code>keyspace_hits / (hits + misses)</code> 從 95% 掉到 70%，origin QPS 跟著飆，下游 DB 開始吃緊。</p>
<p><strong>根因</strong>：用了 <code>allkeys-random</code>，或 <code>allkeys-lru</code> 撞上批次掃描污染，淘汰演算法把熱 key 跟冷 key 一視同仁，熱 key 被淘汰後下一個請求 miss、回源、再寫回，形成淘汰與回填的拉鋸，hit rate 持續惡化。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>有明顯熱資料就用 <code>allkeys-lfu</code>，讓頻率高的 key 留下</li>
<li>把 maxmemory-samples 調到 10 提高淘汰精準度</li>
<li>根因常是記憶體真的不夠——<code>evicted_keys</code> 持續高代表 working set 超過 maxmemory，該擴容或分片，不是純調 policy 能解</li>
<li>熱 key 本身過熱（單 key QPS 遠超其他）要走 local cache + Redis 兩層，對應 <a href="/blog/backend/02-cache-redis/high-concurrency-access/" data-link-title="2.1 高併發下的 Redis 讀寫邊界" data-link-desc="說明高併發服務如何共用 Redis client、控制 pipeline 與避免 cache stampede">2.6 high concurrency</a></li>
</ol>
<h2 id="capacity--cost-邊界">Capacity / cost 邊界</h2>
<p>記憶體配置的容量判讀，核心是「working set 對 maxmemory 的比值」與「淘汰是否健康」：</p>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>健康區間</th>
          <th>警戒與動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>used_memory / maxmemory</code></td>
          <td>&lt; 80%</td>
          <td>&gt; 85% 告警、&gt; 95% 接近 OOM 或大量淘汰</td>
      </tr>
      <tr>
          <td><code>mem_fragmentation_ratio</code></td>
          <td>1.0 - 1.5</td>
          <td>&gt; 1.5 開 active defrag、&lt; 1.0 在用 swap 要救火</td>
      </tr>
      <tr>
          <td><code>evicted_keys</code> 速率</td>
          <td>接近 0（working set 放得下）</td>
          <td>持續高 → working set 超量、該擴容 / 分片</td>
      </tr>
      <tr>
          <td>hit rate</td>
          <td>&gt; 90%（多數 cache）</td>
          <td>持續下滑 → 淘汰太兇或 TTL 太短</td>
      </tr>
      <tr>
          <td>fork 期間 RSS 峰值</td>
          <td>&lt; 機器 RAM</td>
          <td>接近 RAM → maxmemory headroom 不足、降 maxmemory</td>
      </tr>
  </tbody>
</table>
<p>撞牆後的路由判斷：</p>
<ul>
<li><strong>單機記憶體不夠、working set 持續超量</strong>：垂直擴容（換更大記憶體機型）是第一步，但有單機上限。超過後走 <a href="/blog/backend/02-cache-redis/vendors/redis/cluster-resharding/" data-link-title="Redis Cluster Re-sharding：source = target，但 topology 重劃的 5 段流程" data-link-desc="Redis cluster re-sharding 是 5 type migration 漏類實證 — source / target 同 cluster、無 schema / paradigm 差、但 16384 slot 重分配是核心；本文涵蓋 4 種 re-sharding driver、slot migration 機制、redis-cli --cluster rebalance / reshard 工具、5 個 production 踩雷（cluster busy / replica lag / client cache stale / cross-slot transaction / monitor gap）">Redis Cluster 分片</a>，把 keyspace 切到多 node。</li>
<li><strong>想用 Redis API 但要極致單機記憶體效率</strong>：<a href="/blog/backend/02-cache-redis/vendors/dragonflydb/" data-link-title="DragonflyDB" data-link-desc="高效能 Redis / Memcached 相容替代、多核架構">DragonflyDB</a> 的 dashtable 在同 dataset 下通常比 Redis 省 20-40% 記憶體（依資料形狀、以官方 benchmark 為準），且單機多核能撐到 Redis 要靠 cluster 才能達到的規模——若 cluster re-sharding 頻繁觸發，評估直接遷 DragonflyDB 是否更省維運。</li>
<li><strong>資料其實不能淘汰（被當 source-of-truth）</strong>：那它不是 cache，該走 durable store。AWS 生態下用 <a href="/blog/backend/02-cache-redis/vendors/aws-elasticache/" data-link-title="AWS ElastiCache" data-link-desc="AWS managed Redis / Valkey / Memcached">MemoryDB</a>（Redis-compatible durable），或把正式狀態放回 <a href="/blog/backend/01-database/" data-link-title="模組一：資料庫與持久化" data-link-desc="整理 SQL、transaction、migration 與 repository adapter 的後端實務">database 模組</a>。</li>
</ul>
<h2 id="整合--下一步">整合 / 下一步</h2>
<p>記憶體與淘汰是 Redis 運維的第一層旋鈕，但它跟其他子系統耦合：</p>
<ul>
<li><strong>跟 <a href="/blog/backend/02-cache-redis/vendors/redis/persistence-fork-latency/" data-link-title="Redis 持久化與 fork latency：AOF、RDB 與那一次卡住整個 cluster 的 fork" data-link-desc="Redis 的 RDB save 與 AOF rewrite 都靠一次 fork()，而 fork 在大記憶體實例上會凍結主執行緒數百毫秒、複製分頁讓記憶體逼近翻倍。本文展開 AOF / RDB 的機制與 fsync 取捨、copy-on-write 的記憶體放大、5 個把持久化寫成延遲尖峰與資料遺失的 production 踩坑，以及 cache 場景到底要不要持久化的邊界">persistence / fork latency</a></strong>：fork 期間的 copy-on-write 是 maxmemory headroom 的主要消耗者，記憶體調校跟持久化調校必須一起看。</li>
<li><strong>跟 <a href="/blog/backend/02-cache-redis/ttl-eviction/" data-link-title="2.3 TTL 與 eviction" data-link-desc="整理過期策略、容量控制與熱點資料">TTL 與 eviction 概念</a></strong>：TTL 設計決定哪些 key 帶過期時間，直接影響 <code>volatile-*</code> policy 的淘汰範圍。</li>
<li><strong>跟 <a href="/blog/backend/02-cache-redis/cases/failure-cache-stampede-rollout-regression/" data-link-title="2.C9 反例：快取切換引發 Stampede 回歸" data-link-desc="快取策略切換若缺乏保護，會導致回源壓力與錯誤率連鎖上升。">cache stampede</a></strong>：大量 key 同時被淘汰或同時過期會引發回源雪崩，eviction 調校要跟 TTL jitter / singleflight 一起設計。</li>
<li><strong>跟 <a href="/blog/backend/09-performance-capacity/cases/tubi-elasticache-ml-feature-store/" data-link-title="9.C25 Tubi：從 ScyllaDB 遷到 ElastiCache、ML feature store 達 sub-10ms p99" data-link-desc="Tubi 把 ML 推薦的 feature store 從 ScyllaDB 遷到 ElastiCache for Redis、99 百分位延遲降到 10ms 以下">Tubi 的 cache vs durable 選型</a></strong>：Tubi 把 ML feature store 從 ScyllaDB 遷到 ElastiCache，前提是「feature 可重新計算」——這個判斷決定了 eviction 是可接受的，記憶體調校才有意義。資料若不可重建，問題不在淘汰 policy，在選錯了儲存層。</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li>上游 vendor 頁：<a href="/blog/backend/02-cache-redis/vendors/redis/" data-link-title="Redis" data-link-desc="OSS in-memory data structure store、cache 主流">Redis</a></li>
<li>同 vendor deep article：<a href="/blog/backend/02-cache-redis/vendors/redis/persistence-fork-latency/" data-link-title="Redis 持久化與 fork latency：AOF、RDB 與那一次卡住整個 cluster 的 fork" data-link-desc="Redis 的 RDB save 與 AOF rewrite 都靠一次 fork()，而 fork 在大記憶體實例上會凍結主執行緒數百毫秒、複製分頁讓記憶體逼近翻倍。本文展開 AOF / RDB 的機制與 fsync 取捨、copy-on-write 的記憶體放大、5 個把持久化寫成延遲尖峰與資料遺失的 production 踩坑，以及 cache 場景到底要不要持久化的邊界">persistence 與 fork latency</a>、<a href="/blog/backend/02-cache-redis/vendors/redis/cluster-resharding/" data-link-title="Redis Cluster Re-sharding：source = target，但 topology 重劃的 5 段流程" data-link-desc="Redis cluster re-sharding 是 5 type migration 漏類實證 — source / target 同 cluster、無 schema / paradigm 差、但 16384 slot 重分配是核心；本文涵蓋 4 種 re-sharding driver、slot migration 機制、redis-cli --cluster rebalance / reshard 工具、5 個 production 踩雷（cluster busy / replica lag / client cache stale / cross-slot transaction / monitor gap）">Cluster re-sharding</a></li>
<li>上游概念：<a href="/blog/backend/02-cache-redis/ttl-eviction/" data-link-title="2.3 TTL 與 eviction" data-link-desc="整理過期策略、容量控制與熱點資料">2.3 TTL 與 eviction</a></li>
<li>Methodology：<a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章寫作方法論</a></li>
</ul>
]]></content:encoded></item><item><title>MySQL ProxySQL 配置：connection / query / route / response 四段 lifecycle 跟 query rule 設計</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/proxysql-config/</link><pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/proxysql-config/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/" data-link-title="MySQL" data-link-desc="高併發網路服務常用關聯式資料庫、Vitess / PlanetScale 分片生態、GitHub / Shopify / Facebook 規模驗證">MySQL&lt;/a> overview 的 implementation-layer deep article。Overview 已說明 MySQL 在 OLTP 譜系的定位、本文聚焦 &lt;em>ProxySQL 配置&lt;/em> — connection pool + query routing 的 4 段 lifecycle 跟 rule chain 設計。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="proxysql-lifecycle每個-query-走-4-段">ProxySQL Lifecycle：每個 query 走 4 段&lt;/h2>
&lt;p>從 application 連 ProxySQL 到拿到 response、每個 query 都走完整 4 段：&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">1. Connection 接入 → application connect 到 ProxySQL（不是 MySQL）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">2. Query parse + rule match → ProxySQL 解析 query、match query rule chain
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">3. Backend route → 決定走哪個 hostgroup（primary / replica）+ 哪個 server
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">4. Response 返回 → 將 result set 回 application、connection 可被 reuse&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>每段都有獨立配置 + failure mode + 觀測 metric。ProxySQL 不是 &lt;em>簡單的 connection pool&lt;/em>、是 &lt;em>query-aware proxy&lt;/em> — 看得到 SQL 內容才能做 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/read-write-split/" data-link-title="Read-Write Split" data-link-desc="說明讀寫流量如何分流到 primary 與 replica，以及它引入的一致性責任">read/write split&lt;/a>、replica lag-aware routing、query mirroring。&lt;/p>
&lt;p>跟 &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）跟容量規劃">PostgreSQL pgBouncer&lt;/a> 比、pgBouncer 是 &lt;em>transaction-level pool&lt;/em>（只看連線、不看 SQL）、ProxySQL 是 &lt;em>query-level proxy&lt;/em>（看 SQL、做 routing decision）。能力不同、target use case 不同。&lt;/p>
&lt;h2 id="stage-1connection-接入--hostgroup--server--user-三層-schema">Stage 1：Connection 接入 — Hostgroup / Server / User 三層 schema&lt;/h2>
&lt;p>ProxySQL 不直接 expose backend MySQL、用 &lt;em>hostgroup&lt;/em> 作為 routing 抽象。Application 不知道有幾個 backend、只知道 ProxySQL。&lt;/p>
&lt;p>&lt;strong>核心 table（在 &lt;code>main&lt;/code> database）&lt;/strong>：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Table&lt;/th>
 &lt;th>角色&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>mysql_servers&lt;/code>&lt;/td>
 &lt;td>列每個 backend MySQL server、屬於哪個 hostgroup&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>mysql_replication_hostgroups&lt;/code>&lt;/td>
 &lt;td>定義 writer hostgroup ↔ reader hostgroup 配對、自動偵測 primary 切換&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>mysql_users&lt;/code>&lt;/td>
 &lt;td>列允許連 ProxySQL 的 application user、預設 hostgroup&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>mysql_query_rules&lt;/code>&lt;/td>
 &lt;td>Query rule chain、決定哪個 query 走哪個 hostgroup&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>典型部署&lt;/strong>：&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/mysql/" data-link-title="MySQL" data-link-desc="高併發網路服務常用關聯式資料庫、Vitess / PlanetScale 分片生態、GitHub / Shopify / Facebook 規模驗證">MySQL</a> overview 的 implementation-layer deep article。Overview 已說明 MySQL 在 OLTP 譜系的定位、本文聚焦 <em>ProxySQL 配置</em> — connection pool + query routing 的 4 段 lifecycle 跟 rule chain 設計。</p></blockquote>
<hr>
<h2 id="proxysql-lifecycle每個-query-走-4-段">ProxySQL Lifecycle：每個 query 走 4 段</h2>
<p>從 application 連 ProxySQL 到拿到 response、每個 query 都走完整 4 段：</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">1. Connection 接入        →  application connect 到 ProxySQL（不是 MySQL）
</span></span><span class="line"><span class="ln">2</span><span class="cl">2. Query parse + rule match  → ProxySQL 解析 query、match query rule chain
</span></span><span class="line"><span class="ln">3</span><span class="cl">3. Backend route          →  決定走哪個 hostgroup（primary / replica）+ 哪個 server
</span></span><span class="line"><span class="ln">4</span><span class="cl">4. Response 返回          →  將 result set 回 application、connection 可被 reuse</span></span></code></pre></div><p>每段都有獨立配置 + failure mode + 觀測 metric。ProxySQL 不是 <em>簡單的 connection pool</em>、是 <em>query-aware proxy</em> — 看得到 SQL 內容才能做 <a href="/blog/backend/knowledge-cards/read-write-split/" data-link-title="Read-Write Split" data-link-desc="說明讀寫流量如何分流到 primary 與 replica，以及它引入的一致性責任">read/write split</a>、replica lag-aware routing、query mirroring。</p>
<p>跟 <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）跟容量規劃">PostgreSQL pgBouncer</a> 比、pgBouncer 是 <em>transaction-level pool</em>（只看連線、不看 SQL）、ProxySQL 是 <em>query-level proxy</em>（看 SQL、做 routing decision）。能力不同、target use case 不同。</p>
<h2 id="stage-1connection-接入--hostgroup--server--user-三層-schema">Stage 1：Connection 接入 — Hostgroup / Server / User 三層 schema</h2>
<p>ProxySQL 不直接 expose backend MySQL、用 <em>hostgroup</em> 作為 routing 抽象。Application 不知道有幾個 backend、只知道 ProxySQL。</p>
<p><strong>核心 table（在 <code>main</code> database）</strong>：</p>
<table>
  <thead>
      <tr>
          <th>Table</th>
          <th>角色</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>mysql_servers</code></td>
          <td>列每個 backend MySQL server、屬於哪個 hostgroup</td>
      </tr>
      <tr>
          <td><code>mysql_replication_hostgroups</code></td>
          <td>定義 writer hostgroup ↔ reader hostgroup 配對、自動偵測 primary 切換</td>
      </tr>
      <tr>
          <td><code>mysql_users</code></td>
          <td>列允許連 ProxySQL 的 application user、預設 hostgroup</td>
      </tr>
      <tr>
          <td><code>mysql_query_rules</code></td>
          <td>Query rule chain、決定哪個 query 走哪個 hostgroup</td>
      </tr>
  </tbody>
</table>
<p><strong>典型部署</strong>：</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">-- 進 ProxySQL admin (6032 port)
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="n">mysql</span><span class="w"> </span><span class="o">-</span><span class="n">uadmin</span><span class="w"> </span><span class="o">-</span><span class="n">padmin</span><span class="w"> </span><span class="o">-</span><span class="n">h127</span><span class="p">.</span><span class="mi">0</span><span class="p">.</span><span class="mi">0</span><span class="p">.</span><span class="mi">1</span><span class="w"> </span><span class="o">-</span><span class="n">P6032</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w"></span><span class="c1">-- 設 2 個 hostgroup：10=writer、20=reader
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="n">mysql_servers</span><span class="p">(</span><span class="n">hostgroup_id</span><span class="p">,</span><span class="w"> </span><span class="n">hostname</span><span class="p">,</span><span class="w"> </span><span class="n">port</span><span class="p">,</span><span class="w"> </span><span class="n">weight</span><span class="p">,</span><span class="w"> </span><span class="n">max_connections</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="k">VALUES</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">  </span><span class="p">(</span><span class="mi">10</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;primary.example.com&#39;</span><span class="p">,</span><span class="w"> </span><span class="mi">3306</span><span class="p">,</span><span class="w"> </span><span class="mi">1000</span><span class="p">,</span><span class="w"> </span><span class="mi">200</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="p">(</span><span class="mi">20</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;replica1.example.com&#39;</span><span class="p">,</span><span class="w"> </span><span class="mi">3306</span><span class="p">,</span><span class="w"> </span><span class="mi">1000</span><span class="p">,</span><span class="w"> </span><span class="mi">100</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="p">(</span><span class="mi">20</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;replica2.example.com&#39;</span><span class="p">,</span><span class="w"> </span><span class="mi">3306</span><span class="p">,</span><span class="w"> </span><span class="mi">1000</span><span class="p">,</span><span class="w"> </span><span class="mi">100</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></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w"></span><span class="c1">-- 自動偵測 primary（用 read_only flag）
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="n">mysql_replication_hostgroups</span><span class="p">(</span><span class="n">writer_hostgroup</span><span class="p">,</span><span class="w"> </span><span class="n">reader_hostgroup</span><span class="p">,</span><span class="w"> </span><span class="k">comment</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="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="mi">10</span><span class="p">,</span><span class="w"> </span><span class="mi">20</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;production cluster&#39;</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></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w"></span><span class="c1">-- 設 application user、預設走 reader（保守）
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="c1"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="n">mysql_users</span><span class="p">(</span><span class="n">username</span><span class="p">,</span><span class="w"> </span><span class="n">password</span><span class="p">,</span><span class="w"> </span><span class="n">default_hostgroup</span><span class="p">,</span><span class="w"> </span><span class="n">max_connections</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">VALUES</span><span class="w"> </span><span class="p">(</span><span class="s1">&#39;app&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;app_password&#39;</span><span class="p">,</span><span class="w"> </span><span class="mi">20</span><span class="p">,</span><span class="w"> </span><span class="mi">1000</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></span><span class="line"><span class="ln">19</span><span class="cl"><span class="w"></span><span class="c1">-- 套用設定到 runtime
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="c1"></span><span class="k">LOAD</span><span class="w"> </span><span class="n">MYSQL</span><span class="w"> </span><span class="n">SERVERS</span><span class="w"> </span><span class="k">TO</span><span class="w"> </span><span class="n">RUNTIME</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="w"></span><span class="k">LOAD</span><span class="w"> </span><span class="n">MYSQL</span><span class="w"> </span><span class="n">USERS</span><span class="w"> </span><span class="k">TO</span><span class="w"> </span><span class="n">RUNTIME</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="w"></span><span class="c1">-- 持久化到 disk（重啟保留）
</span></span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="c1"></span><span class="n">SAVE</span><span class="w"> </span><span class="n">MYSQL</span><span class="w"> </span><span class="n">SERVERS</span><span class="w"> </span><span class="k">TO</span><span class="w"> </span><span class="n">DISK</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="n">SAVE</span><span class="w"> </span><span class="n">MYSQL</span><span class="w"> </span><span class="n">USERS</span><span class="w"> </span><span class="k">TO</span><span class="w"> </span><span class="n">DISK</span><span class="p">;</span></span></span></code></pre></div><p>注意 ProxySQL 的 <em>三層 state</em>：<code>disk</code>（持久化）→ <code>memory</code>（編輯區）→ <code>runtime</code>（實際運作）。每次改完要 <code>LOAD ... TO RUNTIME</code> 才生效、<code>SAVE ... TO DISK</code> 才能 reboot 保留。沒 <code>SAVE</code> 重啟後 config 消失是新手最常踩的雷。</p>
<h2 id="stage-2query-parse--rule-match--query-rule-engine">Stage 2：Query Parse + Rule Match — query rule engine</h2>
<p>ProxySQL 不只 forward connection、看 <em>SQL 內容</em> 決定怎麼 route。Query rule 是 <em>ordered chain</em>、match 第一個符合的 rule。</p>
<p><strong>Query rule 核心欄位</strong>：</p>
<table>
  <thead>
      <tr>
          <th>欄位</th>
          <th>意義</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>rule_id</code></td>
          <td>排序（越小越先 match）</td>
      </tr>
      <tr>
          <td><code>match_pattern</code></td>
          <td>regex 比對 SQL（支援 <code>^SELECT</code> / <code>FOR UPDATE</code> 等）</td>
      </tr>
      <tr>
          <td><code>destination_hostgroup</code></td>
          <td>match 後送哪個 hostgroup</td>
      </tr>
      <tr>
          <td><code>apply</code></td>
          <td>match 後是否停 chain（1=stop、0=繼續看後面 rule）</td>
      </tr>
      <tr>
          <td><code>cache_ttl</code></td>
          <td>result cache TTL（毫秒）— ProxySQL 內建 query cache</td>
      </tr>
      <tr>
          <td><code>mirror_hostgroup</code></td>
          <td>query 鏡像送到第二個 hostgroup（不等 response、用於 shadow test）</td>
      </tr>
  </tbody>
</table>
<p><strong>典型讀寫分離 rule</strong>：</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">-- Rule 100: SELECT ... FOR UPDATE 必須走 primary
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="n">mysql_query_rules</span><span class="p">(</span><span class="n">rule_id</span><span class="p">,</span><span class="w"> </span><span class="n">active</span><span class="p">,</span><span class="w"> </span><span class="n">match_pattern</span><span class="p">,</span><span class="w"> </span><span class="n">destination_hostgroup</span><span class="p">,</span><span class="w"> </span><span class="n">apply</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="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="mi">100</span><span class="p">,</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;^SELECT.*FOR UPDATE$&#39;</span><span class="p">,</span><span class="w"> </span><span class="mi">10</span><span class="p">,</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></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w"></span><span class="c1">-- Rule 200: 一般 SELECT 走 replica（reader）
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="n">mysql_query_rules</span><span class="p">(</span><span class="n">rule_id</span><span class="p">,</span><span class="w"> </span><span class="n">active</span><span class="p">,</span><span class="w"> </span><span class="n">match_pattern</span><span class="p">,</span><span class="w"> </span><span class="n">destination_hostgroup</span><span class="p">,</span><span class="w"> </span><span class="n">apply</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">VALUES</span><span class="w"> </span><span class="p">(</span><span class="mi">200</span><span class="p">,</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;^SELECT&#39;</span><span class="p">,</span><span class="w"> </span><span class="mi">20</span><span class="p">,</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"> 8</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w"></span><span class="c1">-- Rule 300: BEGIN / START TRANSACTION 走 primary
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="n">mysql_query_rules</span><span class="p">(</span><span class="n">rule_id</span><span class="p">,</span><span class="w"> </span><span class="n">active</span><span class="p">,</span><span class="w"> </span><span class="n">match_pattern</span><span class="p">,</span><span class="w"> </span><span class="n">destination_hostgroup</span><span class="p">,</span><span class="w"> </span><span class="n">apply</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="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="mi">300</span><span class="p">,</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;^(BEGIN|START TRANSACTION)&#39;</span><span class="p">,</span><span class="w"> </span><span class="mi">10</span><span class="p">,</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">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">-- 其他（INSERT / UPDATE / DELETE）預設走 default_hostgroup（user 設的）
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1">-- application user default 設 10 (writer)、所以寫入自動走 primary
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="c1"></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">LOAD</span><span class="w"> </span><span class="n">MYSQL</span><span class="w"> </span><span class="n">QUERY</span><span class="w"> </span><span class="n">RULES</span><span class="w"> </span><span class="k">TO</span><span class="w"> </span><span class="n">RUNTIME</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">SAVE</span><span class="w"> </span><span class="n">MYSQL</span><span class="w"> </span><span class="n">QUERY</span><span class="w"> </span><span class="n">RULES</span><span class="w"> </span><span class="k">TO</span><span class="w"> </span><span class="n">DISK</span><span class="p">;</span></span></span></code></pre></div><p><strong>Rule 順序很重要</strong>：<code>rule_id</code> 100 先 match、200 再 match、依此類推。Rule 200 比 100 寬鬆（任何 SELECT）、所以 <code>FOR UPDATE</code> 必須先 match rule 100 才不會誤送 replica。</p>
<h2 id="stage-3backend-route--replica-lag-aware--circuit-breaker">Stage 3：Backend Route — replica lag-aware + circuit breaker</h2>
<p>Rule match 後 ProxySQL 從 hostgroup 內挑一個 server。Backend selection 不是 pure round-robin、考慮：</p>
<ul>
<li><em>Weight</em>：每個 server <code>weight</code> 比例分配（典型用於 replica capacity 不同）</li>
<li><em>Replica lag</em>：若 hostgroup 設 <code>max_replication_lag</code>、lag 超過 threshold 的 replica 自動暫時退出</li>
<li><em>Connection count</em>：避免某個 server connection 滿</li>
<li><em>Server status</em>：<code>mysql_servers.status</code> (ONLINE / SHUNNED / OFFLINE_SOFT / OFFLINE_HARD) 決定是否可用</li>
</ul>
<p><strong>Replica lag-aware routing 配置</strong>：</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">-- 給整個 reader hostgroup 設 lag threshold
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">UPDATE</span><span class="w"> </span><span class="n">mysql_servers</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">SET</span><span class="w"> </span><span class="n">max_replication_lag</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">5</span><span class="w">  </span><span class="c1">-- 秒
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="k">WHERE</span><span class="w"> </span><span class="n">hostgroup_id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">20</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></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w"></span><span class="k">LOAD</span><span class="w"> </span><span class="n">MYSQL</span><span class="w"> </span><span class="n">SERVERS</span><span class="w"> </span><span class="k">TO</span><span class="w"> </span><span class="n">RUNTIME</span><span class="p">;</span></span></span></code></pre></div><p>ProxySQL 內部用 <em>monitor module</em> 定期跑 <code>SHOW SLAVE STATUS</code>、lag 超過 5 秒 → 該 replica 暫時退出 reader hostgroup。讀 query 自動避開 lagging replica。</p>
<p><strong>Circuit breaker（自動 shun）</strong>：server 連續失敗 → ProxySQL 自動 <code>SHUNNED</code>、避免持續打 broken server。但 <em>application 層仍要處理 retry</em>、ProxySQL 不保證 query 100% 成功。</p>
<h2 id="stage-4response-返回--connection-multiplexing">Stage 4：Response 返回 — connection multiplexing</h2>
<p>ProxySQL 對 application connection 跟 backend connection 是 <em>N:M 多工</em>：</p>
<ul>
<li>Application connection 跟 ProxySQL 1:1</li>
<li>ProxySQL 跟 backend MySQL connection 共用 pool（multiplexing）</li>
</ul>
<p><strong>Multiplexing 條件</strong>：</p>
<ul>
<li>Transaction 內：connection 綁定特定 backend（保 transaction atomicity）</li>
<li>跨 transaction：connection 可以換 backend</li>
<li><code>SET</code> statement 改 session variable：connection 黏死 backend（防 session state leak）</li>
<li>User variable（<code>@var</code>）：connection 黏死 backend</li>
</ul>
<p><strong>結果</strong>：application 看到的是「自己有 1000 個 connection」、ProxySQL 後端可能只有 100 connection 到 MySQL。對 connection-bound MySQL（max_connections 限制）是關鍵 cost saving。</p>
<h2 id="5-個-production-踩雷">5 個 Production 踩雷</h2>
<h3 id="1-query-rule-順序錯亂--for-update-被-select-route-到-replica">1. Query rule 順序錯亂 — <code>FOR UPDATE</code> 被 SELECT route 到 replica</h3>
<p>Rule 200（<code>^SELECT</code>）寫在 rule 100（<code>^SELECT.*FOR UPDATE$</code>）之前、ProxySQL match 第一個 rule（rule 200）就停、<code>SELECT ... FOR UPDATE</code> 被送 replica、replica 沒 lock、application 假設有 lock 跑 race condition。</p>
<p>修法：</p>
<ul>
<li><code>rule_id</code> 排序：精確 rule（多條件 regex）放小、寬鬆 rule 放大</li>
<li>用 <code>apply=1</code> 強制停 chain、不要讓 query 繼續往下 match</li>
<li>跑 ProxySQL <code>SHOW PROCESSLIST</code> + audit log 確認 routing 正確</li>
</ul>
<h3 id="2-connection-漂移--multiplexing-把-session-variable-弄丟">2. Connection 漂移 — Multiplexing 把 session variable 弄丟</h3>
<p>Application 跑 <code>SET sql_mode=...</code>、ProxySQL 把這 connection 暫時黏死 backend 1。下個 query ProxySQL forget、把 connection unstick、實際 forward 到 backend 2（沒 <code>SET sql_mode</code>）、SQL 解析行為不同、application bug。</p>
<p>修法：</p>
<ul>
<li>用 <code>mysql-multiplexing=false</code> 全 disable（最簡單但浪費 connection pool 效率）</li>
<li>或在 application init 連線後跑的 <code>SET</code> 全列在 <code>mysql_users.connect_init</code>（每個 connection ProxySQL 自動跑、不會漂移）</li>
<li>避免 application 中途改 session variable、改成全部走 ProxySQL connect_init</li>
</ul>
<h3 id="3-write-不小心-route-到-replica--default_hostgroup-設錯">3. Write 不小心 route 到 replica — <code>default_hostgroup</code> 設錯</h3>
<p>Application user <code>default_hostgroup</code> 設 20 (reader)、INSERT / UPDATE / DELETE 沒 match 到任何 rule（沒寫 catch-all write rule）、走 default → 送 replica → replica 是 read-only → error。或更糟：replica 不是 read-only mode、寫入 <em>寫到 replica 上</em>、replication 反向不同步、data corruption。</p>
<p>修法：</p>
<ul>
<li>Application user <code>default_hostgroup</code> 設 10 (writer) — 寫入預設走 primary</li>
<li>Replica MySQL 一定要 <code>read_only=1</code>（防 stale write 寫到 replica）</li>
<li>監控 <code>mysql_query_rules</code> match 率、寫入 query 應該大部分透過 default_hostgroup 路由、不是個別 rule</li>
</ul>
<h3 id="4-runtime--disk-schema-drift--改了-runtime-沒-save重啟-config-消失">4. Runtime / disk schema drift — 改了 runtime 沒 save、重啟 config 消失</h3>
<p><code>LOAD ... TO RUNTIME</code> 跟 <code>SAVE ... TO DISK</code> 是兩個獨立操作。On-call 在事故中改 ProxySQL 配置（add server、調 query rule）、<code>LOAD</code> 套到 runtime 但忘記 <code>SAVE</code>、隔天 ProxySQL 重啟（OS update / crash）、config 回到 disk 版本、半夜 alert。</p>
<p>修法：</p>
<ul>
<li>每次 <code>LOAD ... TO RUNTIME</code> 後立刻 <code>SAVE ... TO DISK</code>（變成 habit）</li>
<li>用 IaC（Terraform / Ansible）管 ProxySQL config、不要手動改 admin</li>
<li>監控：對比 <code>runtime_mysql_servers</code> 跟 <code>mysql_servers</code>（disk）、有 diff 即告警</li>
</ul>
<h3 id="5-mirror-traffic-副作用--insert-鏡像到-staging-寫了兩次">5. Mirror traffic 副作用 — INSERT 鏡像到 staging 寫了兩次</h3>
<p><code>mirror_hostgroup</code> 把 query 鏡像送到第二個 hostgroup（不等 response、用於 shadow test 新 schema）。但 <em>鏡像是真實執行</em>、不是 dry-run。鏡像 INSERT 到 staging hostgroup → staging 真的多了 row。如果 staging hostgroup 接到 production 表（誤接）、production 寫入 doubled。</p>
<p>修法：</p>
<ul>
<li>Mirror 只用於 <em>獨立 staging cluster</em>、不混用 production schema</li>
<li>Mirror 設定要 review（規則 <code>match_pattern</code> 跟 <code>mirror_hostgroup</code> 配對）</li>
<li>開 mirror 前在 staging 跑 dry-run、確認 schema 跟 production isolated</li>
</ul>
<h2 id="容量規劃要點">容量規劃要點</h2>
<p>對 100 application instance × 50 connection / instance = 5000 application connection 場景：</p>
<table>
  <thead>
      <tr>
          <th>配置</th>
          <th>ProxySQL 設定</th>
          <th>MySQL backend 配置</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Application → ProxySQL</td>
          <td><code>mysql-max_connections=10000</code></td>
          <td>不影響</td>
      </tr>
      <tr>
          <td>ProxySQL → MySQL primary</td>
          <td><code>max_connections=200</code>（per server）</td>
          <td>MySQL <code>max_connections=300</code>（多 100 buffer for admin）</td>
      </tr>
      <tr>
          <td>ProxySQL → MySQL replica</td>
          <td><code>max_connections=200</code>（per server）</td>
          <td>同上</td>
      </tr>
      <tr>
          <td>ProxySQL 數量（HA）</td>
          <td>至少 2 instance（HAProxy / VIP）</td>
          <td>-</td>
      </tr>
      <tr>
          <td>Memory per ProxySQL</td>
          <td>2-4 GB（query rule cache + connection pool）</td>
          <td>-</td>
      </tr>
  </tbody>
</table>
<p>ProxySQL 本身需要 HA：放兩個 instance 後面接 VIP（keepalived）或 HAProxy。Application 連 VIP / HAProxy、不直接連 ProxySQL hostname（單點失效）。</p>
<h2 id="跟其他模組整合">跟其他模組整合</h2>
<h3 id="跟-replication-topology">跟 Replication topology</h3>
<p>ProxySQL 透過 <em>monitor module</em> 自動偵測 primary（檢查 <code>read_only</code> flag）+ replica lag（檢查 <code>Seconds_Behind_Master</code>）。這個 monitor 依賴 MySQL replication 已配好（GTID + binlog ROW format）。詳見 <a href="/blog/backend/01-database/vendors/mysql/replication-topology/" data-link-title="MySQL Replication Topology：async / semi-sync / GTID 不是三選一、是三個 trade-off 軸的疊加" data-link-desc="MySQL replication 不是「選 async 還是 semi-sync」、是 *durability / latency / consistency* 三個 trade-off 軸的疊加；GTID 是跨 mode 的 infrastructure layer、不是第三種 mode。本文走 3 軸取捨模型 → async / semi-sync 行為對比 → GTID 替代 binlog-position 的好處 → 配置 step-by-step → 5 production 踩雷（lag 暴衝 / semi-sync 退回 async / GTID gap / Loss-Less semi-sync 真的 loss-less / chained replication 雪崩）→ 跟 Aurora MySQL / Vitess / ProxySQL / Orchestrator 整合">Replication Topology</a>。</p>
<h3 id="跟-orchestrator-ha">跟 Orchestrator HA</h3>
<p>Orchestrator 自動 failover 後新 primary 的 <code>read_only</code> flag 變 0、舊 primary 變 1。ProxySQL monitor 偵測到、自動把 hostgroup 10（writer）的 server 切換、application 不必改 connection string。</p>
<p>詳見 <em>Orchestrator failover 設計</em> 篇（待寫）。</p>
<h3 id="跟-osc-toolgh-ost--pt-osc">跟 OSC tool（gh-ost / pt-osc）</h3>
<p>ProxySQL 可以 <em>暫時 throttle</em> application 對某張表的寫入（query rule <code>delay</code> 欄位）、配合 OSC tool cut-over 時段降低 metadata lock 衝突。</p>
<p>詳見 <a href="/blog/backend/01-database/vendors/mysql/online-schema-change-tools/" data-link-title="MySQL Online Schema Change：gh-ost 跟 pt-online-schema-change 兩條完全不同的 ghost table 路徑" data-link-desc="MySQL ALTER TABLE 可能鎖整張表，production 需要 online schema change 流程。gh-ost（GitHub）跟 pt-online-schema-change（Percona）都用 ghost table 解決、但底層機制完全不同：pt-osc 用 trigger 同步、gh-ost 用 binlog stream 同步。本文走兩工具機制對照表 → trigger vs binlog 各自取捨 → 配置 step-by-step → 5 production 踩雷（trigger overhead / binlog 延遲 / FK constraint / hot trigger lock / 切換瞬間 deadlock）→ 何時用哪一個">Online Schema Change Tools</a>。</p>
<h3 id="跟-aurora-mysql--rds-proxy">跟 Aurora MySQL / RDS Proxy</h3>
<p>Aurora MySQL 推 <em>RDS Proxy</em>（AWS managed proxy）取代 ProxySQL — 跟 IAM 整合、failover &lt; 30 秒。但 RDS Proxy <em>沒有 query routing rule engine</em>（只做 connection pool）、不能讀寫分離。Aurora user 仍可能用 ProxySQL 在前面、再用 RDS Proxy 作 backend connection pool。</p>
<p>詳見 <a href="/blog/backend/01-database/vendors/aurora/" data-link-title="AWS Aurora" data-link-desc="AWS managed PostgreSQL / MySQL、storage / compute 分離、&#43;75% 效能改善的 production 證據">Aurora vendor page</a>。</p>
<h3 id="跟-postgresql-pgbouncer-對比">跟 PostgreSQL pgBouncer 對比</h3>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>ProxySQL（MySQL）</th>
          <th>pgBouncer（PostgreSQL）</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>抽象層</td>
          <td>Query-level proxy</td>
          <td>Transaction-level pool</td>
      </tr>
      <tr>
          <td>Query routing</td>
          <td>內建（rule engine）</td>
          <td>無（不看 SQL）</td>
      </tr>
      <tr>
          <td>Connection pool</td>
          <td>內建</td>
          <td>核心功能</td>
      </tr>
      <tr>
          <td>Read/write split</td>
          <td>內建（自動 + rule）</td>
          <td>要 application 層或 HAProxy 配</td>
      </tr>
      <tr>
          <td>Replica lag-aware</td>
          <td>內建</td>
          <td>無</td>
      </tr>
      <tr>
          <td>Query cache</td>
          <td>內建</td>
          <td>無</td>
      </tr>
  </tbody>
</table>
<p>ProxySQL 是 <em>query 層中介</em>、pgBouncer 是 <em>connection 層中介</em>。詳見 <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 配置</a>。</p>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/mysql/" data-link-title="MySQL" data-link-desc="高併發網路服務常用關聯式資料庫、Vitess / PlanetScale 分片生態、GitHub / Shopify / Facebook 規模驗證">MySQL vendor overview</a></li>
<li><a href="/blog/backend/01-database/vendors/mysql/replication-topology/" data-link-title="MySQL Replication Topology：async / semi-sync / GTID 不是三選一、是三個 trade-off 軸的疊加" data-link-desc="MySQL replication 不是「選 async 還是 semi-sync」、是 *durability / latency / consistency* 三個 trade-off 軸的疊加；GTID 是跨 mode 的 infrastructure layer、不是第三種 mode。本文走 3 軸取捨模型 → async / semi-sync 行為對比 → GTID 替代 binlog-position 的好處 → 配置 step-by-step → 5 production 踩雷（lag 暴衝 / semi-sync 退回 async / GTID gap / Loss-Less semi-sync 真的 loss-less / chained replication 雪崩）→ 跟 Aurora MySQL / Vitess / ProxySQL / Orchestrator 整合">MySQL Replication Topology</a>（read replica routing 前提）</li>
<li><a href="/blog/backend/01-database/vendors/mysql/online-schema-change-tools/" data-link-title="MySQL Online Schema Change：gh-ost 跟 pt-online-schema-change 兩條完全不同的 ghost table 路徑" data-link-desc="MySQL ALTER TABLE 可能鎖整張表，production 需要 online schema change 流程。gh-ost（GitHub）跟 pt-online-schema-change（Percona）都用 ghost table 解決、但底層機制完全不同：pt-osc 用 trigger 同步、gh-ost 用 binlog stream 同步。本文走兩工具機制對照表 → trigger vs binlog 各自取捨 → 配置 step-by-step → 5 production 踩雷（trigger overhead / binlog 延遲 / FK constraint / hot trigger lock / 切換瞬間 deadlock）→ 何時用哪一個">MySQL Online Schema Change Tools</a>（OSC + ProxySQL throttle 整合）</li>
<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）跟容量規劃">PostgreSQL pgBouncer</a>（PG sibling、不同抽象層）</li>
<li><a href="/blog/backend/01-database/vendors/aurora/" data-link-title="AWS Aurora" data-link-desc="AWS managed PostgreSQL / MySQL、storage / compute 分離、&#43;75% 效能改善的 production 證據">Aurora vendor page</a>（RDS Proxy + ProxySQL 取捨）</li>
<li><a href="/blog/backend/knowledge-cards/connection-pool/" data-link-title="Connection Pool" data-link-desc="說明連線池如何限制下游資源並影響服務容量">Connection Pool 卡片</a></li>
<li>官方：<a href="https://proxysql.com/documentation/">ProxySQL Documentation</a></li>
</ul>
]]></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><item><title>Kafka Multi-tenant 治理：quota 限流、ACL 授權與 topic 生命週期</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/multi-tenant-quota-acl/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/multi-tenant-quota-acl/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Apache Kafka&lt;/a> overview「Multi-tenant 與配額治理」「Topic 生命週期治理」兩段的 implementation-layer deep article。Overview 說明這些議題對應哪些案例跟子議題、本文展開具體的 quota / ACL 配置、授權模型推導、故障徵兆與修法。&lt;/p>&lt;/blockquote>
&lt;h2 id="共享叢集的治理問題一個叢集多個互不信任的租戶">共享叢集的治理問題：一個叢集、多個互不信任的租戶&lt;/h2>
&lt;p>Multi-tenant Kafka 的核心問題是把一個物理叢集切成多個彼此隔離的邏輯空間、讓每個團隊用同一組 broker 卻不互相干擾。當 Kafka 從單一團隊的工具長成全公司的事件總線、叢集承載的不再是一條 pipeline、而是數十到數百個團隊的 producer 跟 consumer。這時叢集的瓶頸從「broker 夠不夠快」轉成「怎麼防止某個團隊的流量、權限、或 topic 失控波及其他所有人」。&lt;/p>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/uber-kafka-infrastructure-evolution/" data-link-title="3.C6 Uber：Kafka 事件平台演進" data-link-desc="事件平台從團隊自管走向多租戶共享基礎設施。">Uber 的 Kafka 平台演進&lt;/a>把這個轉換描述為「從單隊列問題提升到平台治理問題」。當事件平台服務眾多團隊、重點是配額、隔離、觀測與運維標準化、而非只擴 broker。擴 broker 解決的是總容量、解決不了「單一租戶吃光共享資源」這類隔離問題。&lt;/p>
&lt;p>共享叢集的治理分三個獨立的軸、各自處理不同的失控來源：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>治理軸&lt;/th>
 &lt;th>防的是什麼&lt;/th>
 &lt;th>工具&lt;/th>
 &lt;th>失控後果&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Quota（資源配額）&lt;/td>
 &lt;td>單租戶吃滿頻寬 / request 容量、餓死其他租戶&lt;/td>
 &lt;td>&lt;code>kafka-configs.sh&lt;/code> 設 byte rate&lt;/td>
 &lt;td>鄰居 producer 寫入卡死、consumer lag&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>ACL（存取授權）&lt;/td>
 &lt;td>租戶讀寫不屬於自己的 topic、或被未授權方寫入&lt;/td>
 &lt;td>&lt;code>kafka-acls.sh&lt;/code> + broker authorizer&lt;/td>
 &lt;td>資料外洩、跨租戶污染、誤刪 topic&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>生命週期（治理）&lt;/td>
 &lt;td>死 topic 累積、partition 數爆炸壓垮 metadata 面&lt;/td>
 &lt;td>命名規範 + 活躍判準 + 自動回收&lt;/td>
 &lt;td>controller 變慢、rebalance 風暴&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>三軸正交：quota 設好不代表權限對、ACL 鎖好不代表 topic 不會爆炸。下面逐軸展開、每軸都對應 production 踩過的失控場景。本文 quota 與 ACL 操作以 Kafka 4.2.0（KRaft 模式、&lt;code>apache/kafka:latest&lt;/code>）實機驗證。&lt;/p>
&lt;h2 id="quota把頻寬與-request-容量切給租戶">Quota：把頻寬與 request 容量切給租戶&lt;/h2>
&lt;p>Quota 是 broker 端對 client 的流量上限、由 broker 在超限時主動 throttle（延遲回應）而非拒絕、讓單一租戶無法把共享頻寬吃光。Kafka 的 quota 是 broker-side 強制、不依賴 client 自律 —— 即使 client 不配合、broker 也會在回應裡插入 throttle 延遲、把該 client 的有效吞吐壓回配額內。&lt;/p>
&lt;h3 id="三類-quota-度量">三類 quota 度量&lt;/h3>
&lt;p>Kafka quota 度量三種資源、對應三類飽和：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Quota 鍵&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;code>producer_byte_rate&lt;/code>&lt;/td>
 &lt;td>bytes/sec&lt;/td>
 &lt;td>單一 client 每秒寫入 broker 的 bytes&lt;/td>
 &lt;td>寫入端 network / disk I/O 飽和&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>consumer_byte_rate&lt;/code>&lt;/td>
 &lt;td>bytes/sec&lt;/td>
 &lt;td>單一 client 每秒從 broker 讀取的 bytes&lt;/td>
 &lt;td>讀取端 network 飽和、fan-out 過大&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>request_percentage&lt;/code>&lt;/td>
 &lt;td>百分比&lt;/td>
 &lt;td>單一 client 佔用 broker request handler 的 CPU 時間&lt;/td>
 &lt;td>broker CPU 飽和、小訊息高頻請求&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>前兩個 byte rate 防的是頻寬類飽和、適合「大訊息、穩定流量」的租戶。&lt;code>request_percentage&lt;/code> 防的是另一種失控 —— 某租戶送大量極小的 request（例如每筆一個 byte、每秒幾萬筆）、byte rate 看起來很低、卻把 broker 的 request handler thread 佔滿。這種「請求數爆炸但流量不大」的攻擊型 pattern 只有 &lt;code>request_percentage&lt;/code> 抓得到。一個 broker 預設有 N 個 request handler thread、&lt;code>request_percentage=200&lt;/code> 代表允許該 client 用掉 2 條 thread 的時間（100% = 1 條）。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Apache Kafka</a> overview「Multi-tenant 與配額治理」「Topic 生命週期治理」兩段的 implementation-layer deep article。Overview 說明這些議題對應哪些案例跟子議題、本文展開具體的 quota / ACL 配置、授權模型推導、故障徵兆與修法。</p></blockquote>
<h2 id="共享叢集的治理問題一個叢集多個互不信任的租戶">共享叢集的治理問題：一個叢集、多個互不信任的租戶</h2>
<p>Multi-tenant Kafka 的核心問題是把一個物理叢集切成多個彼此隔離的邏輯空間、讓每個團隊用同一組 broker 卻不互相干擾。當 Kafka 從單一團隊的工具長成全公司的事件總線、叢集承載的不再是一條 pipeline、而是數十到數百個團隊的 producer 跟 consumer。這時叢集的瓶頸從「broker 夠不夠快」轉成「怎麼防止某個團隊的流量、權限、或 topic 失控波及其他所有人」。</p>
<p><a href="/blog/backend/03-message-queue/cases/uber-kafka-infrastructure-evolution/" data-link-title="3.C6 Uber：Kafka 事件平台演進" data-link-desc="事件平台從團隊自管走向多租戶共享基礎設施。">Uber 的 Kafka 平台演進</a>把這個轉換描述為「從單隊列問題提升到平台治理問題」。當事件平台服務眾多團隊、重點是配額、隔離、觀測與運維標準化、而非只擴 broker。擴 broker 解決的是總容量、解決不了「單一租戶吃光共享資源」這類隔離問題。</p>
<p>共享叢集的治理分三個獨立的軸、各自處理不同的失控來源：</p>
<table>
  <thead>
      <tr>
          <th>治理軸</th>
          <th>防的是什麼</th>
          <th>工具</th>
          <th>失控後果</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Quota（資源配額）</td>
          <td>單租戶吃滿頻寬 / request 容量、餓死其他租戶</td>
          <td><code>kafka-configs.sh</code> 設 byte rate</td>
          <td>鄰居 producer 寫入卡死、consumer lag</td>
      </tr>
      <tr>
          <td>ACL（存取授權）</td>
          <td>租戶讀寫不屬於自己的 topic、或被未授權方寫入</td>
          <td><code>kafka-acls.sh</code> + broker authorizer</td>
          <td>資料外洩、跨租戶污染、誤刪 topic</td>
      </tr>
      <tr>
          <td>生命週期（治理）</td>
          <td>死 topic 累積、partition 數爆炸壓垮 metadata 面</td>
          <td>命名規範 + 活躍判準 + 自動回收</td>
          <td>controller 變慢、rebalance 風暴</td>
      </tr>
  </tbody>
</table>
<p>三軸正交：quota 設好不代表權限對、ACL 鎖好不代表 topic 不會爆炸。下面逐軸展開、每軸都對應 production 踩過的失控場景。本文 quota 與 ACL 操作以 Kafka 4.2.0（KRaft 模式、<code>apache/kafka:latest</code>）實機驗證。</p>
<h2 id="quota把頻寬與-request-容量切給租戶">Quota：把頻寬與 request 容量切給租戶</h2>
<p>Quota 是 broker 端對 client 的流量上限、由 broker 在超限時主動 throttle（延遲回應）而非拒絕、讓單一租戶無法把共享頻寬吃光。Kafka 的 quota 是 broker-side 強制、不依賴 client 自律 —— 即使 client 不配合、broker 也會在回應裡插入 throttle 延遲、把該 client 的有效吞吐壓回配額內。</p>
<h3 id="三類-quota-度量">三類 quota 度量</h3>
<p>Kafka quota 度量三種資源、對應三類飽和：</p>
<table>
  <thead>
      <tr>
          <th>Quota 鍵</th>
          <th>單位</th>
          <th>限制對象</th>
          <th>飽和訊號</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>producer_byte_rate</code></td>
          <td>bytes/sec</td>
          <td>單一 client 每秒寫入 broker 的 bytes</td>
          <td>寫入端 network / disk I/O 飽和</td>
      </tr>
      <tr>
          <td><code>consumer_byte_rate</code></td>
          <td>bytes/sec</td>
          <td>單一 client 每秒從 broker 讀取的 bytes</td>
          <td>讀取端 network 飽和、fan-out 過大</td>
      </tr>
      <tr>
          <td><code>request_percentage</code></td>
          <td>百分比</td>
          <td>單一 client 佔用 broker request handler 的 CPU 時間</td>
          <td>broker CPU 飽和、小訊息高頻請求</td>
      </tr>
  </tbody>
</table>
<p>前兩個 byte rate 防的是頻寬類飽和、適合「大訊息、穩定流量」的租戶。<code>request_percentage</code> 防的是另一種失控 —— 某租戶送大量極小的 request（例如每筆一個 byte、每秒幾萬筆）、byte rate 看起來很低、卻把 broker 的 request handler thread 佔滿。這種「請求數爆炸但流量不大」的攻擊型 pattern 只有 <code>request_percentage</code> 抓得到。一個 broker 預設有 N 個 request handler thread、<code>request_percentage=200</code> 代表允許該 client 用掉 2 條 thread 的時間（100% = 1 條）。</p>
<h3 id="三種套用層級">三種套用層級</h3>
<p>Quota 可以套在三種 entity 上、精度遞增：</p>
<table>
  <thead>
      <tr>
          <th>套用層級</th>
          <th>entity 指定</th>
          <th>適用情境</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>client-id</td>
          <td><code>--entity-type clients --entity-name &lt;id&gt;</code></td>
          <td>沒有認證、用 client.id 區分服務</td>
      </tr>
      <tr>
          <td>user</td>
          <td><code>--entity-type users --entity-name &lt;user&gt;</code></td>
          <td>有 SASL 認證、整個租戶共用一個 quota</td>
      </tr>
      <tr>
          <td>user + client-id</td>
          <td>兩個 entity 同時指定</td>
          <td>同租戶內不同服務分別配額（最細）</td>
      </tr>
  </tbody>
</table>
<p>層級的選擇取決於認證模型。沒開認證的叢集只能用 client-id —— 但 client.id 由 client 自行宣告、可偽造、只適合內部信任環境的粗略區分。開了 SASL 認證後、user 才是可信的租戶邊界、quota 綁 user 才有隔離意義。最細的 user + client-id 組合用在「同一個租戶內、batch 匯入服務跟即時 API 服務要分開限流」這種情境：整個 billing 租戶有一個總配額、但裡面的 <code>batch-importer</code> 再單獨壓低、避免夜間批次把租戶配額吃光、害同租戶的即時服務沒頻寬。</p>
<h3 id="設定與查詢實機驗證">設定與查詢（實機驗證）</h3>
<p>設 client-id 層級、同時給 producer 跟 consumer byte rate：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">kafka-configs.sh --bootstrap-server localhost:9092 --alter <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --add-config <span class="s1">&#39;producer_byte_rate=1048576,consumer_byte_rate=2097152&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --entity-type clients --entity-name svc-orders
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># Completed updating config for client svc-orders.</span></span></span></code></pre></div><p>設 user 層級、含 <code>request_percentage</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">kafka-configs.sh --bootstrap-server localhost:9092 --alter <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --add-config <span class="s1">&#39;producer_byte_rate=5242880,consumer_byte_rate=10485760,request_percentage=200&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --entity-type users --entity-name tenant-billing
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># Completed updating config for user tenant-billing.</span></span></span></code></pre></div><p>設 user + client-id 組合層級（同租戶內單獨壓低 batch 服務）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">kafka-configs.sh --bootstrap-server localhost:9092 --alter <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --add-config <span class="s1">&#39;producer_byte_rate=524288&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --entity-type users --entity-name tenant-billing <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --entity-type clients --entity-name batch-importer
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># Completed updating config for user tenant-billing.</span></span></span></code></pre></div><p>查詢時 entity 指定要對齊設定時的層級。查 user 層級：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">kafka-configs.sh --bootstrap-server localhost:9092 --describe <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --entity-type users --entity-name tenant-billing
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"># Quota configs for user-principal &#39;tenant-billing&#39; are</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">#   consumer_byte_rate=1.048576E7, request_percentage=200.0, producer_byte_rate=5242880.0</span></span></span></code></pre></div><p>組合層級要兩個 entity 都帶、否則查不到：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">kafka-configs.sh --bootstrap-server localhost:9092 --describe <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --entity-type users --entity-name tenant-billing <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --entity-type clients --entity-name batch-importer
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># Quota configs for user-principal &#39;tenant-billing&#39;, client-id &#39;batch-importer&#39; are</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1">#   producer_byte_rate=524288.0</span></span></span></code></pre></div><p>不帶 <code>--entity-name</code> 而只給 <code>--entity-type clients</code> 會列出所有 client-id 層級的 quota、適合稽核整個叢集的 quota 分布。</p>
<h2 id="acl把存取權限綁到-principal">ACL：把存取權限綁到 principal</h2>
<p>ACL 是 broker 對每個操作的授權檢查、把「誰（principal）能對什麼資源（resource）做什麼操作（operation）從哪裡來（host）」綁成一條規則、broker 在每次 produce / fetch / admin 操作前比對。Quota 管的是「用多少」、ACL 管的是「能不能用」—— 兩者正交、quota 不限制權限、ACL 不限制流量。</p>
<h3 id="授權模型四要素">授權模型四要素</h3>
<p>一條 ACL 由四個維度構成、四個維度交集才決定一次操作是否放行：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>含義</th>
          <th>範例值</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>principal</td>
          <td>操作的發起身分</td>
          <td><code>User:svc-orders</code></td>
      </tr>
      <tr>
          <td>resource</td>
          <td>被操作的對象（type + name + pattern）</td>
          <td>topic <code>orders.events</code>、group <code>fulfillment-workers</code></td>
      </tr>
      <tr>
          <td>operation</td>
          <td>動作</td>
          <td><code>Write</code> / <code>Read</code> / <code>Describe</code> / <code>All</code></td>
      </tr>
      <tr>
          <td>host</td>
          <td>來源 IP（<code>*</code> 為不限）</td>
          <td><code>10.0.3.21</code></td>
      </tr>
  </tbody>
</table>
<p>resource 的 pattern type 是隔離設計的關鍵：<code>LITERAL</code> 精確匹配單一資源名、<code>PREFIXED</code> 匹配整個前綴。多租戶的 topic 隔離靠 prefixed ACL 加命名規範 —— 給 <code>tenant-billing</code> 一條 <code>billing.</code> 前綴的 <code>All</code> 權限、它就能自由管理所有 <code>billing.</code> 開頭的 topic、卻碰不到 <code>orders.</code> 或別租戶的命名空間。命名規範在這裡不只是整潔、是授權邊界本身。</p>
<p>operation 的選擇要對齊角色。一個 producer 需要 topic 的 <code>Write</code> 跟 <code>Describe</code>（描述 partition metadata）；一個 consumer 需要 topic 的 <code>Read</code> <code>Describe</code> 加上 consumer group 的 <code>Read</code> <code>Describe</code>（commit offset 要對 group 有權）。漏掉 group 的 ACL 是常見錯誤：consumer 能讀到訊息、卻 commit 不了 offset、表現成不斷重複消費。</p>
<h3 id="kraft-的-standardauthorizer">KRaft 的 StandardAuthorizer</h3>
<p>ACL 的儲存與判定由 broker 的 authorizer 負責。KRaft 模式用 <code>org.apache.kafka.metadata.authorizer.StandardAuthorizer</code>、ACL 存在 metadata log（取代 ZooKeeper 時代的 <code>AclAuthorizer</code> 把 ACL 存在 ZK）。預設的 <code>apache/kafka</code> 容器不開 authorizer —— 不開時所有操作放行、ACL 指令也無從生效。啟用需要在 broker 設三項：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-properties" data-lang="properties"><span class="line"><span class="ln">1</span><span class="cl"><span class="na">authorizer.class.name</span><span class="o">=</span><span class="s">org.apache.kafka.metadata.authorizer.StandardAuthorizer</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="na">super.users</span><span class="o">=</span><span class="s">User:admin</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="na">allow.everyone.if.no.acl.found</span><span class="o">=</span><span class="s">false</span></span></span></code></pre></div><p><code>super.users</code> 列出繞過所有 ACL 檢查的管理身分、用來開機跟救援；少了它、開 authorizer 後第一個操作就會把自己鎖在外面。<code>allow.everyone.if.no.acl.found=false</code> 是隔離的前提 —— 設 <code>true</code> 時「沒有任何 ACL 的資源對所有人開放」、等於 deny-list 模式、漏設一個 topic 就全公司可讀。多租戶必須走 <code>false</code> 的 allow-list 模式：預設拒絕、明確授權才放行。</p>
<blockquote>
<p>本文 ACL 操作以實機驗證：用上述三項 env（<code>KAFKA_AUTHORIZER_CLASS_NAME</code> / <code>KAFKA_SUPER_USERS='User:ANONYMOUS'</code> / <code>KAFKA_ALLOW_EVERYONE_IF_NO_ACL_FOUND=false</code>）配完整 KRaft single-node 設定起容器、PLAINTEXT 連線的 principal 為 <code>User:ANONYMOUS</code>、設為 super user 後即可用 <code>kafka-acls.sh</code> 操作。</p></blockquote>
<h3 id="acl-配置實機驗證">ACL 配置（實機驗證）</h3>
<p>給 producer 對單一 topic 的 write + describe：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">kafka-acls.sh --bootstrap-server localhost:9092 --add <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --allow-principal User:svc-orders <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --operation Write --operation Describe <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --topic orders.events</span></span></code></pre></div><p>給 consumer topic 的 read + describe、外加 consumer group 的權限（一條指令同時建兩個 resource 的 ACL）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">kafka-acls.sh --bootstrap-server localhost:9092 --add <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --allow-principal User:svc-fulfillment <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --operation Read --operation Describe <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --topic orders.events <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  --group fulfillment-workers</span></span></code></pre></div><p>prefixed ACL 把整個命名空間授權給一個租戶：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">kafka-acls.sh --bootstrap-server localhost:9092 --add <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --allow-principal User:tenant-billing <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --operation All <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --resource-pattern-type prefixed <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  --topic billing.
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"># Adding ACLs for resource</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1">#   `ResourcePattern(resourceType=TOPIC, name=billing., patternType=PREFIXED)`</span></span></span></code></pre></div><p>host 限制把同一 principal 的權限綁到特定來源 IP：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">kafka-acls.sh --bootstrap-server localhost:9092 --add <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --allow-principal User:svc-orders <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --allow-host 10.0.3.21 <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --operation Write <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  --topic orders.events</span></span></code></pre></div><p>deny 規則的優先序高於 allow —— 同一 principal 即使有 allow、命中 deny 就拒絕。用來在大範圍 allow（如 prefixed <code>All</code>）之上挖一個例外：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">kafka-acls.sh --bootstrap-server localhost:9092 --add <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --deny-principal User:svc-orders <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --deny-host 10.0.9.99 <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --operation Write <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  --topic orders.events</span></span></code></pre></div><p>列出特定 topic 的全部 ACL、用於稽核：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">kafka-acls.sh --bootstrap-server localhost:9092 --list --topic orders.events</span></span></code></pre></div><h2 id="topic-生命週期治理命名ownership-與回收">Topic 生命週期治理：命名、ownership 與回收</h2>
<p>Topic 生命週期治理把「topic 的建立、歸屬、淘汰」變成有規則的流程、避免死 topic 累積與 partition 數爆炸壓垮叢集的 metadata 面。Kafka 的每個 partition 都是 controller 要追蹤的 metadata 單位；topic 只增不減時、partition 總數隨團隊數線性成長、最終 controller 的 metadata 處理、broker 的 leader election、client 的 metadata fetch 都跟著變慢。</p>
<h3 id="命名規範劃出-ownership">命名規範劃出 ownership</h3>
<p>Topic 命名規範把 ownership 跟隔離邊界編碼進名字本身。一個可治理的命名規範通常含三段：租戶 / 領域前綴、語意名、版本。例如 <code>billing.invoices.v1</code> —— <code>billing.</code> 前綴對齊 prefixed ACL 的隔離邊界跟 quota 的租戶歸屬、<code>invoices</code> 是語意、<code>v1</code> 給 schema 演進留出平行存在的空間。命名規範在多租戶不是風格問題、是三個治理軸的共同錨點：ACL 靠前綴授權、quota 靠前綴歸屬、回收靠前綴找 owner。</p>
<p>實機建 topic 時 Kafka 4.2.0 對 <code>.</code> 跟 <code>_</code> 混用會出 metric 名稱碰撞警告：</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">WARNING: Due to limitations in metric names, topics with a period (&#39;.&#39;)
</span></span><span class="line"><span class="ln">2</span><span class="cl">or underscore (&#39;_&#39;) could collide. To avoid issues it is best to use
</span></span><span class="line"><span class="ln">3</span><span class="cl">either, but not both.</span></span></code></pre></div><p>成因是 metric 名把 topic 名裡的 <code>.</code> 跟 <code>_</code> 都正規化掉、<code>billing.invoices</code> 跟 <code>billing_invoices</code> 可能對映到同一條 metric。命名規範應在 <code>.</code> 跟 <code>_</code> 之間選一個當分隔符、全叢集一致、避免監控數據互相污染。</p>
<h3 id="活躍判準與自動回收">活躍判準與自動回收</h3>
<p>死 topic 的回收靠可量化的活躍判準。<a href="/blog/backend/03-message-queue/cases/linkedin-topicgc-kafka-governance/" data-link-title="3.C3 LinkedIn：TopicGC 與 Kafka 治理轉換" data-link-desc="Kafka topic 從手動治理轉自動治理對叢集的影響。">LinkedIn 的 TopicGC</a>以自動治理取代手動清理未使用 topic、降低 metadata 壓力並改善 produce / consume 效能。它的判讀是：當 queue 規模擴大、僅靠容量擴充不夠、topic 生命週期與治理自動化會成為可靠性關鍵。</p>
<p>TopicGC 是 LinkedIn 的內部系統、不是 Kafka 內建指令；它揭示的是一套可借鏡的回收流程結構：</p>
<ol>
<li>定義活躍判準：以 last produce / last consume timestamp 判斷 topic 是否仍在使用、設一段觀察窗（例如 N 天無寫入且無讀取）。</li>
<li>分級回收：先標記（soft）、進入待回收狀態並通知 owner、保留一段 grace period、無人認領才真正刪除（hard）。兩段式避免誤刪仍有低頻流量的 topic。</li>
<li>保留稽核：每次標記與刪除留紀錄、回收前後比對 controller log、partition 數量、produce / consume 效能指標、確認治理有效且無誤傷。</li>
</ol>
<p>回收條件的設定要對齊業務節奏。純看 produce timestamp 會誤判「低頻但關鍵」的 topic（如月結批次）；活躍判準要同時看 produce 跟 consume、且觀察窗要長於最長的合法閒置週期。</p>
<h2 id="production-故障演練">Production 故障演練</h2>
<h3 id="case-1單一租戶暴衝吃滿頻寬quota-缺位">Case 1：單一租戶暴衝吃滿頻寬（quota 缺位）</h3>
<p><strong>徵兆</strong>：某團隊上線一支新 backfill job、開始全速寫入；同叢集其他租戶的 producer 端 <code>request-latency</code> p99 從個位數 ms 跳到數百 ms、consumer lag 全面上升；broker network out 打到網卡上限、但 CPU 不高。受害的不是暴衝者自己、是所有共用 broker 的鄰居。</p>
<p><strong>根因</strong>：叢集沒設任何 producer quota、或只對部分租戶設了 quota。沒有 broker-side throttle 時、單一 client 能用滿 broker 的 network / disk I/O、把共享頻寬擠光。byte rate 飽和的特徵是 network 打滿但 CPU 不高 —— 區別於 <code>request_percentage</code> 缺位導致的 CPU 飽和。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>立即對暴衝 client 設 <code>producer_byte_rate</code>、broker 即時 throttle、無需重啟。</li>
<li>建立 quota 預設值：對所有 client-id（或 user）設一個保守的 default byte rate、新租戶上線自動受限、避免「漏設就無限」。</li>
<li>區分 byte rate 與 request_percentage 飽和：network 打滿設 byte rate、CPU 打滿（高頻小訊息）補 <code>request_percentage</code>。</li>
<li>容量規劃：把各租戶 quota 總和對齊 broker 的 network / disk 容量、留 headroom、避免「每個 quota 都合理但加總超過物理上限」。</li>
</ol>
<h3 id="case-2acl-設太鬆或太緊">Case 2：ACL 設太鬆或太緊</h3>
<p><strong>徵兆（太鬆）</strong>：稽核發現某 consumer 服務能讀到不屬於它的租戶 topic；或某 topic 被預期外的 principal 寫入、資料被污染。最壞情況是 <code>allow.everyone.if.no.acl.found=true</code> 下漏設 ACL 的 topic 對全叢集可讀寫。</p>
<p><strong>徵兆（太緊）</strong>：consumer 能讀訊息卻不斷重複消費、log 顯示 commit offset 被拒；或 producer 報 <code>TOPIC_AUTHORIZATION_FAILED</code>、明明該有權限。</p>
<p><strong>根因</strong>：太鬆來自 deny-list 心態 —— <code>allow.everyone.if.no.acl.found=true</code> 把「沒設 ACL」當成「開放」、漏設就外洩。太緊通常是漏掉 operation 或 resource：consumer 只給了 topic 的 <code>Read</code>、漏給 consumer group 的 <code>Read</code> <code>Describe</code>、於是讀得到但 commit 不了、表現成重複消費；producer 漏給 <code>Describe</code>、拿不到 partition metadata。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>走 allow-list：<code>allow.everyone.if.no.acl.found=false</code>、預設拒絕、明確授權才放行。</li>
<li>ACL 對齊角色模板：producer = topic Write + Describe；consumer = topic Read + Describe 加 group Read + Describe；漏 group ACL 是重複消費的常見根因。</li>
<li>用 prefixed ACL 而非逐 topic 設、把授權邊界對齊命名規範前綴、減少漏設。</li>
<li>稽核流程：定期 <code>kafka-acls.sh --list</code> 比對預期授權矩陣、把 ACL 納入版本控制與 review、而非手動逐條加。</li>
</ol>
<h3 id="case-3topic-數量爆炸壓垮-metadata-面">Case 3：Topic 數量爆炸壓垮 metadata 面</h3>
<p><strong>徵兆</strong>：叢集 topic / partition 總數隨團隊增長爬到數萬以上；controller failover 時間從秒級拉長到分鐘級；broker 啟動載入 metadata 變慢；client 的 metadata fetch 變大變慢、rebalance 期間出現連鎖延遲。容量沒滿、但整個叢集的 control plane 變鈍。</p>
<p><strong>根因</strong>：partition 是 controller 要追蹤的 metadata 單位、數量只增不減。每個團隊隨手建 topic、每個 topic 又開高 partition 數、總 partition 數線性甚至超線性成長、壓垮 metadata 處理。KRaft 相比 ZooKeeper 提高了 metadata 上限、但上限仍存在、不是無限。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>Partition 數規劃納入 topic 建立流程：partition 數對應並行度上限、不是越多越好；多餘 partition 是純 metadata 成本。詳見 <a href="/blog/backend/knowledge-cards/partition/" data-link-title="Partition" data-link-desc="說明事件流如何切分成多個可並行處理的有序片段">Partition</a> 卡。</li>
<li>回收死 topic 釋放 partition slot：見 Case 4 與生命週期治理段。</li>
<li>監控 metadata 壓力訊號：controller log、partition 總數、controller failover 時間設告警、在壓垮前介入。</li>
<li>規模化路徑：單叢集 metadata 逼近上限時、評估分群（依關鍵程度分多叢集）、見 overview 的 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Cross-region 與分層叢集</a>段與 <a href="/blog/backend/03-message-queue/cases/linkedin-kafka-tiered-clusters/" data-link-title="3.C4 LinkedIn：Kafka 分層叢集治理" data-link-desc="Kafka 從單叢集走向 tiered clusters 的轉換案例。">LinkedIn Tiered Clusters</a>案例。</li>
</ol>
<h3 id="case-4unused-topic-未回收">Case 4：Unused topic 未回收</h3>
<p><strong>徵兆</strong>：叢集裡大量 topic 數月無 produce 也無 consume、卻持續佔 partition slot 跟 metadata；沒人記得某些 topic 屬於哪個團隊、不敢刪；新 topic 想建時撞到 partition 上限、被迫先擴叢集而非先回收。</p>
<p><strong>根因</strong>：沒有活躍判準與回收流程、topic 只建不刪。歸屬資訊沒編碼進命名、回收時找不到 owner、於是「不敢刪」成為預設、死 topic 無限累積。這是 Case 3（metadata 爆炸）的慢性來源。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>建立活躍判準：以 last produce / last consume timestamp 加觀察窗判定死 topic、觀察窗長於最長合法閒置週期（避免誤刪月結類低頻 topic）。</li>
<li>兩段式回收：先 soft 標記並通知 owner、grace period 內無人認領才 hard 刪除、避免誤刪。</li>
<li>命名規範補 ownership：前綴對齊團隊、回收時能直接找到 owner、消除「不敢刪」。</li>
<li>自動化加稽核：參考 <a href="/blog/backend/03-message-queue/cases/linkedin-topicgc-kafka-governance/" data-link-title="3.C3 LinkedIn：TopicGC 與 Kafka 治理轉換" data-link-desc="Kafka topic 從手動治理轉自動治理對叢集的影響。">TopicGC</a>的流程結構、回收前後比對 metadata 與效能指標、留稽核紀錄。</li>
</ol>
<h2 id="容量與規模邊界">容量與規模邊界</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>估算 / 訊號</th>
          <th>警戒與下一步</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Quota 總和 vs 物理容量</td>
          <td>各租戶 byte rate 加總對 broker network / disk 容量</td>
          <td>加總逼近物理上限要重新切分、留 headroom</td>
      </tr>
      <tr>
          <td>ACL 條目數</td>
          <td>逐 topic 設會隨 topic 數線性成長</td>
          <td>改 prefixed ACL 對齊命名規範、降條目數與漏設風險</td>
      </tr>
      <tr>
          <td>Partition 總數</td>
          <td>controller failover 時間、metadata fetch 延遲</td>
          <td>逼近上限先回收死 topic、再評估分群</td>
      </tr>
      <tr>
          <td>Topic 活躍率</td>
          <td>有 produce / consume 的 topic 佔比</td>
          <td>死 topic 比例高代表缺回收流程、補活躍判準</td>
      </tr>
  </tbody>
</table>
<p>Quota 與 ACL 是 broker-side 即時生效、不需重啟、可隨租戶調整、運維成本低。生命週期治理是持續流程、不是一次性操作 —— 死 topic 會持續產生、回收要常態化。三軸的共同前提是命名規範：沒有可治理的命名、quota 找不到歸屬、ACL 邊界對不齊、回收找不到 owner。多租戶治理的第一步是先把命名規範立起來、再談 quota 與 ACL。</p>
<h2 id="整合與下一步">整合與下一步</h2>
<h3 id="跟-overview-與案例的對位">跟 overview 與案例的對位</h3>
<ul>
<li>上游 vendor 頁：<a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Apache Kafka</a> —— 本文展開其「Multi-tenant 與配額治理」「Topic 生命週期治理」兩段</li>
<li>平台治理案例：<a href="/blog/backend/03-message-queue/cases/uber-kafka-infrastructure-evolution/" data-link-title="3.C6 Uber：Kafka 事件平台演進" data-link-desc="事件平台從團隊自管走向多租戶共享基礎設施。">3.C6 Uber Kafka 事件平台</a> —— 單隊列問題提升到平台治理</li>
<li>生命週期案例：<a href="/blog/backend/03-message-queue/cases/linkedin-topicgc-kafka-governance/" data-link-title="3.C3 LinkedIn：TopicGC 與 Kafka 治理轉換" data-link-desc="Kafka topic 從手動治理轉自動治理對叢集的影響。">3.C3 LinkedIn TopicGC</a> —— 自動回收與 metadata 壓力</li>
<li>規模化分群：<a href="/blog/backend/03-message-queue/cases/linkedin-kafka-tiered-clusters/" data-link-title="3.C4 LinkedIn：Kafka 分層叢集治理" data-link-desc="Kafka 從單叢集走向 tiered clusters 的轉換案例。">3.C4 LinkedIn Tiered Clusters</a> —— metadata 逼近上限時的多叢集路徑</li>
<li>自管轉 managed 的 ACL cutover：<a href="/blog/backend/03-message-queue/cases/vmware-kafka-to-msk/" data-link-title="3.C2 VMware Tanzu CloudHealth：Kafka 轉 Amazon MSK" data-link-desc="自管 Kafka 遷移到託管平台時的治理重點。">3.C2 VMware → MSK</a></li>
</ul>
<h3 id="跟安全模組對位">跟安全模組對位</h3>
<p>ACL 是 Kafka 內建的授權層、處理 broker 級的 principal × resource 授權。完整的 secret 管理（SASL 認證憑證怎麼發、輪替、撤銷）屬於 <a href="/blog/backend/07-security-data-protection/" data-link-title="模組七：資安與資料保護" data-link-desc="以問題驅動方式擴充資安知識網：先定義服務環節問題，再以案例作為觸發式參考">07 資料保護與安全模組</a>的範疇 —— ACL 綁的 principal 從哪來、由認證層決定、ACL 只負責「這個 principal 能做什麼」。多租戶的完整信任鏈是「認證確認身分（07）→ ACL 授權操作（本文）→ quota 限制用量（本文）」三層。</p>
<h3 id="下一步議題">下一步議題</h3>
<ul>
<li>Schema 治理：跨租戶共用 topic 時、schema compatibility 是另一層契約治理、見 overview 的 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">KRaft 與 Schema Registry</a>段</li>
<li>Consumer group ACL 細節：跟 <a href="/blog/backend/knowledge-cards/consumer-group/" data-link-title="Consumer Group" data-link-desc="說明一組 consumer 如何共同分攤 stream 或 topic 的處理責任">Consumer group</a> rebalance 的互動</li>
<li>Quota 與 <a href="/blog/backend/knowledge-cards/delivery-semantics/" data-link-title="Delivery Semantics" data-link-desc="說明事件投遞語意如何定義遺失、重複、順序與補償策略">delivery semantics</a>：throttle 延遲對 producer timeout / retry 的影響</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li>上游 vendor 頁：<a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Apache Kafka</a></li>
<li>對位 deep article（同模組）：本模組其他 Kafka deep article 見 vendor 頁進階主題段</li>
<li>跨模組授權鏈：<a href="/blog/backend/07-security-data-protection/" data-link-title="模組七：資安與資料保護" data-link-desc="以問題驅動方式擴充資安知識網：先定義服務環節問題，再以案例作為觸發式參考">07 資料保護與安全模組</a></li>
<li>方法論：<a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章的寫作方法論</a></li>
<li>知識卡：<a href="/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">Topic</a>、<a href="/blog/backend/knowledge-cards/partition/" data-link-title="Partition" data-link-desc="說明事件流如何切分成多個可並行處理的有序片段">Partition</a>、<a href="/blog/backend/knowledge-cards/consumer-group/" data-link-title="Consumer Group" data-link-desc="說明一組 consumer 如何共同分攤 stream 或 topic 的處理責任">Consumer group</a></li>
</ul>
]]></content:encoded></item><item><title>Redis 持久化與 fork latency：AOF、RDB 與那一次卡住整個 cluster 的 fork</title><link>https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/redis/persistence-fork-latency/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/redis/persistence-fork-latency/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/redis/" data-link-title="Redis" data-link-desc="OSS in-memory data structure store、cache 主流">Redis&lt;/a> overview 的 implementation-layer deep article。持久化跟&lt;a href="https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/redis/memory-eviction-tuning/" data-link-title="Redis 記憶體與淘汰調校：maxmemory-policy、LFU 與碎片化的實戰判讀" data-link-desc="Redis 的記憶體是一條會在半夜爆掉的曲線：maxmemory 設多少、policy 選 LRU 還 LFU、碎片化什麼時候開始吃掉 30% RAM、OOM 時 noeviction 怎麼讓寫入全部失敗。本文展開 Redis 記憶體會計模型、eviction policy 的選型判讀、5 個把記憶體配置寫成 production 事故的踩坑，以及單機記憶體撞牆後該往 cluster 還是 DragonflyDB 走的邊界">記憶體調校&lt;/a>互相耦合（fork 的 copy-on-write 是 maxmemory headroom 的主要消耗者），兩篇建議一起讀。機制以 &lt;a href="https://redis.io/docs/latest/operate/oss_and_stack/management/persistence/">Redis persistence 官方文件&lt;/a> 為準、最後檢查日 2026-06-16。&lt;/p>&lt;/blockquote>
&lt;h2 id="fork-那一瞬間">fork 那一瞬間&lt;/h2>
&lt;p>Redis 是單執行緒處理命令的，這是它延遲可預測的根基——直到它需要把記憶體裡的資料寫到磁碟。RDB snapshot 跟 AOF rewrite 都不能在主執行緒上慢慢做（會凍結所有命令），於是 Redis 的解法是 &lt;code>fork()&lt;/code>：複製出一個子進程，由子進程把當下的記憶體快照寫到磁碟，主進程繼續服務。&lt;/p>
&lt;p>問題在 &lt;code>fork()&lt;/code> 本身不是免費的。Linux 的 &lt;code>fork()&lt;/code> 要複製父進程的分頁表（page table），記憶體越大、分頁表越大，這個複製動作越久——而它發生在主執行緒上，是阻塞的。一個 20GB 的 Redis 實例，fork 可能凍結主執行緒數百毫秒到一秒。在這段時間裡，所有命令排隊，p99 延遲從 1ms 跳到 500ms+。&lt;/p>
&lt;p>更糟的是 fork 之後。&lt;code>fork()&lt;/code> 用 copy-on-write：子進程跟父進程共享實體分頁，直到某一方寫入才複製。子進程只讀（在寫 snapshot），但父進程持續服務寫入，每改一個分頁就觸發一次複製。寫入越密集、snapshot 跑越久，被複製的分頁越多，最壞情況記憶體接近翻倍。這就是為什麼 Redis 的 maxmemory 必須留 headroom——不是給資料，是給 fork 期間的分頁複製。&lt;/p>
&lt;p>理解持久化，本質是理解「fork 的延遲尖峰」與「資料持久性」之間的取捨。本文按這條線展開機制、配置與踩坑。&lt;/p>
&lt;h2 id="核心概念aof-與-rdb-是兩種不同的持久性語意">核心概念：AOF 與 RDB 是兩種不同的持久性語意&lt;/h2>
&lt;p>Redis 的兩種持久化不是「二選一的同類選項」，它們回答的是不同問題。&lt;/p>
&lt;p>&lt;strong>RDB 是某個時間點的記憶體快照&lt;/strong>。它把整個 dataset 序列化成一個緊湊的二進位檔（&lt;code>dump.rdb&lt;/code>）。優點是檔案小、還原快（直接載入記憶體）、fork 一次寫完。缺點是兩次 snapshot 之間的寫入會在崩潰時全部遺失——RDB 的持久性顆粒度是「上一次 save 到現在」，可能是幾分鐘的資料。&lt;/p>
&lt;p>&lt;strong>AOF 是命令的 append-only log&lt;/strong>。每個改變資料的命令（&lt;code>SET&lt;/code>、&lt;code>LPUSH&lt;/code>&amp;hellip;）被追加到 log 檔，還原時重放整個 log。優點是持久性顆粒度細（最多丟 &lt;code>fsync&lt;/code> 策略決定的一小段）。缺點是 log 會無限增長，需要定期 rewrite 壓縮——而 rewrite 也要 fork。&lt;/p>
&lt;p>兩者的 fork 觸發點不同但機制相同：RDB 是 &lt;code>BGSAVE&lt;/code>（手動或 save 規則觸發）fork，AOF 是 &lt;code>BGREWRITEAOF&lt;/code>（log 太大時觸發）fork。兩個若同時跑，記憶體壓力疊加。&lt;/p>
&lt;h3 id="aof-的-fsync-策略決定丟多少資料">AOF 的 fsync 策略決定丟多少資料&lt;/h3>
&lt;p>AOF 寫 log 分兩步：先 write 到 OS 的 page cache，再 fsync 刷到磁碟。&lt;code>appendfsync&lt;/code> 控制 fsync 頻率，這是持久性與延遲的核心旋鈕：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>&lt;code>appendfsync&lt;/code>&lt;/th>
 &lt;th>fsync 時機&lt;/th>
 &lt;th>崩潰最多丟&lt;/th>
 &lt;th>延遲影響&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>always&lt;/code>&lt;/td>
 &lt;td>每個寫命令&lt;/td>
 &lt;td>幾乎不丟&lt;/td>
 &lt;td>每次寫都等磁碟、延遲最高&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>everysec&lt;/code>&lt;/td>
 &lt;td>每秒一次（背景）&lt;/td>
 &lt;td>最多 1 秒&lt;/td>
 &lt;td>多數場景的平衡點（預設）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>no&lt;/code>&lt;/td>
 &lt;td>交給 OS（~30 秒）&lt;/td>
 &lt;td>OS 決定、可能丟很多&lt;/td>
 &lt;td>延遲最低、持久性最弱&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;code>everysec&lt;/code> 是多數場景的預設選擇——背景執行緒每秒 fsync，主執行緒不等磁碟，崩潰最多丟 1 秒。但要注意：當磁碟 I/O 飽和，背景 fsync 跑超過 1 秒沒完成，主執行緒會被迫等待（避免 buffer 無限堆積），這時延遲尖峰跟 &lt;code>always&lt;/code> 一樣明顯。&lt;/p>
&lt;h3 id="混合持久化rdb-preamble--aof-tail">混合持久化：RDB preamble + AOF tail&lt;/h3>
&lt;p>Redis 4.0 後的 &lt;code>aof-use-rdb-preamble yes&lt;/code>（4.0+ 預設開）把兩者結合：AOF rewrite 時，先寫一段 RDB 格式的快照當前綴，後面接增量命令 log。還原時先快速載入 RDB preamble，再重放尾端的 log。這拿到了 RDB 的還原速度與 AOF 的細顆粒持久性，是目前的建議配置。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/02-cache-redis/vendors/redis/" data-link-title="Redis" data-link-desc="OSS in-memory data structure store、cache 主流">Redis</a> overview 的 implementation-layer deep article。持久化跟<a href="/blog/backend/02-cache-redis/vendors/redis/memory-eviction-tuning/" data-link-title="Redis 記憶體與淘汰調校：maxmemory-policy、LFU 與碎片化的實戰判讀" data-link-desc="Redis 的記憶體是一條會在半夜爆掉的曲線：maxmemory 設多少、policy 選 LRU 還 LFU、碎片化什麼時候開始吃掉 30% RAM、OOM 時 noeviction 怎麼讓寫入全部失敗。本文展開 Redis 記憶體會計模型、eviction policy 的選型判讀、5 個把記憶體配置寫成 production 事故的踩坑，以及單機記憶體撞牆後該往 cluster 還是 DragonflyDB 走的邊界">記憶體調校</a>互相耦合（fork 的 copy-on-write 是 maxmemory headroom 的主要消耗者），兩篇建議一起讀。機制以 <a href="https://redis.io/docs/latest/operate/oss_and_stack/management/persistence/">Redis persistence 官方文件</a> 為準、最後檢查日 2026-06-16。</p></blockquote>
<h2 id="fork-那一瞬間">fork 那一瞬間</h2>
<p>Redis 是單執行緒處理命令的，這是它延遲可預測的根基——直到它需要把記憶體裡的資料寫到磁碟。RDB snapshot 跟 AOF rewrite 都不能在主執行緒上慢慢做（會凍結所有命令），於是 Redis 的解法是 <code>fork()</code>：複製出一個子進程，由子進程把當下的記憶體快照寫到磁碟，主進程繼續服務。</p>
<p>問題在 <code>fork()</code> 本身不是免費的。Linux 的 <code>fork()</code> 要複製父進程的分頁表（page table），記憶體越大、分頁表越大，這個複製動作越久——而它發生在主執行緒上，是阻塞的。一個 20GB 的 Redis 實例，fork 可能凍結主執行緒數百毫秒到一秒。在這段時間裡，所有命令排隊，p99 延遲從 1ms 跳到 500ms+。</p>
<p>更糟的是 fork 之後。<code>fork()</code> 用 copy-on-write：子進程跟父進程共享實體分頁，直到某一方寫入才複製。子進程只讀（在寫 snapshot），但父進程持續服務寫入，每改一個分頁就觸發一次複製。寫入越密集、snapshot 跑越久，被複製的分頁越多，最壞情況記憶體接近翻倍。這就是為什麼 Redis 的 maxmemory 必須留 headroom——不是給資料，是給 fork 期間的分頁複製。</p>
<p>理解持久化，本質是理解「fork 的延遲尖峰」與「資料持久性」之間的取捨。本文按這條線展開機制、配置與踩坑。</p>
<h2 id="核心概念aof-與-rdb-是兩種不同的持久性語意">核心概念：AOF 與 RDB 是兩種不同的持久性語意</h2>
<p>Redis 的兩種持久化不是「二選一的同類選項」，它們回答的是不同問題。</p>
<p><strong>RDB 是某個時間點的記憶體快照</strong>。它把整個 dataset 序列化成一個緊湊的二進位檔（<code>dump.rdb</code>）。優點是檔案小、還原快（直接載入記憶體）、fork 一次寫完。缺點是兩次 snapshot 之間的寫入會在崩潰時全部遺失——RDB 的持久性顆粒度是「上一次 save 到現在」，可能是幾分鐘的資料。</p>
<p><strong>AOF 是命令的 append-only log</strong>。每個改變資料的命令（<code>SET</code>、<code>LPUSH</code>&hellip;）被追加到 log 檔，還原時重放整個 log。優點是持久性顆粒度細（最多丟 <code>fsync</code> 策略決定的一小段）。缺點是 log 會無限增長，需要定期 rewrite 壓縮——而 rewrite 也要 fork。</p>
<p>兩者的 fork 觸發點不同但機制相同：RDB 是 <code>BGSAVE</code>（手動或 save 規則觸發）fork，AOF 是 <code>BGREWRITEAOF</code>（log 太大時觸發）fork。兩個若同時跑，記憶體壓力疊加。</p>
<h3 id="aof-的-fsync-策略決定丟多少資料">AOF 的 fsync 策略決定丟多少資料</h3>
<p>AOF 寫 log 分兩步：先 write 到 OS 的 page cache，再 fsync 刷到磁碟。<code>appendfsync</code> 控制 fsync 頻率，這是持久性與延遲的核心旋鈕：</p>
<table>
  <thead>
      <tr>
          <th><code>appendfsync</code></th>
          <th>fsync 時機</th>
          <th>崩潰最多丟</th>
          <th>延遲影響</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>always</code></td>
          <td>每個寫命令</td>
          <td>幾乎不丟</td>
          <td>每次寫都等磁碟、延遲最高</td>
      </tr>
      <tr>
          <td><code>everysec</code></td>
          <td>每秒一次（背景）</td>
          <td>最多 1 秒</td>
          <td>多數場景的平衡點（預設）</td>
      </tr>
      <tr>
          <td><code>no</code></td>
          <td>交給 OS（~30 秒）</td>
          <td>OS 決定、可能丟很多</td>
          <td>延遲最低、持久性最弱</td>
      </tr>
  </tbody>
</table>
<p><code>everysec</code> 是多數場景的預設選擇——背景執行緒每秒 fsync，主執行緒不等磁碟，崩潰最多丟 1 秒。但要注意：當磁碟 I/O 飽和，背景 fsync 跑超過 1 秒沒完成，主執行緒會被迫等待（避免 buffer 無限堆積），這時延遲尖峰跟 <code>always</code> 一樣明顯。</p>
<h3 id="混合持久化rdb-preamble--aof-tail">混合持久化：RDB preamble + AOF tail</h3>
<p>Redis 4.0 後的 <code>aof-use-rdb-preamble yes</code>（4.0+ 預設開）把兩者結合：AOF rewrite 時，先寫一段 RDB 格式的快照當前綴，後面接增量命令 log。還原時先快速載入 RDB preamble，再重放尾端的 log。這拿到了 RDB 的還原速度與 AOF 的細顆粒持久性，是目前的建議配置。</p>
<h2 id="配置持久化的設定路徑">配置：持久化的設定路徑</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># --- RDB snapshot 規則（多久 + 多少改動觸發 BGSAVE）---</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"># redis.conf:</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1">#   save 900 1      # 900 秒內有 1 個 key 改動</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1">#   save 300 100    # 300 秒內有 100 個改動</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1">#   save 60 10000   # 60 秒內有 10000 個改動</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"># 純 cache 不需要 RDB 可關閉：</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1">#   save &#34;&#34;</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="c1"># --- AOF 設定 ---</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">redis-cli CONFIG SET appendonly yes
</span></span><span class="line"><span class="ln">11</span><span class="cl">redis-cli CONFIG SET appendfsync everysec
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"># AOF rewrite 觸發條件：比上次 rewrite 大 100% 且至少 64MB</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">redis-cli CONFIG SET auto-aof-rewrite-percentage <span class="m">100</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">redis-cli CONFIG SET auto-aof-rewrite-min-size 64mb
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="c1"># 混合持久化（4.0+ 預設）</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">redis-cli CONFIG SET aof-use-rdb-preamble yes</span></span></code></pre></div><p>降低 fork 衝擊的兩個系統層設定：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 1. 關閉 Transparent Huge Pages（THP）——THP 會讓 copy-on-write 以 2MB 為單位複製、放大 fork 後的記憶體與延遲</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nb">echo</span> never &gt; /sys/kernel/mm/transparent_hugepage/enabled
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 2. 允許 overcommit memory——fork 時 Linux 預設可能因 overcommit 檢查拒絕 fork、導致 BGSAVE 失敗</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># /etc/sysctl.conf:</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1">#   vm.overcommit_memory = 1</span></span></span></code></pre></div><p>這兩個是 Redis 官方明確建議的系統設定，沒設好會直接讓 fork 失敗或放大延遲尖峰。</p>
<h2 id="production-故障演練">Production 故障演練</h2>
<h3 id="case-1bgsave-那一刻-p99-延遲尖峰">Case 1：BGSAVE 那一刻 p99 延遲尖峰</h3>
<p><strong>徵兆</strong>：監控上每隔一段時間（對齊 save 規則）出現規律的延遲尖峰，p99 從 2ms 跳到 300-800ms，持續一兩秒後恢復。<code>INFO stats</code> 的 <code>latest_fork_usec</code> 顯示某次 fork 花了 700000 微秒（0.7 秒）。</p>
<p><strong>根因</strong>：大記憶體實例的 <code>fork()</code> 要複製分頁表，這個動作阻塞主執行緒。實例越大尖峰越明顯，THP 開著會更嚴重。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>確認 THP 關閉（最常見的放大原因）</li>
<li>把 RDB save 規則放寬或關閉——純 cache 場景靠 AOF 或乾脆不持久化</li>
<li>大實例考慮分片，把單實例記憶體降下來，fork 成本隨之降低</li>
<li>在 replica 上做持久化（master 只服務、replica 負責 BGSAVE），把 fork 尖峰移出服務路徑</li>
</ol>
<h3 id="case-2fork-期間記憶體翻倍觸發-oom">Case 2：fork 期間記憶體翻倍觸發 OOM</h3>
<p><strong>徵兆</strong>：BGSAVE 開始後記憶體快速上升，<code>used_memory_rss</code> 在 snapshot 期間衝高，撞到機器 RAM 上限，Linux OOM killer 把 redis-server 進程 SIGKILL，無預警下線。</p>
<p><strong>根因</strong>：copy-on-write 在寫入密集期間複製大量分頁，maxmemory 沒留足夠 headroom。maxmemory 設成 RAM 的 90%+ 時，fork 期間的分頁複製把 RSS 推爆系統。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>maxmemory 設成 RAM 的 60-70%，留 30-40% 給 fork copy-on-write（寫入越密集留越多）</li>
<li>設 <code>vm.overcommit_memory = 1</code> 避免 fork 直接被拒</li>
<li>在低寫入時段（夜間）排程 BGSAVE，減少 fork 期間被複製的分頁</li>
<li>監控 <code>latest_fork_usec</code> 與 BGSAVE 期間的 RSS 峰值，跟 <a href="/blog/backend/02-cache-redis/vendors/redis/memory-eviction-tuning/" data-link-title="Redis 記憶體與淘汰調校：maxmemory-policy、LFU 與碎片化的實戰判讀" data-link-desc="Redis 的記憶體是一條會在半夜爆掉的曲線：maxmemory 設多少、policy 選 LRU 還 LFU、碎片化什麼時候開始吃掉 30% RAM、OOM 時 noeviction 怎麼讓寫入全部失敗。本文展開 Redis 記憶體會計模型、eviction policy 的選型判讀、5 個把記憶體配置寫成 production 事故的踩坑，以及單機記憶體撞牆後該往 cluster 還是 DragonflyDB 走的邊界">記憶體調校</a>的 headroom 計算合看</li>
</ol>
<h3 id="case-3aof-everysec-在磁碟飽和時退化成-always">Case 3：AOF everysec 在磁碟飽和時退化成 always</h3>
<p><strong>徵兆</strong>：平常延遲穩定，某段時間（通常伴隨大量寫入或磁碟被其他進程佔用）延遲全面上升，<code>INFO</code> 的 <code>aof_delayed_fsync</code> 計數持續增加。</p>
<p><strong>根因</strong>：<code>everysec</code> 的背景 fsync 應該每秒完成，但磁碟 I/O 飽和時 fsync 跑超過 1 秒。Redis 為了不讓 AOF buffer 無限堆積，會在主執行緒上阻塞等 fsync 完成——<code>everysec</code> 在這個情境下退化成接近 <code>always</code> 的延遲行為。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>用獨立的高 IOPS 磁碟給 AOF（不要跟 OS / log / 其他服務共用 I/O）</li>
<li>監控 <code>aof_delayed_fsync</code>，持續增加代表磁碟跟不上寫入</li>
<li>評估 <code>no-appendfsync-on-rewrite yes</code>——AOF rewrite 期間暫停 fsync，避免 rewrite 的 I/O 跟 fsync 互搶（代價是 rewrite 期間崩潰丟更多）</li>
<li>寫入吞吐超過單磁碟負荷是擴容訊號，不是調 fsync 能解</li>
</ol>
<h3 id="case-4aof-檔尾損壞讓-redis-起不來">Case 4：AOF 檔尾損壞讓 Redis 起不來</h3>
<p><strong>徵兆</strong>：Redis 崩潰後重啟失敗，log 顯示 <code>Bad file format reading the append only file</code>，服務無法載入 AOF。</p>
<p><strong>根因</strong>：崩潰發生在 AOF 寫到一半，最後一條命令只寫了部分 byte，AOF 檔尾不完整。Redis 預設 <code>aof-load-truncated yes</code> 應能容忍尾端截斷，但若損壞在中段（罕見的磁碟錯誤）或設了 <code>aof-load-truncated no</code>，載入直接失敗。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>確認 <code>aof-load-truncated yes</code>（預設），容忍尾端截斷自動修復</li>
<li>中段損壞用 <code>redis-check-aof --fix appendonly.aof</code> 修復（會截掉損壞點之後的內容、有資料遺失）</li>
<li>修復前先備份原 AOF 檔，不要直接覆蓋</li>
<li>混合持久化下還原優先用 RDB preamble，降低純 AOF replay 的損壞風險</li>
</ol>
<h3 id="case-5以為有持久化其實-bgsave-一直在失敗">Case 5：以為有持久化、其實 BGSAVE 一直在失敗</h3>
<p><strong>徵兆</strong>：某次需要從 RDB 還原時發現 <code>dump.rdb</code> 是好幾天前的，期間的資料全沒了。回查 log 發現 BGSAVE 一直報 <code>Can't save in background: fork: Cannot allocate memory</code>。</p>
<p><strong>根因</strong>：<code>vm.overcommit_memory</code> 是預設的 0，Linux 在 fork 時做嚴格的記憶體檢查——當 Redis 已用掉大半 RAM，fork 估算可能需要翻倍記憶體而被拒。BGSAVE 靜默失敗，RDB 停留在最後一次成功的版本，但沒人在看 log。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>設 <code>vm.overcommit_memory = 1</code>，讓 fork 在記憶體吃緊時仍能成功（靠 copy-on-write 實際不會真的翻倍）</li>
<li>監控 <code>rdb_last_bgsave_status</code> 與 <code>aof_last_bgrewrite_status</code>，<code>err</code> 要立刻告警</li>
<li>監控 <code>rdb_last_save_time</code>，距今太久代表持久化已停擺</li>
<li>持久化的存在不等於可用——定期演練從備份還原，驗證 RDB / AOF 真的能載入</li>
</ol>
<h2 id="capacity--cost-邊界">Capacity / cost 邊界</h2>
<p>持久化的容量判讀，圍繞 fork 成本與磁碟負荷：</p>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>健康區間</th>
          <th>警戒與動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>latest_fork_usec</code></td>
          <td>&lt; 100ms（小實例）</td>
          <td>&gt; 數百 ms → 實例太大、考慮分片或 replica 持久化</td>
      </tr>
      <tr>
          <td>fork 期間 RSS 峰值</td>
          <td>&lt; 機器 RAM</td>
          <td>接近 RAM → maxmemory headroom 不足</td>
      </tr>
      <tr>
          <td><code>aof_delayed_fsync</code></td>
          <td>接近 0</td>
          <td>持續增加 → 磁碟 I/O 跟不上、換高 IOPS 磁碟</td>
      </tr>
      <tr>
          <td><code>rdb_last_bgsave_status</code></td>
          <td><code>ok</code></td>
          <td><code>err</code> → fork 失敗、查 overcommit / 記憶體</td>
      </tr>
      <tr>
          <td>AOF 檔大小 / dataset</td>
          <td>rewrite 後接近 dataset 大小</td>
          <td>遠大於 dataset → rewrite 沒觸發、檢查閾值</td>
      </tr>
  </tbody>
</table>
<p>撞牆後的路由判斷：</p>
<ul>
<li><strong>fork 尖峰無法接受、實例又必須大</strong>：把持久化移到 replica（master 純服務），或走 <a href="/blog/backend/02-cache-redis/vendors/redis/cluster-resharding/" data-link-title="Redis Cluster Re-sharding：source = target，但 topology 重劃的 5 段流程" data-link-desc="Redis cluster re-sharding 是 5 type migration 漏類實證 — source / target 同 cluster、無 schema / paradigm 差、但 16384 slot 重分配是核心；本文涵蓋 4 種 re-sharding driver、slot migration 機制、redis-cli --cluster rebalance / reshard 工具、5 個 production 踩雷（cluster busy / replica lag / client cache stale / cross-slot transaction / monitor gap）">Cluster 分片</a>降低單實例記憶體。</li>
<li><strong>大記憶體下 fork 成本是結構性瓶頸</strong>：<a href="/blog/backend/02-cache-redis/vendors/dragonflydb/" data-link-title="DragonflyDB" data-link-desc="高效能 Redis / Memcached 相容替代、多核架構">DragonflyDB</a> 用 fork-less snapshot 機制，大記憶體場景的快照不付 fork 的延遲與記憶體翻倍代價——若 fork 尖峰是主要痛點，這是值得評估的架構替代。</li>
<li><strong>需要真正的 source-of-truth 持久性（不是盡力而為）</strong>：Redis 持久化本質是 cache 的回填保險，不是交易級持久性。要強持久性走 <a href="/blog/backend/02-cache-redis/vendors/aws-elasticache/" data-link-title="AWS ElastiCache" data-link-desc="AWS managed Redis / Valkey / Memcached">MemoryDB</a>（multi-AZ transaction log）或 <a href="/blog/backend/01-database/" data-link-title="模組一：資料庫與持久化" data-link-desc="整理 SQL、transaction、migration 與 repository adapter 的後端實務">database 模組</a>。</li>
</ul>
<h2 id="整合--下一步">整合 / 下一步</h2>
<p>持久化決策的起點其實是一個選型問題：這份資料是 cache 還是 source-of-truth。</p>
<ul>
<li><strong>跟 <a href="/blog/backend/02-cache-redis/vendors/redis/memory-eviction-tuning/" data-link-title="Redis 記憶體與淘汰調校：maxmemory-policy、LFU 與碎片化的實戰判讀" data-link-desc="Redis 的記憶體是一條會在半夜爆掉的曲線：maxmemory 設多少、policy 選 LRU 還 LFU、碎片化什麼時候開始吃掉 30% RAM、OOM 時 noeviction 怎麼讓寫入全部失敗。本文展開 Redis 記憶體會計模型、eviction policy 的選型判讀、5 個把記憶體配置寫成 production 事故的踩坑，以及單機記憶體撞牆後該往 cluster 還是 DragonflyDB 走的邊界">記憶體調校</a></strong>：fork 的 copy-on-write 是 maxmemory headroom 的主要消耗者，兩者必須一起算。</li>
<li><strong>跟 <a href="/blog/backend/02-cache-redis/vendors/redis/sentinel-ha-failover/" data-link-title="Redis Sentinel 與 failover 時序：從 master 死掉到 client 重連的每一段" data-link-desc="Redis Sentinel 的 failover 不是一個瞬間動作，是 down 偵測 → quorum 確認 → 選主 → 提升 → 配置廣播 → client 重連的一條時序鏈，每一段都有自己的延遲與失敗模式。本文展開 Sentinel 的判定模型與這條時序、5 個讓 failover 卡住或丟資料的 production 踩坑，以及 Sentinel 撐不住該往 Cluster 或 managed 走的邊界">replication / failover</a></strong>：replica 是承接持久化負擔的地方，也是 fork 尖峰的替代執行點。</li>
<li><strong>跟 <a href="/blog/backend/09-performance-capacity/cases/tubi-elasticache-ml-feature-store/" data-link-title="9.C25 Tubi：從 ScyllaDB 遷到 ElastiCache、ML feature store 達 sub-10ms p99" data-link-desc="Tubi 把 ML 推薦的 feature store 從 ScyllaDB 遷到 ElastiCache for Redis、99 百分位延遲降到 10ms 以下">Tubi 的 cache vs durable 選型</a></strong>：Tubi 把 ML feature store 從 ScyllaDB（durable）遷到 ElastiCache，判斷是「feature 可重新計算」——這正是「不需要持久化」的判斷，持久化配置應隨之簡化甚至關閉。反過來，若資料不可重建，問題在選錯儲存層，不在持久化調校。</li>
<li><strong>跟 <a href="/blog/backend/02-cache-redis/cache-copy-freshness-boundary/" data-link-title="2.7 Cache Copy Boundary 與 Freshness" data-link-desc="說明快取何時只是可重建副本，何時會影響交易、權限或配額正確性。">cache copy boundary</a></strong>：服務若把 Redis 當主要 serving layer，持久化決定了重啟後是冷啟動回源雪崩還是溫啟動，跟 stampede 防護直接相關。</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li>上游 vendor 頁：<a href="/blog/backend/02-cache-redis/vendors/redis/" data-link-title="Redis" data-link-desc="OSS in-memory data structure store、cache 主流">Redis</a></li>
<li>同 vendor deep article：<a href="/blog/backend/02-cache-redis/vendors/redis/memory-eviction-tuning/" data-link-title="Redis 記憶體與淘汰調校：maxmemory-policy、LFU 與碎片化的實戰判讀" data-link-desc="Redis 的記憶體是一條會在半夜爆掉的曲線：maxmemory 設多少、policy 選 LRU 還 LFU、碎片化什麼時候開始吃掉 30% RAM、OOM 時 noeviction 怎麼讓寫入全部失敗。本文展開 Redis 記憶體會計模型、eviction policy 的選型判讀、5 個把記憶體配置寫成 production 事故的踩坑，以及單機記憶體撞牆後該往 cluster 還是 DragonflyDB 走的邊界">記憶體與淘汰調校</a>、<a href="/blog/backend/02-cache-redis/vendors/redis/cluster-resharding/" data-link-title="Redis Cluster Re-sharding：source = target，但 topology 重劃的 5 段流程" data-link-desc="Redis cluster re-sharding 是 5 type migration 漏類實證 — source / target 同 cluster、無 schema / paradigm 差、但 16384 slot 重分配是核心；本文涵蓋 4 種 re-sharding driver、slot migration 機制、redis-cli --cluster rebalance / reshard 工具、5 個 production 踩雷（cluster busy / replica lag / client cache stale / cross-slot transaction / monitor gap）">Cluster re-sharding</a></li>
<li>上游概念：<a href="/blog/backend/02-cache-redis/cache-copy-freshness-boundary/" data-link-title="2.7 Cache Copy Boundary 與 Freshness" data-link-desc="說明快取何時只是可重建副本，何時會影響交易、權限或配額正確性。">2.7 cache copy boundary</a></li>
<li>Methodology：<a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章寫作方法論</a></li>
</ul>
]]></content:encoded></item><item><title>MySQL Orchestrator Failover：HA 工具自己怎麼 HA？raft cluster + GTID-based promotion 的兩段 paradox</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/orchestrator-failover/</link><pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/orchestrator-failover/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/" data-link-title="MySQL" data-link-desc="高併發網路服務常用關聯式資料庫、Vitess / PlanetScale 分片生態、GitHub / Shopify / Facebook 規模驗證">MySQL&lt;/a> overview 的 implementation-layer deep article。Overview 已說明 MySQL 在 OLTP 譜系的定位、本文聚焦 &lt;em>Orchestrator failover&lt;/em> — 自動 HA 的工具雙層架構跟 5 段 decision tree。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;blockquote>
&lt;p>用詞註：Orchestrator 工具命名與 MySQL 5.7- SQL 命令（&lt;code>SHOW SLAVE STATUS&lt;/code> / &lt;code>CHANGE MASTER TO&lt;/code> / &lt;code>STOP SLAVE&lt;/code> 等）沿用 &lt;em>master / slave&lt;/em>。MySQL 8.0+ 改採 &lt;em>primary / replica&lt;/em>、但 SQL syntax 仍保留別名。本文出現 master / slave 處對應 8.0 primary / replica 概念。&lt;/p>&lt;/blockquote>
&lt;p>讀者第一個會問的問題：「Orchestrator 自己會壞嗎？壞了誰 failover Orchestrator？」這個 paradox 是 &lt;em>任何 HA 工具&lt;/em> 的核心議題、PostgreSQL 的 Patroni 用 DCS（etcd / Consul）解決、MySQL 的 Orchestrator 用 &lt;em>內建 raft cluster&lt;/em> 解決：&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">被管的 (Layer 1): primary MySQL → replica MySQL → replica MySQL → ...
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">管理者 (Layer 2): orchestrator instance × 3 (or 5) — 用 raft 自己選 leader
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">管理者狀態存放 (Layer 3): 每個 orchestrator instance 自己有 MySQL backend (state)&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Orchestrator 3 個 instance 構成 &lt;em>raft cluster&lt;/em>、自己選 leader。Leader 才有 &lt;em>寫入 state&lt;/em> + &lt;em>發起 failover&lt;/em> 權限、其他 instance follower 同步 state。Leader 失聯 → raft 重新選 leader（&amp;lt; 10 秒）、新 leader 繼續 manage MySQL topology。&lt;/p>
&lt;p>跟 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/patroni-ha/" data-link-title="PostgreSQL Patroni HA：從 leader 失聯到 client 重連的 5 段 failover lifecycle" data-link-desc="Patroni 把 PostgreSQL HA 拆成 detection / election / promotion / reconfiguration / recovery 五段 lifecycle、每段都有獨立配置跟 failure mode；DCS quorum &amp;#43; watchdog 防 split-brain、async/sync replication 取捨、5 個 production 踩雷、跟 PgBouncer / HAProxy / cert-manager 整合">PostgreSQL Patroni&lt;/a> 不同：Patroni 需要 &lt;em>外部 DCS&lt;/em>（etcd / Consul）作為 source of truth、Patroni 本身 stateless；Orchestrator 內建 raft、不需要外部 DCS、但每個 orchestrator instance 需要 &lt;em>自己的 MySQL backend&lt;/em> 存 state。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/mysql/" data-link-title="MySQL" data-link-desc="高併發網路服務常用關聯式資料庫、Vitess / PlanetScale 分片生態、GitHub / Shopify / Facebook 規模驗證">MySQL</a> overview 的 implementation-layer deep article。Overview 已說明 MySQL 在 OLTP 譜系的定位、本文聚焦 <em>Orchestrator failover</em> — 自動 HA 的工具雙層架構跟 5 段 decision tree。</p></blockquote>
<hr>
<blockquote>
<p>用詞註：Orchestrator 工具命名與 MySQL 5.7- SQL 命令（<code>SHOW SLAVE STATUS</code> / <code>CHANGE MASTER TO</code> / <code>STOP SLAVE</code> 等）沿用 <em>master / slave</em>。MySQL 8.0+ 改採 <em>primary / replica</em>、但 SQL syntax 仍保留別名。本文出現 master / slave 處對應 8.0 primary / replica 概念。</p></blockquote>
<p>讀者第一個會問的問題：「Orchestrator 自己會壞嗎？壞了誰 failover Orchestrator？」這個 paradox 是 <em>任何 HA 工具</em> 的核心議題、PostgreSQL 的 Patroni 用 DCS（etcd / Consul）解決、MySQL 的 Orchestrator 用 <em>內建 raft cluster</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">被管的 (Layer 1):       primary MySQL → replica MySQL → replica MySQL → ...
</span></span><span class="line"><span class="ln">2</span><span class="cl">管理者 (Layer 2):       orchestrator instance × 3 (or 5) — 用 raft 自己選 leader
</span></span><span class="line"><span class="ln">3</span><span class="cl">管理者狀態存放 (Layer 3): 每個 orchestrator instance 自己有 MySQL backend (state)</span></span></code></pre></div><p>Orchestrator 3 個 instance 構成 <em>raft cluster</em>、自己選 leader。Leader 才有 <em>寫入 state</em> + <em>發起 failover</em> 權限、其他 instance follower 同步 state。Leader 失聯 → raft 重新選 leader（&lt; 10 秒）、新 leader 繼續 manage MySQL topology。</p>
<p>跟 <a href="/blog/backend/01-database/vendors/postgresql/patroni-ha/" data-link-title="PostgreSQL Patroni HA：從 leader 失聯到 client 重連的 5 段 failover lifecycle" data-link-desc="Patroni 把 PostgreSQL HA 拆成 detection / election / promotion / reconfiguration / recovery 五段 lifecycle、每段都有獨立配置跟 failure mode；DCS quorum &#43; watchdog 防 split-brain、async/sync replication 取捨、5 個 production 踩雷、跟 PgBouncer / HAProxy / cert-manager 整合">PostgreSQL Patroni</a> 不同：Patroni 需要 <em>外部 DCS</em>（etcd / Consul）作為 source of truth、Patroni 本身 stateless；Orchestrator 內建 raft、不需要外部 DCS、但每個 orchestrator instance 需要 <em>自己的 MySQL backend</em> 存 state。</p>
<h2 id="orchestrator-雙層架構管-mysql-的-layer-2">Orchestrator 雙層架構：管 MySQL 的 Layer 2</h2>
<p>Layer 1 是 <em>被管的</em> MySQL cluster — primary + replica 群。Layer 2 是 <em>管理者</em> — orchestrator instance 群。Layer 2 監視 Layer 1、Layer 2 自己用 raft 自管。</p>
<p><strong>Layer 1 對 Orchestrator 的需求</strong>：</p>
<ul>
<li>所有 MySQL server 啟用 <code>binlog</code> + <code>log_slave_updates</code>（讓 Orchestrator 看得到 binlog event）</li>
<li>啟用 GTID（Orchestrator failover decision 依賴 GTID 比較進度、不用算 binlog position）</li>
<li>每個 server 有 <em>orchestrator user</em>（<code>GRANT SUPER, REPLICATION CLIENT, REPLICATION SLAVE, PROCESS ON *.* TO 'orchestrator'@'%'</code>）</li>
</ul>
<p><strong>Layer 2 配置</strong>：</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="c1"># /etc/orchestrator.conf.json (簡化)</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="na">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="na">&#34;MySQLOrchestratorHost&#34;: &#34;orchestrator-backend.example.com&#34;,</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="na">&#34;MySQLOrchestratorPort&#34;: 3306,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="na">&#34;MySQLOrchestratorDatabase&#34;: &#34;orchestrator&#34;,</span>
</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 class="c1"># 用 backend MySQL（每個 orchestrator instance 自己一個）+ raft 同步</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="na">&#34;RaftEnabled&#34;: true,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="na">&#34;RaftDataDir&#34;: &#34;/var/lib/orchestrator&#34;,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="na">&#34;RaftBind&#34;: &#34;10.0.1.10:10008&#34;,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="na">&#34;RaftNodes&#34;: [</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="na">&#34;orchestrator1.example.com:10008&#34;,</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="na">&#34;orchestrator2.example.com:10008&#34;,</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="na">&#34;orchestrator3.example.com:10008&#34;</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">  <span class="na">],</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">
</span></span><span class="line"><span class="ln">17</span><span class="cl">  <span class="c1"># Topology discovery</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">  <span class="na">&#34;DiscoverByShowSlaveHosts&#34;: true,</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">  <span class="na">&#34;InstancePollSeconds&#34;: 5,</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">
</span></span><span class="line"><span class="ln">21</span><span class="cl">  <span class="c1"># Failover detection</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">  <span class="na">&#34;FailureDetectionPeriodBlockMinutes&#34;: 60,</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">  <span class="na">&#34;RecoveryPeriodBlockSeconds&#34;: 3600,</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">
</span></span><span class="line"><span class="ln">25</span><span class="cl">  <span class="c1"># Failover automation</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">  <span class="na">&#34;RecoverMasterClusterFilters&#34;: [&#34;*&#34;],</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">  <span class="na">&#34;RecoverIntermediateMasterClusterFilters&#34;: [&#34;*&#34;],</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">  <span class="na">&#34;PreFailoverProcesses&#34;: [&#34;/usr/local/bin/orchestrator-fence-master.sh&#34;],</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">  <span class="na">&#34;PostFailoverProcesses&#34;: [&#34;/usr/local/bin/orchestrator-notify-proxysql.sh&#34;]</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl"><span class="na">}</span></span></span></code></pre></div><h2 id="stage-1topology-discovery--自動發現--manual-seed">Stage 1：Topology Discovery — 自動發現 + manual seed</h2>
<p>Orchestrator 啟動後 <em>seed</em> 一個或多個 MySQL server、自動發現整個 topology：</p>
<ul>
<li>連 seed server → <code>SHOW SLAVE HOSTS</code> → 發現所有 replica</li>
<li>對每個 replica 跑 <code>SHOW MASTER STATUS</code> + <code>SHOW SLAVE STATUS</code> → 建立 <em>父子關係 graph</em></li>
<li>持續 poll（<code>InstancePollSeconds=5</code>）每 5 秒更新 topology state</li>
</ul>
<p><strong>Topology graph 的 node</strong>：</p>
<ul>
<li><em>Master</em>：no slave status、被多個 replica 指</li>
<li><em>Intermediate master</em>：有 slave status 也有下游 replica（chained replication）</li>
<li><em>Co-master</em>：互相 replicate（罕見、active-passive failover 場景）</li>
<li><em>Replica</em>：有 slave status、無下游</li>
</ul>
<p>Topology 可視化：Orchestrator UI（web）顯示 cluster 樹狀圖、操作員可手動 drag-and-drop replica 重新 attach。</p>
<h2 id="stage-2failure-detection--區分真壞跟假壞">Stage 2：Failure Detection — 區分真壞跟假壞</h2>
<p>Orchestrator 不是 <em>單一 ping 失敗就 failover</em>、有 <em>holistic detection</em>：</p>
<table>
  <thead>
      <tr>
          <th>指標</th>
          <th>解讀</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Master <code>connect fail</code></td>
          <td>可能 network blip、不一定真壞</td>
      </tr>
      <tr>
          <td>Master <code>timeout poll</code></td>
          <td>可能 master loaded、不一定真壞</td>
      </tr>
      <tr>
          <td><strong>Replica 全部 <code>IO error</code></strong></td>
          <td>Master 真的對 replica 不可達、強訊號</td>
      </tr>
      <tr>
          <td>Replica 看到 master 還活著</td>
          <td>Master 對 orchestrator 不可達、可能是 <em>orchestrator network</em> 問題、不是 master</td>
      </tr>
      <tr>
          <td>Replica lag 暴增</td>
          <td>Master 可能還活著但 overload、不一定要 failover</td>
      </tr>
  </tbody>
</table>
<p><strong>Detection rule</strong>：Master <em>自己連不上</em> + <em>至少一個 replica 也看 master IO error</em> → 判定 <code>DeadMaster</code>。單一 orchestrator 連不上 master 不觸發 — 防 orchestrator network 隔離造成的 false positive failover。</p>
<h2 id="stage-3failover-decision-tree--選哪個-replica-promote">Stage 3：Failover Decision Tree — 選哪個 replica promote</h2>
<p>判定 <code>DeadMaster</code> 後不是 <em>選最近的 replica</em>、用 decision tree：</p>
<ol>
<li><strong>GTID 最新的 replica</strong>：跟舊 master 同步最完整（用 <code>Executed_Gtid_Set</code> 對比）</li>
<li><strong>同 DC / AZ 的 replica</strong>（如果有 multi-DC 配置）</li>
<li><strong>手動指定的 promotion candidate</strong>（<code>promote_rule=must</code> 或 <code>prefer</code>）</li>
<li><strong>Semi-sync ack 的 replica</strong>（如果 semi-sync 啟用）</li>
</ol>
<p>GTID 最新是基本要求。其他規則是 <em>tie-breaker</em>。</p>
<p><strong>Errant transaction 處理</strong>：選出的 candidate replica 如果有 <em>errant GTID</em>（master 沒有但 replica 有的 transaction）、Orchestrator <em>不會 promote 這個 replica</em>（怕 errant transaction 變成 new master state）。改選次優 candidate。</p>
<h2 id="stage-4promote-action--5-步-atomic理想情況">Stage 4：Promote Action — 5 步 atomic（理想情況）</h2>
<p>選好 candidate 後執行：</p>
<ol>
<li><strong>Fence 舊 master</strong>（pre-failover hook）：把舊 master 對外停掉、防 split-brain</li>
<li><strong>STOP SLAVE on candidate</strong>：candidate 不再從舊 master pull binlog</li>
<li><strong>RESET SLAVE ALL on candidate</strong>：candidate 清掉 slave 配置、變成獨立 master</li>
<li><strong>Re-attach 其他 replica</strong>：用 <code>CHANGE MASTER TO MASTER_HOST=&lt;candidate&gt;, MASTER_AUTO_POSITION=1</code>（GTID auto-position）</li>
<li><strong>Post-failover hook</strong>：通知 ProxySQL / HAProxy / DNS 切流量</li>
</ol>
<p>每步任一失敗、Orchestrator 可能停在中間狀態、需要 <em>人工介入</em>。</p>
<h2 id="stage-5recovery--old-master-怎麼處理">Stage 5：Recovery — Old master 怎麼處理</h2>
<p>Failover 完、舊 master 可能：</p>
<ul>
<li><em>真的死了</em>：物理 server 故障 / region outage → 不必處理、未來修好作為新 replica re-attach</li>
<li><em>Network blip 後復活</em>：舊 master 自己 <em>仍認為自己是 master</em>、再次接受寫入會造成 split-brain</li>
</ul>
<p>修法：</p>
<ul>
<li><em>Fencing</em>（必須）：pre-failover hook 把舊 master 對外 firewall 掉、或 force <code>read_only=1</code>、防舊 master 復活後接受寫入</li>
<li><em>Manual reset</em>：舊 master 復活後人工 confirm 是否變成新 master 的 replica（不要自動、自動容易誤判）</li>
</ul>
<p>Orchestrator UI 在偵測到 errant master 時會標 warning、不會自動處理。</p>
<h2 id="5-個-production-踩雷">5 個 Production 踩雷</h2>
<h3 id="1-split-brain--pre-failover-hook-沒-fence-舊-master">1. Split-brain — pre-failover hook 沒 fence 舊 master</h3>
<p>舊 master network blip 後復活、orchestrator 已 promote 新 master、application 部分 instance 連舊 master、部分連新 master、雙寫造成 data divergence。</p>
<p>修法：</p>
<ul>
<li><em>Pre-failover hook 必須 fence</em>（不是可選）：
<ul>
<li>物理 fencing：透過 IPMI 重啟 / 關 server</li>
<li>Network fencing：透過 firewall rule 切斷 server 對外連線</li>
<li>MySQL fencing：<code>SET GLOBAL read_only=1</code> + <code>KILL</code> 所有 active connection</li>
</ul>
</li>
<li>用 <em>VIP / DNS</em> 配合：fence 完才切 VIP / DNS 到新 master、避免 application 連舊 IP</li>
<li>不依賴 application 連線 string 動態變更（DNS TTL 期間仍可能連舊 IP）</li>
</ul>
<h3 id="2-pre-failover-hook-失敗--orchestrator-該停還是該繼續">2. Pre-failover hook 失敗 — Orchestrator 該停還是該繼續</h3>
<p>Pre-failover hook 跑失敗（fence script 因為 SSH 不通、IPMI 沒回應）。Orchestrator 有兩種策略：</p>
<ul>
<li><em>PostponeReplicaRecoveryOnLagMinutes</em>：等 hook 成功才繼續、可能永遠 stuck</li>
<li><em>FailMasterPromotionOnLagMinutes</em>：放棄 promotion、留 cluster degraded（無 master）</li>
</ul>
<p>兩者都不理想。多數 production 選 <em>PostponeReplicaRecoveryOnLagMinutes=10</em>：等 10 分鐘 hook 成功、超時則 alert 人工介入、不繼續 auto-promote（人工 review 才是正確選擇）。</p>
<h3 id="3-anti-flapping-窗口太短--master-抖動-vs-真死">3. Anti-flapping 窗口太短 — Master 抖動 vs 真死</h3>
<p><code>FailureDetectionPeriodBlockMinutes=60</code>：偵測一次 failure 後 60 分鐘內不再 trigger failover（即使再偵測到 failure）。預設 60 分鐘對 <em>第一次 failover 後 master 仍不穩</em> 的場景太長 — 60 分鐘內 master 真的死了第二次、orchestrator 不 failover。預設 60 分鐘對 <em>網路抖動</em> 的場景太短 — 60 分鐘內可能 multiple failover、cluster 一直在 promote。</p>
<p>修法：</p>
<ul>
<li>評估自己 cluster 的 <em>typical recovery time</em>：1-2 小時、設 <code>FailureDetectionPeriodBlockMinutes=120</code></li>
<li>監控 <em>failover 頻率</em>、單週 &gt; 2 次表示底層問題（網路 / hardware）、不是調 anti-flapping window 解決</li>
</ul>
<h3 id="4-gtid-errant-transaction--orchestrator-拒絕-promote-但沒講原因">4. GTID errant transaction — Orchestrator 拒絕 promote 但沒講原因</h3>
<p>Candidate replica 有 <em>errant GTID</em>（從別處 inject 的 transaction）、Orchestrator 拒絕 promote、log 訊息 <code>errant GTID detected</code>、但 <em>沒寫實際是哪個 GTID</em>。On-call 在事故中沒辦法 debug。</p>
<p>修法：</p>
<ul>
<li>平時 <em>監控 errant GTID</em>：定期跑 <code>pt-show-grants</code> + GTID 比對、不要等 failover 才發現</li>
<li>Orchestrator 的 <code>OrchestratorIssuesAGtidPurge</code> 設 true：preview mode 看 errant GTID 的位置</li>
<li>Errant GTID 來源通常是 <em>人為 inject</em>（DBA 直接寫 replica 然後 binlog 出現）、教育 DBA 不要直接連 replica 寫</li>
</ul>
<h3 id="5-vip--proxysql-整合斷層--切流量延遲">5. VIP / ProxySQL 整合斷層 — 切流量延遲</h3>
<p>Post-failover hook 跑完 <em>script 上報</em>「我切完了」、但實際 <em>VIP / DNS / ProxySQL 還沒看到變化</em>。Application 連 stale endpoint 30 秒、寫入失敗。</p>
<p>修法：</p>
<ul>
<li><em>Post-failover hook 不只 trigger 切換、要 wait 切換完成</em>：
<ul>
<li>VIP：等 <code>arping</code> 確認新 IP 已 propagate</li>
<li>ProxySQL：等 <code>mysql_servers</code> runtime table 更新 + 確認 monitor module 看到新 primary</li>
<li>DNS：先把 TTL 降到極短（5 秒）、再切 DNS、等 TTL 過</li>
</ul>
</li>
<li>Orchestrator <code>PostFailoverProcessesFailOnError=true</code>：hook 失敗整個 failover 標記失敗、人工檢查</li>
<li>ProxySQL 用 <code>mysql_replication_hostgroups</code> 自動偵測 read_only flag、可不依賴 hook（推薦）</li>
</ul>
<h2 id="容量規劃要點">容量規劃要點</h2>
<table>
  <thead>
      <tr>
          <th>元件</th>
          <th>配置建議</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Orchestrator instance 數量</td>
          <td>3（raft cluster 最小、odd number、容忍 1 個故障）</td>
      </tr>
      <tr>
          <td>每個 instance MySQL backend</td>
          <td>1 個獨立 MySQL（不要共用、不要用被管的 cluster）</td>
      </tr>
      <tr>
          <td>Backend MySQL spec</td>
          <td>t3.small 級別、Orchestrator state ~1 GB</td>
      </tr>
      <tr>
          <td>Network latency</td>
          <td>raft 同 region 內、跨 AZ 可接受（&lt; 5ms）、跨 region 不推薦</td>
      </tr>
      <tr>
          <td>InstancePollSeconds</td>
          <td>5 秒（預設）— 越小越敏感、越大越省連線</td>
      </tr>
  </tbody>
</table>
<p>3 instance raft cluster 容忍 1 instance 故障。5 instance 容忍 2 instance 故障但 quorum cost 高、99% 場景 3 個夠用。</p>
<h2 id="跟其他模組整合">跟其他模組整合</h2>
<h3 id="跟-replication-topology">跟 Replication topology</h3>
<p>Orchestrator 100% 依賴 GTID + binlog ROW format（<a href="/blog/backend/01-database/vendors/mysql/replication-topology/" data-link-title="MySQL Replication Topology：async / semi-sync / GTID 不是三選一、是三個 trade-off 軸的疊加" data-link-desc="MySQL replication 不是「選 async 還是 semi-sync」、是 *durability / latency / consistency* 三個 trade-off 軸的疊加；GTID 是跨 mode 的 infrastructure layer、不是第三種 mode。本文走 3 軸取捨模型 → async / semi-sync 行為對比 → GTID 替代 binlog-position 的好處 → 配置 step-by-step → 5 production 踩雷（lag 暴衝 / semi-sync 退回 async / GTID gap / Loss-Less semi-sync 真的 loss-less / chained replication 雪崩）→ 跟 Aurora MySQL / Vitess / ProxySQL / Orchestrator 整合">Replication Topology</a>）。沒 GTID 用 binlog position、failover 時 re-pointing 容易出錯、Orchestrator 強烈建議 GTID。</p>
<h3 id="跟-proxysql">跟 ProxySQL</h3>
<p><a href="/blog/backend/01-database/vendors/mysql/proxysql-config/" data-link-title="MySQL ProxySQL 配置：connection / query / route / response 四段 lifecycle 跟 query rule 設計" data-link-desc="ProxySQL 是 MySQL 生態的 connection pool &#43; query routing 標準。本文走 connection → query parse → route → response 四段 lifecycle、query rule engine 的 rule chain 設計、Hostgroup / Server / User 三層 schema、配置 step-by-step（讀寫分離 &#43; replica lag-aware routing）、5 production 踩雷（query rule 順序錯亂 / connection 漂移 / write 路由到 replica / runtime / disk schema drift / mirror traffic 副作用）、跟 Replication / Orchestrator / HAProxy 整合">ProxySQL</a> 用 <code>mysql_replication_hostgroups</code> 自動偵測 <code>read_only</code> flag — orchestrator 切完新 master 後、ProxySQL monitor module 自動看到新 master 的 <code>read_only=0</code>、自動更新 routing、application 不用改 connection string。</p>
<p>這個 <em>無需 post-failover hook 通知 ProxySQL</em> 的整合是 ProxySQL + Orchestrator 組合的最大優勢、比手動 hook 通知 VIP / DNS 可靠。</p>
<h3 id="跟-patronipostgresql-對應">跟 Patroni（PostgreSQL 對應）</h3>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>Orchestrator</th>
          <th>Patroni</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>DCS</td>
          <td>內建 raft（不需外部）</td>
          <td>外部（etcd / Consul / ZooKeeper）</td>
      </tr>
      <tr>
          <td>State storage</td>
          <td>每 instance 一個 MySQL backend</td>
          <td>DCS 本身</td>
      </tr>
      <tr>
          <td>Topology discovery</td>
          <td>自動 + manual seed</td>
          <td>自動（透過 DCS）</td>
      </tr>
      <tr>
          <td>Fencing</td>
          <td>Pre-failover hook（自實作）</td>
          <td>Watchdog（內建）</td>
      </tr>
      <tr>
          <td>5+ year 生產驗證</td>
          <td>GitHub / Booking.com / Shopify</td>
          <td>Zalando / 多個歐美企業</td>
      </tr>
  </tbody>
</table>
<p>兩者角色相同、設計取捨不同。Patroni 對 DCS 高依賴、Orchestrator 對自己 backend MySQL 高依賴。</p>
<h3 id="跟-rds--aurora-mysql">跟 RDS / Aurora MySQL</h3>
<p>AWS RDS / Aurora 內建 multi-AZ failover、<em>不用 Orchestrator</em>。Aurora failover &lt; 30 秒、RDS failover ~60-120 秒。Aurora 把 replication / failover 整套封進 storage layer、application 看到的是 reader endpoint + writer endpoint。</p>
<p>詳見 <a href="/blog/backend/01-database/vendors/aurora/" data-link-title="AWS Aurora" data-link-desc="AWS managed PostgreSQL / MySQL、storage / compute 分離、&#43;75% 效能改善的 production 證據">Aurora vendor page</a>。</p>
<h3 id="跟-vitess">跟 Vitess</h3>
<p>Vitess shard 內部用 <em>VTOrc</em>（Vitess fork of Orchestrator）— 概念跟 Orchestrator 一致、針對 Vitess topology metadata 適配。</p>
<p>詳見 <em>Vitess sharding 設計</em> 篇（待寫）。</p>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/mysql/" data-link-title="MySQL" data-link-desc="高併發網路服務常用關聯式資料庫、Vitess / PlanetScale 分片生態、GitHub / Shopify / Facebook 規模驗證">MySQL vendor overview</a></li>
<li><a href="/blog/backend/01-database/vendors/mysql/replication-topology/" data-link-title="MySQL Replication Topology：async / semi-sync / GTID 不是三選一、是三個 trade-off 軸的疊加" data-link-desc="MySQL replication 不是「選 async 還是 semi-sync」、是 *durability / latency / consistency* 三個 trade-off 軸的疊加；GTID 是跨 mode 的 infrastructure layer、不是第三種 mode。本文走 3 軸取捨模型 → async / semi-sync 行為對比 → GTID 替代 binlog-position 的好處 → 配置 step-by-step → 5 production 踩雷（lag 暴衝 / semi-sync 退回 async / GTID gap / Loss-Less semi-sync 真的 loss-less / chained replication 雪崩）→ 跟 Aurora MySQL / Vitess / ProxySQL / Orchestrator 整合">MySQL Replication Topology</a>（GTID 是 Orchestrator pre-requisite）</li>
<li><a href="/blog/backend/01-database/vendors/mysql/proxysql-config/" data-link-title="MySQL ProxySQL 配置：connection / query / route / response 四段 lifecycle 跟 query rule 設計" data-link-desc="ProxySQL 是 MySQL 生態的 connection pool &#43; query routing 標準。本文走 connection → query parse → route → response 四段 lifecycle、query rule engine 的 rule chain 設計、Hostgroup / Server / User 三層 schema、配置 step-by-step（讀寫分離 &#43; replica lag-aware routing）、5 production 踩雷（query rule 順序錯亂 / connection 漂移 / write 路由到 replica / runtime / disk schema drift / mirror traffic 副作用）、跟 Replication / Orchestrator / HAProxy 整合">MySQL ProxySQL 配置</a>（Orchestrator + ProxySQL 自動失效切換組合）</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/patroni-ha/" data-link-title="PostgreSQL Patroni HA：從 leader 失聯到 client 重連的 5 段 failover lifecycle" data-link-desc="Patroni 把 PostgreSQL HA 拆成 detection / election / promotion / reconfiguration / recovery 五段 lifecycle、每段都有獨立配置跟 failure mode；DCS quorum &#43; watchdog 防 split-brain、async/sync replication 取捨、5 個 production 踩雷、跟 PgBouncer / HAProxy / cert-manager 整合">PostgreSQL Patroni HA</a>（PG sibling、不同 HA 機制）</li>
<li><a href="/blog/backend/01-database/vendors/aurora/" data-link-title="AWS Aurora" data-link-desc="AWS managed PostgreSQL / MySQL、storage / compute 分離、&#43;75% 效能改善的 production 證據">Aurora vendor page</a>（managed MySQL、Orchestrator 不需要）</li>
<li><a href="/blog/backend/knowledge-cards/quorum/" data-link-title="Quorum" data-link-desc="分散式系統以多數節點同意作為提交或讀取有效性的門檻">quorum 卡片</a> / <a href="/blog/backend/knowledge-cards/failover/" data-link-title="Failover" data-link-desc="說明主要服務或節點失效時如何切換到備援能力">failover 卡片</a></li>
<li>官方：<a href="https://github.com/openark/orchestrator">orchestrator GitHub</a> / <a href="https://github.com/openark/orchestrator/tree/master/docs">orchestrator docs</a></li>
</ul>
]]></content:encoded></item><item><title>PostgreSQL Index Selection：B-tree / GIN / GiST / BRIN / Hash 對應 workload 的決策樹</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/index-selection/</link><pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/index-selection/</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>index 選型&lt;/em> — 何時用哪種 index、跟 &lt;a href="https://tarrragon.github.io/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&lt;/a> 的「為什麼這個 plan 慢」互補。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="6-種-index-method-對應-workload">6 種 Index Method 對應 Workload&lt;/h2>
&lt;p>PG 有 6 種 index access method、各有自己擅長的 query pattern：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Index method&lt;/th>
 &lt;th>適用 query pattern&lt;/th>
 &lt;th>典型 column type&lt;/th>
 &lt;th>儲存成本&lt;/th>
 &lt;th>&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>B-tree&lt;/td>
 &lt;td>&lt;code>=&lt;/code> / &lt;code>&amp;lt;&lt;/code> / &lt;code>&amp;gt;&lt;/code> / &lt;code>BETWEEN&lt;/code> / &lt;code>IS NULL&lt;/code> / &lt;code>LIKE 'prefix%'&lt;/code>&lt;/td>
 &lt;td>任何 scalar、最常用&lt;/td>
 &lt;td>中&lt;/td>
 &lt;td>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Hash&lt;/td>
 &lt;td>純 &lt;code>=&lt;/code> 比對&lt;/td>
 &lt;td>scalar、不常用&lt;/td>
 &lt;td>低&lt;/td>
 &lt;td>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>GIN&lt;/td>
 &lt;td>&lt;code>@&amp;gt;&lt;/code> / &lt;code>?&lt;/code> / `?&lt;/td>
 &lt;td>` / FTS / array 包含&lt;/td>
 &lt;td>JSONB / tsvector / array&lt;/td>
 &lt;td>高（write 慢）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>GiST&lt;/td>
 &lt;td>範圍 / 空間 / 自訂 operator&lt;/td>
 &lt;td>geometry / tsvector / range&lt;/td>
 &lt;td>中&lt;/td>
 &lt;td>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>SP-GiST&lt;/td>
 &lt;td>Non-balanced 樹結構&lt;/td>
 &lt;td>IP / phone prefix / quad-tree&lt;/td>
 &lt;td>中&lt;/td>
 &lt;td>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>BRIN&lt;/td>
 &lt;td>大表的 range scan、physical order 跟 logical order 相關&lt;/td>
 &lt;td>timestamp / id（append-only）&lt;/td>
 &lt;td>極低&lt;/td>
 &lt;td>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>選錯 index 的代價：&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>index 選型</em> — 何時用哪種 index、跟 <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> 的「為什麼這個 plan 慢」互補。</p></blockquote>
<hr>
<h2 id="6-種-index-method-對應-workload">6 種 Index Method 對應 Workload</h2>
<p>PG 有 6 種 index access method、各有自己擅長的 query pattern：</p>
<table>
  <thead>
      <tr>
          <th>Index method</th>
          <th>適用 query pattern</th>
          <th>典型 column type</th>
          <th>儲存成本</th>
          <th></th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>B-tree</td>
          <td><code>=</code> / <code>&lt;</code> / <code>&gt;</code> / <code>BETWEEN</code> / <code>IS NULL</code> / <code>LIKE 'prefix%'</code></td>
          <td>任何 scalar、最常用</td>
          <td>中</td>
          <td></td>
      </tr>
      <tr>
          <td>Hash</td>
          <td>純 <code>=</code> 比對</td>
          <td>scalar、不常用</td>
          <td>低</td>
          <td></td>
      </tr>
      <tr>
          <td>GIN</td>
          <td><code>@&gt;</code> / <code>?</code> / `?</td>
          <td>` / FTS / array 包含</td>
          <td>JSONB / tsvector / array</td>
          <td>高（write 慢）</td>
      </tr>
      <tr>
          <td>GiST</td>
          <td>範圍 / 空間 / 自訂 operator</td>
          <td>geometry / tsvector / range</td>
          <td>中</td>
          <td></td>
      </tr>
      <tr>
          <td>SP-GiST</td>
          <td>Non-balanced 樹結構</td>
          <td>IP / phone prefix / quad-tree</td>
          <td>中</td>
          <td></td>
      </tr>
      <tr>
          <td>BRIN</td>
          <td>大表的 range scan、physical order 跟 logical order 相關</td>
          <td>timestamp / id（append-only）</td>
          <td>極低</td>
          <td></td>
      </tr>
  </tbody>
</table>
<p>選錯 index 的代價：</p>
<ul>
<li><strong>Write workload</strong>：每 write 都更新所有相關 index、5 個 unused index = 5x write 放大</li>
<li><strong>Storage</strong>：JSONB 加 GIN 可能比表本身還大</li>
<li><strong>Plan misjudge</strong>：planner 看到 index 不一定用、<code>EXPLAIN</code> 才確認</li>
</ul>
<h2 id="b-tree預設選擇95-workload-適用">B-tree：預設選擇、95% workload 適用</h2>
<p>B-tree 是 PG 預設 index、CREATE INDEX 不指定 method 就是 B-tree：</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_orders_user_id</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="p">(</span><span class="n">user_id</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="k">CREATE</span><span class="w"> </span><span class="k">INDEX</span><span class="w"> </span><span class="n">idx_orders_created_at</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="p">(</span><span class="n">created_at</span><span class="p">);</span></span></span></code></pre></div><p>B-tree 擅長的 query：</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">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">user_id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">42</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></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w"></span><span class="c1">-- 範圍
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">created_at</span><span class="w"> </span><span class="k">BETWEEN</span><span class="w"> </span><span class="s1">&#39;2025-01-01&#39;</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="s1">&#39;2025-01-31&#39;</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></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w"></span><span class="c1">-- IS NULL
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">shipped_at</span><span class="w"> </span><span class="k">IS</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"> 9</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w"></span><span class="c1">-- Prefix LIKE
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">products</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">sku</span><span class="w"> </span><span class="k">LIKE</span><span class="w"> </span><span class="s1">&#39;ABC%&#39;</span><span class="p">;</span></span></span></code></pre></div><p>B-tree 不擅長：</p>
<ul>
<li><code>LIKE '%suffix'</code>（前綴 wildcard）→ 改 trigram + GIN</li>
<li><code>column @&gt; array</code>（包含）→ 改 GIN</li>
<li>JSON 內部 path query → 改 GIN on JSONB</li>
</ul>
<p><strong>Multi-column B-tree</strong> 的順序很重要：</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">-- 假設常 query: WHERE user_id = ? AND status = ?
</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_orders_user_status</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="p">(</span><span class="n">user_id</span><span class="p">,</span><span class="w"> </span><span class="n">status</span><span class="p">);</span><span class="w">  </span><span class="c1">-- 對
</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_orders_status_user</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="p">(</span><span class="n">status</span><span class="p">,</span><span class="w"> </span><span class="n">user_id</span><span class="p">);</span><span class="w">  </span><span class="c1">-- 錯（status 選擇性低）</span></span></span></code></pre></div><p>順序原則：</p>
<ol>
<li><strong>等值 column 在前</strong>（高選擇性）</li>
<li><strong>範圍 column 在後</strong>（B-tree leftmost 規則）</li>
<li><strong>selectivity 高的在前</strong>（filter 更多 row）</li>
</ol>
<h2 id="ginjsonb--fts--array-的標配">GIN：JSONB / FTS / Array 的標配</h2>
<p>GIN（Generalized Inverted Index）對「一個 value 內含多個 sub-element」的 column 高效：</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">-- JSONB
</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_products_metadata</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">products</span><span class="w"> </span><span class="k">USING</span><span class="w"> </span><span class="n">GIN</span><span class="w"> </span><span class="p">(</span><span class="n">metadata</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></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w"></span><span class="c1">-- Array
</span></span></span><span class="line"><span class="ln"> 5</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_articles_tags</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">articles</span><span class="w"> </span><span class="k">USING</span><span class="w"> </span><span class="n">GIN</span><span class="w"> </span><span class="p">(</span><span class="n">tags</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></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w"></span><span class="c1">-- Full-text search
</span></span></span><span class="line"><span class="ln"> 8</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_articles_content</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">articles</span><span class="w"> </span><span class="k">USING</span><span class="w"> </span><span class="n">GIN</span><span class="w"> </span><span class="p">(</span><span class="n">to_tsvector</span><span class="p">(</span><span class="s1">&#39;english&#39;</span><span class="p">,</span><span class="w"> </span><span class="n">content</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></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w"></span><span class="c1">-- Trigram（fuzzy match）
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></span><span class="k">CREATE</span><span class="w"> </span><span class="n">EXTENSION</span><span class="w"> </span><span class="n">pg_trgm</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="k">CREATE</span><span class="w"> </span><span class="k">INDEX</span><span class="w"> </span><span class="n">idx_products_name_trgm</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">products</span><span class="w"> </span><span class="k">USING</span><span class="w"> </span><span class="n">GIN</span><span class="w"> </span><span class="p">(</span><span class="n">name</span><span class="w"> </span><span class="n">gin_trgm_ops</span><span class="p">);</span></span></span></code></pre></div><p>GIN 代價：</p>
<ul>
<li><strong>Write 慢 2-10x</strong>：每個 sub-element 都要更新 inverted index</li>
<li><strong>Storage 大</strong>：可能比表還大</li>
<li><strong>Vacuum 沉重</strong>：bloat 累積快</li>
</ul>
<p><strong>Operator class</strong> 選擇影響大：</p>
<table>
  <thead>
      <tr>
          <th>Op class</th>
          <th>適用</th>
          <th>索引大小</th>
          <th>支援 operator</th>
          <th></th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>jsonb_ops</code>（預設）</td>
          <td>通用</td>
          <td>大</td>
          <td><code>@&gt;</code> / <code>?</code> / `?</td>
          <td><code>/</code>?&amp;`</td>
      </tr>
      <tr>
          <td><code>jsonb_path_ops</code></td>
          <td>只 <code>@&gt;</code> containment</td>
          <td>1/3-1/2</td>
          <td>只 <code>@&gt;</code></td>
          <td></td>
      </tr>
  </tbody>
</table>
<p>只用 <code>@&gt;</code> query 時、<code>jsonb_path_ops</code> 救大量 storage。</p>
<h2 id="gist範圍--空間--自訂">GiST：範圍 / 空間 / 自訂</h2>
<p>GiST（Generalized Search Tree）擅長範圍跟空間：</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">-- 範圍 type（PostgreSQL 內建 int4range / tsrange 等）
</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_bookings_period</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">bookings</span><span class="w"> </span><span class="k">USING</span><span class="w"> </span><span class="n">GiST</span><span class="w"> </span><span class="p">(</span><span class="n">period</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></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="c1">-- 空間（PostGIS）
</span></span></span><span class="line"><span class="ln">5</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_locations_geom</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">locations</span><span class="w"> </span><span class="k">USING</span><span class="w"> </span><span class="n">GiST</span><span class="w"> </span><span class="p">(</span><span class="n">geom</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></span><span class="line"><span class="ln">7</span><span class="cl"><span class="w"></span><span class="c1">-- Exclusion constraint（範圍不重疊）
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1"></span><span class="k">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">bookings</span><span class="w"> </span><span class="k">ADD</span><span class="w"> </span><span class="k">CONSTRAINT</span><span class="w"> </span><span class="n">no_overlap</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">EXCLUDE</span><span class="w"> </span><span class="k">USING</span><span class="w"> </span><span class="n">GiST</span><span class="w"> </span><span class="p">(</span><span class="n">room_id</span><span class="w"> </span><span class="k">WITH</span><span class="w"> </span><span class="o">=</span><span class="p">,</span><span class="w"> </span><span class="n">period</span><span class="w"> </span><span class="k">WITH</span><span class="w"> </span><span class="o">&amp;&amp;</span><span class="p">);</span></span></span></code></pre></div><p>GiST vs GIN 對 FTS 的選擇：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>GIN</th>
          <th>GiST</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Lookup 速度</td>
          <td>快 3x</td>
          <td>慢</td>
      </tr>
      <tr>
          <td>Update 速度</td>
          <td>慢 3x</td>
          <td>快</td>
      </tr>
      <tr>
          <td>索引大小</td>
          <td>大</td>
          <td>小</td>
      </tr>
      <tr>
          <td>適合場景</td>
          <td>Read-heavy FTS</td>
          <td>Write-heavy / 即時更新</td>
      </tr>
  </tbody>
</table>
<p>多數 FTS workload 選 GIN — read 占多、index size 換 query latency 划算。</p>
<h2 id="brin大表--physical-order-correlated">BRIN：大表 + Physical Order Correlated</h2>
<p>BRIN（Block Range Index）對 <em>physical 儲存順序跟 logical 順序強相關</em> 的 column 高效：</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">-- timestamp column（append-only insert、physical 順序 = 時間順序）
</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_events_created_at</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">events</span><span class="w"> </span><span class="k">USING</span><span class="w"> </span><span class="n">BRIN</span><span class="w"> </span><span class="p">(</span><span class="n">created_at</span><span class="p">);</span></span></span></code></pre></div><p>BRIN 機制：每個 block range（預設 128 page）記 min/max、query 時跳過 range 外的 block。</p>
<p>適用場景：</p>
<ul>
<li><strong>append-only 表</strong>：log、metrics、events</li>
<li><strong>大表</strong>（10GB+）：B-tree 太貴、BRIN 1/1000 大小</li>
<li><strong>column physical order 跟 query 一致</strong>：時間欄、自增 id</li>
</ul>
<p><strong>BRIN 失效情境</strong>：</p>
<ul>
<li>UPDATE 破壞 physical order（row 被 vacuum 移到別 block）→ BRIN 失效</li>
<li>隨機 insert（uuid / hash id）→ BRIN range 完全沒選擇性</li>
</ul>
<p><strong>何時不該用 BRIN</strong>：表 &lt; 1GB（沒省 storage 收益）、column 沒 physical order correlation（CLUSTER 後可能改善）。</p>
<h2 id="partial-index條件式-index-救-storage">Partial Index：條件式 index 救 storage</h2>
<p>對 <em>只 query 部分 row</em> 的 column、partial index 救大量 storage：</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">-- 只 index unshipped order
</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_orders_unshipped</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="p">(</span><span class="n">created_at</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="k">WHERE</span><span class="w"> </span><span class="n">shipped_at</span><span class="w"> </span><span class="k">IS</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"> 4</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w"></span><span class="c1">-- 只 index active user
</span></span></span><span class="line"><span class="ln"> 6</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_users_active</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">users</span><span class="w"> </span><span class="p">(</span><span class="n">email</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">WHERE</span><span class="w"> </span><span class="n">status</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;active&#39;</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></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w"></span><span class="c1">-- 只 index 高金額 transaction
</span></span></span><span class="line"><span class="ln">10</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_orders_high_value</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="p">(</span><span class="n">user_id</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="k">WHERE</span><span class="w"> </span><span class="n">total</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="mi">1000</span><span class="p">;</span></span></span></code></pre></div><p>Partial index 的 query 要 <em>完全匹配 WHERE 條件</em> 才用得到：</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">-- 用得到 partial index
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">shipped_at</span><span class="w"> </span><span class="k">IS</span><span class="w"> </span><span class="k">NULL</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="n">created_at</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="s1">&#39;2025-01-01&#39;</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></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="c1">-- 用不到（planner 不 prove WHERE 包含 partial 條件）
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">created_at</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="s1">&#39;2025-01-01&#39;</span><span class="p">;</span></span></span></code></pre></div><p>實務 size 救法：unshipped order 只 1% 總量、partial index 1/100 大小。</p>
<h2 id="expression-index對函式結果-index">Expression Index：對函式結果 index</h2>





<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">-- 對 lowercased email index（case-insensitive search）
</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_users_email_lower</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">users</span><span class="w"> </span><span class="p">(</span><span class="k">lower</span><span class="p">(</span><span class="n">email</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="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">users</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="k">lower</span><span class="p">(</span><span class="n">email</span><span class="p">)</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">lower</span><span class="p">(</span><span class="s1">&#39;USER@example.com&#39;</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></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w"></span><span class="c1">-- 對 JSONB 內部欄位
</span></span></span><span class="line"><span class="ln"> 6</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_products_category</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">products</span><span class="w"> </span><span class="p">((</span><span class="n">metadata</span><span class="o">-&gt;&gt;</span><span class="s1">&#39;category&#39;</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">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">products</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">metadata</span><span class="o">-&gt;&gt;</span><span class="s1">&#39;category&#39;</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;shoes&#39;</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></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w"></span><span class="c1">-- 對日期截斷
</span></span></span><span class="line"><span class="ln">10</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_orders_day</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="p">(</span><span class="n">date_trunc</span><span class="p">(</span><span class="s1">&#39;day&#39;</span><span class="p">,</span><span class="w"> </span><span class="n">created_at</span><span class="p">));</span></span></span></code></pre></div><p>Expression 必須 IMMUTABLE — <code>now()</code> / <code>random()</code> 不能用、<code>timezone('UTC', ts)</code> 可以。</p>
<h2 id="covering-indexinclude避免回表">Covering Index（INCLUDE）：避免回表</h2>
<p>PG 11+ 支援 INCLUDE column：</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">-- 只 index user_id、但 query 常要 email
</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_users_user_id_covering</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">users</span><span class="w"> </span><span class="p">(</span><span class="n">user_id</span><span class="p">)</span><span class="w"> </span><span class="n">INCLUDE</span><span class="w"> </span><span class="p">(</span><span class="n">email</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></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="c1">-- Index-only scan：不用回表
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">email</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">users</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">user_id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">42</span><span class="p">;</span></span></span></code></pre></div><p>INCLUDE column 不參與 sorting / equality、只放 leaf node、救 IO。</p>
<h2 id="index-選擇決策樹">Index 選擇決策樹</h2>





<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">Query pattern 是什麼？
</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">├─ 等值 / 範圍 / prefix LIKE / IS NULL
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">│  └─ B-tree（90% 場景）
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">│     ├─ 只 query 部分 row？→ Partial B-tree
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">│     ├─ 對函式結果？→ Expression B-tree
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">│     └─ 需要回表更多 column？→ Covering（INCLUDE）
</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">├─ JSONB 內部 query / array 包含 / FTS
</span></span><span class="line"><span class="ln">10</span><span class="cl">│  └─ GIN
</span></span><span class="line"><span class="ln">11</span><span class="cl">│     ├─ 只用 @&gt;？→ jsonb_path_ops 救 storage
</span></span><span class="line"><span class="ln">12</span><span class="cl">│     └─ FTS write-heavy？→ 改 GiST
</span></span><span class="line"><span class="ln">13</span><span class="cl">│
</span></span><span class="line"><span class="ln">14</span><span class="cl">├─ 範圍 type（int4range / tsrange）/ 空間
</span></span><span class="line"><span class="ln">15</span><span class="cl">│  └─ GiST
</span></span><span class="line"><span class="ln">16</span><span class="cl">│
</span></span><span class="line"><span class="ln">17</span><span class="cl">├─ 大表 + append-only + physical order correlated
</span></span><span class="line"><span class="ln">18</span><span class="cl">│  └─ BRIN
</span></span><span class="line"><span class="ln">19</span><span class="cl">│
</span></span><span class="line"><span class="ln">20</span><span class="cl">├─ 純 equality + 簡單 column
</span></span><span class="line"><span class="ln">21</span><span class="cl">│  └─ Hash（很少用、B-tree 通常更好）
</span></span><span class="line"><span class="ln">22</span><span class="cl">│
</span></span><span class="line"><span class="ln">23</span><span class="cl">└─ Non-balanced 樹（IP prefix / quad-tree）
</span></span><span class="line"><span class="ln">24</span><span class="cl">   └─ SP-GiST（罕見）</span></span></code></pre></div><h2 id="5-個-production-踩雷">5 個 Production 踩雷</h2>
<h3 id="case-1過度-indexwrite-放大">Case 1：過度 index（write 放大）</h3>
<p><strong>情境</strong>：team「為了 query 快」對 20 個 column 各建 index、寫入量大時 INSERT 慢 10x。</p>
<p>每個 INSERT 要更新 20 個 index、WAL volume 也跟著放大、replication lag 拉長。</p>
<p>修法：</p>
<ul>
<li>用 <code>pg_stat_user_indexes</code> 找 <em>idx_scan = 0</em> 的 index、可能根本沒用</li>
<li>用 <code>pg_stat_statements</code> 找實際被執行的 query、反推真正需要的 index</li>
<li>同 column 多 index（user_id 單欄 + (user_id, status) 多欄）通常可拆掉單欄</li>
</ul>
<h3 id="case-2partial-index-條件跟-query-不匹配">Case 2：Partial index 條件跟 query 不匹配</h3>
<p><strong>情境</strong>：建 <code>WHERE status = 'active'</code> partial index、application query 寫 <code>WHERE status IN ('active')</code>、planner 不 prove 等價、不用 index。</p>
<p>修法：</p>
<ul>
<li>Partial 條件用最 generic form（避免 IN / OR 跟 = 的差異）</li>
<li>寫完用 <code>EXPLAIN</code> 驗證 query 真的用到 partial index</li>
<li>Application 統一 query 寫法、不要混 <code>=</code> 跟 <code>IN</code> 跟 <code>ANY</code></li>
</ul>
<h3 id="case-3b-tree-對-jsonb-內部欄位無效">Case 3：B-tree 對 JSONB 內部欄位無效</h3>
<p><strong>情境</strong>：對 <code>metadata</code> JSONB column 建 B-tree、query <code>metadata-&gt;&gt;'category' = 'shoes'</code> 不用 index。</p>
<p>B-tree 對 <em>整個 JSONB</em> 排序、但 path query 不是整個 JSONB 的比對。</p>
<p>修法：</p>
<ul>
<li>對固定 path 建 expression index：<code>CREATE INDEX ... ON products ((metadata-&gt;&gt;'category'))</code></li>
<li>對動態 path 建 GIN index：<code>CREATE INDEX ... USING GIN (metadata)</code></li>
<li>兩者並存可、<code>EXPLAIN</code> 看 planner 選哪個</li>
</ul>
<h3 id="case-4brin-對非-correlated-資料無效">Case 4：BRIN 對非 correlated 資料無效</h3>
<p><strong>情境</strong>：對 <code>user_id</code> 建 BRIN index（user_id 是隨機 UUID）、query 完全跑 seq scan。</p>
<p>UUID 沒 physical order correlation、每個 block range 的 min/max 涵蓋整個 ID space、BRIN 完全沒 prune 效果。</p>
<p>修法：</p>
<ul>
<li>BRIN 只用 <code>timestamp</code> / 自增 <code>id</code> / 其他自然 correlate 的 column</li>
<li>用 <code>pg_stats</code> 看 <code>correlation</code> value、&lt; 0.1 就不適合 BRIN</li>
<li>真要對 random column 加 index、回 B-tree</li>
</ul>
<h3 id="case-5multi-column-index-順序錯">Case 5：Multi-column index 順序錯</h3>
<p><strong>情境</strong>：常見 query <code>WHERE status = 'pending' AND user_id = 42</code>、建 index <code>(status, user_id)</code>、效能差。</p>
<p><code>status</code> 只 5 個 distinct value、選擇性 1/5；<code>user_id</code> 1M distinct、選擇性 1/1M。Index leftmost 是 status、scan range 太大。</p>
<p>修法：</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">INDEX</span><span class="w"> </span><span class="n">idx_user_status</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="p">(</span><span class="n">user_id</span><span class="p">,</span><span class="w"> </span><span class="n">status</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></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="c1">-- 或加 partial 限定低選擇性 column
</span></span></span><span class="line"><span class="ln">5</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_orders_pending</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="p">(</span><span class="n">user_id</span><span class="p">)</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">status</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;pending&#39;</span><span class="p">;</span></span></span></code></pre></div><h2 id="跟-mysql-index-差異">跟 MySQL Index 差異</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>PostgreSQL</th>
          <th>MySQL</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Index method</td>
          <td>6 種（B-tree / Hash / GIN / GiST / SP-GiST / BRIN）</td>
          <td>主要 B-tree、空間另算 R-tree</td>
      </tr>
      <tr>
          <td>預設</td>
          <td>B-tree</td>
          <td>B-tree（InnoDB clustered）</td>
      </tr>
      <tr>
          <td>Clustered index</td>
          <td>沒有原生（CLUSTER 一次性）</td>
          <td>InnoDB primary key 永遠 clustered</td>
      </tr>
      <tr>
          <td>Covering</td>
          <td>INCLUDE（PG 11+）</td>
          <td>自然支援（secondary index 帶 PK）</td>
      </tr>
      <tr>
          <td>JSON index</td>
          <td>GIN on JSONB（強）</td>
          <td>functional index on JSON（弱）</td>
      </tr>
      <tr>
          <td>Partial index</td>
          <td>原生支援</td>
          <td>8.0+ 支援（受限）</td>
      </tr>
      <tr>
          <td>Expression index</td>
          <td>原生支援</td>
          <td>5.7+ functional index</td>
      </tr>
      <tr>
          <td>BRIN-like</td>
          <td>原生</td>
          <td>沒有</td>
      </tr>
      <tr>
          <td>Spatial</td>
          <td>GiST / PostGIS</td>
          <td>R-tree（基本）</td>
      </tr>
  </tbody>
</table>
<p>PG index 系統比 MySQL 表達力高、但代價是 <em>選對 index method 是 application 責任</em>、MySQL 預設 B-tree 多數場景夠用。</p>
<h2 id="相關連結">相關連結</h2>
<ul>
<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>：EXPLAIN 看 index 用沒用</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/jsonb-deep-dive/" data-link-title="PostgreSQL JSONB Deep Dive：Binary Storage &#43; GIN Index 為什麼是結構性優勢" data-link-desc="PG JSONB（9.4&#43;）是 *binary 儲存的 JSON*、可直接 GIN index、是 PG 在 JSON workload 的結構性優勢、跟 MongoDB / MySQL 8.0 JSON_TABLE 比仍領先。本文走 JSON vs JSONB 差異、GIN index 機制（jsonb_ops vs jsonb_path_ops）、operator &#43; path query、partial JSONB indexing、5 production 踩雷（大 JSONB 跟 TOAST / nested update / index 選錯 op class / jsonb_path_query 跟 jsonb_path_exists 行為差 / partial index 條件搞錯）、何時用 JSONB vs 拆 column">jsonb-deep-dive</a>：JSONB + GIN 細節</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/full-text-search/" data-link-title="PostgreSQL Full-Text Search：tsvector / tsquery / GIN index 跟 pg_trgm fuzzy 三層搜尋" data-link-desc="PG 內建 full-text search 用 *tsvector / tsquery / GIN index* 三件組、適合中小規模搜尋（&lt; 100M 文件）；pg_trgm 提供 fuzzy match。本文走 FTS 機制（tsvector 是 lexeme &#43; position 的 vector）、3 種 query（match / ranking / weighted）、multi-language support、跟 pg_trgm fuzzy match 互補、5 production 踩雷（dictionary 選錯 / GIN 跟 GiST 取捨 / ranking 評分權重 / multi-language column 處理 / 何時不該用 PG FTS 改 Elasticsearch）">full-text-search</a>：FTS + GIN</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>：index bloat</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/online-schema-change/" data-link-title="PostgreSQL Online Schema Change：先用 ALTER 內建特性、不能解才 pg_repack / pg-osc" data-link-desc="PostgreSQL ALTER TABLE 對多數變更已是 *fast catalog-only*（add column nullable / drop column / 改 default），不必走 ghost table tool。本文走 PG 內建 fast DDL 行為、何時必須走 pg_repack / pg-osc、兩工具機制對比（trigger-based vs WAL-shipping）、配置 step-by-step、5 production 踩雷（lock 升級 / VACUUM FULL 誤用 / pg_repack version mismatch / concurrent index 失敗清理 / generated stored column 不能 online）、跟 MySQL gh-ost / pt-osc sibling 對比">online-schema-change</a>：CREATE INDEX CONCURRENTLY</li>
</ul>
<h2 id="下一步">下一步</h2>
<ul>
<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> 驗證 index 有沒有被 plan 用到</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><item><title>Redis Sentinel 與 failover 時序：從 master 死掉到 client 重連的每一段</title><link>https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/redis/sentinel-ha-failover/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/redis/sentinel-ha-failover/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/redis/" data-link-title="Redis" data-link-desc="OSS in-memory data structure store、cache 主流">Redis&lt;/a> overview 的 implementation-layer deep article。Sentinel 處理的是「單 master 容量夠、但 master 不能是單點」的 HA 場景；要橫向擴容超過單機記憶體則走 &lt;a href="https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/redis/cluster-resharding/" data-link-title="Redis Cluster Re-sharding：source = target，但 topology 重劃的 5 段流程" data-link-desc="Redis cluster re-sharding 是 5 type migration 漏類實證 — source / target 同 cluster、無 schema / paradigm 差、但 16384 slot 重分配是核心；本文涵蓋 4 種 re-sharding driver、slot migration 機制、redis-cli --cluster rebalance / reshard 工具、5 個 production 踩雷（cluster busy / replica lag / client cache stale / cross-slot transaction / monitor gap）">Cluster re-sharding&lt;/a>，兩者解的問題不同。機制以 &lt;a href="https://redis.io/docs/latest/operate/oss_and_stack/management/sentinel/">Redis Sentinel 官方文件&lt;/a> 為準、最後檢查日 2026-06-16。&lt;/p>&lt;/blockquote>
&lt;h2 id="failover-是一條時序鏈不是一個瞬間">Failover 是一條時序鏈、不是一個瞬間&lt;/h2>
&lt;p>「master 掛了 Sentinel 會自動切換」這句話把 failover 講成一個原子動作，但真正在 production 出事時，問題永遠出在這條鏈的某一段卡住。把 failover 攤開成時序，才看得到延遲跟資料遺失藏在哪：&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">T0 master 失去回應
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> ↓ (down-after-milliseconds)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">T1 單一 Sentinel 標記 master 為 SDOWN（主觀下線）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> ↓ (Sentinel 之間互問)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">T2 達到 quorum 數量的 Sentinel 同意 → ODOWN（客觀下線）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> ↓ (Sentinel 之間選出 leader 來主導 failover)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">T3 leader Sentinel 從 replica 中挑一個當新 master
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> ↓ (SLAVEOF NO ONE + 其他 replica 改指向新 master)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">T4 新 master 提升完成
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> ↓ (Sentinel 廣播新 topology、更新 DNS / 通知 client)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">T5 client 發現新 master、重連、恢復寫入&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>從 T0 到 T5 的總時間決定了「寫入中斷多久」。每一段都有對應的旋鈕跟失敗模式：T0→T1 由 &lt;code>down-after-milliseconds&lt;/code> 控制（太短誤判、太長反應慢）；T1→T2 由 quorum 設定控制（太低腦裂風險、太高切不動）；T4→T5 由 client 的 topology 感知能力控制。理解 failover 就是理解這條鏈的每一段。&lt;/p>
&lt;p>對把 cache 當主要 serving layer 的服務，這條鏈的長度直接是業務中斷時間。&lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/tinder-elasticache-valkey-matching/" data-link-title="9.C6 Tinder：ElastiCache for Valkey 撐 4700 萬月活的配對引擎" data-link-desc="Tinder 用 Amazon ElastiCache for Valkey 提供配對引擎所需的次毫秒延遲快取層">Tinder 的配對引擎&lt;/a>每次滑動讀多個 cache、cache miss 是邊緣案例——failover 期間若寫入中斷十幾秒，新寫入的 profile 互動全部 hang，sub-millisecond 的 SLA 在這幾秒徹底失守。這也是為什麼大規模服務多半走 managed multi-AZ failover（見 ElastiCache）而非自管 Sentinel。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/02-cache-redis/vendors/redis/" data-link-title="Redis" data-link-desc="OSS in-memory data structure store、cache 主流">Redis</a> overview 的 implementation-layer deep article。Sentinel 處理的是「單 master 容量夠、但 master 不能是單點」的 HA 場景；要橫向擴容超過單機記憶體則走 <a href="/blog/backend/02-cache-redis/vendors/redis/cluster-resharding/" data-link-title="Redis Cluster Re-sharding：source = target，但 topology 重劃的 5 段流程" data-link-desc="Redis cluster re-sharding 是 5 type migration 漏類實證 — source / target 同 cluster、無 schema / paradigm 差、但 16384 slot 重分配是核心；本文涵蓋 4 種 re-sharding driver、slot migration 機制、redis-cli --cluster rebalance / reshard 工具、5 個 production 踩雷（cluster busy / replica lag / client cache stale / cross-slot transaction / monitor gap）">Cluster re-sharding</a>，兩者解的問題不同。機制以 <a href="https://redis.io/docs/latest/operate/oss_and_stack/management/sentinel/">Redis Sentinel 官方文件</a> 為準、最後檢查日 2026-06-16。</p></blockquote>
<h2 id="failover-是一條時序鏈不是一個瞬間">Failover 是一條時序鏈、不是一個瞬間</h2>
<p>「master 掛了 Sentinel 會自動切換」這句話把 failover 講成一個原子動作，但真正在 production 出事時，問題永遠出在這條鏈的某一段卡住。把 failover 攤開成時序，才看得到延遲跟資料遺失藏在哪：</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">T0   master 失去回應
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">     ↓ (down-after-milliseconds)
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">T1   單一 Sentinel 標記 master 為 SDOWN（主觀下線）
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">     ↓ (Sentinel 之間互問)
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">T2   達到 quorum 數量的 Sentinel 同意 → ODOWN（客觀下線）
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">     ↓ (Sentinel 之間選出 leader 來主導 failover)
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">T3   leader Sentinel 從 replica 中挑一個當新 master
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">     ↓ (SLAVEOF NO ONE + 其他 replica 改指向新 master)
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">T4   新 master 提升完成
</span></span><span class="line"><span class="ln">10</span><span class="cl">     ↓ (Sentinel 廣播新 topology、更新 DNS / 通知 client)
</span></span><span class="line"><span class="ln">11</span><span class="cl">T5   client 發現新 master、重連、恢復寫入</span></span></code></pre></div><p>從 T0 到 T5 的總時間決定了「寫入中斷多久」。每一段都有對應的旋鈕跟失敗模式：T0→T1 由 <code>down-after-milliseconds</code> 控制（太短誤判、太長反應慢）；T1→T2 由 quorum 設定控制（太低腦裂風險、太高切不動）；T4→T5 由 client 的 topology 感知能力控制。理解 failover 就是理解這條鏈的每一段。</p>
<p>對把 cache 當主要 serving layer 的服務，這條鏈的長度直接是業務中斷時間。<a href="/blog/backend/09-performance-capacity/cases/tinder-elasticache-valkey-matching/" data-link-title="9.C6 Tinder：ElastiCache for Valkey 撐 4700 萬月活的配對引擎" data-link-desc="Tinder 用 Amazon ElastiCache for Valkey 提供配對引擎所需的次毫秒延遲快取層">Tinder 的配對引擎</a>每次滑動讀多個 cache、cache miss 是邊緣案例——failover 期間若寫入中斷十幾秒，新寫入的 profile 互動全部 hang，sub-millisecond 的 SLA 在這幾秒徹底失守。這也是為什麼大規模服務多半走 managed multi-AZ failover（見 ElastiCache）而非自管 Sentinel。</p>
<h2 id="核心概念sentinel-的判定模型">核心概念：Sentinel 的判定模型</h2>
<p>Sentinel 是獨立於 Redis 資料節點的監控進程，它的判定靠兩層共識避免單一 Sentinel 誤判。</p>
<p><strong>SDOWN（Subjectively Down，主觀下線）</strong>：單一 Sentinel 在 <code>down-after-milliseconds</code> 內收不到 master 的有效回應（<code>PING</code>），就主觀認定它下線。這只是一個 Sentinel 的意見，不觸發 failover。</p>
<p><strong>ODOWN（Objectively Down，客觀下線）</strong>：當標記 SDOWN 的 Sentinel 數量達到 <code>quorum</code> 設定值，master 被客觀認定下線。只有 master 的 ODOWN 才會觸發 failover（replica 的下線只標記不 failover）。</p>
<p><code>quorum</code> 是「多少個 Sentinel 同意才算真的下線」，它跟「多少個 Sentinel 同意才能執行 failover」是兩個不同的數字——後者需要 Sentinel 的多數（majority），確保同時只有一個 leader 主導 failover，避免兩個 Sentinel 各自提升不同 replica 造成腦裂。</p>
<p><strong>為什麼 Sentinel 要部署奇數個且至少三個</strong>：quorum 跟 majority 都需要足夠的 Sentinel 投票。兩個 Sentinel 無法在其中一個故障時達成 majority；三個才能容忍一個故障。Sentinel 應部署在不同故障域（不同 AZ / 機架），且不要跟 Redis 資料節點同生共死。</p>
<p><strong>Sentinel 不是 proxy</strong>：client 不透過 Sentinel 讀寫資料。client 向 Sentinel 查詢「現在的 master 是誰」，拿到地址後直連 Redis。failover 後 client 必須重新向 Sentinel 查詢——這是 T4→T5 的關鍵，client library 要支援 Sentinel 模式才能自動完成。</p>
<h2 id="配置sentinel-的設定路徑">配置：Sentinel 的設定路徑</h2>
<p>最小三 Sentinel 配置，每個 Sentinel 一份 <code>sentinel.conf</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># sentinel.conf</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"># 監控名為 mymaster 的 master、quorum=2（三個 Sentinel 中兩個同意算 ODOWN）</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">sentinel monitor mymaster 10.0.0.1 <span class="m">6379</span> <span class="m">2</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"># 多久收不到回應算 SDOWN（5 秒）</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">sentinel down-after-milliseconds mymaster <span class="m">5000</span>
</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 class="c1"># failover 後同時最多幾個 replica 去 resync 新 master</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"># 設 1 = 串行 resync、避免所有 replica 同時 resync 拖垮新 master</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">sentinel parallel-syncs mymaster <span class="m">1</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"># failover 整體逾時（三分鐘內沒完成算失敗、可重試）</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">sentinel failover-timeout mymaster <span class="m">180000</span></span></span></code></pre></div><p>啟動 Sentinel：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">redis-sentinel /path/to/sentinel.conf
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># 或 redis-server /path/to/sentinel.conf --sentinel</span></span></span></code></pre></div><p>client 端要用 Sentinel-aware 連線（以 Python redis-py 為例）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kn">from</span> <span class="nn">redis.sentinel</span> <span class="kn">import</span> <span class="n">Sentinel</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">sentinel</span> <span class="o">=</span> <span class="n">Sentinel</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="p">[(</span><span class="s2">&#34;10.0.0.10&#34;</span><span class="p">,</span> <span class="mi">26379</span><span class="p">),</span> <span class="p">(</span><span class="s2">&#34;10.0.0.11&#34;</span><span class="p">,</span> <span class="mi">26379</span><span class="p">),</span> <span class="p">(</span><span class="s2">&#34;10.0.0.12&#34;</span><span class="p">,</span> <span class="mi">26379</span><span class="p">)],</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="n">socket_timeout</span><span class="o">=</span><span class="mf">0.5</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"># 寫入走 master（failover 後自動重新發現）</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="n">master</span> <span class="o">=</span> <span class="n">sentinel</span><span class="o">.</span><span class="n">master_for</span><span class="p">(</span><span class="s2">&#34;mymaster&#34;</span><span class="p">,</span> <span class="n">socket_timeout</span><span class="o">=</span><span class="mf">0.5</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="n">master</span><span class="o">.</span><span class="n">set</span><span class="p">(</span><span class="s2">&#34;key&#34;</span><span class="p">,</span> <span class="s2">&#34;value&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"># 讀取可走 replica</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="n">replica</span> <span class="o">=</span> <span class="n">sentinel</span><span class="o">.</span><span class="n">slave_for</span><span class="p">(</span><span class="s2">&#34;mymaster&#34;</span><span class="p">,</span> <span class="n">socket_timeout</span><span class="o">=</span><span class="mf">0.5</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="n">replica</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;key&#34;</span><span class="p">)</span></span></span></code></pre></div><p>關鍵：client 透過 <code>master_for</code> 拿到的是一個會在 failover 後重新查詢 Sentinel 的連線封裝，不是寫死的 IP。直接寫死 master IP 的 client 在 failover 後會持續連到死掉的舊 master。</p>
<h3 id="防腦裂的兩個-master-端設定">防腦裂的兩個 master 端設定</h3>
<p>Sentinel 選主的同時，要防止舊 master 復活後繼續接受寫入（split-brain）。在 Redis master 端設：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 至少要有 1 個 replica 連著、且 replica lag &lt; 10 秒、master 才接受寫入</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">redis-cli CONFIG SET min-replicas-to-write <span class="m">1</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">redis-cli CONFIG SET min-replicas-max-lag <span class="m">10</span></span></span></code></pre></div><p>這讓被網路隔離的舊 master（連不到 replica）自動停止接受寫入，避免它在隔離期間累積的寫入在復活後跟新 master 衝突。</p>
<h2 id="production-故障演練">Production 故障演練</h2>
<h3 id="case-1down-after-太短網路抖動誤觸-failover">Case 1：down-after 太短、網路抖動誤觸 failover</h3>
<p><strong>徵兆</strong>：master 其實沒死，只是一次短暫的網路抖動或 GC 暫停，Sentinel 卻觸發了 failover，造成一次不必要的中斷；甚至反覆 failover（flapping）。</p>
<p><strong>根因</strong>：<code>down-after-milliseconds</code> 設太短（例如 1000ms），master 一個短暫的 STW GC 或跨 AZ 網路抖動就超過閾值，被誤判 SDOWN→ODOWN。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><code>down-after-milliseconds</code> 設成能容忍正常抖動的值（5000-10000ms 是常見起點），用實際 RTT 與 GC pause 分布反推</li>
<li>quorum 設成多數而非 1，要求多個 Sentinel 同時看到下線，過濾單一 Sentinel 的網路問題</li>
<li>Sentinel 跟 Redis 不要跨高延遲鏈路放，網路品質直接影響誤判率</li>
<li>監控 failover 觸發頻率，flapping 是調參訊號</li>
</ol>
<h3 id="case-2failover-後-client-連到死掉的舊-master">Case 2：failover 後 client 連到死掉的舊 master</h3>
<p><strong>徵兆</strong>：failover 完成、Sentinel 日誌顯示新 master 已提升，但部分 application 持續寫入失敗或寫到舊 master（資料進黑洞），<code>CLIENT LIST</code> 在新 master 上看不到這些 client。</p>
<p><strong>根因</strong>：client 寫死了 master IP，或用的 client library 不支援 Sentinel 模式，failover 後不會重新向 Sentinel 查詢新 master。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>client 一律用 Sentinel-aware 連線（<code>master_for</code> / lettuce 的 Sentinel 配置），不寫死 IP</li>
<li>確認 client library 版本支援 Sentinel 且配置正確（連的是 Sentinel port 26379，不是 Redis 6379）</li>
<li>對 latency-sensitive 服務，failover 後可主動 rolling restart application，清掉殘留連線</li>
<li>設 <code>min-replicas-to-write</code> 讓被隔離的舊 master 自動停寫，即使 client 連上去也寫不進，避免資料進黑洞</li>
</ol>
<h3 id="case-3選到-lag-大的-replicafailover-丟資料">Case 3：選到 lag 大的 replica、failover 丟資料</h3>
<p><strong>徵兆</strong>：failover 後發現最近幾秒的寫入不見了，新 master 的資料比預期舊。</p>
<p><strong>根因</strong>：Redis replication 是非同步的，replica 之間 lag 不一。Sentinel 選主會優先選 lag 小的（靠 <code>replica-priority</code> 與複製 offset），但若所有 replica 都 lag 大（master 寫入遠快於複製），無論選哪個都會丟掉未複製的寫入。Sentinel 的 failover 保證可用性，不保證零資料遺失。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>設 <code>min-replicas-to-write</code> + <code>min-replicas-max-lag</code>，lag 過大時 master 主動停寫，限制資料遺失窗口</li>
<li>監控 replication lag（<code>master_repl_offset</code> vs replica 的 offset），lag 持續大代表複製跟不上寫入，要降寫入或擴容</li>
<li>用 <code>replica-priority</code> 把不適合當 master 的 replica（例如做備份的、跨區的）設成 0 排除</li>
<li>需要零資料遺失的場景，Sentinel 的非同步複製不夠，走 <a href="/blog/backend/02-cache-redis/vendors/aws-elasticache/" data-link-title="AWS ElastiCache" data-link-desc="AWS managed Redis / Valkey / Memcached">MemoryDB</a> 的 multi-AZ transaction log（強一致持久性）</li>
</ol>
<h3 id="case-4腦裂舊-master-復活後雙寫衝突">Case 4：腦裂——舊 master 復活後雙寫衝突</h3>
<p><strong>徵兆</strong>：網路分區期間 Sentinel 提升了新 master，分區恢復後舊 master 回來，兩個 master 各自接受過寫入，資料出現衝突或舊 master 的寫入被覆蓋遺失。</p>
<p><strong>根因</strong>：舊 master 在分區期間被隔離（連不到 Sentinel 多數），但 client 若還連得到它且它沒設停寫保護，就繼續接受寫入。分區恢復後舊 master 被降為 replica，它在分區期間的寫入被新 master 的資料覆蓋。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><code>min-replicas-to-write 1</code> + <code>min-replicas-max-lag 10</code> 是核心防護——被隔離的舊 master 連不到 replica，自動停寫</li>
<li>Sentinel 部署在多數能存活的故障域，確保分區時多數 Sentinel 在新 master 那側</li>
<li>接受 Redis 的 CAP 取捨：Sentinel 偏向可用性，極端分區下無法完全避免資料遺失，要強一致走別的儲存層</li>
<li>failover 後監控舊 master 復活的降級流程，確認它正確變成 replica 且 resync</li>
</ol>
<h3 id="case-5parallel-syncs-設太大failover-後新-master-被-resync-拖垮">Case 5：parallel-syncs 設太大、failover 後新 master 被 resync 拖垮</h3>
<p><strong>徵兆</strong>：failover 完成的瞬間新 master 延遲暴增、甚至短暫無回應，所有 replica 同時對它發起全量同步。</p>
<p><strong>根因</strong>：<code>parallel-syncs</code> 設成大於 1（或等於 replica 數），failover 後所有 replica 同時對新 master 做 full resync。full resync 要新 master 做 BGSAVE（fork、見 <a href="/blog/backend/02-cache-redis/vendors/redis/persistence-fork-latency/" data-link-title="Redis 持久化與 fork latency：AOF、RDB 與那一次卡住整個 cluster 的 fork" data-link-desc="Redis 的 RDB save 與 AOF rewrite 都靠一次 fork()，而 fork 在大記憶體實例上會凍結主執行緒數百毫秒、複製分頁讓記憶體逼近翻倍。本文展開 AOF / RDB 的機制與 fsync 取捨、copy-on-write 的記憶體放大、5 個把持久化寫成延遲尖峰與資料遺失的 production 踩坑，以及 cache 場景到底要不要持久化的邊界">persistence deep article</a>）並把 RDB 傳給每個 replica，多個同時進行直接打爆新 master。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><code>parallel-syncs</code> 設 1，replica 串行 resync，犧牲一點恢復速度換新 master 不被拖垮</li>
<li>確認 master 端 <code>repl-backlog-size</code> 夠大，讓短暫斷線的 replica 走部分同步（partial resync）而非全量</li>
<li>監控 failover 後新 master 的 CPU / 記憶體，resync 期間是脆弱窗口</li>
<li>resync 的 fork 成本跟 <a href="/blog/backend/02-cache-redis/vendors/redis/memory-eviction-tuning/" data-link-title="Redis 記憶體與淘汰調校：maxmemory-policy、LFU 與碎片化的實戰判讀" data-link-desc="Redis 的記憶體是一條會在半夜爆掉的曲線：maxmemory 設多少、policy 選 LRU 還 LFU、碎片化什麼時候開始吃掉 30% RAM、OOM 時 noeviction 怎麼讓寫入全部失敗。本文展開 Redis 記憶體會計模型、eviction policy 的選型判讀、5 個把記憶體配置寫成 production 事故的踩坑，以及單機記憶體撞牆後該往 cluster 還是 DragonflyDB 走的邊界">記憶體 headroom</a> 直接相關，新 master 也要留 fork 空間</li>
</ol>
<h2 id="capacity--cost-邊界">Capacity / cost 邊界</h2>
<p>Sentinel 的容量判讀，圍繞 failover 時間與資料遺失窗口：</p>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>健康區間</th>
          <th>警戒與動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>failover 總時間（T0→T5）</td>
          <td>數秒到十幾秒</td>
          <td>過長 → 查 down-after / parallel-syncs / client</td>
      </tr>
      <tr>
          <td>failover 觸發頻率</td>
          <td>罕見（真實故障才觸發）</td>
          <td>flapping → down-after 太短、quorum 太低</td>
      </tr>
      <tr>
          <td>replication lag</td>
          <td>&lt; 1 秒</td>
          <td>持續大 → 寫入超過複製能力、failover 會丟資料</td>
      </tr>
      <tr>
          <td>Sentinel 數量</td>
          <td>奇數、≥ 3、跨故障域</td>
          <td>&lt; 3 或同故障域 → 無法容忍 Sentinel 故障</td>
      </tr>
      <tr>
          <td>寫入中斷可容忍時間</td>
          <td>業務定義</td>
          <td>不可容忍 → Sentinel 不夠、走 managed multi-AZ</td>
      </tr>
  </tbody>
</table>
<p>撞牆後的路由判斷：</p>
<ul>
<li><strong>單 master 容量不夠（記憶體 / 吞吐超過單機）</strong>：Sentinel 解 HA 不解容量。要橫向擴容走 <a href="/blog/backend/02-cache-redis/vendors/redis/cluster-resharding/" data-link-title="Redis Cluster Re-sharding：source = target，但 topology 重劃的 5 段流程" data-link-desc="Redis cluster re-sharding 是 5 type migration 漏類實證 — source / target 同 cluster、無 schema / paradigm 差、但 16384 slot 重分配是核心；本文涵蓋 4 種 re-sharding driver、slot migration 機制、redis-cli --cluster rebalance / reshard 工具、5 個 production 踩雷（cluster busy / replica lag / client cache stale / cross-slot transaction / monitor gap）">Redis Cluster</a>，它自帶 sharding 與 per-shard failover。</li>
<li><strong>不想自己運維 Sentinel 與 failover 演練</strong>：<a href="/blog/backend/02-cache-redis/vendors/aws-elasticache/" data-link-title="AWS ElastiCache" data-link-desc="AWS managed Redis / Valkey / Memcached">ElastiCache</a> 的 Multi-AZ 自動 failover 把這條時序鏈託管，failover ~30 秒到幾分鐘，省掉 Sentinel 部署與調參，代價是 managed premium。</li>
<li><strong>需要零資料遺失的強持久性</strong>：Sentinel 的非同步複製在 failover 時會丟未複製的寫入。要強一致走 <a href="/blog/backend/02-cache-redis/vendors/aws-elasticache/" data-link-title="AWS ElastiCache" data-link-desc="AWS managed Redis / Valkey / Memcached">MemoryDB</a> 的 multi-AZ transaction log。</li>
</ul>
<h2 id="整合--下一步">整合 / 下一步</h2>
<p>Sentinel 是 HA 的一層，但它的每一段都跟其他子系統耦合：</p>
<ul>
<li><strong>跟 <a href="/blog/backend/02-cache-redis/vendors/redis/cluster-resharding/" data-link-title="Redis Cluster Re-sharding：source = target，但 topology 重劃的 5 段流程" data-link-desc="Redis cluster re-sharding 是 5 type migration 漏類實證 — source / target 同 cluster、無 schema / paradigm 差、但 16384 slot 重分配是核心；本文涵蓋 4 種 re-sharding driver、slot migration 機制、redis-cli --cluster rebalance / reshard 工具、5 個 production 踩雷（cluster busy / replica lag / client cache stale / cross-slot transaction / monitor gap）">Cluster re-sharding</a></strong>：Sentinel 是「不分片的 HA」，Cluster 是「分片 + 每 shard 自帶 failover」。容量需求決定走哪條，本文是前者。</li>
<li><strong>跟 <a href="/blog/backend/02-cache-redis/vendors/redis/persistence-fork-latency/" data-link-title="Redis 持久化與 fork latency：AOF、RDB 與那一次卡住整個 cluster 的 fork" data-link-desc="Redis 的 RDB save 與 AOF rewrite 都靠一次 fork()，而 fork 在大記憶體實例上會凍結主執行緒數百毫秒、複製分頁讓記憶體逼近翻倍。本文展開 AOF / RDB 的機制與 fsync 取捨、copy-on-write 的記憶體放大、5 個把持久化寫成延遲尖峰與資料遺失的 production 踩坑，以及 cache 場景到底要不要持久化的邊界">persistence / fork latency</a></strong>：failover 後的 resync 靠 BGSAVE（fork），新 master 的 fork 成本是 resync 期間的脆弱點。</li>
<li><strong>跟 <a href="/blog/backend/02-cache-redis/vendors/redis/memory-eviction-tuning/" data-link-title="Redis 記憶體與淘汰調校：maxmemory-policy、LFU 與碎片化的實戰判讀" data-link-desc="Redis 的記憶體是一條會在半夜爆掉的曲線：maxmemory 設多少、policy 選 LRU 還 LFU、碎片化什麼時候開始吃掉 30% RAM、OOM 時 noeviction 怎麼讓寫入全部失敗。本文展開 Redis 記憶體會計模型、eviction policy 的選型判讀、5 個把記憶體配置寫成 production 事故的踩坑，以及單機記憶體撞牆後該往 cluster 還是 DragonflyDB 走的邊界">記憶體調校</a></strong>：新 master 提升後要承接全部寫入並支援 replica resync 的 fork，記憶體 headroom 不能少。</li>
<li><strong>跟 <a href="/blog/backend/02-cache-redis/cases/meta-cache-consistency-upgrade/" data-link-title="2.C1 Meta：Cache Consistency 升級" data-link-desc="快取 invalidation 一致性如何從常見錯誤演進到高可信治理。">Meta cache consistency</a></strong>：failover / replica promotion 期間的 stale read 與一致性議題，是大規模 cache 治理的核心，Sentinel 的非同步複製是 stale window 的來源之一。</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li>上游 vendor 頁：<a href="/blog/backend/02-cache-redis/vendors/redis/" data-link-title="Redis" data-link-desc="OSS in-memory data structure store、cache 主流">Redis</a></li>
<li>同 vendor deep article：<a href="/blog/backend/02-cache-redis/vendors/redis/cluster-resharding/" data-link-title="Redis Cluster Re-sharding：source = target，但 topology 重劃的 5 段流程" data-link-desc="Redis cluster re-sharding 是 5 type migration 漏類實證 — source / target 同 cluster、無 schema / paradigm 差、但 16384 slot 重分配是核心；本文涵蓋 4 種 re-sharding driver、slot migration 機制、redis-cli --cluster rebalance / reshard 工具、5 個 production 踩雷（cluster busy / replica lag / client cache stale / cross-slot transaction / monitor gap）">Cluster re-sharding</a>、<a href="/blog/backend/02-cache-redis/vendors/redis/persistence-fork-latency/" data-link-title="Redis 持久化與 fork latency：AOF、RDB 與那一次卡住整個 cluster 的 fork" data-link-desc="Redis 的 RDB save 與 AOF rewrite 都靠一次 fork()，而 fork 在大記憶體實例上會凍結主執行緒數百毫秒、複製分頁讓記憶體逼近翻倍。本文展開 AOF / RDB 的機制與 fsync 取捨、copy-on-write 的記憶體放大、5 個把持久化寫成延遲尖峰與資料遺失的 production 踩坑，以及 cache 場景到底要不要持久化的邊界">persistence 與 fork latency</a>、<a href="/blog/backend/02-cache-redis/vendors/redis/memory-eviction-tuning/" data-link-title="Redis 記憶體與淘汰調校：maxmemory-policy、LFU 與碎片化的實戰判讀" data-link-desc="Redis 的記憶體是一條會在半夜爆掉的曲線：maxmemory 設多少、policy 選 LRU 還 LFU、碎片化什麼時候開始吃掉 30% RAM、OOM 時 noeviction 怎麼讓寫入全部失敗。本文展開 Redis 記憶體會計模型、eviction policy 的選型判讀、5 個把記憶體配置寫成 production 事故的踩坑，以及單機記憶體撞牆後該往 cluster 還是 DragonflyDB 走的邊界">記憶體與淘汰調校</a></li>
<li>平行 vendor：<a href="/blog/backend/02-cache-redis/vendors/aws-elasticache/" data-link-title="AWS ElastiCache" data-link-desc="AWS managed Redis / Valkey / Memcached">AWS ElastiCache</a>（managed multi-AZ failover）</li>
<li>Methodology：<a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章寫作方法論</a></li>
</ul>
]]></content:encoded></item><item><title>MySQL InnoDB Tuning：為什麼一個 100 GB DB 在 64 GB RAM server 上 query 慢 5 倍</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/innodb-tuning/</link><pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/innodb-tuning/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/" data-link-title="MySQL" data-link-desc="高併發網路服務常用關聯式資料庫、Vitess / PlanetScale 分片生態、GitHub / Shopify / Facebook 規模驗證">MySQL&lt;/a> overview 的 implementation-layer deep article。Overview 已說明 MySQL 在 OLTP 譜系的定位、本文聚焦 &lt;em>InnoDB engine tuning&lt;/em> — 4 個影響最大的 knob 跟對應 production 行為。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="開場常見痛點">開場：常見痛點&lt;/h2>
&lt;p>一個 100 GB MySQL DB、64 GB RAM 的 server、p99 query latency 從 5ms 飆到 50ms。第一直覺是 server overload — 但 CPU &amp;lt; 30%、disk IO 50 IOPS。為什麼慢？&lt;/p>
&lt;p>打開 &lt;code>SHOW VARIABLES LIKE 'innodb_buffer_pool_size'&lt;/code>：&lt;code>134217728&lt;/code>（128 MB）。對 64 GB RAM server、buffer pool 只用了 128 MB、剩 99.9% 的 working set 每次 query 都要從 disk 讀。CPU 閒、disk 沒滿、是因為 &lt;em>MySQL 自己不用 RAM&lt;/em> — 用 InnoDB 預設值跑 100 GB DB 等於 disk-only 模式。&lt;/p>
&lt;p>這個案例展示 InnoDB tuning 的核心：MySQL 預設值是 &lt;em>為 16 GB RAM 設計&lt;/em>、production server RAM 越大、預設值離 optimal 越遠。&lt;/p>
&lt;h2 id="4-個-critical-knob">4 個 critical knob&lt;/h2>
&lt;p>對 90% production case、調這 4 個就解決大部分 InnoDB 性能問題：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Knob&lt;/th>
 &lt;th>預設&lt;/th>
 &lt;th>對 production 建議&lt;/th>
 &lt;th>影響&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>innodb_buffer_pool_size&lt;/code>&lt;/td>
 &lt;td>128 MB&lt;/td>
 &lt;td>系統 RAM 50-75%（dedicated server 75%）&lt;/td>
 &lt;td>讀效能（資料能否在 RAM）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>innodb_log_file_size&lt;/code>&lt;/td>
 &lt;td>48 MB（×2 file）&lt;/td>
 &lt;td>1-4 GB（依寫吞吐、8.0.30+ 改 &lt;code>innodb_redo_log_capacity&lt;/code>）&lt;/td>
 &lt;td>寫效能（flush 頻率）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>innodb_flush_log_at_trx_commit&lt;/code>&lt;/td>
 &lt;td>1 (full ACID)&lt;/td>
 &lt;td>1（金融 / 訂單）/ 2（高吞吐可容 1 秒 loss）&lt;/td>
 &lt;td>寫吞吐 vs durability&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>innodb_io_capacity&lt;/code> + &lt;code>_max&lt;/code>&lt;/td>
 &lt;td>200 / 2000&lt;/td>
 &lt;td>SSD: 2000 / 20000; NVMe: 10000 / 40000&lt;/td>
 &lt;td>flush 速度（適配儲存）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>其他 knob（&lt;code>innodb_thread_concurrency&lt;/code> / &lt;code>innodb_buffer_pool_instances&lt;/code> / &lt;code>innodb_read_io_threads&lt;/code> 等）也有影響、但對多數 case &lt;em>先把這 4 個調對&lt;/em> 比微調其他 20 個重要。&lt;/p>
&lt;h2 id="knob-1buffer-pool--把-working-set-拉進-ram">Knob 1：Buffer pool — 把 working set 拉進 RAM&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/buffer-pool/" data-link-title="Buffer Pool" data-link-desc="說明資料庫如何用記憶體快取磁碟頁，以降低 I/O 並影響查詢效能">InnoDB buffer pool&lt;/a> 是 &lt;em>page cache&lt;/em> — 從 disk 讀過的 16 KB page 快取在 RAM、下次 query 直接 RAM 讀。Buffer pool 越大、cache hit ratio 越高、disk IO 越少。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/mysql/" data-link-title="MySQL" data-link-desc="高併發網路服務常用關聯式資料庫、Vitess / PlanetScale 分片生態、GitHub / Shopify / Facebook 規模驗證">MySQL</a> overview 的 implementation-layer deep article。Overview 已說明 MySQL 在 OLTP 譜系的定位、本文聚焦 <em>InnoDB engine tuning</em> — 4 個影響最大的 knob 跟對應 production 行為。</p></blockquote>
<hr>
<h2 id="開場常見痛點">開場：常見痛點</h2>
<p>一個 100 GB MySQL DB、64 GB RAM 的 server、p99 query latency 從 5ms 飆到 50ms。第一直覺是 server overload — 但 CPU &lt; 30%、disk IO 50 IOPS。為什麼慢？</p>
<p>打開 <code>SHOW VARIABLES LIKE 'innodb_buffer_pool_size'</code>：<code>134217728</code>（128 MB）。對 64 GB RAM server、buffer pool 只用了 128 MB、剩 99.9% 的 working set 每次 query 都要從 disk 讀。CPU 閒、disk 沒滿、是因為 <em>MySQL 自己不用 RAM</em> — 用 InnoDB 預設值跑 100 GB DB 等於 disk-only 模式。</p>
<p>這個案例展示 InnoDB tuning 的核心：MySQL 預設值是 <em>為 16 GB RAM 設計</em>、production server RAM 越大、預設值離 optimal 越遠。</p>
<h2 id="4-個-critical-knob">4 個 critical knob</h2>
<p>對 90% production case、調這 4 個就解決大部分 InnoDB 性能問題：</p>
<table>
  <thead>
      <tr>
          <th>Knob</th>
          <th>預設</th>
          <th>對 production 建議</th>
          <th>影響</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>innodb_buffer_pool_size</code></td>
          <td>128 MB</td>
          <td>系統 RAM 50-75%（dedicated server 75%）</td>
          <td>讀效能（資料能否在 RAM）</td>
      </tr>
      <tr>
          <td><code>innodb_log_file_size</code></td>
          <td>48 MB（×2 file）</td>
          <td>1-4 GB（依寫吞吐、8.0.30+ 改 <code>innodb_redo_log_capacity</code>）</td>
          <td>寫效能（flush 頻率）</td>
      </tr>
      <tr>
          <td><code>innodb_flush_log_at_trx_commit</code></td>
          <td>1 (full ACID)</td>
          <td>1（金融 / 訂單）/ 2（高吞吐可容 1 秒 loss）</td>
          <td>寫吞吐 vs durability</td>
      </tr>
      <tr>
          <td><code>innodb_io_capacity</code> + <code>_max</code></td>
          <td>200 / 2000</td>
          <td>SSD: 2000 / 20000; NVMe: 10000 / 40000</td>
          <td>flush 速度（適配儲存）</td>
      </tr>
  </tbody>
</table>
<p>其他 knob（<code>innodb_thread_concurrency</code> / <code>innodb_buffer_pool_instances</code> / <code>innodb_read_io_threads</code> 等）也有影響、但對多數 case <em>先把這 4 個調對</em> 比微調其他 20 個重要。</p>
<h2 id="knob-1buffer-pool--把-working-set-拉進-ram">Knob 1：Buffer pool — 把 working set 拉進 RAM</h2>
<p><a href="/blog/backend/knowledge-cards/buffer-pool/" data-link-title="Buffer Pool" data-link-desc="說明資料庫如何用記憶體快取磁碟頁，以降低 I/O 並影響查詢效能">InnoDB buffer pool</a> 是 <em>page cache</em> — 從 disk 讀過的 16 KB page 快取在 RAM、下次 query 直接 RAM 讀。Buffer pool 越大、cache hit ratio 越高、disk IO 越少。</p>
<p><strong>Sizing</strong>：</p>
<ul>
<li><em>Dedicated MySQL server</em>：RAM 70-80%（剩 20-30% 給 OS / MySQL 其他結構 / connection buffer）</li>
<li><em>Shared server</em>：RAM 30-50%（看其他 process 需求）</li>
<li><em>Container / Kubernetes</em>：對 container memory limit 70%（不是 host RAM）</li>
</ul>





<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="c1"># 64 GB RAM dedicated server</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="na">innodb_buffer_pool_size</span> <span class="o">=</span> <span class="s">48G</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="na">innodb_buffer_pool_instances</span> <span class="o">=</span> <span class="s">8  # 分 8 個 instance 降 mutex contention（每 instance 6 GB）</span></span></span></code></pre></div><p><strong>Buffer pool warm-up</strong>：MySQL 重啟後 buffer pool 是空的、要慢慢從 disk 把熱資料拉回 RAM。預設 5.7+ MySQL 啟動時 <em>dump buffer pool LRU list 到 disk</em>、重啟時 <em>自動 restore</em>：</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">innodb_buffer_pool_dump_at_shutdown</span> <span class="o">=</span> <span class="s">1</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="na">innodb_buffer_pool_load_at_startup</span> <span class="o">=</span> <span class="s">1</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="na">innodb_buffer_pool_dump_pct</span> <span class="o">=</span> <span class="s">75  # 只 dump 最 hot 的 75% page list</span></span></span></code></pre></div><p>沒這個 warm-up、重啟後第 1 個小時 query latency 都偏高、application 看到 p99 spike。</p>
<h2 id="knob-2redo-log--flush-頻率跟寫吞吐">Knob 2：Redo log — flush 頻率跟寫吞吐</h2>
<p>InnoDB 寫入 <em>先寫 redo log（順序寫）</em>、再非同步寫到 data file（隨機寫）。Redo log 滿了強迫 flush data file、flush 期間寫吞吐降。</p>
<p><code>innodb_log_file_size</code> 控制每個 log file 大小（預設 2 個 file）：</p>
<ul>
<li>5.7：預設 48 MB × 2 = 96 MB total</li>
<li>8.0：預設仍是 48 MB × 2、8.0.30+ 改用動態 <code>innodb_redo_log_capacity</code>（default 100 MB total）</li>
</ul>
<p>對 5K WPS server、預設容量可能 <em>每分鐘 flush 一次</em>、寫吞吐持續 stall。提高到 1-4 GB total、flush 改成每 30 分鐘一次、寫吞吐穩定。</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">innodb_log_file_size</span> <span class="o">=</span> <span class="s">2G       # 大寫吞吐 server 設 1-4 GB</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="na">innodb_log_files_in_group</span> <span class="o">=</span> <span class="s">2   # 預設 2 個就夠</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="na">innodb_log_buffer_size</span> <span class="o">=</span> <span class="s">64M    # log 寫 disk 前的 RAM buffer</span></span></span></code></pre></div><p><strong>Trade-off</strong>：log file 越大、recovery 時間越長（crash 後 InnoDB 要 replay 全部 log）。1 GB log 通常 &lt; 1 分鐘 recovery、4 GB 可能 5 分鐘以上。SSD / NVMe 這個 trade-off 不嚴重、HDD 要注意。</p>
<p>MySQL 8.0+ 改進：log file 可動態調整（不用重啟）、且 <em>automatic redo log writer threads</em> 降低 mutex contention。</p>
<h2 id="knob-3flush-method--acid-vs-吞吐">Knob 3：Flush method — ACID vs 吞吐</h2>
<p><code>innodb_flush_log_at_trx_commit</code> 控制 <em>每個 transaction commit 時要不要 flush log 到 disk</em>：</p>
<ul>
<li><code>1</code>（預設）：每次 commit fsync log file → <em>zero data loss on crash</em></li>
<li><code>2</code>：每次 commit 寫 log file（但 OS-level cache、不 fsync）→ <em>server crash 不丟、OS crash 丟 1 秒</em></li>
<li><code>0</code>：每秒 fsync 一次 → <em>任何 crash 丟 1 秒</em></li>
</ul>
<p><code>sync_binlog</code> 對應 binlog（不是 InnoDB log）：</p>
<ul>
<li><code>1</code>（建議）：每次 commit fsync binlog</li>
<li><code>0</code>：依賴 OS sync、容易丟 binlog → replication / CDC 風險</li>
</ul>
<p><strong>Production 組合</strong>：</p>
<table>
  <thead>
      <tr>
          <th>用途</th>
          <th><code>innodb_flush_log_at_trx_commit</code></th>
          <th><code>sync_binlog</code></th>
          <th>寫吞吐</th>
          <th>Crash data loss</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>金融 / 訂單 / 支付</td>
          <td>1</td>
          <td>1</td>
          <td>baseline</td>
          <td>0</td>
      </tr>
      <tr>
          <td>一般 web 應用</td>
          <td>1</td>
          <td>1</td>
          <td>baseline</td>
          <td>0</td>
      </tr>
      <tr>
          <td>高寫吞吐 + 容忍 1 sec loss</td>
          <td>2</td>
          <td>1</td>
          <td>+30-50%</td>
          <td>OS crash 丟 1 秒</td>
      </tr>
      <tr>
          <td>Dev / test</td>
          <td>2</td>
          <td>0</td>
          <td>+50-100%</td>
          <td>不重要</td>
      </tr>
      <tr>
          <td>不要這樣設</td>
          <td>0</td>
          <td>0</td>
          <td>+100%</td>
          <td>任意 crash 丟資料</td>
      </tr>
  </tbody>
</table>
<p>多數 production 用 <code>1 + 1</code>、雖然慢但 <em>簡單可預測</em>。改成 <code>2 + 1</code> 之前要明確 <em>能容忍 1 秒 data loss</em>、且通常 review 過 Disaster Recovery Plan。</p>
<h2 id="knob-4io-capacity--適配儲存">Knob 4：IO capacity — 適配儲存</h2>
<p>InnoDB 後台 flush 速度受 <code>innodb_io_capacity</code> 限制：</p>
<ul>
<li><code>innodb_io_capacity</code>（一般）：後台 flush 目標 IOPS</li>
<li><code>innodb_io_capacity_max</code>（突發）：emergency flush 上限</li>
</ul>
<p><strong>對應儲存類型</strong>：</p>
<table>
  <thead>
      <tr>
          <th>儲存</th>
          <th>IOPS 能力</th>
          <th><code>innodb_io_capacity</code></th>
          <th><code>innodb_io_capacity_max</code></th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>7200 RPM HDD</td>
          <td>~80 IOPS</td>
          <td>100</td>
          <td>200</td>
      </tr>
      <tr>
          <td>SSD (SATA)</td>
          <td>10K-50K IOPS</td>
          <td>2000</td>
          <td>20000</td>
      </tr>
      <tr>
          <td>NVMe SSD</td>
          <td>100K-500K IOPS</td>
          <td>10000</td>
          <td>40000</td>
      </tr>
      <tr>
          <td>EBS gp3</td>
          <td>3000-16000 IOPS</td>
          <td>5000</td>
          <td>16000</td>
      </tr>
      <tr>
          <td>EBS io2</td>
          <td>50K-256K IOPS</td>
          <td>20000</td>
          <td>60000</td>
      </tr>
  </tbody>
</table>
<p>預設 <code>200 / 2000</code> 是 <em>為 HDD 設計</em>、SSD / NVMe server 用預設值 = InnoDB 自我限速、flush 慢、寫入瓶頸。</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="c1"># NVMe SSD server</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="na">innodb_io_capacity</span> <span class="o">=</span> <span class="s">10000</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="na">innodb_io_capacity_max</span> <span class="o">=</span> <span class="s">40000</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="na">innodb_flush_neighbors</span> <span class="o">=</span> <span class="s">0  # NVMe 不需要 group flush 相鄰 page</span></span></span></code></pre></div><h2 id="5-個-production-踩雷">5 個 Production 踩雷</h2>
<h3 id="1-buffer-pool-沒-warm-up--重啟後-1-小時-p99-飆">1. Buffer pool 沒 warm-up — 重啟後 1 小時 p99 飆</h3>
<p>MySQL 重啟（OS upgrade / config change / failover）後、buffer pool 是空的、所有 query 第一次都 disk 讀、p99 latency 飆 5-10x、application 看到 timeout。</p>
<p>修法：</p>
<ul>
<li>啟用 <code>innodb_buffer_pool_dump_at_shutdown=1</code> + <code>innodb_buffer_pool_load_at_startup=1</code></li>
<li>對 <em>沒 graceful shutdown</em> 的 crash（OOM / kernel panic）、buffer pool 沒 dump、warm-up 後第一個小時仍辛苦</li>
<li>重要 server 重啟前手動 dump：<code>SET GLOBAL innodb_buffer_pool_dump_now=ON</code></li>
<li>對於不能容忍 cold cache 的場景、failover 前 <em>先 pre-warm new primary</em>（用 query replay 把 hot data 拉到 buffer pool）</li>
</ul>
<h3 id="2-log-file-size-設太小--checkpoint-storm">2. Log file size 設太小 — checkpoint storm</h3>
<p><code>innodb_log_file_size=48M</code> 預設、高寫吞吐 server log 每分鐘 flush 一次、flush 期間 <em>checkpoint storm</em> — 寫吞吐降 50%、p99 暴增。錯誤訊號是 <code>innodb_log_waits</code> 持續 &gt; 0。</p>
<p>修法：</p>
<ul>
<li>監控 <code>SHOW STATUS LIKE 'Innodb_log_waits'</code> — 應該長期接近 0</li>
<li>提高 <code>innodb_log_file_size</code> 到 1-4 GB（依寫吞吐）</li>
<li>8.0+ 可動態調整、5.7 需要 <em>正常 shutdown</em> 後改、開啟前先 dump buffer pool（避免 cold cache）</li>
</ul>
<h3 id="3-sync_binlog0-換速度--replication-永久-broken-風險">3. <code>sync_binlog=0</code> 換速度 — replication 永久 broken 風險</h3>
<p>開發 / staging 改 <code>sync_binlog=0</code>（加快寫入）、後來複製到 production 配置、production 同樣 <code>sync_binlog=0</code>。OS crash 後 binlog 缺最後幾秒 transaction、replica 跟 primary GTID set diverge、replication broken、要 <em>重建 replica from base backup</em>（小時級 recovery）。</p>
<p>修法：</p>
<ul>
<li><em>Production 永遠用 <code>sync_binlog=1</code></em>、不要為了寫吞吐犧牲 binlog durability</li>
<li>開發 / staging 配置跟 production 隔離、不要直接 copy config</li>
<li>Replica 失聯後 <em>用 GTID 自動 re-attach</em>（不是 binlog position）— 仍然需要 binlog 完整、<code>sync_binlog=0</code> 仍是風險</li>
</ul>
<h3 id="4-io-scheduler--不是-innodb-tuning-但影響大">4. IO scheduler — 不是 InnoDB tuning 但影響大</h3>
<p>Linux <code>noop</code> / <code>deadline</code> / <code>cfq</code> IO scheduler 對 SSD / NVMe 影響大：</p>
<ul>
<li><code>cfq</code>（traditional spinning disk default）：對 SSD 嚴重 bottleneck</li>
<li><code>deadline</code>：對 SSD 較好、但有 latency cap</li>
<li><code>noop</code> / <code>none</code>：對 NVMe 最好（讓 device 自己處理 queue）</li>
</ul>
<p><strong>Production check</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">cat /sys/block/sda/queue/scheduler
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># 應該顯示： [none] mq-deadline (NVMe)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"># 或：         noop deadline [cfq] (cfq 是錯的)</span></span></span></code></pre></div><p>不是 InnoDB knob、但影響 InnoDB IO behavior &gt; 30%。InnoDB tuning 前先確認 OS-level IO scheduler 對。</p>
<h3 id="5-undo-log-膨脹--purge-跟不上">5. Undo log 膨脹 — purge 跟不上</h3>
<p>Undo log 紀錄 <em>未來可能 rollback 需要的舊版本 row</em>。長 transaction（hours-level）讓 undo log 持續累積、不能 purge、最後 InnoDB tablespace 膨脹幾 GB、disk 滿。</p>
<p>訊號：</p>
<ul>
<li><code>SHOW ENGINE INNODB STATUS</code> 看 <code>History list length</code> 持續成長（正常 &lt; 1000、異常 millions）</li>
<li><code>information_schema.innodb_metrics</code> 的 <code>trx_rseg_history_len</code></li>
</ul>
<p>修法：</p>
<ul>
<li>找 long-running transaction：<code>SELECT * FROM information_schema.innodb_trx WHERE trx_started &lt; NOW() - INTERVAL 1 HOUR</code></li>
<li>KILL 該 transaction（謹慎、可能 application bug）</li>
<li>8.0+ 用 separate undo tablespace（<code>innodb_undo_tablespaces</code>）、不污染 main tablespace、且可以 truncate</li>
</ul>
<h2 id="容量規劃要點">容量規劃要點</h2>
<p>對 64 GB RAM、NVMe SSD、5K WPS、100 GB DB 的 server：</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="c1"># my.cnf production-ready baseline</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="k">[mysqld]</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"># Buffer pool (75% RAM)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="na">innodb_buffer_pool_size</span> <span class="o">=</span> <span class="s">48G</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="na">innodb_buffer_pool_instances</span> <span class="o">=</span> <span class="s">8</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="na">innodb_buffer_pool_dump_at_shutdown</span> <span class="o">=</span> <span class="s">1</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="na">innodb_buffer_pool_load_at_startup</span> <span class="o">=</span> <span class="s">1</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="c1"># Redo log</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="na">innodb_log_file_size</span> <span class="o">=</span> <span class="s">2G</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="na">innodb_log_files_in_group</span> <span class="o">=</span> <span class="s">2</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="na">innodb_log_buffer_size</span> <span class="o">=</span> <span class="s">64M</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1"># Flush behavior</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="na">innodb_flush_log_at_trx_commit</span> <span class="o">=</span> <span class="s">1</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="na">sync_binlog</span> <span class="o">=</span> <span class="s">1</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="na">innodb_flush_method</span> <span class="o">=</span> <span class="s">O_DIRECT  # 跳過 OS page cache 避免 double cache</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="c1"># IO capacity (NVMe)</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="na">innodb_io_capacity</span> <span class="o">=</span> <span class="s">10000</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="na">innodb_io_capacity_max</span> <span class="o">=</span> <span class="s">40000</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="na">innodb_flush_neighbors</span> <span class="o">=</span> <span class="s">0</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="na">innodb_lru_scan_depth</span> <span class="o">=</span> <span class="s">1024</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">
</span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="c1"># Concurrency</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl"><span class="na">innodb_thread_concurrency</span> <span class="o">=</span> <span class="s">0  # 0 = no limit (8.0+ 推薦)</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl"><span class="na">innodb_read_io_threads</span> <span class="o">=</span> <span class="s">8</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl"><span class="na">innodb_write_io_threads</span> <span class="o">=</span> <span class="s">8</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">
</span></span><span class="line"><span class="ln">30</span><span class="cl"><span class="c1"># 額外</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl"><span class="na">innodb_file_per_table</span> <span class="o">=</span> <span class="s">1</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl"><span class="na">innodb_strict_mode</span> <span class="o">=</span> <span class="s">1</span></span></span></code></pre></div><p>跨不同 server spec、<code>buffer_pool_size</code> / <code>io_capacity</code> 隨硬體調整、其他 knob 變動小。</p>
<h2 id="跟其他模組整合">跟其他模組整合</h2>
<h3 id="跟-replication-topology">跟 Replication topology</h3>
<p><code>sync_binlog=1</code> + <code>innodb_flush_log_at_trx_commit=1</code> 是 <em>durability baseline</em>、影響 <a href="/blog/backend/01-database/vendors/mysql/replication-topology/" data-link-title="MySQL Replication Topology：async / semi-sync / GTID 不是三選一、是三個 trade-off 軸的疊加" data-link-desc="MySQL replication 不是「選 async 還是 semi-sync」、是 *durability / latency / consistency* 三個 trade-off 軸的疊加；GTID 是跨 mode 的 infrastructure layer、不是第三種 mode。本文走 3 軸取捨模型 → async / semi-sync 行為對比 → GTID 替代 binlog-position 的好處 → 配置 step-by-step → 5 production 踩雷（lag 暴衝 / semi-sync 退回 async / GTID gap / Loss-Less semi-sync 真的 loss-less / chained replication 雪崩）→ 跟 Aurora MySQL / Vitess / ProxySQL / Orchestrator 整合">Replication Topology</a> 的 <em>primary durability</em>。Semi-sync 加在這基礎上提供 <em>跨 server durability</em>。</p>
<h3 id="跟-proxysql">跟 ProxySQL</h3>
<p>ProxySQL connection pool 降低 <em>MySQL connection 開銷</em>、但 <em>每個 connection</em> 仍消耗 8-10 MB RAM（thread stack + session buffer）。Buffer pool 設 75% RAM 後、剩 25% 給 connection / temporary buffer / OS。Connection 太多會擠掉 buffer pool。</p>
<p>詳見 <a href="/blog/backend/01-database/vendors/mysql/proxysql-config/" data-link-title="MySQL ProxySQL 配置：connection / query / route / response 四段 lifecycle 跟 query rule 設計" data-link-desc="ProxySQL 是 MySQL 生態的 connection pool &#43; query routing 標準。本文走 connection → query parse → route → response 四段 lifecycle、query rule engine 的 rule chain 設計、Hostgroup / Server / User 三層 schema、配置 step-by-step（讀寫分離 &#43; replica lag-aware routing）、5 production 踩雷（query rule 順序錯亂 / connection 漂移 / write 路由到 replica / runtime / disk schema drift / mirror traffic 副作用）、跟 Replication / Orchestrator / HAProxy 整合">ProxySQL 配置</a>。</p>
<h3 id="跟-aurora-mysql">跟 Aurora MySQL</h3>
<p>Aurora 改寫 InnoDB storage layer、上方 knob 大多 <em>Aurora 自動管理</em>：</p>
<ul>
<li>Buffer pool size：Aurora compute instance 自動配</li>
<li>Redo log：Aurora 自己的 distributed log、不用 <code>innodb_log_file_size</code></li>
<li><code>sync_binlog</code> / <code>innodb_flush_log_at_trx_commit</code>：Aurora storage layer 保證 durability、應用層 knob 影響小</li>
</ul>
<p>Aurora user 仍可 tune <code>innodb_buffer_pool_size</code> 等、但操作面從 InnoDB 內部議題變成 <em>Aurora instance class 選擇</em>。詳見 <a href="/blog/backend/01-database/vendors/aurora/" data-link-title="AWS Aurora" data-link-desc="AWS managed PostgreSQL / MySQL、storage / compute 分離、&#43;75% 效能改善的 production 證據">Aurora vendor page</a>。</p>
<h3 id="跟-osc-tool">跟 OSC tool</h3>
<p>InnoDB tuning 不直接影響 OSC 工具行為、但 <em>log file size 太小</em> 時 gh-ost / pt-osc 寫 ghost table 容易 trigger checkpoint storm、放慢整個 schema migration。詳見 <a href="/blog/backend/01-database/vendors/mysql/online-schema-change-tools/" data-link-title="MySQL Online Schema Change：gh-ost 跟 pt-online-schema-change 兩條完全不同的 ghost table 路徑" data-link-desc="MySQL ALTER TABLE 可能鎖整張表，production 需要 online schema change 流程。gh-ost（GitHub）跟 pt-online-schema-change（Percona）都用 ghost table 解決、但底層機制完全不同：pt-osc 用 trigger 同步、gh-ost 用 binlog stream 同步。本文走兩工具機制對照表 → trigger vs binlog 各自取捨 → 配置 step-by-step → 5 production 踩雷（trigger overhead / binlog 延遲 / FK constraint / hot trigger lock / 切換瞬間 deadlock）→ 何時用哪一個">Online Schema Change Tools</a>。</p>
<h2 id="觀測-metric">觀測 metric</h2>
<p><code>SHOW STATUS LIKE</code> + Performance Schema 提供：</p>
<ul>
<li><code>Innodb_buffer_pool_read_requests</code> / <code>_reads</code> → cache hit ratio = <code>1 - reads/read_requests</code>、應該 &gt; 99%</li>
<li><code>Innodb_log_waits</code> → checkpoint pressure、應該 = 0</li>
<li><code>Innodb_log_write_requests</code> / <code>_writes</code> → log buffer 效率</li>
<li><code>Innodb_rows_inserted</code> / <code>_updated</code> / <code>_read</code> → workload 形狀</li>
<li><code>Innodb_row_lock_waits</code> / <code>_time</code> → lock contention</li>
</ul>
<p>把這些丟進 <a href="/blog/backend/04-observability/vendors/datadog/" data-link-title="Datadog" data-link-desc="All-in-one SaaS 觀測平台、APM / Logs / Metrics / RUM / Security">Datadog</a> / <a href="/blog/backend/04-observability/vendors/prometheus/" data-link-title="Prometheus" data-link-desc="Pull-based metrics 主流 OSS、PromQL 與 alerting">Prometheus</a> 透過 <a href="https://github.com/prometheus/mysqld_exporter">mysqld_exporter</a> / <a href="https://www.percona.com/software/database-tools/percona-monitoring-and-management">Percona Monitoring</a> 持續 trend。</p>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/mysql/" data-link-title="MySQL" data-link-desc="高併發網路服務常用關聯式資料庫、Vitess / PlanetScale 分片生態、GitHub / Shopify / Facebook 規模驗證">MySQL vendor overview</a></li>
<li><a href="/blog/backend/01-database/vendors/mysql/replication-topology/" data-link-title="MySQL Replication Topology：async / semi-sync / GTID 不是三選一、是三個 trade-off 軸的疊加" data-link-desc="MySQL replication 不是「選 async 還是 semi-sync」、是 *durability / latency / consistency* 三個 trade-off 軸的疊加；GTID 是跨 mode 的 infrastructure layer、不是第三種 mode。本文走 3 軸取捨模型 → async / semi-sync 行為對比 → GTID 替代 binlog-position 的好處 → 配置 step-by-step → 5 production 踩雷（lag 暴衝 / semi-sync 退回 async / GTID gap / Loss-Less semi-sync 真的 loss-less / chained replication 雪崩）→ 跟 Aurora MySQL / Vitess / ProxySQL / Orchestrator 整合">MySQL Replication Topology</a>（<code>sync_binlog</code> 跟 replication 互動）</li>
<li><a href="/blog/backend/01-database/vendors/mysql/proxysql-config/" data-link-title="MySQL ProxySQL 配置：connection / query / route / response 四段 lifecycle 跟 query rule 設計" data-link-desc="ProxySQL 是 MySQL 生態的 connection pool &#43; query routing 標準。本文走 connection → query parse → route → response 四段 lifecycle、query rule engine 的 rule chain 設計、Hostgroup / Server / User 三層 schema、配置 step-by-step（讀寫分離 &#43; replica lag-aware routing）、5 production 踩雷（query rule 順序錯亂 / connection 漂移 / write 路由到 replica / runtime / disk schema drift / mirror traffic 副作用）、跟 Replication / Orchestrator / HAProxy 整合">MySQL ProxySQL 配置</a>（connection 跟 buffer pool 爭 RAM）</li>
<li><a href="/blog/backend/01-database/vendors/aurora/" data-link-title="AWS Aurora" data-link-desc="AWS managed PostgreSQL / MySQL、storage / compute 分離、&#43;75% 效能改善的 production 證據">Aurora vendor page</a>（managed MySQL、InnoDB tuning 部分轉手）</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 整合">PostgreSQL Autovacuum Tuning</a>（PG sibling、不同 engine 內部 tuning）</li>
<li>官方：<a href="https://dev.mysql.com/doc/refman/8.0/en/innodb-default-se.html">InnoDB Configuration</a> / <a href="https://www.percona.com/blog/mysql-101-tuning-mysql-after-installation/">Percona Tuning Guide</a></li>
</ul>
]]></content:encoded></item><item><title>Redis 連線與 pipeline：RTT 稅、連線池與一次往返打包多命令</title><link>https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/redis/connection-pipeline-latency/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/redis/connection-pipeline-latency/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/redis/" data-link-title="Redis" data-link-desc="OSS in-memory data structure store、cache 主流">Redis&lt;/a> overview 的 implementation-layer deep article。連線與往返是 application 端量到的延遲主因，跟 server 端的&lt;a href="https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/redis/memory-eviction-tuning/" data-link-title="Redis 記憶體與淘汰調校：maxmemory-policy、LFU 與碎片化的實戰判讀" data-link-desc="Redis 的記憶體是一條會在半夜爆掉的曲線：maxmemory 設多少、policy 選 LRU 還 LFU、碎片化什麼時候開始吃掉 30% RAM、OOM 時 noeviction 怎麼讓寫入全部失敗。本文展開 Redis 記憶體會計模型、eviction policy 的選型判讀、5 個把記憶體配置寫成 production 事故的踩坑，以及單機記憶體撞牆後該往 cluster 還是 DragonflyDB 走的邊界">記憶體&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/redis/persistence-fork-latency/" data-link-title="Redis 持久化與 fork latency：AOF、RDB 與那一次卡住整個 cluster 的 fork" data-link-desc="Redis 的 RDB save 與 AOF rewrite 都靠一次 fork()，而 fork 在大記憶體實例上會凍結主執行緒數百毫秒、複製分頁讓記憶體逼近翻倍。本文展開 AOF / RDB 的機制與 fsync 取捨、copy-on-write 的記憶體放大、5 個把持久化寫成延遲尖峰與資料遺失的 production 踩坑，以及 cache 場景到底要不要持久化的邊界">持久化&lt;/a>調校互補。pipeline 機制以 &lt;a href="https://redis.io/docs/latest/develop/use/pipelining/">Redis pipelining 官方文件&lt;/a> 為準、最後檢查日 2026-06-16。&lt;/p>&lt;/blockquote>
&lt;h2 id="延遲不在-redis在往返">延遲不在 Redis、在往返&lt;/h2>
&lt;p>把單一 &lt;code>GET&lt;/code> 丟進 &lt;code>redis-cli --latency&lt;/code>，會看到 server 端執行時間是微秒級。但 application 端的 APM 量到的 Redis 呼叫卻是 1-3ms。這個差距不是 Redis 變慢了，是網路往返（round-trip time，RTT）——命令從 application 送到 Redis、結果送回來，這趟來回就是毫秒級，而 Redis 的執行只佔其中一小部分。&lt;/p>
&lt;p>這個認知翻轉了 Redis 優化的方向：當你的服務每個請求要打 10 個 Redis 命令，瓶頸不是 Redis 的吞吐，是 10 次 RTT 疊加成 10-30ms。pipelining 常被講成「批次發命令省效能」，但它真正消除的是 RTT 稅——把 10 次往返打包成 1 次往返，server 端執行時間幾乎不變，但 application 端延遲從 10×RTT 降到 1×RTT。&lt;/p>
&lt;p>對每次互動要查多個 cache 的服務，這筆 RTT 稅是延遲預算的主要支出。&lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/snap-gcp-keydb-cross-cloud/" data-link-title="9.C35 Snap：GCP &amp;#43; KeyDB 在 multi-cloud 架構下的低延遲快取" data-link-desc="Snap 用 GCP 上的 KeyDB cluster 減少跨 cloud cache 延遲、用 TPU 訓練廣告推薦模型">Snap 在 multi-cloud 架構下的痛點&lt;/a>正是這個放大版：application 在一個 cloud、cache 在另一個，每次 lookup 多吃 5-30ms 跨 cloud RTT，「5ms × 10 cache lookup = 50ms 額外延遲」。Snap 把 KeyDB 部署到同 cloud 減少跨 cloud RTT，本質就是降低往返稅。本文處理 RTT 的會計、連線池配置與 pipeline 的正確使用。&lt;/p>
&lt;h2 id="核心概念rtt-會計與三種降稅手段">核心概念：RTT 會計與三種降稅手段&lt;/h2>
&lt;p>Redis 一次請求的延遲拆成三段：client 序列化 + 送出、網路往返（RTT）、server 執行。多數 cache 場景下 RTT 是主導項，server 執行可忽略。降低總延遲有三種手段，對應三種「省 RTT」的方式：&lt;/p>
&lt;p>&lt;strong>連線池消除「每次都建連線」的稅&lt;/strong>。建立 TCP 連線（三次握手）本身就是一趟 RTT，若還有 TLS 再加幾趟。每個請求都新建連線等於每次都付建連稅。連線池讓連線重用，把建連成本攤平到接近零。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/02-cache-redis/vendors/redis/" data-link-title="Redis" data-link-desc="OSS in-memory data structure store、cache 主流">Redis</a> overview 的 implementation-layer deep article。連線與往返是 application 端量到的延遲主因，跟 server 端的<a href="/blog/backend/02-cache-redis/vendors/redis/memory-eviction-tuning/" data-link-title="Redis 記憶體與淘汰調校：maxmemory-policy、LFU 與碎片化的實戰判讀" data-link-desc="Redis 的記憶體是一條會在半夜爆掉的曲線：maxmemory 設多少、policy 選 LRU 還 LFU、碎片化什麼時候開始吃掉 30% RAM、OOM 時 noeviction 怎麼讓寫入全部失敗。本文展開 Redis 記憶體會計模型、eviction policy 的選型判讀、5 個把記憶體配置寫成 production 事故的踩坑，以及單機記憶體撞牆後該往 cluster 還是 DragonflyDB 走的邊界">記憶體</a>、<a href="/blog/backend/02-cache-redis/vendors/redis/persistence-fork-latency/" data-link-title="Redis 持久化與 fork latency：AOF、RDB 與那一次卡住整個 cluster 的 fork" data-link-desc="Redis 的 RDB save 與 AOF rewrite 都靠一次 fork()，而 fork 在大記憶體實例上會凍結主執行緒數百毫秒、複製分頁讓記憶體逼近翻倍。本文展開 AOF / RDB 的機制與 fsync 取捨、copy-on-write 的記憶體放大、5 個把持久化寫成延遲尖峰與資料遺失的 production 踩坑，以及 cache 場景到底要不要持久化的邊界">持久化</a>調校互補。pipeline 機制以 <a href="https://redis.io/docs/latest/develop/use/pipelining/">Redis pipelining 官方文件</a> 為準、最後檢查日 2026-06-16。</p></blockquote>
<h2 id="延遲不在-redis在往返">延遲不在 Redis、在往返</h2>
<p>把單一 <code>GET</code> 丟進 <code>redis-cli --latency</code>，會看到 server 端執行時間是微秒級。但 application 端的 APM 量到的 Redis 呼叫卻是 1-3ms。這個差距不是 Redis 變慢了，是網路往返（round-trip time，RTT）——命令從 application 送到 Redis、結果送回來，這趟來回就是毫秒級，而 Redis 的執行只佔其中一小部分。</p>
<p>這個認知翻轉了 Redis 優化的方向：當你的服務每個請求要打 10 個 Redis 命令，瓶頸不是 Redis 的吞吐，是 10 次 RTT 疊加成 10-30ms。pipelining 常被講成「批次發命令省效能」，但它真正消除的是 RTT 稅——把 10 次往返打包成 1 次往返，server 端執行時間幾乎不變，但 application 端延遲從 10×RTT 降到 1×RTT。</p>
<p>對每次互動要查多個 cache 的服務，這筆 RTT 稅是延遲預算的主要支出。<a href="/blog/backend/09-performance-capacity/cases/snap-gcp-keydb-cross-cloud/" data-link-title="9.C35 Snap：GCP &#43; KeyDB 在 multi-cloud 架構下的低延遲快取" data-link-desc="Snap 用 GCP 上的 KeyDB cluster 減少跨 cloud cache 延遲、用 TPU 訓練廣告推薦模型">Snap 在 multi-cloud 架構下的痛點</a>正是這個放大版：application 在一個 cloud、cache 在另一個，每次 lookup 多吃 5-30ms 跨 cloud RTT，「5ms × 10 cache lookup = 50ms 額外延遲」。Snap 把 KeyDB 部署到同 cloud 減少跨 cloud RTT，本質就是降低往返稅。本文處理 RTT 的會計、連線池配置與 pipeline 的正確使用。</p>
<h2 id="核心概念rtt-會計與三種降稅手段">核心概念：RTT 會計與三種降稅手段</h2>
<p>Redis 一次請求的延遲拆成三段：client 序列化 + 送出、網路往返（RTT）、server 執行。多數 cache 場景下 RTT 是主導項，server 執行可忽略。降低總延遲有三種手段，對應三種「省 RTT」的方式：</p>
<p><strong>連線池消除「每次都建連線」的稅</strong>。建立 TCP 連線（三次握手）本身就是一趟 RTT，若還有 TLS 再加幾趟。每個請求都新建連線等於每次都付建連稅。連線池讓連線重用，把建連成本攤平到接近零。</p>
<p><strong>pipelining 把 N 次 RTT 壓成 1 次</strong>。連續送 N 個命令而不等每個的回應，一次讀回 N 個結果。這要求這 N 個命令彼此無依賴（後一個不需要前一個的結果）。</p>
<p><strong>Lua script / 多 key 命令把多操作合成 1 次往返且原子</strong>。當命令之間有依賴（讀了再決定怎麼寫），pipeline 不適用（後面的命令送出時前面的結果還沒回來），這時用 Lua script 把邏輯放到 server 端一次執行，省 RTT 又拿到原子性。</p>
<h3 id="pipeline-跟-multi-是不同的東西">pipeline 跟 MULTI 是不同的東西</h3>
<p>這兩個常被混淆，但解的問題不同：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>pipeline</th>
          <th>MULTI / EXEC（transaction）</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>主要目的</td>
          <td>省 RTT（效能）</td>
          <td>原子性（多命令不被打斷）</td>
      </tr>
      <tr>
          <td>原子性</td>
          <td>無——命令間可能插入其他 client</td>
          <td>有——EXEC 內命令連續執行不被插入</td>
      </tr>
      <tr>
          <td>回應時機</td>
          <td>全部送完一次讀回</td>
          <td>EXEC 後一次回所有結果</td>
      </tr>
      <tr>
          <td>失敗處理</td>
          <td>各命令獨立成敗</td>
          <td>入隊期語法錯整批拒、執行期錯不回滾</td>
      </tr>
      <tr>
          <td>適用</td>
          <td>大量無依賴命令的批次讀寫</td>
          <td>需要「一組命令不被其他 client 插隊」</td>
      </tr>
  </tbody>
</table>
<p>pipeline 純粹是傳輸層優化，不保證原子性——pipeline 裡的命令在 server 端仍可能跟其他 client 的命令交錯。要原子性用 MULTI/EXEC 或 Lua。兩者也可以組合（在 pipeline 裡送 MULTI&hellip;EXEC）。</p>
<p>注意 Redis 的 MULTI/EXEC 不是關聯式 DB 的 transaction：執行期某命令出錯（例如對 string 做 list 操作）不會回滾已執行的命令，它沒有 rollback。</p>
<h2 id="配置連線池與-pipeline-的設定路徑">配置：連線池與 pipeline 的設定路徑</h2>
<p>連線池配置（以 Python redis-py 為例，多數 client library 概念一致）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kn">import</span> <span class="nn">redis</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">pool</span> <span class="o">=</span> <span class="n">redis</span><span class="o">.</span><span class="n">ConnectionPool</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="n">host</span><span class="o">=</span><span class="s2">&#34;10.0.0.1&#34;</span><span class="p">,</span> <span class="n">port</span><span class="o">=</span><span class="mi">6379</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="n">max_connections</span><span class="o">=</span><span class="mi">50</span><span class="p">,</span>          <span class="c1"># 池上限、依並發量與 Redis maxclients 反推</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="n">socket_timeout</span><span class="o">=</span><span class="mf">0.5</span><span class="p">,</span>          <span class="c1"># 單命令逾時（秒）——必設、否則慢命令拖垮 caller</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="n">socket_connect_timeout</span><span class="o">=</span><span class="mf">0.5</span><span class="p">,</span>  <span class="c1"># 建連逾時</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="n">health_check_interval</span><span class="o">=</span><span class="mi">30</span><span class="p">,</span>    <span class="c1"># 定期檢查連線存活、清掉壞連線</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="n">r</span> <span class="o">=</span> <span class="n">redis</span><span class="o">.</span><span class="n">Redis</span><span class="p">(</span><span class="n">connection_pool</span><span class="o">=</span><span class="n">pool</span><span class="p">)</span></span></span></code></pre></div><p><code>socket_timeout</code> 是最常被遺漏卻最關鍵的設定——沒設逾時，一個慢命令或網路黑洞會讓 caller 無限等待，連鎖拖垮上游。</p>
<p>pipeline 的使用：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># pipeline：N 個無依賴命令、一次往返</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">pipe</span> <span class="o">=</span> <span class="n">r</span><span class="o">.</span><span class="n">pipeline</span><span class="p">(</span><span class="n">transaction</span><span class="o">=</span><span class="kc">False</span><span class="p">)</span>  <span class="c1"># transaction=False 純 pipeline、不包 MULTI</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="k">for</span> <span class="n">uid</span> <span class="ow">in</span> <span class="n">user_ids</span><span class="p">:</span>                  <span class="c1"># 假設要拿 100 個 user 的 profile</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="n">pipe</span><span class="o">.</span><span class="n">hgetall</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;user:</span><span class="si">{</span><span class="n">uid</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="n">results</span> <span class="o">=</span> <span class="n">pipe</span><span class="o">.</span><span class="n">execute</span><span class="p">()</span>              <span class="c1"># 一次往返拿回 100 個結果</span></span></span></code></pre></div><p>依賴型操作改用 Lua（命令間有讀後寫的依賴，pipeline 不適用）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 原子的 check-and-set：讀目前值、符合條件才更新——一次往返且原子</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">lua</span> <span class="o">=</span> <span class="s2">&#34;&#34;&#34;
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="s2">local current = redis.call(&#39;GET&#39;, KEYS[1])
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="s2">if current == ARGV[1] then
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="s2">  redis.call(&#39;SET&#39;, KEYS[1], ARGV[2])
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="s2">  return 1
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="s2">end
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="s2">return 0
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="s2">&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="n">cas</span> <span class="o">=</span> <span class="n">r</span><span class="o">.</span><span class="n">register_script</span><span class="p">(</span><span class="n">lua</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="n">cas</span><span class="p">(</span><span class="n">keys</span><span class="o">=</span><span class="p">[</span><span class="s2">&#34;lock:resource&#34;</span><span class="p">],</span> <span class="n">args</span><span class="o">=</span><span class="p">[</span><span class="s2">&#34;old_token&#34;</span><span class="p">,</span> <span class="s2">&#34;new_token&#34;</span><span class="p">])</span></span></span></code></pre></div><p><code>MGET</code> / <code>MSET</code> / <code>HMGET</code> 等原生多 key 命令是最簡單的省 RTT 手段——能用多 key 命令就不用 pipeline，更省事且原子。</p>
<h2 id="production-故障演練">Production 故障演練</h2>
<h3 id="case-1每請求新建連線延遲全是建連稅">Case 1：每請求新建連線、延遲全是建連稅</h3>
<p><strong>徵兆</strong>：Redis 呼叫延遲偏高且不穩，<code>INFO stats</code> 的 <code>total_connections_received</code> 速率極高（接近 QPS），Redis 的 <code>connected_clients</code> 反覆上下震盪。</p>
<p><strong>根因</strong>：application 沒用連線池，或每個請求 <code>redis.Redis(...)</code> 重新建立 client。每次請求付一趟 TCP 握手（加 TLS 更多）的 RTT，建連稅疊在每個請求上。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>用連線池並重用，client 物件在 application 生命週期內共用，不是每請求建立</li>
<li>短生命週期環境（Lambda / serverless）把連線池放在 handler 外（容器重用時連線存活）</li>
<li>監控 <code>total_connections_received</code> 速率，遠高於合理重連頻率代表沒重用</li>
<li>TLS 場景的建連稅更高，連線重用的收益更大</li>
</ol>
<h3 id="case-2沒設-socket_timeout一個慢命令拖垮整條鏈">Case 2：沒設 socket_timeout、一個慢命令拖垮整條鏈</h3>
<p><strong>徵兆</strong>：某次 Redis 短暫卡頓（fork 尖峰、網路抖動），application 端大量請求 hang 住不回，thread / connection 被耗盡，影響擴散到跟 Redis 無關的請求。</p>
<p><strong>根因</strong>：連線沒設 <code>socket_timeout</code>。Redis 一旦慢回應或網路黑洞，caller 無限等待，佔住 thread 與連線，連鎖拖垮整個服務。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>一律設 <code>socket_timeout</code>（cache 場景通常幾百 ms 就該逾時，cache 本來就該快）</li>
<li>逾時後 application 要有 fallback（回源或降級），不是把逾時當 fatal</li>
<li>連線池 <code>max_connections</code> 設上限，避免無限建連把 Redis 的 <code>maxclients</code> 打滿</li>
<li>fork 尖峰是常見的慢源頭，對應 <a href="/blog/backend/02-cache-redis/vendors/redis/persistence-fork-latency/" data-link-title="Redis 持久化與 fork latency：AOF、RDB 與那一次卡住整個 cluster 的 fork" data-link-desc="Redis 的 RDB save 與 AOF rewrite 都靠一次 fork()，而 fork 在大記憶體實例上會凍結主執行緒數百毫秒、複製分頁讓記憶體逼近翻倍。本文展開 AOF / RDB 的機制與 fsync 取捨、copy-on-write 的記憶體放大、5 個把持久化寫成延遲尖峰與資料遺失的 production 踩坑，以及 cache 場景到底要不要持久化的邊界">persistence deep article</a> 的延遲尖峰治理</li>
</ol>
<h3 id="case-3一個巨大-pipeline-把-server-跟-client-都撐爆">Case 3：一個巨大 pipeline 把 server 跟 client 都撐爆</h3>
<p><strong>徵兆</strong>：用 pipeline 批次處理時，某次塞了幾十萬個命令進一個 pipeline，Redis 記憶體尖峰、client 端記憶體爆，甚至 OOM。</p>
<p><strong>根因</strong>：pipeline 把所有命令的 request 跟 response 都 buffer 起來。一次塞太多，server 端要 buffer 全部 reply（計入 <code>used_memory</code>、見 <a href="/blog/backend/02-cache-redis/vendors/redis/memory-eviction-tuning/" data-link-title="Redis 記憶體與淘汰調校：maxmemory-policy、LFU 與碎片化的實戰判讀" data-link-desc="Redis 的記憶體是一條會在半夜爆掉的曲線：maxmemory 設多少、policy 選 LRU 還 LFU、碎片化什麼時候開始吃掉 30% RAM、OOM 時 noeviction 怎麼讓寫入全部失敗。本文展開 Redis 記憶體會計模型、eviction policy 的選型判讀、5 個把記憶體配置寫成 production 事故的踩坑，以及單機記憶體撞牆後該往 cluster 還是 DragonflyDB 走的邊界">記憶體調校</a> 的 output buffer），client 端要 hold 全部結果，雙邊記憶體尖峰。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>pipeline 分批（chunk），每批幾百到幾千命令，不要一個 pipeline 塞無上限</li>
<li>大量資料的掃描用 <code>SCAN</code> 游標分批，不要 <code>KEYS *</code> 一次撈</li>
<li>監控 client output buffer（<code>CLIENT LIST</code> 的 <code>omem</code>），異常大代表有巨型 pipeline 或慢 consumer</li>
<li>批次大小靠 RTT 與記憶體權衡——批次越大省越多 RTT，但記憶體尖峰越高</li>
</ol>
<h3 id="case-4在-cluster-模式對跨-slot-key-開-pipeline--transaction-失敗">Case 4：在 cluster 模式對跨 slot key 開 pipeline / transaction 失敗</h3>
<p><strong>徵兆</strong>：單機 Redis 上運作正常的 pipeline 或 MULTI，搬到 <a href="/blog/backend/02-cache-redis/vendors/redis/cluster-resharding/" data-link-title="Redis Cluster Re-sharding：source = target，但 topology 重劃的 5 段流程" data-link-desc="Redis cluster re-sharding 是 5 type migration 漏類實證 — source / target 同 cluster、無 schema / paradigm 差、但 16384 slot 重分配是核心；本文涵蓋 4 種 re-sharding driver、slot migration 機制、redis-cli --cluster rebalance / reshard 工具、5 個 production 踩雷（cluster busy / replica lag / client cache stale / cross-slot transaction / monitor gap）">Redis Cluster</a> 後報 <code>CROSSSLOT Keys in request don't hash to the same slot</code>。</p>
<p><strong>根因</strong>：Cluster 模式下 MULTI/EXEC 與某些多 key 命令要求所有 key 在同一個 hash slot。pipeline 在 cluster 下也要按 slot 分組送到對應 node——若 client library 不自動處理跨 slot，會失敗。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>同組操作的 key 用 hash tag <code>{...}</code> 強制同 slot（例如 <code>user:{123}:profile</code>、<code>user:{123}:settings</code>）</li>
<li>用支援 cluster pipeline 的 client library，它會自動按 slot 分組</li>
<li>設計階段就考慮 key 的 slot 分布，避免事後重構，對應 cluster re-sharding 的 hash tag 治理</li>
<li>跨 slot 的批次邏輯改用 application 端聚合，不依賴 server 端原子性</li>
</ol>
<h3 id="case-5把-pipeline-當-transaction-用出現資料競態">Case 5：把 pipeline 當 transaction 用、出現資料競態</h3>
<p><strong>徵兆</strong>：用 pipeline 做「讀一個值、根據它決定寫什麼」的邏輯，高並發下偶發資料不一致——兩個 client 讀到同樣的舊值、各自寫入，一方覆蓋另一方。</p>
<p><strong>根因</strong>：把 pipeline 誤當原子操作。pipeline 只是把命令打包傳輸，命令之間 server 端仍可能插入其他 client 的命令——它沒有原子性。讀後寫的依賴邏輯放 pipeline 裡，等於沒有任何併發保護。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>讀後寫的依賴邏輯用 Lua script（server 端原子執行），不用 pipeline</li>
<li>樂觀鎖場景用 <code>WATCH</code> + MULTI/EXEC（watch 的 key 被改則 EXEC 失敗、重試）</li>
<li>分清楚需求：要省 RTT 用 pipeline，要原子性用 Lua / MULTI，兩者目的不同</li>
<li>distributed lock 場景見 <a href="/blog/backend/02-cache-redis/distributed-lock/" data-link-title="2.4 distributed lock 與租約" data-link-desc="整理鎖語意、租約風險與適用場景">2.5 distributed lock</a>，Redis 的鎖有自己的正確性陷阱</li>
</ol>
<h2 id="capacity--cost-邊界">Capacity / cost 邊界</h2>
<p>連線與往返的容量判讀，圍繞連線數與每請求往返次數：</p>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>健康區間</th>
          <th>警戒與動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>connected_clients</code></td>
          <td>穩定、遠低於 <code>maxclients</code></td>
          <td>接近 maxclients → 池太大或洩漏、調池上限</td>
      </tr>
      <tr>
          <td><code>total_connections_received</code> 速率</td>
          <td>低（連線重用）</td>
          <td>接近 QPS → 沒用連線池、每請求建連</td>
      </tr>
      <tr>
          <td>每請求 Redis 往返次數</td>
          <td>盡量合併（多 key / pipeline）</td>
          <td>多次獨立往返 → 用 pipeline / MGET 合併</td>
      </tr>
      <tr>
          <td>client output buffer (<code>omem</code>)</td>
          <td>小</td>
          <td>大 → 巨型 pipeline 或慢 consumer</td>
      </tr>
      <tr>
          <td>Redis CPU</td>
          <td>有餘裕</td>
          <td>單執行緒 CPU 滿 → 命令太重或 QPS 超單機</td>
      </tr>
  </tbody>
</table>
<p>撞牆後的路由判斷：</p>
<ul>
<li><strong>單執行緒 CPU 打滿、命令吞吐到頂</strong>：Redis 主執行緒單線處理命令，pipeline 省 RTT 但不增加 server 端平行度。CPU 到頂走 <a href="/blog/backend/02-cache-redis/vendors/redis/cluster-resharding/" data-link-title="Redis Cluster Re-sharding：source = target，但 topology 重劃的 5 段流程" data-link-desc="Redis cluster re-sharding 是 5 type migration 漏類實證 — source / target 同 cluster、無 schema / paradigm 差、但 16384 slot 重分配是核心；本文涵蓋 4 種 re-sharding driver、slot migration 機制、redis-cli --cluster rebalance / reshard 工具、5 個 production 踩雷（cluster busy / replica lag / client cache stale / cross-slot transaction / monitor gap）">Cluster 分片</a>把命令分散到多 node。</li>
<li><strong>想要單機多核平行處理命令</strong>：<a href="/blog/backend/02-cache-redis/vendors/dragonflydb/" data-link-title="DragonflyDB" data-link-desc="高效能 Redis / Memcached 相容替代、多核架構">DragonflyDB</a> 的 shared-nothing 多核架構讓命令在單機就能多核平行，Redis 要靠 cluster 才能達到的吞吐它單機就能撐——高吞吐單機 workload 的替代。</li>
<li><strong>跨 cloud / 跨 region 的 RTT 是結構性瓶頸</strong>：<a href="/blog/backend/09-performance-capacity/cases/snap-gcp-keydb-cross-cloud/" data-link-title="9.C35 Snap：GCP &#43; KeyDB 在 multi-cloud 架構下的低延遲快取" data-link-desc="Snap 用 GCP 上的 KeyDB cluster 減少跨 cloud cache 延遲、用 TPU 訓練廣告推薦模型">Snap 的解法</a>是把 cache 部署到跟 application 同 cloud / 同 region，從根本消除跨區 RTT——這是架構層決策，不是 pipeline 能補的。</li>
</ul>
<h2 id="整合--下一步">整合 / 下一步</h2>
<p>連線與往返是 application 端延遲的主因，但它跟 server 端調校互補：</p>
<ul>
<li><strong>跟 <a href="/blog/backend/02-cache-redis/vendors/redis/memory-eviction-tuning/" data-link-title="Redis 記憶體與淘汰調校：maxmemory-policy、LFU 與碎片化的實戰判讀" data-link-desc="Redis 的記憶體是一條會在半夜爆掉的曲線：maxmemory 設多少、policy 選 LRU 還 LFU、碎片化什麼時候開始吃掉 30% RAM、OOM 時 noeviction 怎麼讓寫入全部失敗。本文展開 Redis 記憶體會計模型、eviction policy 的選型判讀、5 個把記憶體配置寫成 production 事故的踩坑，以及單機記憶體撞牆後該往 cluster 還是 DragonflyDB 走的邊界">記憶體調校</a></strong>：巨型 pipeline 的 server 端 reply buffer 計入 <code>used_memory</code>、慢 consumer 的 output buffer 是記憶體洩漏源頭。</li>
<li><strong>跟 <a href="/blog/backend/02-cache-redis/vendors/redis/persistence-fork-latency/" data-link-title="Redis 持久化與 fork latency：AOF、RDB 與那一次卡住整個 cluster 的 fork" data-link-desc="Redis 的 RDB save 與 AOF rewrite 都靠一次 fork()，而 fork 在大記憶體實例上會凍結主執行緒數百毫秒、複製分頁讓記憶體逼近翻倍。本文展開 AOF / RDB 的機制與 fsync 取捨、copy-on-write 的記憶體放大、5 個把持久化寫成延遲尖峰與資料遺失的 production 踩坑，以及 cache 場景到底要不要持久化的邊界">persistence / fork latency</a></strong>：fork 尖峰是 socket_timeout 必須存在的理由之一——慢源頭不只網路。</li>
<li><strong>跟 <a href="/blog/backend/02-cache-redis/vendors/redis/cluster-resharding/" data-link-title="Redis Cluster Re-sharding：source = target，但 topology 重劃的 5 段流程" data-link-desc="Redis cluster re-sharding 是 5 type migration 漏類實證 — source / target 同 cluster、無 schema / paradigm 差、但 16384 slot 重分配是核心；本文涵蓋 4 種 re-sharding driver、slot migration 機制、redis-cli --cluster rebalance / reshard 工具、5 個 production 踩雷（cluster busy / replica lag / client cache stale / cross-slot transaction / monitor gap）">Cluster re-sharding</a></strong>：cluster 模式改變 pipeline / transaction 的 key 分布規則，hash tag 治理是前提。</li>
<li><strong>跟 <a href="/blog/backend/02-cache-redis/high-concurrency-access/" data-link-title="2.1 高併發下的 Redis 讀寫邊界" data-link-desc="說明高併發服務如何共用 Redis client、控制 pipeline 與避免 cache stampede">2.6 high concurrency</a></strong>：高並發下的連線數爆炸與熱 key 是同一組壓力的不同面向，連線池上限與 local cache 兩層都是解法。</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li>上游 vendor 頁：<a href="/blog/backend/02-cache-redis/vendors/redis/" data-link-title="Redis" data-link-desc="OSS in-memory data structure store、cache 主流">Redis</a></li>
<li>同 vendor deep article：<a href="/blog/backend/02-cache-redis/vendors/redis/memory-eviction-tuning/" data-link-title="Redis 記憶體與淘汰調校：maxmemory-policy、LFU 與碎片化的實戰判讀" data-link-desc="Redis 的記憶體是一條會在半夜爆掉的曲線：maxmemory 設多少、policy 選 LRU 還 LFU、碎片化什麼時候開始吃掉 30% RAM、OOM 時 noeviction 怎麼讓寫入全部失敗。本文展開 Redis 記憶體會計模型、eviction policy 的選型判讀、5 個把記憶體配置寫成 production 事故的踩坑，以及單機記憶體撞牆後該往 cluster 還是 DragonflyDB 走的邊界">記憶體與淘汰調校</a>、<a href="/blog/backend/02-cache-redis/vendors/redis/persistence-fork-latency/" data-link-title="Redis 持久化與 fork latency：AOF、RDB 與那一次卡住整個 cluster 的 fork" data-link-desc="Redis 的 RDB save 與 AOF rewrite 都靠一次 fork()，而 fork 在大記憶體實例上會凍結主執行緒數百毫秒、複製分頁讓記憶體逼近翻倍。本文展開 AOF / RDB 的機制與 fsync 取捨、copy-on-write 的記憶體放大、5 個把持久化寫成延遲尖峰與資料遺失的 production 踩坑，以及 cache 場景到底要不要持久化的邊界">persistence 與 fork latency</a>、<a href="/blog/backend/02-cache-redis/vendors/redis/sentinel-ha-failover/" data-link-title="Redis Sentinel 與 failover 時序：從 master 死掉到 client 重連的每一段" data-link-desc="Redis Sentinel 的 failover 不是一個瞬間動作，是 down 偵測 → quorum 確認 → 選主 → 提升 → 配置廣播 → client 重連的一條時序鏈，每一段都有自己的延遲與失敗模式。本文展開 Sentinel 的判定模型與這條時序、5 個讓 failover 卡住或丟資料的 production 踩坑，以及 Sentinel 撐不住該往 Cluster 或 managed 走的邊界">Sentinel 與 failover 時序</a>、<a href="/blog/backend/02-cache-redis/vendors/redis/cluster-resharding/" data-link-title="Redis Cluster Re-sharding：source = target，但 topology 重劃的 5 段流程" data-link-desc="Redis cluster re-sharding 是 5 type migration 漏類實證 — source / target 同 cluster、無 schema / paradigm 差、但 16384 slot 重分配是核心；本文涵蓋 4 種 re-sharding driver、slot migration 機制、redis-cli --cluster rebalance / reshard 工具、5 個 production 踩雷（cluster busy / replica lag / client cache stale / cross-slot transaction / monitor gap）">Cluster re-sharding</a></li>
<li>上游概念：<a href="/blog/backend/02-cache-redis/high-concurrency-access/" data-link-title="2.1 高併發下的 Redis 讀寫邊界" data-link-desc="說明高併發服務如何共用 Redis client、控制 pipeline 與避免 cache stampede">2.6 high concurrency</a>、<a href="/blog/backend/02-cache-redis/distributed-lock/" data-link-title="2.4 distributed lock 與租約" data-link-desc="整理鎖語意、租約風險與適用場景">2.5 distributed lock</a></li>
<li>Methodology：<a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章寫作方法論</a></li>
</ul>
]]></content:encoded></item><item><title>MySQL Binary Log + CDC：Maxwell / Debezium 是 binlog 第二消費者</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/binlog-cdc/</link><pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/binlog-cdc/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/" data-link-title="MySQL" data-link-desc="高併發網路服務常用關聯式資料庫、Vitess / PlanetScale 分片生態、GitHub / Shopify / Facebook 規模驗證">MySQL&lt;/a> overview 的 implementation-layer deep article。Overview 已說明 MySQL 在 OLTP 譜系的定位、本文聚焦 &lt;em>CDC&lt;/em> — Maxwell / Debezium 怎麼讀 binlog 產生 event stream。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;p>MySQL CDC 的核心定位是 &lt;em>binlog consumer&lt;/em>。&lt;/p>
&lt;p>這個誤解來自跟 PostgreSQL CDC（&lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/logical-replication-debezium/" data-link-title="PostgreSQL Logical Replication &amp;#43; Debezium CDC：replication slot × failure × recovery 對照" data-link-desc="PostgreSQL logical replication slot 跟 Debezium CDC 的失效模式對照表：slot lag 撐爆 primary disk / schema change 斷流 / 初始 COPY 鎖表 / zombie slot 不釋放 / replay storm 後 offset reset；publication / subscription / pgoutput 配置、跟 Kafka outbox pattern 整合">Logical Replication + Debezium&lt;/a>）混用名詞。PG 的 logical decoding 是 &lt;em>MySQL 沒有的能力&lt;/em> — PG 有 logical event（INSERT / UPDATE / DELETE 加上欄位 metadata）、輸出格式是 logical（人可讀、schema-aware）。MySQL 的 binlog 是 &lt;em>physical&lt;/em> — 紀錄的是 row 的 binary image、不帶 schema 資訊。&lt;/p>
&lt;p>Maxwell / Debezium 對 MySQL 是 &lt;em>binlog 第二消費者&lt;/em>：&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">Primary MySQL → binlog
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> ├→ Replica 1（讀 binlog 同步）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> ├→ Replica 2
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> └→ Maxwell / Debezium（讀 binlog 解析、發 Kafka）&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>跟 replica 同一份 binlog stream，並非 separate logical decoding output。這個結構決定 CDC consumer 的設計：必須 &lt;em>自己處理 schema&lt;/em>（從 information_schema 拉、跟 binlog event 對齊）、必須 &lt;em>自己 track position&lt;/em>（binlog file + position 或 GTID）。&lt;/p>
&lt;h2 id="binlog-formatstatement--row--mixed">Binlog format：STATEMENT / ROW / MIXED&lt;/h2>
&lt;p>MySQL binlog 有 3 種 format、CDC 只能用 ROW：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Format&lt;/th>
 &lt;th>紀錄內容&lt;/th>
 &lt;th>CDC 可用？&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>STATEMENT&lt;/td>
 &lt;td>原始 SQL statement&lt;/td>
 &lt;td>不可用（CDC 看不到實際改的 row）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>ROW&lt;/td>
 &lt;td>每個改變的 row（before + after image）&lt;/td>
 &lt;td>CDC 標準&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>MIXED&lt;/td>
 &lt;td>預設 STATEMENT、特殊情況用 ROW&lt;/td>
 &lt;td>不推薦（CDC 行為不一致）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>ROW 是 CDC 唯一選擇、production 強制：&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/mysql/" data-link-title="MySQL" data-link-desc="高併發網路服務常用關聯式資料庫、Vitess / PlanetScale 分片生態、GitHub / Shopify / Facebook 規模驗證">MySQL</a> overview 的 implementation-layer deep article。Overview 已說明 MySQL 在 OLTP 譜系的定位、本文聚焦 <em>CDC</em> — Maxwell / Debezium 怎麼讀 binlog 產生 event stream。</p></blockquote>
<hr>
<p>MySQL CDC 的核心定位是 <em>binlog consumer</em>。</p>
<p>這個誤解來自跟 PostgreSQL CDC（<a href="/blog/backend/01-database/vendors/postgresql/logical-replication-debezium/" data-link-title="PostgreSQL Logical Replication &#43; Debezium CDC：replication slot × failure × recovery 對照" data-link-desc="PostgreSQL logical replication slot 跟 Debezium CDC 的失效模式對照表：slot lag 撐爆 primary disk / schema change 斷流 / 初始 COPY 鎖表 / zombie slot 不釋放 / replay storm 後 offset reset；publication / subscription / pgoutput 配置、跟 Kafka outbox pattern 整合">Logical Replication + Debezium</a>）混用名詞。PG 的 logical decoding 是 <em>MySQL 沒有的能力</em> — PG 有 logical event（INSERT / UPDATE / DELETE 加上欄位 metadata）、輸出格式是 logical（人可讀、schema-aware）。MySQL 的 binlog 是 <em>physical</em> — 紀錄的是 row 的 binary image、不帶 schema 資訊。</p>
<p>Maxwell / Debezium 對 MySQL 是 <em>binlog 第二消費者</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">Primary MySQL → binlog
</span></span><span class="line"><span class="ln">2</span><span class="cl">              ├→ Replica 1（讀 binlog 同步）
</span></span><span class="line"><span class="ln">3</span><span class="cl">              ├→ Replica 2
</span></span><span class="line"><span class="ln">4</span><span class="cl">              └→ Maxwell / Debezium（讀 binlog 解析、發 Kafka）</span></span></code></pre></div><p>跟 replica 同一份 binlog stream，並非 separate logical decoding output。這個結構決定 CDC consumer 的設計：必須 <em>自己處理 schema</em>（從 information_schema 拉、跟 binlog event 對齊）、必須 <em>自己 track position</em>（binlog file + position 或 GTID）。</p>
<h2 id="binlog-formatstatement--row--mixed">Binlog format：STATEMENT / ROW / MIXED</h2>
<p>MySQL binlog 有 3 種 format、CDC 只能用 ROW：</p>
<table>
  <thead>
      <tr>
          <th>Format</th>
          <th>紀錄內容</th>
          <th>CDC 可用？</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>STATEMENT</td>
          <td>原始 SQL statement</td>
          <td>不可用（CDC 看不到實際改的 row）</td>
      </tr>
      <tr>
          <td>ROW</td>
          <td>每個改變的 row（before + after image）</td>
          <td>CDC 標準</td>
      </tr>
      <tr>
          <td>MIXED</td>
          <td>預設 STATEMENT、特殊情況用 ROW</td>
          <td>不推薦（CDC 行為不一致）</td>
      </tr>
  </tbody>
</table>
<p>ROW 是 CDC 唯一選擇、production 強制：</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">binlog_format</span> <span class="o">=</span> <span class="s">ROW</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="na">binlog_row_image</span> <span class="o">=</span> <span class="s">FULL  # FULL (all columns) / MINIMAL (only changed) / NOBLOB</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="na">log_bin_use_v1_row_events</span> <span class="o">=</span> <span class="s">0  # 用新版 event format</span></span></span></code></pre></div><p><code>binlog_row_image</code> 取捨：</p>
<ul>
<li><code>FULL</code>：每個 row event 包含所有 column（before + after）、binlog 大、CDC 完整</li>
<li><code>MINIMAL</code>：只包含 changed column + primary key、binlog 省 30-50% 空間、CDC 看不到 <em>未變 column</em></li>
<li><code>NOBLOB</code>：跟 FULL 一樣但 BLOB / TEXT column 只在 changed 時包含、平衡選擇</li>
</ul>
<p>對 <em>CDC 需要 full row payload</em>（例如下游 search index 重建）必須 <code>FULL</code>。對 <em>純 audit log</em> 可以 <code>MINIMAL</code>。</p>
<h2 id="row-format-的-raw-event-結構">ROW format 的 raw event 結構</h2>
<p>Binlog ROW event 的資料形狀是 <em>binary row image</em>，而非 <em>INSERT INTO orders VALUES (1, &lsquo;foo&rsquo;, 100)</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">TABLE_MAP_EVENT     - 對應 table schema metadata (table id + column type)
</span></span><span class="line"><span class="ln">2</span><span class="cl">                      ↓ 接續同一個 transaction 內所有 row event
</span></span><span class="line"><span class="ln">3</span><span class="cl">WRITE_ROWS_EVENT    - INSERT 的新 row image（column values）
</span></span><span class="line"><span class="ln">4</span><span class="cl">UPDATE_ROWS_EVENT   - UPDATE 的 before + after image
</span></span><span class="line"><span class="ln">5</span><span class="cl">DELETE_ROWS_EVENT   - DELETE 的 row image（被刪的 row）
</span></span><span class="line"><span class="ln">6</span><span class="cl">XID_EVENT           - transaction commit marker</span></span></code></pre></div><p>CDC consumer（Maxwell / Debezium）必須：</p>
<ol>
<li>接收 binlog event stream</li>
<li>看到 <code>TABLE_MAP_EVENT</code> 從中拿 table id → 對應 table name（cache 一份）</li>
<li>看到 <code>WRITE/UPDATE/DELETE_ROWS_EVENT</code> 用 table id 反查 schema、把 binary 解析成 column value</li>
<li>包成 JSON / Avro / Protobuf 推到 Kafka</li>
</ol>
<p>關鍵：<em>table schema 不在 binlog 內</em>、CDC consumer 必須 <em>獨立查 information_schema</em>。如果 schema 變了（ALTER TABLE）、CDC 必須 invalidate cache、重新查、否則新 column 的 row event 解析錯亂。</p>
<h2 id="maxwell-vs-debezium">Maxwell vs Debezium</h2>
<p>兩個是 MySQL CDC 主流選擇、不同設計取捨：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>Maxwell</th>
          <th>Debezium MySQL</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>開發者</td>
          <td>Zendesk</td>
          <td>Red Hat</td>
      </tr>
      <tr>
          <td>語言</td>
          <td>Java（單一 binary）</td>
          <td>Java（Kafka Connect plugin）</td>
      </tr>
      <tr>
          <td>部署模式</td>
          <td>Standalone process</td>
          <td>Kafka Connect cluster</td>
      </tr>
      <tr>
          <td>支援 DB</td>
          <td>MySQL only</td>
          <td>MySQL / PostgreSQL / MongoDB / SQL Server / Oracle</td>
      </tr>
      <tr>
          <td>Output format</td>
          <td>JSON（內建）</td>
          <td>JSON / Avro / Protobuf（Kafka Connect）</td>
      </tr>
      <tr>
          <td>Producer</td>
          <td>Kafka / Kinesis / RabbitMQ / Pub/Sub</td>
          <td>Kafka（Kafka Connect 限制）</td>
      </tr>
      <tr>
          <td>Schema registry</td>
          <td>不支援</td>
          <td>支援（Confluent Schema Registry / Apicurio）</td>
      </tr>
      <tr>
          <td>Transformation</td>
          <td>filter / stream-level（內建）</td>
          <td>Single Message Transform (SMT)</td>
      </tr>
      <tr>
          <td>Bootstrapping</td>
          <td>一個 utility 從 <code>SELECT *</code> snapshot</td>
          <td>Built-in snapshot mode</td>
      </tr>
      <tr>
          <td>GTID 支援</td>
          <td>支援</td>
          <td>支援</td>
      </tr>
      <tr>
          <td>簡單性</td>
          <td>高（單一 binary）</td>
          <td>中（Kafka Connect 框架成本）</td>
      </tr>
  </tbody>
</table>
<p>選擇邏輯：</p>
<ul>
<li><em>只用 MySQL + 想要 simple operations</em> → Maxwell</li>
<li><em>已用 Kafka Connect、需要 schema registry、跨多種 DB</em> → Debezium</li>
<li><em>需要 Avro / Protobuf schema 嚴格 governance</em> → Debezium</li>
</ul>
<h2 id="配置-step-by-stepdebezium-mysql-connector">配置 step-by-step（Debezium MySQL connector）</h2>
<p>Debezium 是 Kafka Connect plugin、整套 stack：</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="c"># debezium-mysql.json - 部署到 Kafka Connect REST API</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w"></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">&#34;name&#34;: </span><span class="s2">&#34;orders-mysql-connector&#34;</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="nt">&#34;config&#34;: </span>{<span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">    </span><span class="nt">&#34;connector.class&#34;: </span><span class="s2">&#34;io.debezium.connector.mysql.MySqlConnector&#34;</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">    </span><span class="nt">&#34;database.hostname&#34;: </span><span class="s2">&#34;primary.example.com&#34;</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="nt">&#34;database.port&#34;: </span><span class="s2">&#34;3306&#34;</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="nt">&#34;database.user&#34;: </span><span class="s2">&#34;debezium&#34;</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="nt">&#34;database.password&#34;: </span><span class="s2">&#34;...&#34;</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="nt">&#34;database.server.id&#34;: </span><span class="s2">&#34;184054&#34;</span><span class="p">,</span><span class="w">          </span><span class="c"># 唯一 server ID (跟 MySQL replica 一樣)</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">    </span><span class="nt">&#34;topic.prefix&#34;: </span><span class="s2">&#34;production&#34;</span><span class="p">,</span><span class="w">            </span><span class="c"># Debezium 2.x（舊 1.x 用 database.server.name）</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">    </span><span class="nt">&#34;database.include.list&#34;: </span><span class="s2">&#34;orders_db&#34;</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="nt">&#34;table.include.list&#34;: </span><span class="s2">&#34;orders_db.orders,orders_db.payments&#34;</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></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w">    </span><span class="nt">&#34;database.history.kafka.bootstrap.servers&#34;: </span><span class="s2">&#34;kafka:9092&#34;</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="nt">&#34;database.history.kafka.topic&#34;: </span><span class="s2">&#34;dbhistory.orders&#34;</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="nt">&#34;include.schema.changes&#34;: </span><span class="s2">&#34;true&#34;</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></span><span class="line"><span class="ln">19</span><span class="cl"><span class="w">    </span><span class="nt">&#34;snapshot.mode&#34;: </span><span class="s2">&#34;initial&#34;</span><span class="p">,</span><span class="w">              </span><span class="c"># 或 schema_only / when_needed / never</span><span class="w">
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="w">    </span><span class="nt">&#34;snapshot.locking.mode&#34;: </span><span class="s2">&#34;minimal&#34;</span><span class="p">,</span><span class="w">      </span><span class="c"># 避免 FLUSH TABLES WITH READ LOCK</span><span class="w">
</span></span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="w">    </span><span class="nt">&#34;gtid.source.includes&#34;: </span><span class="s2">&#34;...&#34;</span><span class="p">,</span><span class="w">           </span><span class="c"># 可選 GTID filter</span><span class="w">
</span></span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="w">    </span><span class="nt">&#34;tombstones.on.delete&#34;: </span><span class="s2">&#34;true&#34;</span><span class="p">,</span><span class="w">          </span><span class="c"># DELETE event 同 partition 跟一個 null tombstone</span><span class="w">
</span></span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="w">    </span><span class="nt">&#34;decimal.handling.mode&#34;: </span><span class="s2">&#34;double&#34;</span><span class="w">        </span><span class="c"># DECIMAL 處理: precise / string / double</span><span class="w">
</span></span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="w">  </span>}<span class="w">
</span></span></span><span class="line"><span class="ln">26</span><span class="cl"><span class="w"></span>}</span></span></code></pre></div><p>deploy：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">curl -X POST -H <span class="s2">&#34;Content-Type: application/json&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --data @debezium-mysql.json <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  http://kafka-connect:8083/connectors</span></span></code></pre></div><p>Output topic：<code>production.orders_db.orders</code> / <code>production.orders_db.payments</code> 等 — 每張 table 一個 topic。</p>
<h2 id="配置-step-by-stepmaxwell">配置 step-by-step（Maxwell）</h2>
<p>Maxwell 簡單很多：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl">maxwell <span class="se">\
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="se"></span>  --host<span class="o">=</span>primary.example.com <span class="se">\
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="se"></span>  --user<span class="o">=</span>maxwell <span class="se">\
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="se"></span>  --password<span class="o">=</span>... <span class="se">\
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="se"></span>  --producer<span class="o">=</span>kafka <span class="se">\
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="se"></span>  --kafka.bootstrap.servers<span class="o">=</span>kafka:9092 <span class="se">\
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="se"></span>  --kafka_topic<span class="o">=</span><span class="s2">&#34;maxwell.%{database}.%{table}&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="se"></span>  --filter<span class="o">=</span><span class="s1">&#39;exclude: *.*, include: orders_db.*&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="se"></span>  --gtid_mode<span class="o">=</span><span class="nb">true</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="se"></span>  --output_ddl<span class="o">=</span><span class="nb">true</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="se"></span>  --output_xoffset<span class="o">=</span>true</span></span></code></pre></div><p>Maxwell event format：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="nt">&#34;database&#34;</span><span class="p">:</span> <span class="s2">&#34;orders_db&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="nt">&#34;table&#34;</span><span class="p">:</span> <span class="s2">&#34;orders&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;update&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="nt">&#34;ts&#34;</span><span class="p">:</span> <span class="mi">1715000000</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="nt">&#34;xid&#34;</span><span class="p">:</span> <span class="mi">12345</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="nt">&#34;commit&#34;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="nt">&#34;data&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;id&#34;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="nt">&#34;status&#34;</span><span class="p">:</span> <span class="s2">&#34;shipped&#34;</span><span class="p">,</span> <span class="nt">&#34;amount&#34;</span><span class="p">:</span> <span class="mf">100.50</span> <span class="p">},</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="nt">&#34;old&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;status&#34;</span><span class="p">:</span> <span class="s2">&#34;pending&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Debezium 對應的 event 格式更複雜（envelope + before + after + source + ts_ms 各 nested）、但跟 schema registry 整合好。</p>
<h2 id="5-個-production-踩雷">5 個 Production 踩雷</h2>
<h3 id="1-binlog-retention-太短--cdc-consumer-落後就-re-bootstrap">1. Binlog retention 太短 — CDC consumer 落後就 re-bootstrap</h3>
<p>CDC consumer 失聯（Kafka Connect cluster down、network issue）超過 binlog retention（預設 <code>binlog_expire_logs_seconds=2592000</code>、30 天、但有些 production 縮短到 1 天）、需要的 binlog event 已被 purge、consumer error。</p>
<p>修法：</p>
<ul>
<li><em>Production binlog retention &gt;= 7 天</em>（避免為了 disk 過度縮短）</li>
<li>監控 <code>Master_Log_File</code> 是否還在（如果 retention 設 7 天、確認當前 file 仍存在）</li>
<li>CDC consumer 失聯 alert 設 <em>早於 retention 期</em>（例如 6 天告警、給 24 小時修）</li>
<li>真的 missed binlog、必須 <em>re-snapshot table</em>（用 Debezium <code>snapshot.new.tables</code>）— 24 小時級工作</li>
</ul>
<h3 id="2-ddl-event-處理--schema-change-跟-row-event-對齊">2. DDL event 處理 — schema change 跟 row event 對齊</h3>
<p><code>ALTER TABLE orders ADD COLUMN status VARCHAR(20)</code> 之後、<code>UPDATE_ROWS_EVENT</code> 多一個 column。CDC consumer 如果還用舊 schema cache、解析 row 時欄位數對不上、event 丟。</p>
<p>修法（Debezium）：</p>
<ul>
<li><code>include.schema.changes=true</code>：DDL 進獨立 topic、consumer 監聽更新自己的 schema cache</li>
<li><code>database.history.kafka.topic</code>：Debezium 自己 track schema 歷史</li>
</ul>
<p>修法（Maxwell）：</p>
<ul>
<li><code>--output_ddl=true</code>：DDL 也進 stream、downstream 看到 DDL event 自己更新</li>
<li>沒有內建 schema history、要 <em>application 層處理</em></li>
</ul>
<p>修法（兩者通用）：</p>
<ul>
<li>用 <a href="/blog/backend/01-database/vendors/mysql/online-schema-change-tools/" data-link-title="MySQL Online Schema Change：gh-ost 跟 pt-online-schema-change 兩條完全不同的 ghost table 路徑" data-link-desc="MySQL ALTER TABLE 可能鎖整張表，production 需要 online schema change 流程。gh-ost（GitHub）跟 pt-online-schema-change（Percona）都用 ghost table 解決、但底層機制完全不同：pt-osc 用 trigger 同步、gh-ost 用 binlog stream 同步。本文走兩工具機制對照表 → trigger vs binlog 各自取捨 → 配置 step-by-step → 5 production 踩雷（trigger overhead / binlog 延遲 / FK constraint / hot trigger lock / 切換瞬間 deadlock）→ 何時用哪一個">Online Schema Change Tools</a> 取代直接 ALTER — 工具操作的 DDL 對 CDC consumer 更可預期</li>
<li>Schema 改動 <em>優先 add column 為 nullable</em>、避免 backfill 期間 CDC consumer 看到 mid-state</li>
</ul>
<h3 id="3-binlog_row_imageminimal-讓下游錯亂">3. <code>binlog_row_image=MINIMAL</code> 讓下游錯亂</h3>
<p><code>MINIMAL</code> 省 binlog 空間、但 row event 只含 changed column。下游 <em>search index 重建</em> 需要 <em>full row payload</em> 的場景下、<code>MINIMAL</code> 看不到未變的 column、index 缺欄位。</p>
<p>修法：</p>
<ul>
<li>CDC 需要 full payload 的場景 <em>必須 <code>FULL</code></em>、這項成本要納入容量規劃</li>
<li>如果空間真緊、考慮 <code>NOBLOB</code>（BLOB / TEXT 只在 changed 時包含、其他 column 仍 FULL）</li>
<li><em>統一設定</em>：production 全部 server 同一 binlog_row_image 設定</li>
</ul>
<h3 id="4-kafka-producer-跟-binlog-reader-速度差--lag-累積">4. Kafka producer 跟 binlog reader 速度差 — lag 累積</h3>
<p>Binlog reader 從 MySQL 讀 1000 event/sec、Kafka producer 寫得只有 800 event/sec、CDC consumer 自身 lag 累積、最終 disk 滿（producer 內部 buffer）。</p>
<p>修法：</p>
<ul>
<li>監控 <em>CDC consumer lag</em>：對 Debezium 看 Kafka Connect 的 <code>source-record-poll-rate</code> vs <code>source-record-write-rate</code></li>
<li>Kafka producer tuning：<code>batch.size</code> / <code>linger.ms</code> / <code>compression.type=snappy</code></li>
<li>Kafka broker capacity：partition 數量 ≥ Debezium task 數量、避免 partition 瓶頸</li>
<li>避免把 <em>過多 table</em> 給單一 Debezium connector — 用 <em>table grouping</em>（按 traffic 拆 connector）</li>
</ul>
<h3 id="5-schema-change-跟-downstream-consumer-不同步">5. Schema change 跟 downstream consumer 不同步</h3>
<p>CDC producer（Debezium）正確處理了 schema change、但 <em>downstream Kafka consumer</em> 用舊 schema deserialize、新 column 看不到 / type 解析錯。</p>
<p>修法：</p>
<ul>
<li>用 <em>Schema Registry</em>（Confluent / Apicurio）+ Avro：consumer 訂閱 schema、自動 evolve</li>
<li>不用 schema registry 時、CDC payload 設計 <em>backward-compatible</em>（新 column 為 optional）</li>
<li><em>Application 層 schema change protocol</em>：<a href="/blog/backend/knowledge-cards/expand-contract/" data-link-title="Expand / Contract" data-link-desc="說明先擴充相容面、再收斂舊路徑的遷移做法">Expand / Contract</a> — 先加 column、deploy consumer 認 column、再 backfill、最後 application 寫新 column</li>
<li>大型 schema change 跨多服務、建議 <em>先 freeze CDC stream、做 schema migration、resume stream</em>（極端但確定）</li>
</ul>
<h2 id="容量規劃要點">容量規劃要點</h2>
<table>
  <thead>
      <tr>
          <th>元件</th>
          <th>容量考量</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>MySQL binlog disk</td>
          <td>retention × 寫吞吐 × event size（5K WPS × 1 KB × 7 天 ~= 3 GB / 天 = 21 GB）</td>
      </tr>
      <tr>
          <td>Debezium / Maxwell process</td>
          <td>1 vCPU + 2-4 GB RAM（per connector、視 throughput）</td>
      </tr>
      <tr>
          <td>Kafka topic partition</td>
          <td>每 table 1-10 partition（依寫吞吐）、保 key-based ordering</td>
      </tr>
      <tr>
          <td>Kafka 保留期</td>
          <td>7-30 天（讓 downstream consumer 有 recover window）</td>
      </tr>
      <tr>
          <td>Schema Registry</td>
          <td>&lt; 100 MB storage、replicate 跨 3 broker</td>
      </tr>
  </tbody>
</table>
<p>對 100K WPS server、CDC pipeline cost 大致是 <em>MySQL infra 的 5-10%</em>。</p>
<h2 id="跟其他模組整合">跟其他模組整合</h2>
<h3 id="跟-replication-topology">跟 Replication topology</h3>
<p>CDC 是 <em>binlog 第二消費者</em>、需要 <em>GTID + binlog ROW format</em>（<a href="/blog/backend/01-database/vendors/mysql/replication-topology/" data-link-title="MySQL Replication Topology：async / semi-sync / GTID 不是三選一、是三個 trade-off 軸的疊加" data-link-desc="MySQL replication 不是「選 async 還是 semi-sync」、是 *durability / latency / consistency* 三個 trade-off 軸的疊加；GTID 是跨 mode 的 infrastructure layer、不是第三種 mode。本文走 3 軸取捨模型 → async / semi-sync 行為對比 → GTID 替代 binlog-position 的好處 → 配置 step-by-step → 5 production 踩雷（lag 暴衝 / semi-sync 退回 async / GTID gap / Loss-Less semi-sync 真的 loss-less / chained replication 雪崩）→ 跟 Aurora MySQL / Vitess / ProxySQL / Orchestrator 整合">Replication Topology</a>）。Debezium / Maxwell 都偏好從 <em>replica</em> 讀 binlog（不增加 primary 負擔）、但要小心 replica lag 加在 CDC lag 上。</p>
<h3 id="跟-osc-tool">跟 OSC tool</h3>
<p><a href="/blog/backend/01-database/vendors/mysql/online-schema-change-tools/" data-link-title="MySQL Online Schema Change：gh-ost 跟 pt-online-schema-change 兩條完全不同的 ghost table 路徑" data-link-desc="MySQL ALTER TABLE 可能鎖整張表，production 需要 online schema change 流程。gh-ost（GitHub）跟 pt-online-schema-change（Percona）都用 ghost table 解決、但底層機制完全不同：pt-osc 用 trigger 同步、gh-ost 用 binlog stream 同步。本文走兩工具機制對照表 → trigger vs binlog 各自取捨 → 配置 step-by-step → 5 production 踩雷（trigger overhead / binlog 延遲 / FK constraint / hot trigger lock / 切換瞬間 deadlock）→ 何時用哪一個">gh-ost / pt-osc</a> 跑 schema change 時、會在 binlog 留下大量 row event（copy 既有 row 到 ghost）。CDC consumer 看到這些 event <em>是 normal-looking INSERT</em>、可能誤觸發 downstream side effect。</p>
<p>修法：</p>
<ul>
<li>CDC consumer 過濾 <em>ghost table prefix</em>（<code>_orders_new</code> / <code>_orders_gho</code>）— 不發 downstream</li>
<li>或暫停 CDC 期間跑 OSC（用 Debezium pause API）</li>
</ul>
<h3 id="跟-postgresql-logical-replication--debezium">跟 PostgreSQL Logical Replication + Debezium</h3>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>MySQL（binlog）</th>
          <th>PostgreSQL（logical decoding）</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>抽象層</td>
          <td>Physical（row binary）</td>
          <td>Logical（row + schema-aware）</td>
      </tr>
      <tr>
          <td>Schema metadata</td>
          <td>不在 event 內、要查 information_schema</td>
          <td>在 event 內（plugin output）</td>
      </tr>
      <tr>
          <td>DDL handling</td>
          <td>DDL 本身是 binlog event</td>
          <td>DDL 不在 logical decoding output（要 trigger 自己 capture）</td>
      </tr>
      <tr>
          <td>啟用成本</td>
          <td>binlog ROW + GTID（基本 MySQL replication setup）</td>
          <td>logical replication slot + publication</td>
      </tr>
      <tr>
          <td>Snapshot</td>
          <td><code>SELECT *</code> + binlog catchup</td>
          <td>logical replication initial sync</td>
      </tr>
  </tbody>
</table>
<p>詳見 <a href="/blog/backend/01-database/vendors/postgresql/logical-replication-debezium/" data-link-title="PostgreSQL Logical Replication &#43; Debezium CDC：replication slot × failure × recovery 對照" data-link-desc="PostgreSQL logical replication slot 跟 Debezium CDC 的失效模式對照表：slot lag 撐爆 primary disk / schema change 斷流 / 初始 COPY 鎖表 / zombie slot 不釋放 / replay storm 後 offset reset；publication / subscription / pgoutput 配置、跟 Kafka outbox pattern 整合">PostgreSQL Logical Replication + Debezium</a> — 這是 sibling 對照，用來區分不同 abstraction。</p>
<h3 id="跟-aurora-mysql">跟 Aurora MySQL</h3>
<p>Aurora MySQL 5.7 / 8.0 都支援 binlog + GTID、CDC 可用。但 Aurora 推薦走 <em>Aurora-native database activity streams</em>（不同 abstraction）— 跟 Debezium 共存但有 overlapping。生產上 Debezium 仍是 cross-cloud 跟 vendor-neutral 選項、優先用 Debezium。</p>
<p>詳見 <a href="/blog/backend/01-database/vendors/aurora/" data-link-title="AWS Aurora" data-link-desc="AWS managed PostgreSQL / MySQL、storage / compute 分離、&#43;75% 效能改善的 production 證據">Aurora vendor page</a>。</p>
<h2 id="production-caseshopify-sharded-mysql-cdc">Production case：Shopify sharded MySQL CDC</h2>
<p>Sharded MySQL CDC 的核心責任是把多個 shard 的 binlog 轉成可消費、可回放、可觀測的事件流。<a href="/blog/backend/03-message-queue/cases/kafka-shopify-debezium-cdc/" data-link-title="3.C13 Shopify：Debezium CDC over sharded MySQL" data-link-desc="Shopify 100&#43; MySQL shard、150 Debezium connector、Black Friday 100K records/sec P99 &lt; 10s。">Shopify Debezium CDC over sharded MySQL</a> 提供的工程訊號是 100+ shard、約 150 個 Debezium connector、BFCM 期間 100K records/sec，以及 snapshot lock 與 oversized payload 對 CDC pipeline 的壓力。</p>
<p>這個案例要回收到三個操作判準。第一，connector 數量應跟 shard 拓撲一起設計，避免單一 connector 變成跨 shard bottleneck。第二，snapshot window 要排進 schema migration 與 event consumer 的變更計畫，避免 initial snapshot 把 production read path 壓滿。第三，oversized payload 要在 schema / outbox / topic 分流階段處理，避免 Kafka partition 與 downstream consumer 同時承受大訊息。</p>
<p>Shopify 案例的下一步路由是把本篇和 <a href="/blog/backend/knowledge-cards/database-sharding/" data-link-title="Database Sharding" data-link-desc="說明資料庫如何依 shard key 分散資料、路由請求與承擔跨 shard 查詢成本">Database Sharding</a> 一起讀。若讀者關心 broker 層的 partition、consumer lag 與 replay 策略，接到 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka vendor</a>；若關心資料庫端壓力，回到 <a href="/blog/backend/01-database/vendors/mysql/replication-topology/" data-link-title="MySQL Replication Topology：async / semi-sync / GTID 不是三選一、是三個 trade-off 軸的疊加" data-link-desc="MySQL replication 不是「選 async 還是 semi-sync」、是 *durability / latency / consistency* 三個 trade-off 軸的疊加；GTID 是跨 mode 的 infrastructure layer、不是第三種 mode。本文走 3 軸取捨模型 → async / semi-sync 行為對比 → GTID 替代 binlog-position 的好處 → 配置 step-by-step → 5 production 踩雷（lag 暴衝 / semi-sync 退回 async / GTID gap / Loss-Less semi-sync 真的 loss-less / chained replication 雪崩）→ 跟 Aurora MySQL / Vitess / ProxySQL / Orchestrator 整合">Replication Topology</a> 與 <a href="/blog/backend/01-database/vendors/mysql/online-schema-change-tools/" data-link-title="MySQL Online Schema Change：gh-ost 跟 pt-online-schema-change 兩條完全不同的 ghost table 路徑" data-link-desc="MySQL ALTER TABLE 可能鎖整張表，production 需要 online schema change 流程。gh-ost（GitHub）跟 pt-online-schema-change（Percona）都用 ghost table 解決、但底層機制完全不同：pt-osc 用 trigger 同步、gh-ost 用 binlog stream 同步。本文走兩工具機制對照表 → trigger vs binlog 各自取捨 → 配置 step-by-step → 5 production 踩雷（trigger overhead / binlog 延遲 / FK constraint / hot trigger lock / 切換瞬間 deadlock）→ 何時用哪一個">Online Schema Change Tools</a>。</p>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/mysql/" data-link-title="MySQL" data-link-desc="高併發網路服務常用關聯式資料庫、Vitess / PlanetScale 分片生態、GitHub / Shopify / Facebook 規模驗證">MySQL vendor overview</a></li>
<li><a href="/blog/backend/01-database/vendors/mysql/replication-topology/" data-link-title="MySQL Replication Topology：async / semi-sync / GTID 不是三選一、是三個 trade-off 軸的疊加" data-link-desc="MySQL replication 不是「選 async 還是 semi-sync」、是 *durability / latency / consistency* 三個 trade-off 軸的疊加；GTID 是跨 mode 的 infrastructure layer、不是第三種 mode。本文走 3 軸取捨模型 → async / semi-sync 行為對比 → GTID 替代 binlog-position 的好處 → 配置 step-by-step → 5 production 踩雷（lag 暴衝 / semi-sync 退回 async / GTID gap / Loss-Less semi-sync 真的 loss-less / chained replication 雪崩）→ 跟 Aurora MySQL / Vitess / ProxySQL / Orchestrator 整合">MySQL Replication Topology</a>（binlog ROW + GTID 是 CDC pre-requisite）</li>
<li><a href="/blog/backend/01-database/vendors/mysql/online-schema-change-tools/" data-link-title="MySQL Online Schema Change：gh-ost 跟 pt-online-schema-change 兩條完全不同的 ghost table 路徑" data-link-desc="MySQL ALTER TABLE 可能鎖整張表，production 需要 online schema change 流程。gh-ost（GitHub）跟 pt-online-schema-change（Percona）都用 ghost table 解決、但底層機制完全不同：pt-osc 用 trigger 同步、gh-ost 用 binlog stream 同步。本文走兩工具機制對照表 → trigger vs binlog 各自取捨 → 配置 step-by-step → 5 production 踩雷（trigger overhead / binlog 延遲 / FK constraint / hot trigger lock / 切換瞬間 deadlock）→ 何時用哪一個">MySQL Online Schema Change Tools</a>（OSC + CDC 整合）</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/logical-replication-debezium/" data-link-title="PostgreSQL Logical Replication &#43; Debezium CDC：replication slot × failure × recovery 對照" data-link-desc="PostgreSQL logical replication slot 跟 Debezium CDC 的失效模式對照表：slot lag 撐爆 primary disk / schema change 斷流 / 初始 COPY 鎖表 / zombie slot 不釋放 / replay storm 後 offset reset；publication / subscription / pgoutput 配置、跟 Kafka outbox pattern 整合">PostgreSQL Logical Replication + Debezium</a>（PG sibling、不同 abstraction）</li>
<li><a href="/blog/backend/knowledge-cards/outbox-pattern/" data-link-title="Outbox Pattern" data-link-desc="說明資料庫狀態變更與事件發布如何透過 outbox 維持一致">Outbox pattern 卡片</a>（CDC 跟 outbox 在 application-level event publishing 的關係）</li>
<li><a href="/blog/backend/knowledge-cards/expand-contract/" data-link-title="Expand / Contract" data-link-desc="說明先擴充相容面、再收斂舊路徑的遷移做法">Expand / Contract 卡片</a>（schema migration 跟 CDC consumer）</li>
<li>官方：<a href="https://debezium.io/documentation/reference/stable/connectors/mysql.html">Debezium MySQL Connector</a> / <a href="https://maxwells-daemon.io/">Maxwell</a></li>
</ul>
]]></content:encoded></item><item><title>MySQL Vitess Sharding：VTGate / VTTablet / VReplication / VSchema 四件套協作</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/vitess-sharding/</link><pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/vitess-sharding/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/" data-link-title="MySQL" data-link-desc="高併發網路服務常用關聯式資料庫、Vitess / PlanetScale 分片生態、GitHub / Shopify / Facebook 規模驗證">MySQL&lt;/a> overview 的 implementation-layer deep article。Overview 已說明 MySQL 在 OLTP 譜系的定位、本文聚焦 &lt;em>Vitess sharding&lt;/em> — 4 個 component 協作的完整 sharding 系統。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="問題情境mysql-寫吞吐撞上-single-primary-上限">問題情境：MySQL 寫吞吐撞上 single primary 上限&lt;/h2>
&lt;p>MySQL primary 單機極限大致 50K-100K WPS（依 schema / hardware）。超過這個級別、選項三條：&lt;/p>
&lt;ol>
&lt;li>&lt;em>Application 層 sharding&lt;/em>：每張 table 自己決定怎麼分片、application 寫 routing logic、跨 shard query / migration 都要自己處理&lt;/li>
&lt;li>&lt;em>Vitess&lt;/em>：proxy layer 自動 routing、cross-shard query 可選自動 split、resharding 自動化&lt;/li>
&lt;li>&lt;em>Distributed SQL&lt;/em>（CockroachDB / Spanner / Aurora DSQL）：跟 MySQL 不同 engine、application 改 driver&lt;/li>
&lt;/ol>
&lt;p>選 Vitess 的核心 driver：&lt;em>保留 MySQL wire protocol + 應用層幾乎不必改 + 透明分片&lt;/em>。代價是 4 個 component 的 operational complexity — Vitess 的責任範圍是完整分散式系統，而非單純 proxy。&lt;/p>
&lt;p>閱讀本文前可先對齊 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/database-sharding/" data-link-title="Database Sharding" data-link-desc="說明資料庫如何依 shard key 分散資料、路由請求與承擔跨 shard 查詢成本">Database Sharding&lt;/a> 的 shard key、routing、resharding 與 cross-shard query 語意；容量失衡時再接 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/hot-partition/" data-link-title="Hot Partition" data-link-desc="說明分散式 KV / OLTP 中、單一 partition 流量遠超其他的容量問題">Hot Partition&lt;/a>。&lt;/p>
&lt;h2 id="vitess-四件套每個-component-的責任">Vitess 四件套：每個 component 的責任&lt;/h2>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl"> ┌─────────────────┐
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> Application ────→ │ VTGate │ ← 對外 MySQL wire protocol
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> │ (proxy + parse + route + aggregate) │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> └────┬─────┬──────┘
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> │ │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> ┌────────────┘ └──────────────┐
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> ▼ ▼
&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"> │ VTTablet │ │ VTTablet │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> │ (per-MySQL │ │ (per-MySQL │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> │ sidecar) │ │ sidecar) │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> └─────┬────────┘ └─────┬────────┘
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> │ │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> ▼ ▼
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> ┌──────────────┐ ┌──────────────┐
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> │ MySQL │ │ MySQL │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> │ (Shard -80) │ │ (Shard 80-) │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> └──────────────┘ └──────────────┘
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl"> Topology Service (etcd / Consul / ZooKeeper)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl"> ↑↓ 所有 component 共享 metadata
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl"> VSchema：keyspace 結構、shard 範圍、Vindex 定義&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="vtgate--query-routing-layer">VTGate — query routing layer&lt;/h3>
&lt;p>對 application 看起來像 MySQL（同樣 port、同樣 wire protocol、同樣 query 語法）、實際是 stateless proxy。每個 query VTGate：&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/mysql/" data-link-title="MySQL" data-link-desc="高併發網路服務常用關聯式資料庫、Vitess / PlanetScale 分片生態、GitHub / Shopify / Facebook 規模驗證">MySQL</a> overview 的 implementation-layer deep article。Overview 已說明 MySQL 在 OLTP 譜系的定位、本文聚焦 <em>Vitess sharding</em> — 4 個 component 協作的完整 sharding 系統。</p></blockquote>
<hr>
<h2 id="問題情境mysql-寫吞吐撞上-single-primary-上限">問題情境：MySQL 寫吞吐撞上 single primary 上限</h2>
<p>MySQL primary 單機極限大致 50K-100K WPS（依 schema / hardware）。超過這個級別、選項三條：</p>
<ol>
<li><em>Application 層 sharding</em>：每張 table 自己決定怎麼分片、application 寫 routing logic、跨 shard query / migration 都要自己處理</li>
<li><em>Vitess</em>：proxy layer 自動 routing、cross-shard query 可選自動 split、resharding 自動化</li>
<li><em>Distributed SQL</em>（CockroachDB / Spanner / Aurora DSQL）：跟 MySQL 不同 engine、application 改 driver</li>
</ol>
<p>選 Vitess 的核心 driver：<em>保留 MySQL wire protocol + 應用層幾乎不必改 + 透明分片</em>。代價是 4 個 component 的 operational complexity — Vitess 的責任範圍是完整分散式系統，而非單純 proxy。</p>
<p>閱讀本文前可先對齊 <a href="/blog/backend/knowledge-cards/database-sharding/" data-link-title="Database Sharding" data-link-desc="說明資料庫如何依 shard key 分散資料、路由請求與承擔跨 shard 查詢成本">Database Sharding</a> 的 shard key、routing、resharding 與 cross-shard query 語意；容量失衡時再接 <a href="/blog/backend/knowledge-cards/hot-partition/" data-link-title="Hot Partition" data-link-desc="說明分散式 KV / OLTP 中、單一 partition 流量遠超其他的容量問題">Hot Partition</a>。</p>
<h2 id="vitess-四件套每個-component-的責任">Vitess 四件套：每個 component 的責任</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">                        ┌─────────────────┐
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">   Application ────→    │     VTGate      │  ← 對外 MySQL wire protocol
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">                        │  (proxy + parse + route + aggregate)  │
</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">                             │     │
</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">        │   VTTablet   │                  │   VTTablet   │
</span></span><span class="line"><span class="ln">10</span><span class="cl">        │ (per-MySQL   │                  │ (per-MySQL   │
</span></span><span class="line"><span class="ln">11</span><span class="cl">        │  sidecar)    │                  │  sidecar)    │
</span></span><span class="line"><span class="ln">12</span><span class="cl">        └─────┬────────┘                  └─────┬────────┘
</span></span><span class="line"><span class="ln">13</span><span class="cl">              │                                 │
</span></span><span class="line"><span class="ln">14</span><span class="cl">              ▼                                 ▼
</span></span><span class="line"><span class="ln">15</span><span class="cl">        ┌──────────────┐                  ┌──────────────┐
</span></span><span class="line"><span class="ln">16</span><span class="cl">        │    MySQL     │                  │    MySQL     │
</span></span><span class="line"><span class="ln">17</span><span class="cl">        │  (Shard -80) │                  │  (Shard 80-) │
</span></span><span class="line"><span class="ln">18</span><span class="cl">        └──────────────┘                  └──────────────┘
</span></span><span class="line"><span class="ln">19</span><span class="cl">
</span></span><span class="line"><span class="ln">20</span><span class="cl">   Topology Service (etcd / Consul / ZooKeeper)
</span></span><span class="line"><span class="ln">21</span><span class="cl">   ↑↓ 所有 component 共享 metadata
</span></span><span class="line"><span class="ln">22</span><span class="cl">   VSchema：keyspace 結構、shard 範圍、Vindex 定義</span></span></code></pre></div><h3 id="vtgate--query-routing-layer">VTGate — query routing layer</h3>
<p>對 application 看起來像 MySQL（同樣 port、同樣 wire protocol、同樣 query 語法）、實際是 stateless proxy。每個 query VTGate：</p>
<ol>
<li>Parse SQL → 找出 routing key（從 WHERE column 拿）</li>
<li>查 VSchema → 計算 routing key 對應的 shard</li>
<li>把 query 送該 shard 的 VTTablet</li>
<li>等 response、aggregate（如果是 cross-shard query）、回 application</li>
</ol>
<p>Stateless 設計 → VTGate 可以隨意 scale、放 N 個前面接 LB。多數 production 部署 3-10 個 VTGate per region。</p>
<h3 id="vttablet--per-mysql-agent">VTTablet — per-MySQL agent</h3>
<p>每個 MySQL instance 旁邊都跑一個 VTTablet。VTTablet 責任：</p>
<ul>
<li>把 MySQL primary 標記、上報給 topology</li>
<li>接 VTGate 的 query、轉發給 local MySQL</li>
<li>跑 <em>connection pool</em>（VTGate 跟 VTTablet 之間少量連線、VTTablet 跟 local MySQL 共享 connection）</li>
<li>跑 <em>query plan cache</em> / <em>transactional consistency check</em></li>
<li>處理 <em>online schema change</em>（Vitess 內建 OSC）</li>
<li>跟 VTOrc（fork of Orchestrator）配合做 failover</li>
</ul>
<p>VTTablet 是 Vitess 跟 MySQL 唯一連接點 — 沒 VTTablet 直接連 MySQL 不在 Vitess 管理下。</p>
<h3 id="vreplication--跨-shard-資料移動">VReplication — 跨 shard 資料移動</h3>
<p>VReplication 是 Vitess <em>跨 shard / 跨 keyspace / 跨 cluster</em> 資料移動引擎、底層用 MySQL binlog。用途：</p>
<ul>
<li><em>Resharding</em>：把 shard -80 拆成 -40 + 40-80、VReplication 自動拆 binlog event 對應 shard</li>
<li><em>Materialized view</em>：cross-shard aggregation 預計算</li>
<li><em>MoveTables</em>：跨 keyspace 移 table（schema-level migration）</li>
<li><em>VStream</em>：CDC、binlog event 對外輸出（可接 Kafka / Debezium）</li>
</ul>
<p>VReplication 的主要使用者是 <em>Vitess operator</em>，它和 application 行為直接相關（resharding 期間有 write split 行為）。</p>
<h3 id="vschema--sharding-metadata">VSchema — sharding metadata</h3>
<p>VSchema 是 keyspace 內 <em>哪張 table 怎麼 shard</em> 的定義、JSON 格式存 topology service。例子：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="nt">&#34;sharded&#34;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="nt">&#34;vindexes&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nt">&#34;hash&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">      <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;hash&#34;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="p">}</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 class="nt">&#34;tables&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nt">&#34;orders&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">      <span class="nt">&#34;column_vindexes&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">          <span class="nt">&#34;column&#34;</span><span class="p">:</span> <span class="s2">&#34;user_id&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">          <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;hash&#34;</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">      <span class="p">]</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="nt">&#34;users&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">      <span class="nt">&#34;column_vindexes&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">        <span class="p">{</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">          <span class="nt">&#34;column&#34;</span><span class="p">:</span> <span class="s2">&#34;user_id&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">          <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;hash&#34;</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">      <span class="p">]</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>orders.user_id</code> 跟 <code>users.user_id</code> 用同一個 Vindex（hash）+ 同一個 column → 同 user_id 的 orders + users 落在同 shard、可以 JOIN 不跨 shard。</p>
<h2 id="vindexvitess-的-sharding-function">Vindex：Vitess 的 sharding function</h2>
<p>Vindex 是 Vitess 的 <em>shard key 計算函數</em>。內建多種：</p>
<table>
  <thead>
      <tr>
          <th>Vindex 類型</th>
          <th>計算方式</th>
          <th>適用</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>hash</code></td>
          <td>3DES-based null hash（非 MD5）→ 對應 shard range</td>
          <td>預設、均勻分布、適合 primary key</td>
      </tr>
      <tr>
          <td><code>binary_md5</code></td>
          <td>MD5(binary)</td>
          <td>binary key</td>
      </tr>
      <tr>
          <td><code>unicode_loose_xxhash</code></td>
          <td>xxHash on lowercased unicode</td>
          <td>string key</td>
      </tr>
      <tr>
          <td><code>numeric</code></td>
          <td>直接 numeric value</td>
          <td>連續 numeric range（適合 time-based）</td>
      </tr>
      <tr>
          <td><code>numeric_static_map</code></td>
          <td>預定義 map</td>
          <td>國家 code / region 等少 enum</td>
      </tr>
      <tr>
          <td><code>lookup_hash</code></td>
          <td>透過 lookup table 查 shard</td>
          <td>多個 column 都要 shard、需要二級 index</td>
      </tr>
  </tbody>
</table>
<p>最常用：<code>hash</code>（primary key）+ <code>lookup_hash</code>（secondary access pattern）。</p>
<h2 id="keyspace--shard--tablet-階層">Keyspace / Shard / Tablet 階層</h2>





<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">Keyspace (邏輯 database)
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">   └── Shards
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        ├── -80 (shard range 0-128)
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        │     ├── Primary tablet (1 MySQL primary)
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        │     ├── Replica tablet × 2
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        │     └── RDOnly tablet × 1 (analytics)
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        └── 80- (shard range 128-256)
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">              ├── Primary tablet
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">              ├── Replica tablet × 2
</span></span><span class="line"><span class="ln">10</span><span class="cl">              └── RDOnly tablet × 1</span></span></code></pre></div><p>Shard range 用 <em>binary hex prefix</em>（<code>-80</code> 表示 0 到 0x80、<code>80-</code> 表示 0x80 到 max）— 給 resharding 留 split 餘地（<code>-80</code> 可切成 <code>-40</code> + <code>40-80</code>）。</p>
<p>Tablet type：</p>
<ul>
<li><em>Primary</em>：寫入入口</li>
<li><em>Replica</em>：read traffic（Vitess query rules 控制）</li>
<li><em>RDOnly</em>：純 analytics / backup / VReplication source、低 SLA、不上 production read traffic</li>
</ul>
<h2 id="配置-step-by-steplocal-cluster">配置 step-by-step（local cluster）</h2>
<p>Production 通常用 Kubernetes operator（vitess-operator）部署、但理解概念用 local cluster 最快：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 用 vtctldclient 操作（替代舊的 vtctlclient）</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"># 1. 建 unsharded keyspace</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">vtctldclient CreateKeyspace --durability-policy<span class="o">=</span>semi_sync commerce
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"># 2. 從一個 MySQL primary 開始（unsharded）</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">vtctldclient ApplySchema --sql<span class="o">=</span><span class="s2">&#34;CREATE TABLE orders (id INT PRIMARY KEY, user_id INT)&#34;</span> commerce
</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="c1"># 3. 把 keyspace 改成 sharded、定義 VSchema</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">vtctldclient ApplyVSchema --vschema<span class="o">=</span><span class="s1">&#39;{
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="s1">  &#34;sharded&#34;: true,
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="s1">  &#34;vindexes&#34;: {&#34;hash&#34;: {&#34;type&#34;: &#34;hash&#34;}},
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="s1">  &#34;tables&#34;: {
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="s1">    &#34;orders&#34;: {
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="s1">      &#34;column_vindexes&#34;: [{&#34;column&#34;: &#34;user_id&#34;, &#34;name&#34;: &#34;hash&#34;}]
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="s1">    }
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="s1">  }
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="s1">}&#39;</span> commerce
</span></span><span class="line"><span class="ln">19</span><span class="cl">
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="c1"># 4. 觸發 resharding：unsharded → 2 shards (-80, 80-)</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">vtctldclient Reshard --workflow<span class="o">=</span>initial-shard create <span class="se">\
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="se"></span>  --source-shards<span class="o">=</span><span class="s2">&#34;commerce/0&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="se"></span>  --target-shards<span class="o">=</span><span class="s2">&#34;commerce/-80,commerce/80-&#34;</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">
</span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="c1"># 5. 等資料 copy 完（VReplication 跑）</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">vtctldclient Workflow --keyspace<span class="o">=</span>commerce show initial-shard
</span></span><span class="line"><span class="ln">27</span><span class="cl">
</span></span><span class="line"><span class="ln">28</span><span class="cl"><span class="c1"># 6. SwitchTraffic：先切 RDOnly → 再切 Replica → 最後切 Primary</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">vtctldclient Reshard --workflow<span class="o">=</span>initial-shard switchtraffic <span class="se">\
</span></span></span><span class="line"><span class="ln">30</span><span class="cl"><span class="se"></span>  --tablet-types<span class="o">=</span><span class="s2">&#34;rdonly,replica&#34;</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">vtctldclient Reshard --workflow<span class="o">=</span>initial-shard switchtraffic <span class="se">\
</span></span></span><span class="line"><span class="ln">32</span><span class="cl"><span class="se"></span>  --tablet-types<span class="o">=</span><span class="s2">&#34;primary&#34;</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">
</span></span><span class="line"><span class="ln">34</span><span class="cl"><span class="c1"># 7. 完成、cleanup old shard</span>
</span></span><span class="line"><span class="ln">35</span><span class="cl">vtctldclient Reshard --workflow<span class="o">=</span>initial-shard complete</span></span></code></pre></div><p>實際 production 走 <em>Vitess Kubernetes operator</em>、用 <code>VitessCluster</code> CRD 宣告 desired state、operator 自動操作上面這些 step。</p>
<h2 id="5-個-production-踩雷">5 個 Production 踩雷</h2>
<h3 id="1-cross-shard-transaction--vitess-不支援-atomic預設">1. Cross-shard transaction — Vitess 不支援 atomic（預設）</h3>
<p>兩個 user 的 order 在不同 shard、<code>BEGIN; UPDATE orders WHERE user_id=1; UPDATE orders WHERE user_id=2; COMMIT;</code> 跨兩個 shard。Vitess 預設 <em>不保證 atomic</em> — 兩個 shard 各自 commit、可能一個成功一個失敗、application 看到 partial state。</p>
<p>修法：</p>
<ul>
<li><em>避免 cross-shard transaction</em>：schema design 讓 transaction boundary 落在單一 shard 內</li>
<li>啟用 <em>atomic 2-phase commit</em>（Vitess <code>transaction_mode=TWOPC</code>、實驗性、performance penalty 大）</li>
<li>大規模需要 atomic 的場景應該換 distributed SQL（CockroachDB / Spanner），讓資料庫層承擔跨節點一致性</li>
</ul>
<h3 id="2-vstream-lag--resharding-期間-cdc-落後">2. VStream lag — Resharding 期間 CDC 落後</h3>
<p>Resharding 過程 VReplication 大量寫 binlog event、application <em>本來在用</em> 的 VStream（接 Kafka 等）共享同 binlog stream、可能 lag。Downstream consumer 看到 stale data 1-2 小時。</p>
<p>修法：</p>
<ul>
<li>Resharding 期間 <em>暫停非關鍵 VStream</em>（analytics ETL 可暫停、real-time recommendation 需要保留）</li>
<li>確認 binlog disk capacity &gt; resharding 期間預估 binlog 量 × 2（buffer）</li>
<li>Resharding 完成後 <em>手動驗證</em> VStream offset 已 catch up，把驗證結果留成 cutover evidence</li>
</ul>
<h3 id="3-vindex-不均勻--hot-shard">3. Vindex 不均勻 — Hot shard</h3>
<p>Vindex 預設 <code>hash</code> 對 <em>primary key 均勻分布</em>、但對 <em>natural key</em>（country / region / company_id 等）可能不均勻。10 個 country、其中 1 個 country 佔 80% traffic、單一 shard 永遠 hot。</p>
<p>修法：</p>
<ul>
<li><em>Composite Vindex</em>：combine <code>country + user_id</code> 兩 column 作為 shard key、user-level 仍均勻</li>
<li><em>Synthetic shard key</em>：application 層加 <code>sharding_key=hash(actual_key) % N</code>、控制分布</li>
<li>監控 <em>per-shard QPS</em>：<code>vtctldclient ShowVDiff</code> + Prometheus exporter</li>
<li>Hot shard 出現後 Vitess 可以 resharding 解（split hot shard 為 2 個小 shard）、但工作量大</li>
</ul>
<h3 id="4-resharding-切流量瞬間-deadlock">4. Resharding 切流量瞬間 deadlock</h3>
<p>Resharding 最後的 SwitchTraffic 切 primary 階段、舊 shard 仍接 write、Vitess 切 routing、Application 一瞬間連兩個 shard、相同 user_id 寫入可能跑兩邊、deadlock 或 lost update。</p>
<p>修法：</p>
<ul>
<li><em>SwitchTraffic 用 ReverseTraffic 預備</em>：先 switch、確認問題後可 reverse 回去</li>
<li>切流量 <em>只在 known quiet period</em>（夜間 / 週末早上）</li>
<li>VTGate <code>--retry-count=2</code> + <code>--track-vtgate-deadlock-events</code>：deadlock 自動 retry、不暴露給 application</li>
<li>真的失敗用 <code>Reshard cancel</code> 回 old state，讓 workflow 回到可驗證狀態</li>
</ul>
<h3 id="5-vreplication-workflow-卡住--cancel-前需要保護狀態">5. VReplication workflow 卡住 — cancel 前需要保護狀態</h3>
<p>VReplication workflow 跑到 50% 但 <em>某個 row 解析錯誤</em>（schema mismatch / blob 大小超過 limit）、workflow stuck、進度條卡住、無 timeout。整個 resharding flow halt。</p>
<p>修法：</p>
<ul>
<li>平時跑 <em>staging 資料 dry-run</em>、發現 schema 跟 blob 邊界問題</li>
<li>Workflow 卡住時 <code>vtctldclient Workflow show</code> 看 last_message / row_state</li>
<li>手動修問題 row（直接 MySQL 改）後 <em>resume workflow</em></li>
<li>大 cluster 建議 <em>VReplication 跑前先 SchemaApply audit</em>、確認 source / target schema 兼容</li>
</ul>
<h2 id="vitess-跟自管-sharding-對照">Vitess 跟自管 sharding 對照</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>Vitess</th>
          <th>Application-level sharding</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Application 改動</td>
          <td>幾乎不必（保留 MySQL wire）</td>
          <td>大改（routing logic 寫 application）</td>
      </tr>
      <tr>
          <td>Cross-shard query</td>
          <td>VTGate 自動 split（受限）</td>
          <td>Application 自己處理</td>
      </tr>
      <tr>
          <td>Resharding</td>
          <td>VReplication 自動</td>
          <td>手寫腳本、操作複雜</td>
      </tr>
      <tr>
          <td>Online schema change</td>
          <td>Vitess 內建（VReplication-based）</td>
          <td>用 gh-ost / pt-osc</td>
      </tr>
      <tr>
          <td>Failover</td>
          <td>VTOrc 整合</td>
          <td>自管 Orchestrator</td>
      </tr>
      <tr>
          <td>Operational cost</td>
          <td>高（4 component 要懂）</td>
          <td>中（fewer abstractions、但 application logic 多）</td>
      </tr>
      <tr>
          <td>Cross-keyspace 共用 vindex</td>
          <td>內建（lookup_hash 跨 keyspace）</td>
          <td>自寫</td>
      </tr>
  </tbody>
</table>
<p>Vitess 的 <em>operational complexity</em> 是它的代價。10-20 人 SRE 團隊撐得住、5 人團隊用 <em>managed Vitess（PlanetScale）</em> 更實際。</p>
<h2 id="跟其他模組整合">跟其他模組整合</h2>
<h3 id="跟-replication-topology">跟 Replication topology</h3>
<p>Vitess shard 內部仍用 MySQL replication（<a href="/blog/backend/01-database/vendors/mysql/replication-topology/" data-link-title="MySQL Replication Topology：async / semi-sync / GTID 不是三選一、是三個 trade-off 軸的疊加" data-link-desc="MySQL replication 不是「選 async 還是 semi-sync」、是 *durability / latency / consistency* 三個 trade-off 軸的疊加；GTID 是跨 mode 的 infrastructure layer、不是第三種 mode。本文走 3 軸取捨模型 → async / semi-sync 行為對比 → GTID 替代 binlog-position 的好處 → 配置 step-by-step → 5 production 踩雷（lag 暴衝 / semi-sync 退回 async / GTID gap / Loss-Less semi-sync 真的 loss-less / chained replication 雪崩）→ 跟 Aurora MySQL / Vitess / ProxySQL / Orchestrator 整合">Replication Topology</a>）— 每個 shard 有 primary + replica + rdonly。Vitess durability-policy 控制 primary 寫入是否等 replica ack（semi-sync）。</p>
<h3 id="跟-osc-tool">跟 OSC tool</h3>
<p>Vitess <em>不用 gh-ost / pt-osc</em>、用 VReplication-based online DDL。Vitess online DDL：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">vtctldclient ApplySchema --strategy<span class="o">=</span>vitess <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --sql<span class="o">=</span><span class="s2">&#34;ALTER TABLE orders ADD COLUMN status VARCHAR(20)&#34;</span> commerce</span></span></code></pre></div><p>詳見 <a href="/blog/backend/01-database/vendors/mysql/online-schema-change-tools/" data-link-title="MySQL Online Schema Change：gh-ost 跟 pt-online-schema-change 兩條完全不同的 ghost table 路徑" data-link-desc="MySQL ALTER TABLE 可能鎖整張表，production 需要 online schema change 流程。gh-ost（GitHub）跟 pt-online-schema-change（Percona）都用 ghost table 解決、但底層機制完全不同：pt-osc 用 trigger 同步、gh-ost 用 binlog stream 同步。本文走兩工具機制對照表 → trigger vs binlog 各自取捨 → 配置 step-by-step → 5 production 踩雷（trigger overhead / binlog 延遲 / FK constraint / hot trigger lock / 切換瞬間 deadlock）→ 何時用哪一個">Online Schema Change Tools</a>。</p>
<h3 id="跟-proxysql">跟 ProxySQL</h3>
<p><em>Vitess 取代 ProxySQL</em>。VTGate 本身做 connection pool + query routing、不再需要 ProxySQL。混用會造成 routing 衝突（VTGate 期待自己決定 shard、ProxySQL 跟 VTGate 競爭）。詳見 <a href="/blog/backend/01-database/vendors/mysql/proxysql-config/" data-link-title="MySQL ProxySQL 配置：connection / query / route / response 四段 lifecycle 跟 query rule 設計" data-link-desc="ProxySQL 是 MySQL 生態的 connection pool &#43; query routing 標準。本文走 connection → query parse → route → response 四段 lifecycle、query rule engine 的 rule chain 設計、Hostgroup / Server / User 三層 schema、配置 step-by-step（讀寫分離 &#43; replica lag-aware routing）、5 production 踩雷（query rule 順序錯亂 / connection 漂移 / write 路由到 replica / runtime / disk schema drift / mirror traffic 副作用）、跟 Replication / Orchestrator / HAProxy 整合">ProxySQL 配置</a>。</p>
<h3 id="跟-orchestrator">跟 Orchestrator</h3>
<p>Vitess 用 <em>VTOrc</em>（fork of Orchestrator）作 failover、跟 Vitess topology metadata 整合。不用獨立 Orchestrator。詳見 <a href="/blog/backend/01-database/vendors/mysql/orchestrator-failover/" data-link-title="MySQL Orchestrator Failover：HA 工具自己怎麼 HA？raft cluster &#43; GTID-based promotion 的兩段 paradox" data-link-desc="Orchestrator 是 MySQL HA 自動 failover 的 de facto standard、但讀者第一個問題往往是「HA 工具自己會壞嗎」。本文走 Orchestrator 的雙層架構（管 MySQL 的 raft cluster &#43; 被 raft 管的 orchestrator instance）→ topology discovery → failure detection → failover decision tree → promote action → 5 production 踩雷（split-brain 跟 fencing / pre-failover hook 失敗 / anti-flapping window / GTID errant transaction / VIP 跟 ProxySQL 整合斷層）→ 跟 ProxySQL / Patroni / RDS 對比">Orchestrator failover 設計</a>。</p>
<h3 id="跟-planetscalemanaged-vitess">跟 PlanetScale（managed Vitess）</h3>
<p>PlanetScale 是 <em>Vitess managed service</em>、隱藏 4 component operational complexity、加 branch-based schema workflow。詳見 <a href="/blog/backend/01-database/vendors/mysql/migrate-to-planetscale/" data-link-title="MySQL → PlanetScale：managed Vitess &#43; branch-based schema workflow 的 hybrid shift" data-link-desc="自管 MySQL → PlanetScale 加上 Vitess sharding 跟 branch-based schema workflow。本文走 6 維 audit（Paradigm &#43; Operational &#43; Schema 多軸）、4-phase migration、5 production 踩雷、何時不要遷。">PlanetScale migration playbook</a>。</p>
<h3 id="跟-aurora-mysql">跟 Aurora MySQL</h3>
<p>Aurora 跟 Vitess 是 <em>不同 scale 路徑</em>：</p>
<ul>
<li>Aurora：single-region scaling（storage / compute 分離、最高 ~128 TB）</li>
<li>Vitess：horizontal sharding（無上限、靠加 shard scaling）</li>
</ul>
<p>兩者承擔的容量與操作責任不同。超過 Aurora single-region 上限的場景才考慮 Vitess。詳見 <a href="/blog/backend/01-database/vendors/aurora/" data-link-title="AWS Aurora" data-link-desc="AWS managed PostgreSQL / MySQL、storage / compute 分離、&#43;75% 效能改善的 production 證據">Aurora vendor page</a>。</p>
<h2 id="production-caseyoutube--vitess">Production case：YouTube / Vitess</h2>
<p>Vitess 的 production 責任是把 MySQL shard 拓撲變成應用可查詢、可遷移、可操作的資料庫層。YouTube / Vitess 的公開歷史提供的工程訊號是 VTGate、VTTablet、VReplication 與 VSchema 這組元件分工：application query 進 VTGate、tablet 層包住 MySQL、VSchema 描述 routing / sharding 規則、VReplication 支援 resharding 與資料搬移。</p>
<p>這個案例要回收到三個操作判準。第一，Vitess 是一套 database control plane，而非單一 proxy；導入時要把 topology service、tablet lifecycle、backup、failover 與 schema workflow 一起納入 ownership。第二，VSchema 是 application contract，shard key、lookup vindex 與 cross-shard query 都會影響產品功能設計。第三，VReplication 讓 resharding 可操作，但它仍需要 capacity window、backfill 監控與 cutover plan。</p>
<p>Vitess 的 sibling 路由是 <a href="/blog/backend/01-database/vendors/postgresql/citus-distributed/" data-link-title="PostgreSQL Citus Distributed：用 extension 把 PG 變成 sharded cluster" data-link-desc="Citus 是 PG extension、把單機 PG 變成 *coordinator &#43; worker* sharded cluster、保留 PG SQL &#43; 加 distributed table &#43; reference table &#43; columnar storage。本文走 Citus 架構（coordinator / worker / distribution column）、3 種 table type（distributed / reference / local）、配置 step-by-step、5 production 踩雷（distribution column 選錯 / cross-shard transaction / reference table 過大 / colocate 不對齊 / worker failover）、跟 MySQL Vitess sharding sibling 對比">PostgreSQL Citus Distributed</a> 與 <a href="/blog/backend/01-database/global-distributed-oltp/" data-link-title="1.11 全球分散式 OLTP" data-link-desc="Spanner / Aurora DSQL / Cosmos DB multi-region write / CockroachDB / TiDB 的全球一致性取捨">1.11 全球分散式 OLTP</a>。Citus 保留 PostgreSQL 生態並用 coordinator / worker 拆分資料；CockroachDB / Spanner 則用 distributed SQL 重新定義交易與一致性邊界。選型時要先判斷自己是在延伸 MySQL 投資，還是在重新選 global OLTP model。</p>
<h2 id="何時用-vitess">何時用 Vitess</h2>
<table>
  <thead>
      <tr>
          <th>條件</th>
          <th>評估</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>流量 &gt; 50K WPS、單 primary 撐不住</td>
          <td>是 Vitess scope</td>
      </tr>
      <tr>
          <td>已有大量 MySQL 投資、不想換 distributed SQL</td>
          <td>是</td>
      </tr>
      <tr>
          <td>有 5-10 人 SRE / DBA 團隊</td>
          <td>是</td>
      </tr>
      <tr>
          <td>流量 &lt; 10K WPS</td>
          <td>否（過度設計、用單 MySQL + replica）</td>
      </tr>
      <tr>
          <td>5 人團隊、不想養 DBA</td>
          <td>否（用 PlanetScale managed）</td>
      </tr>
      <tr>
          <td>必須 multi-region 強一致 transaction</td>
          <td>否（CockroachDB / Spanner 才對）</td>
      </tr>
      <tr>
          <td>需要複雜 cross-shard analytics</td>
          <td>否（搭配 BigQuery / Snowflake）</td>
      </tr>
  </tbody>
</table>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/mysql/" data-link-title="MySQL" data-link-desc="高併發網路服務常用關聯式資料庫、Vitess / PlanetScale 分片生態、GitHub / Shopify / Facebook 規模驗證">MySQL vendor overview</a></li>
<li><a href="/blog/backend/01-database/vendors/mysql/replication-topology/" data-link-title="MySQL Replication Topology：async / semi-sync / GTID 不是三選一、是三個 trade-off 軸的疊加" data-link-desc="MySQL replication 不是「選 async 還是 semi-sync」、是 *durability / latency / consistency* 三個 trade-off 軸的疊加；GTID 是跨 mode 的 infrastructure layer、不是第三種 mode。本文走 3 軸取捨模型 → async / semi-sync 行為對比 → GTID 替代 binlog-position 的好處 → 配置 step-by-step → 5 production 踩雷（lag 暴衝 / semi-sync 退回 async / GTID gap / Loss-Less semi-sync 真的 loss-less / chained replication 雪崩）→ 跟 Aurora MySQL / Vitess / ProxySQL / Orchestrator 整合">MySQL Replication Topology</a>（Vitess shard 內部）</li>
<li><a href="/blog/backend/01-database/vendors/mysql/online-schema-change-tools/" data-link-title="MySQL Online Schema Change：gh-ost 跟 pt-online-schema-change 兩條完全不同的 ghost table 路徑" data-link-desc="MySQL ALTER TABLE 可能鎖整張表，production 需要 online schema change 流程。gh-ost（GitHub）跟 pt-online-schema-change（Percona）都用 ghost table 解決、但底層機制完全不同：pt-osc 用 trigger 同步、gh-ost 用 binlog stream 同步。本文走兩工具機制對照表 → trigger vs binlog 各自取捨 → 配置 step-by-step → 5 production 踩雷（trigger overhead / binlog 延遲 / FK constraint / hot trigger lock / 切換瞬間 deadlock）→ 何時用哪一個">MySQL Online Schema Change Tools</a>（Vitess 不用 gh-ost / pt-osc）</li>
<li><a href="/blog/backend/01-database/vendors/mysql/proxysql-config/" data-link-title="MySQL ProxySQL 配置：connection / query / route / response 四段 lifecycle 跟 query rule 設計" data-link-desc="ProxySQL 是 MySQL 生態的 connection pool &#43; query routing 標準。本文走 connection → query parse → route → response 四段 lifecycle、query rule engine 的 rule chain 設計、Hostgroup / Server / User 三層 schema、配置 step-by-step（讀寫分離 &#43; replica lag-aware routing）、5 production 踩雷（query rule 順序錯亂 / connection 漂移 / write 路由到 replica / runtime / disk schema drift / mirror traffic 副作用）、跟 Replication / Orchestrator / HAProxy 整合">MySQL ProxySQL 配置</a>（Vitess 取代 ProxySQL）</li>
<li><a href="/blog/backend/01-database/vendors/mysql/orchestrator-failover/" data-link-title="MySQL Orchestrator Failover：HA 工具自己怎麼 HA？raft cluster &#43; GTID-based promotion 的兩段 paradox" data-link-desc="Orchestrator 是 MySQL HA 自動 failover 的 de facto standard、但讀者第一個問題往往是「HA 工具自己會壞嗎」。本文走 Orchestrator 的雙層架構（管 MySQL 的 raft cluster &#43; 被 raft 管的 orchestrator instance）→ topology discovery → failure detection → failover decision tree → promote action → 5 production 踩雷（split-brain 跟 fencing / pre-failover hook 失敗 / anti-flapping window / GTID errant transaction / VIP 跟 ProxySQL 整合斷層）→ 跟 ProxySQL / Patroni / RDS 對比">MySQL Orchestrator failover</a>（VTOrc fork）</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/citus-distributed/" data-link-title="PostgreSQL Citus Distributed：用 extension 把 PG 變成 sharded cluster" data-link-desc="Citus 是 PG extension、把單機 PG 變成 *coordinator &#43; worker* sharded cluster、保留 PG SQL &#43; 加 distributed table &#43; reference table &#43; columnar storage。本文走 Citus 架構（coordinator / worker / distribution column）、3 種 table type（distributed / reference / local）、配置 step-by-step、5 production 踩雷（distribution column 選錯 / cross-shard transaction / reference table 過大 / colocate 不對齊 / worker failover）、跟 MySQL Vitess sharding sibling 對比">PostgreSQL Citus Distributed</a>（PG sibling、coordinator + worker 模型 vs Vitess VTGate + tablet）</li>
<li><a href="/blog/backend/01-database/global-distributed-oltp/" data-link-title="1.11 全球分散式 OLTP" data-link-desc="Spanner / Aurora DSQL / Cosmos DB multi-region write / CockroachDB / TiDB 的全球一致性取捨">1.11 全球分散式 OLTP</a>（Vitess vs CockroachDB vs Spanner）</li>
<li><a href="/blog/backend/knowledge-cards/database-sharding/" data-link-title="Database Sharding" data-link-desc="說明資料庫如何依 shard key 分散資料、路由請求與承擔跨 shard 查詢成本">Database Sharding</a>（shard key、routing、resharding 與 cross-shard query）</li>
<li>官方：<a href="https://vitess.io/docs/">Vitess Documentation</a> / <a href="https://github.com/planetscale/vitess-operator">Vitess Operator</a></li>
</ul>
]]></content:encoded></item><item><title>PostgreSQL Citus Distributed：用 extension 把 PG 變成 sharded cluster</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/citus-distributed/</link><pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/citus-distributed/</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>Citus distributed extension&lt;/em> — 把 PG 變成 sharded cluster 的方式。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;p>當 PG single-primary 寫吞吐撞上單機極限（50K-100K WPS）、選項三條：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Application 層 sharding&lt;/strong>：應用層自管 shard routing&lt;/li>
&lt;li>&lt;strong>Citus&lt;/strong>：PG extension、自動 routing + cross-shard query&lt;/li>
&lt;li>&lt;strong>Distributed SQL&lt;/strong>（CockroachDB / Aurora DSQL / Spanner）：不同 engine&lt;/li>
&lt;/ol>
&lt;p>選 Citus 的核心 driver：&lt;em>保留 PG SQL syntax + extension 生態&lt;/em>。但「應用層幾乎不必改」是樂觀說法 — 實際上 application 必須圍繞 distribution column 重設計（query 加 filter / transaction 限定同 shard / reference table 量控制）、跟 Vitess 比 cross-shard query 自動化弱。代價是 &lt;em>coordinator / worker 部署複雜度 + cross-shard query 限制 + application schema 改造工作量&lt;/em>。&lt;/p>
&lt;p>閱讀本文前可先對齊 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/database-sharding/" data-link-title="Database Sharding" data-link-desc="說明資料庫如何依 shard key 分散資料、路由請求與承擔跨 shard 查詢成本">Database Sharding&lt;/a> 的 shard key、routing、resharding 與 cross-shard query 語意；容量失衡時再接 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/hot-partition/" data-link-title="Hot Partition" data-link-desc="說明分散式 KV / OLTP 中、單一 partition 流量遠超其他的容量問題">Hot Partition&lt;/a>。&lt;/p>
&lt;p>跟 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/vitess-sharding/" data-link-title="MySQL Vitess Sharding：VTGate / VTTablet / VReplication / VSchema 四件套協作" data-link-desc="Vitess 不只是 MySQL sharding proxy、是 4 個 component 協作的完整 sharding 系統 — VTGate（query routing layer）、VTTablet（per-MySQL agent）、VReplication（跨 shard 資料移動）、VSchema（sharding metadata）。本文走 4 件套各自責任、keyspace / shard / tablet 架構、shard key 設計（Vindex）、配置 step-by-step、5 production 踩雷（cross-shard transaction / VStream lag / Vindex 不均勻 / resharding 切流 / VReplication 卡住）、跟自管 sharding 跟 PlanetScale 的對比">MySQL Vitess sharding&lt;/a> 的核心差異：Citus 是 &lt;em>PG extension&lt;/em>（PG 自己跑）、Vitess 是 &lt;em>獨立 proxy + tablet 系統&lt;/em>（包 MySQL）。Citus 用 PG 原生機制（FDW / extension hook）、Vitess 是 &lt;em>外部包裝&lt;/em>。&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>Citus distributed extension</em> — 把 PG 變成 sharded cluster 的方式。</p></blockquote>
<hr>
<p>當 PG single-primary 寫吞吐撞上單機極限（50K-100K WPS）、選項三條：</p>
<ol>
<li><strong>Application 層 sharding</strong>：應用層自管 shard routing</li>
<li><strong>Citus</strong>：PG extension、自動 routing + cross-shard query</li>
<li><strong>Distributed SQL</strong>（CockroachDB / Aurora DSQL / Spanner）：不同 engine</li>
</ol>
<p>選 Citus 的核心 driver：<em>保留 PG SQL syntax + extension 生態</em>。但「應用層幾乎不必改」是樂觀說法 — 實際上 application 必須圍繞 distribution column 重設計（query 加 filter / transaction 限定同 shard / reference table 量控制）、跟 Vitess 比 cross-shard query 自動化弱。代價是 <em>coordinator / worker 部署複雜度 + cross-shard query 限制 + application schema 改造工作量</em>。</p>
<p>閱讀本文前可先對齊 <a href="/blog/backend/knowledge-cards/database-sharding/" data-link-title="Database Sharding" data-link-desc="說明資料庫如何依 shard key 分散資料、路由請求與承擔跨 shard 查詢成本">Database Sharding</a> 的 shard key、routing、resharding 與 cross-shard query 語意；容量失衡時再接 <a href="/blog/backend/knowledge-cards/hot-partition/" data-link-title="Hot Partition" data-link-desc="說明分散式 KV / OLTP 中、單一 partition 流量遠超其他的容量問題">Hot Partition</a>。</p>
<p>跟 <a href="/blog/backend/01-database/vendors/mysql/vitess-sharding/" data-link-title="MySQL Vitess Sharding：VTGate / VTTablet / VReplication / VSchema 四件套協作" data-link-desc="Vitess 不只是 MySQL sharding proxy、是 4 個 component 協作的完整 sharding 系統 — VTGate（query routing layer）、VTTablet（per-MySQL agent）、VReplication（跨 shard 資料移動）、VSchema（sharding metadata）。本文走 4 件套各自責任、keyspace / shard / tablet 架構、shard key 設計（Vindex）、配置 step-by-step、5 production 踩雷（cross-shard transaction / VStream lag / Vindex 不均勻 / resharding 切流 / VReplication 卡住）、跟自管 sharding 跟 PlanetScale 的對比">MySQL Vitess sharding</a> 的核心差異：Citus 是 <em>PG extension</em>（PG 自己跑）、Vitess 是 <em>獨立 proxy + tablet 系統</em>（包 MySQL）。Citus 用 PG 原生機制（FDW / extension hook）、Vitess 是 <em>外部包裝</em>。</p>
<h2 id="citus-架構coordinator--worker">Citus 架構：Coordinator + Worker</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">                ┌─────────────────┐
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">   Application  │   Coordinator   │  ← 對外 PG wire protocol、planner、routing
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">                │   (Citus + PG)  │
</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">                     │     │
</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">        │ Worker 1 │         │ Worker 2 │  ← 各跑 PG + Citus extension
</span></span><span class="line"><span class="ln">10</span><span class="cl">        │  (PG)    │         │  (PG)    │
</span></span><span class="line"><span class="ln">11</span><span class="cl">        │ shard 1,3│         │ shard 2,4│
</span></span><span class="line"><span class="ln">12</span><span class="cl">        └──────────┘         └──────────┘</span></span></code></pre></div><p><strong>Coordinator</strong>：</p>
<ul>
<li>對 application 看起來像 PG（同 port / 同 wire protocol）</li>
<li>接 SQL → Citus planner 把 query 分解 + route 給 worker</li>
<li>不存 data（distributed table 的 shard 在 worker 上）</li>
<li>存 <em>metadata</em>（哪個 shard 在哪個 worker）</li>
</ul>
<p><strong>Worker</strong>：</p>
<ul>
<li>標準 PG instance + Citus extension</li>
<li>各存若干 shard</li>
<li>接 coordinator 來的 query、跑 local execute、回結果</li>
</ul>
<p><strong>Shard</strong>：</p>
<ul>
<li>Distributed table 拆成 N 個 shard（預設 32）</li>
<li>每 shard 是 worker 上的 <em>physical PG table</em>（含 <code>_&lt;shardid&gt;</code> 後綴）</li>
<li>行為跟一般 PG table 一樣、可以直接連 worker 用 PG 工具 access</li>
</ul>
<h2 id="3-種-table-type">3 種 Table Type</h2>
<h3 id="distributed-table--跨-shard-切分">Distributed table — 跨 shard 切分</h3>





<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">-- 建一般 PG table
</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">orders</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">id</span><span class="w"> </span><span class="n">BIGSERIAL</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="n">user_id</span><span class="w"> </span><span class="nb">BIGINT</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">amount</span><span class="w"> </span><span class="nb">DECIMAL</span><span class="p">(</span><span class="mi">10</span><span class="p">,</span><span class="mi">2</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">created_at</span><span class="w"> </span><span class="k">TIMESTAMP</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">PRIMARY</span><span class="w"> </span><span class="k">KEY</span><span class="w"> </span><span class="p">(</span><span class="n">user_id</span><span class="p">,</span><span class="w"> </span><span class="n">id</span><span class="p">)</span><span class="w">  </span><span class="c1">-- PK 必須含 distribution column
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"></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></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w"></span><span class="c1">-- 用 Citus 把它變 distributed
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">create_distributed_table</span><span class="p">(</span><span class="s1">&#39;orders&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;user_id&#39;</span><span class="p">);</span></span></span></code></pre></div><p><code>user_id</code> 是 <em>distribution column</em> — Citus 用它的 hash 決定 row 屬哪個 shard。<code>PK 必須含 distribution column</code>（跟 MySQL partitioning 同要求）。</p>
<p>跟 Vitess Vindex 對比：</p>
<ul>
<li>Citus：hash distribution column → shard（單一 hash function、不可選 algorithm）</li>
<li>Vitess：Vindex 可選多種（hash / lookup_hash / xxhash / null）</li>
</ul>
<h3 id="reference-table--全-shard-共有">Reference table — 全 shard 共有</h3>





<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">products</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">SERIAL</span><span class="w"> </span><span class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</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">name</span><span class="w"> </span><span class="nb">VARCHAR</span><span class="p">(</span><span class="mi">100</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="n">price</span><span class="w"> </span><span class="nb">DECIMAL</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></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></span><span class="line"><span class="ln">7</span><span class="cl"><span class="w"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">create_reference_table</span><span class="p">(</span><span class="s1">&#39;products&#39;</span><span class="p">);</span></span></span></code></pre></div><p><code>products</code> 在 <em>每個 worker 都有完整 copy</em>、寫入 coordinator 廣播給所有 worker。</p>
<p>用途：</p>
<ul>
<li>小 lookup table（country code / product category 等）</li>
<li>跨 distributed table JOIN 時、reference table 在每 worker 上、不必 cross-shard</li>
<li>寫入頻率低（廣播 cost 跟 worker 數 linear）</li>
</ul>
<h3 id="local-table--coordinator-上的-pg-table">Local table — Coordinator 上的 PG table</h3>





<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">audit_log</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">SERIAL</span><span class="w"> </span><span class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</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">event</span><span class="w"> </span><span class="n">JSONB</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></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="c1">-- 不調用 Citus function、預設留在 coordinator</span></span></span></code></pre></div><p>行為跟一般 PG table 一樣。用於 <em>不需 distribute</em> 的 table（如 admin metadata）。</p>
<h2 id="colocation跨-distributed-table-同-shard-對齊">Colocation：跨 distributed table 同 shard 對齊</h2>
<p>當兩個 distributed table 都用 <em>同 distribution column</em>（例如 <code>user_id</code>）+ 同 shard count、Citus 自動 colocate：</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">SELECT</span><span class="w"> </span><span class="n">create_distributed_table</span><span class="p">(</span><span class="s1">&#39;orders&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;user_id&#39;</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="k">SELECT</span><span class="w"> </span><span class="n">create_distributed_table</span><span class="p">(</span><span class="s1">&#39;user_addresses&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;user_id&#39;</span><span class="p">,</span><span class="w"> </span><span class="n">colocate_with</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="s1">&#39;orders&#39;</span><span class="p">);</span></span></span></code></pre></div><p>Colocate 後：</p>
<ul>
<li><code>user_id = 100</code> 的 orders 跟 user_addresses 在 <em>同一 worker shard</em></li>
<li>JOIN 不跨 worker、效率高</li>
<li>可用 PG 原生 FK constraint（cross-table 但同 shard）</li>
</ul>
<p>Colocate 是 Citus 設計的核心 <em>跨 table 一致性</em> 機制。沒 colocate 的 cross-table query 變 cross-worker、效率大降。</p>
<h2 id="配置-step-by-steplocal-cluster">配置 step-by-step（local cluster）</h2>
<p>Production 用 Citus Cloud（Microsoft 託管）或 Azure Cosmos DB for PostgreSQL（同 engine）。Self-hosted：</p>
<h3 id="step-1coordinator--worker-都裝-pg--citus">Step 1：Coordinator + worker 都裝 PG + Citus</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 在每個 node（coordinator + 2 worker）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">apt install postgresql-14
</span></span><span class="line"><span class="ln">3</span><span class="cl">apt install postgresql-14-citus-12.0
</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"><span class="c1"># postgresql.conf</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="nv">shared_preload_libraries</span> <span class="o">=</span> <span class="s1">&#39;citus&#39;</span>
</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">systemctl restart postgresql</span></span></code></pre></div>




<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">-- 在每個 node 跑
</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="n">EXTENSION</span><span class="w"> </span><span class="n">citus</span><span class="p">;</span></span></span></code></pre></div><h3 id="step-2coordinator-註冊-worker">Step 2：Coordinator 註冊 worker</h3>





<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">-- 在 coordinator 跑
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">citus_add_node</span><span class="p">(</span><span class="s1">&#39;worker1.example.com&#39;</span><span class="p">,</span><span class="w"> </span><span class="mi">5432</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="k">SELECT</span><span class="w"> </span><span class="n">citus_add_node</span><span class="p">(</span><span class="s1">&#39;worker2.example.com&#39;</span><span class="p">,</span><span class="w"> </span><span class="mi">5432</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></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="c1">-- 確認
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">citus_get_active_worker_nodes</span><span class="p">();</span></span></span></code></pre></div><h3 id="step-3建-distributed-table">Step 3：建 distributed table</h3>





<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">orders</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="n">BIGSERIAL</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">user_id</span><span class="w"> </span><span class="nb">BIGINT</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">4</span><span class="cl"><span class="w">    </span><span class="n">amount</span><span class="w"> </span><span class="nb">DECIMAL</span><span class="p">(</span><span class="mi">10</span><span class="p">,</span><span class="mi">2</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">created_at</span><span class="w"> </span><span class="k">TIMESTAMP</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="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</span><span class="w"> </span><span class="p">(</span><span class="n">user_id</span><span class="p">,</span><span class="w"> </span><span class="n">id</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="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="w"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">create_distributed_table</span><span class="p">(</span><span class="s1">&#39;orders&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;user_id&#39;</span><span class="p">);</span></span></span></code></pre></div><p>Citus 自動把 <code>orders</code> 拆成 32 個 shard（<code>orders_102008</code> 等）、分配到 worker。</p>
<h3 id="step-4application-連-coordinator">Step 4：Application 連 coordinator</h3>
<p>Application connection string 連 coordinator IP / port（不必知道 worker 存在）。</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">-- 從 application 跑 query、Citus 透明 route
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="p">(</span><span class="n">user_id</span><span class="p">,</span><span class="w"> </span><span class="n">amount</span><span class="p">)</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="mi">12345</span><span class="p">,</span><span class="w"> </span><span class="mi">50</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="c1">-- → Citus 看 user_id=12345 hash 屬 shard 17、route 給對應 worker
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></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">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">user_id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">12345</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="c1">-- → Single-shard query、極快
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="w"></span><span class="k">SELECT</span><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">FROM</span><span class="w"> </span><span class="n">orders</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="c1">-- → Cross-shard aggregation、Citus 並行跑、合併結果</span></span></span></code></pre></div><h2 id="5-個-production-踩雷">5 個 Production 踩雷</h2>
<h3 id="1-distribution-column-選錯--cross-shard-query-變主流">1. Distribution column 選錯 — Cross-shard query 變主流</h3>
<p>選 <code>created_at</code> 或 <code>id</code>（auto increment）作 distribution column、看起來均勻、實際 <em>application query 多以 user_id 為主</em>、變成 <em>每個 query 都 cross-shard</em>、performance 雪崩。</p>
<p>修法：</p>
<ul>
<li><em>Distribution column 選 application 最常 filter / join 的 column</em>（通常是 <code>tenant_id</code> / <code>user_id</code>）</li>
<li>Audit application top query、確認 distribution column 對齊 query pattern</li>
<li>改 distribution column 要 <em>rewrite 所有 shard</em>、像 resharding、大工程</li>
</ul>
<h3 id="2-cross-shard-transaction-限制">2. Cross-shard transaction 限制</h3>
<p>跨多 shard 的 transaction（如：UPDATE 兩個 user_id 不同的 row）Citus 用 <em>2PC</em>（two-phase commit）但有限制：</p>
<ul>
<li>Multi-statement transaction 跨 shard 需明確開 <code>SET citus.multi_shard_modify_mode = 'sequential'</code></li>
<li>部分 isolation level 不保證 serializable across shards</li>
<li>DDL 跨 shard 是 sequential</li>
</ul>
<p>修法：</p>
<ul>
<li>Schema design 避免 cross-shard transaction（同 colocation group 內 transaction 沒問題）</li>
<li>必要 cross-shard 場景明確設 multi-shard mode</li>
<li>對 <em>strict cross-shard consistency</em>、考慮 distributed SQL（CockroachDB / Aurora DSQL）</li>
</ul>
<h3 id="3-reference-table-過大--寫入廣播-cost-爆">3. Reference table 過大 — 寫入廣播 cost 爆</h3>
<p>Reference table 在每 worker 都有 copy、寫入 <em>廣播給所有 worker</em>。Reference table 100K row + 高頻寫入 → 寫一次寫 N worker、cost N x。</p>
<p>修法：</p>
<ul>
<li>Reference table 限 <em>小 + 寫入頻率低</em> 的 lookup data</li>
<li>超大表不該是 reference table、考慮 distributed</li>
<li>監控 reference table 寫入 rate、超 threshold 重新評估</li>
</ul>
<h3 id="4-colocate-沒對齊--隱性-cross-shard-join">4. Colocate 沒對齊 — 隱性 cross-shard JOIN</h3>





<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">-- 看似可以、實際 cross-shard 慢
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="n">o</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="n">user_addresses</span><span class="w"> </span><span class="n">ua</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="n">user_id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">ua</span><span class="p">.</span><span class="n">user_id</span><span class="p">;</span></span></span></code></pre></div><p>若 <code>user_addresses</code> 沒 <code>colocate_with =&gt; 'orders'</code>、兩表 shard 分配獨立、JOIN 跨 worker。</p>
<p>修法：</p>
<ul>
<li>建相關 table 時 <code>colocate_with</code> 對齊</li>
<li>用 <code>SELECT * FROM citus_tables</code> 看 colocation_id、確認對齊</li>
<li>跨非 colocate table 的 JOIN 用 <em>materialized view</em> 或 application 層拆 query 避開</li>
</ul>
<h3 id="5-worker-failover--coordinator-必須知道">5. Worker failover — Coordinator 必須知道</h3>
<p>Worker 故障、Citus 預設 <em>coordinator 看到 query 失敗、不自動 failover</em>。</p>
<p>修法（Citus 11+）：</p>
<ul>
<li>用 <em>shard replication</em>（<code>citus.shard_replication_factor = 2</code>）— 每 shard 在 2 個 worker 有 copy</li>
<li>配 PG streaming replication 在 worker 層、外加 Patroni 管 failover</li>
<li>Coordinator 失敗 → 整個 cluster 失能、coordinator 也要 HA（Patroni）</li>
</ul>
<p>跟 Vitess 對比 Citus 的 HA story 較弱、production 必須認真規劃。</p>
<h2 id="何時用-citus">何時用 Citus</h2>
<table>
  <thead>
      <tr>
          <th>條件</th>
          <th>建議</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Multi-tenant SaaS、tenant_id 為自然 distribution</td>
          <td>是</td>
      </tr>
      <tr>
          <td>寫吞吐 &gt; 50K WPS、單 PG 撐不住</td>
          <td>是</td>
      </tr>
      <tr>
          <td>需要保留 PG SQL + extension（pgvector / TimescaleDB）</td>
          <td>是</td>
      </tr>
      <tr>
          <td>應用 query pattern 80% 都用同一 distribution column</td>
          <td>是</td>
      </tr>
      <tr>
          <td>應用大量 ad-hoc cross-tenant aggregation</td>
          <td>否（cross-shard 慢）</td>
      </tr>
      <tr>
          <td>強 cross-shard consistency 需求</td>
          <td>否（用 CockroachDB）</td>
      </tr>
      <tr>
          <td>想 zero-ops managed</td>
          <td>Azure Cosmos DB for PostgreSQL（同 engine）</td>
      </tr>
  </tbody>
</table>
<h2 id="容量規劃">容量規劃</h2>
<ul>
<li>Coordinator: 中等 CPU + RAM、metadata 不大、不存 data</li>
<li>Worker: per-worker spec 同 single PG production</li>
<li>Shard count: 預設 32、實務常設 worker count × 4-8</li>
<li>Replication factor: production 至少 2</li>
</ul>
<h2 id="跟其他模組整合">跟其他模組整合</h2>
<h3 id="跟-replication-topology">跟 Replication topology</h3>
<p>Coordinator + worker 各跑 PG streaming replication、Citus 不取代 PG replication。Worker failover 用 Patroni / streaming replication。詳見 <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>。</p>
<h3 id="跟-pg-extensions">跟 PG Extensions</h3>
<p>Citus 跟其他 PG extension 多數兼容（pgvector / TimescaleDB / pg_stat_statements）— 它維持 <em>extension</em> 形態，保留 PostgreSQL 生態接點。詳見 <em>PG Extension Ecosystem</em> 篇（待寫）。</p>
<h3 id="跟-mysql-vitess">跟 MySQL Vitess</h3>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>Citus</th>
          <th>Vitess</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>部署模型</td>
          <td>PG extension</td>
          <td>獨立 proxy + tablet</td>
      </tr>
      <tr>
          <td>主要場景</td>
          <td>Multi-tenant SaaS</td>
          <td>超大規模分片</td>
      </tr>
      <tr>
          <td>Cross-shard JOIN</td>
          <td>colocate 對齊 + reference table</td>
          <td>VTGate 自動 split + aggregate</td>
      </tr>
      <tr>
          <td>FK</td>
          <td>同 colocation 內可用</td>
          <td>Vitess 18+ 支援、cross-shard 限制</td>
      </tr>
      <tr>
          <td>HA</td>
          <td>依賴 Patroni + replication factor</td>
          <td>VTOrc + replication</td>
      </tr>
      <tr>
          <td>學習曲線</td>
          <td>中（PG ops 經驗夠）</td>
          <td>高（4 component）</td>
      </tr>
  </tbody>
</table>
<p>Citus 對 <em>PG-native</em> 場景更平順、Vitess 對 <em>MySQL-native</em> 場景更平順、不直接競爭。詳見 <a href="/blog/backend/01-database/vendors/mysql/vitess-sharding/" data-link-title="MySQL Vitess Sharding：VTGate / VTTablet / VReplication / VSchema 四件套協作" data-link-desc="Vitess 不只是 MySQL sharding proxy、是 4 個 component 協作的完整 sharding 系統 — VTGate（query routing layer）、VTTablet（per-MySQL agent）、VReplication（跨 shard 資料移動）、VSchema（sharding metadata）。本文走 4 件套各自責任、keyspace / shard / tablet 架構、shard key 設計（Vindex）、配置 step-by-step、5 production 踩雷（cross-shard transaction / VStream lag / Vindex 不均勻 / resharding 切流 / VReplication 卡住）、跟自管 sharding 跟 PlanetScale 的對比">MySQL Vitess Sharding</a>。</p>
<h2 id="相關連結">相關連結</h2>
<ul>
<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 vendor overview</a></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 整合">PG Replication Topology</a>（per-worker replication）</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 對比">PG MVCC + Lock Model</a>（cross-shard transaction lock 行為）</li>
<li><a href="/blog/backend/01-database/global-distributed-oltp/" data-link-title="1.11 全球分散式 OLTP" data-link-desc="Spanner / Aurora DSQL / Cosmos DB multi-region write / CockroachDB / TiDB 的全球一致性取捨">1.11 全球分散式 OLTP</a>（Citus vs CockroachDB vs Spanner）</li>
<li><a href="/blog/backend/01-database/vendors/mysql/vitess-sharding/" data-link-title="MySQL Vitess Sharding：VTGate / VTTablet / VReplication / VSchema 四件套協作" data-link-desc="Vitess 不只是 MySQL sharding proxy、是 4 個 component 協作的完整 sharding 系統 — VTGate（query routing layer）、VTTablet（per-MySQL agent）、VReplication（跨 shard 資料移動）、VSchema（sharding metadata）。本文走 4 件套各自責任、keyspace / shard / tablet 架構、shard key 設計（Vindex）、配置 step-by-step、5 production 踩雷（cross-shard transaction / VStream lag / Vindex 不均勻 / resharding 切流 / VReplication 卡住）、跟自管 sharding 跟 PlanetScale 的對比">MySQL Vitess Sharding</a>（sibling、不同實作）</li>
<li><a href="/blog/backend/01-database/vendors/cosmosdb/" data-link-title="Azure Cosmos DB" data-link-desc="全球分散式 multi-model DB、5 個 consistency levels、Microsoft 自家 dogfood 證據">Cosmos DB vendor</a>（Azure Cosmos DB for PostgreSQL = managed Citus）</li>
<li>官方：<a href="https://docs.citusdata.com/">Citus Documentation</a> / <a href="https://github.com/citusdata/citus">Citus on GitHub</a></li>
</ul>
]]></content:encoded></item><item><title>MySQL 8.0 Modern SQL：CTE / window function / JSON_TABLE 不是「終於跟上 PG」、是進入 SQL 工程深度的入場券</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/modern-sql-features/</link><pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/modern-sql-features/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/" data-link-title="MySQL" data-link-desc="高併發網路服務常用關聯式資料庫、Vitess / PlanetScale 分片生態、GitHub / Shopify / Facebook 規模驗證">MySQL&lt;/a> overview 的 implementation-layer deep article。Overview 已說明 MySQL 在 OLTP 譜系的定位、本文聚焦 &lt;em>8.0 modern SQL 特性&lt;/em> — 5 個關鍵能力 + 跟 PostgreSQL 對應特性的對比。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;p>「MySQL 是 SQL 簡單版」是個過時觀念。&lt;/p>
&lt;p>這個觀念的來源很合理：MySQL 5.x 時代沒 CTE、window function 要嗑 hack、recursive query 寫不出來、JSON 處理是字串 substring 拼接、複雜分析 query 只能丟去 PostgreSQL 或 Snowflake。整整 10 年 SQL 進階特性 MySQL 全缺、PostgreSQL 全有。&lt;/p>
&lt;p>MySQL 8.0（2018 推出）改變這件事。CTE / window function / lateral derived table / JSON_TABLE / hash join / atomic DDL / role-based authentication / common table expression 全部進來。&lt;strong>這不是「終於跟上 PG」、是 MySQL 第一次有資格進入 SQL 工程深度討論&lt;/strong>。但有 caveats：每個特性的 &lt;em>行為實現&lt;/em> 跟 PostgreSQL 對應特性都有 &lt;em>微妙差異&lt;/em>、不能假設 PG 經驗直接套用。&lt;/p>
&lt;p>對從 PostgreSQL 過來評估 MySQL 的讀者：本文是 &lt;em>特性對等驗證&lt;/em> — 哪些 8.0 特性真的可以 production 用、哪些是 marketing 但實作有 gap。對既有 MySQL 5.7 user：本文是 &lt;em>upgrade 5.7 → 8.0 的具體 ROI&lt;/em> — 從 SQL feature 角度看升級值不值得。&lt;/p>
&lt;h2 id="5-個關鍵特性--pg-對比">5 個關鍵特性 + PG 對比&lt;/h2>
&lt;h3 id="特性-1ctecommon-table-expression">特性 1：CTE（Common Table Expression）&lt;/h3>
&lt;p>MySQL 8.0 / PG 8.4+ 都支援。&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="c1">-- MySQL 8.0 + PG 都 OK
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">WITH&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">order_summary&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&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"> 3&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">user_id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">SUM&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">amount&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">total&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">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">orders&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="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">created_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;2026-01-01&amp;#39;&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="k">GROUP&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">user_id&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="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="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">u&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">name&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">os&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">total&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="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">users&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">u&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">order_summary&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">os&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">u&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">os&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">user_id&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="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">os&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">total&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">1000&lt;/span>&lt;span class="p">;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>行為差異&lt;/strong>：&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/mysql/" data-link-title="MySQL" data-link-desc="高併發網路服務常用關聯式資料庫、Vitess / PlanetScale 分片生態、GitHub / Shopify / Facebook 規模驗證">MySQL</a> overview 的 implementation-layer deep article。Overview 已說明 MySQL 在 OLTP 譜系的定位、本文聚焦 <em>8.0 modern SQL 特性</em> — 5 個關鍵能力 + 跟 PostgreSQL 對應特性的對比。</p></blockquote>
<hr>
<p>「MySQL 是 SQL 簡單版」是個過時觀念。</p>
<p>這個觀念的來源很合理：MySQL 5.x 時代沒 CTE、window function 要嗑 hack、recursive query 寫不出來、JSON 處理是字串 substring 拼接、複雜分析 query 只能丟去 PostgreSQL 或 Snowflake。整整 10 年 SQL 進階特性 MySQL 全缺、PostgreSQL 全有。</p>
<p>MySQL 8.0（2018 推出）改變這件事。CTE / window function / lateral derived table / JSON_TABLE / hash join / atomic DDL / role-based authentication / common table expression 全部進來。<strong>這不是「終於跟上 PG」、是 MySQL 第一次有資格進入 SQL 工程深度討論</strong>。但有 caveats：每個特性的 <em>行為實現</em> 跟 PostgreSQL 對應特性都有 <em>微妙差異</em>、不能假設 PG 經驗直接套用。</p>
<p>對從 PostgreSQL 過來評估 MySQL 的讀者：本文是 <em>特性對等驗證</em> — 哪些 8.0 特性真的可以 production 用、哪些是 marketing 但實作有 gap。對既有 MySQL 5.7 user：本文是 <em>upgrade 5.7 → 8.0 的具體 ROI</em> — 從 SQL feature 角度看升級值不值得。</p>
<h2 id="5-個關鍵特性--pg-對比">5 個關鍵特性 + PG 對比</h2>
<h3 id="特性-1ctecommon-table-expression">特性 1：CTE（Common Table Expression）</h3>
<p>MySQL 8.0 / PG 8.4+ 都支援。</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">-- MySQL 8.0 + PG 都 OK
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">WITH</span><span class="w"> </span><span class="n">order_summary</span><span class="w"> </span><span class="k">AS</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="k">SELECT</span><span class="w"> </span><span class="n">user_id</span><span class="p">,</span><span class="w"> </span><span class="k">SUM</span><span class="p">(</span><span class="n">amount</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">total</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">FROM</span><span class="w"> </span><span class="n">orders</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">WHERE</span><span class="w"> </span><span class="n">created_at</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="s1">&#39;2026-01-01&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</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="n">user_id</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><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="k">SELECT</span><span class="w"> </span><span class="n">u</span><span class="p">.</span><span class="n">name</span><span class="p">,</span><span class="w"> </span><span class="n">os</span><span class="p">.</span><span class="n">total</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">FROM</span><span class="w"> </span><span class="n">users</span><span class="w"> </span><span class="n">u</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="n">order_summary</span><span class="w"> </span><span class="n">os</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">u</span><span class="p">.</span><span class="n">id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">os</span><span class="p">.</span><span class="n">user_id</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">WHERE</span><span class="w"> </span><span class="n">os</span><span class="p">.</span><span class="n">total</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="mi">1000</span><span class="p">;</span></span></span></code></pre></div><p><strong>行為差異</strong>：</p>
<ul>
<li><strong>MySQL 8.0</strong>：CTE <em>不 materialize 為預設</em>、optimizer 把 CTE 視為 <em>inlined subquery</em>、CTE 引用兩次以上會 <em>重複計算</em></li>
<li><strong>PostgreSQL（&lt; 12）</strong>：CTE <em>fence by default</em>（materialize barrier）、optimizer 不 push predicate 進 CTE</li>
<li><strong>PostgreSQL（12+）</strong>：CTE 行為跟 MySQL 接近、有 <code>MATERIALIZED</code> / <code>NOT MATERIALIZED</code> keyword 明示</li>
</ul>
<p>對 PG 12+ user：可以套 MySQL 經驗。對 PG 11 以下 user：CTE 行為跟 MySQL 不一樣、要重看 query plan。</p>
<p><strong>Recursive CTE</strong>：</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">WITH</span><span class="w"> </span><span class="k">RECURSIVE</span><span class="w"> </span><span class="n">org_chart</span><span class="w"> </span><span class="k">AS</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="k">SELECT</span><span class="w"> </span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="n">name</span><span class="p">,</span><span class="w"> </span><span class="n">manager_id</span><span class="p">,</span><span class="w"> </span><span class="mi">0</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">depth</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w">    </span><span class="k">FROM</span><span class="w"> </span><span class="n">employees</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">manager_id</span><span class="w"> </span><span class="k">IS</span><span class="w"> </span><span class="k">NULL</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">UNION</span><span class="w"> </span><span class="k">ALL</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">SELECT</span><span class="w"> </span><span class="n">e</span><span class="p">.</span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="n">e</span><span class="p">.</span><span class="n">name</span><span class="p">,</span><span class="w"> </span><span class="n">e</span><span class="p">.</span><span class="n">manager_id</span><span class="p">,</span><span class="w"> </span><span class="n">oc</span><span class="p">.</span><span class="n">depth</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="mi">1</span><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w">    </span><span class="k">FROM</span><span class="w"> </span><span class="n">employees</span><span class="w"> </span><span class="n">e</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="n">org_chart</span><span class="w"> </span><span class="n">oc</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">e</span><span class="p">.</span><span class="n">manager_id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">oc</span><span class="p">.</span><span class="n">id</span><span class="w">
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><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="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">org_chart</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">depth</span><span class="w"> </span><span class="o">&lt;=</span><span class="w"> </span><span class="mi">10</span><span class="p">;</span></span></span></code></pre></div><p>兩家都支援、但 MySQL 8.0 有 <em>深度上限</em>（<code>cte_max_recursion_depth=1000</code>、預設 1000、PG 預設 unlimited）。複雜 hierarchical query（深度 &gt; 1000）MySQL 需要顯式提高 limit。</p>
<h3 id="特性-2window-function">特性 2：Window Function</h3>
<p>MySQL 8.0 / PG 8.4+ 都支援、語法同 SQL standard。</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">SELECT</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">order_id</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">user_id</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="n">amount</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">SUM</span><span class="p">(</span><span class="n">amount</span><span class="p">)</span><span class="w"> </span><span class="n">OVER</span><span class="w"> </span><span class="p">(</span><span class="n">PARTITION</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">user_id</span><span class="w"> </span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">created_at</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">running_total</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">RANK</span><span class="p">()</span><span class="w"> </span><span class="n">OVER</span><span class="w"> </span><span class="p">(</span><span class="n">PARTITION</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">user_id</span><span class="w"> </span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">amount</span><span class="w"> </span><span class="k">DESC</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">rank_in_user</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">FROM</span><span class="w"> </span><span class="n">orders</span><span class="p">;</span></span></span></code></pre></div><p><strong>行為差異</strong>：</p>
<ul>
<li><strong>執行 plan</strong>：MySQL 8.0 用 <em>window iterator</em>、單 partition 內 sort、外加 in-memory window buffer。PostgreSQL 有更成熟的 <em>WindowAgg node</em>、複雜 frame spec 處理更好</li>
<li><strong>Frame spec 支援度</strong>：兩家都支援 ROWS / RANGE / GROUPS、但 <em>GROUPS frame</em> MySQL 是 8.0.16+ 才補進、PG 11+ 才補</li>
<li><strong>大資料量 spill behavior</strong>：MySQL window function 超過 <code>sort_buffer_size</code>（預設 256K）會 spill 到 disk、Performance 雪崩。PG 用 <code>work_mem</code>（預設 4MB）、寬裕些但也會 spill</li>
</ul>
<p>對長期用 PG window function 寫複雜 reporting query 的 user：MySQL 8.0 可以做、但 <em>效能 tune</em> 工作量大、不是 drop-in。</p>
<h3 id="特性-3json_tablepg-主要賣點對比">特性 3：JSON_TABLE（PG 主要賣點對比）</h3>
<p>這是 user 點到的對比重點。</p>
<p><strong>MySQL 8.0 的 JSON_TABLE</strong>：</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">SELECT</span><span class="w"> </span><span class="n">t</span><span class="p">.</span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="n">j</span><span class="p">.</span><span class="n">name</span><span class="p">,</span><span class="w"> </span><span class="n">j</span><span class="p">.</span><span class="n">price</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="n">products</span><span class="w"> </span><span class="n">t</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">JSON_TABLE</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="n">t</span><span class="p">.</span><span class="n">metadata</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="s1">&#39;$.variants[*]&#39;</span><span class="w"> </span><span class="n">COLUMNS</span><span class="w"> </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">name</span><span class="w"> </span><span class="nb">VARCHAR</span><span class="p">(</span><span class="mi">50</span><span class="p">)</span><span class="w"> </span><span class="n">PATH</span><span class="w"> </span><span class="s1">&#39;$.name&#39;</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">price</span><span class="w"> </span><span class="nb">DECIMAL</span><span class="p">(</span><span class="mi">10</span><span class="p">,</span><span class="mi">2</span><span class="p">)</span><span class="w"> </span><span class="n">PATH</span><span class="w"> </span><span class="s1">&#39;$.price&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">         </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="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">j</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">WHERE</span><span class="w"> </span><span class="n">t</span><span class="p">.</span><span class="n">category</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;shoes&#39;</span><span class="p">;</span></span></span></code></pre></div><p>JSON_TABLE 把 JSON document 內的 array element 展開成 <em>relational rows</em>、然後可以 JOIN / WHERE / GROUP BY。SQL:2016 standard 規範。</p>
<p><strong>PostgreSQL 對應</strong>：</p>
<p>PG 17+ 有 <code>JSON_TABLE</code>（SQL:2016 standard、跟 MySQL 同語法）、但歷史上 PG user 用兩條不同路線：</p>
<ol>
<li>
<p><strong>JSONB operator</strong>（PG 9.4+）：</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">SELECT</span><span class="w"> </span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="n">metadata</span><span class="o">-&gt;</span><span class="s1">&#39;variants&#39;</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">variants</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="n">products</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="n">metadata</span><span class="w"> </span><span class="o">@&gt;</span><span class="w"> </span><span class="s1">&#39;{&#34;category&#34;: &#34;shoes&#34;}&#39;</span><span class="p">;</span></span></span></code></pre></div></li>
<li>
<p><strong>jsonb_path_query</strong>（PG 12+）：</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">SELECT</span><span class="w"> </span><span class="n">t</span><span class="p">.</span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="n">v</span><span class="p">.</span><span class="n">name</span><span class="p">,</span><span class="w"> </span><span class="n">v</span><span class="p">.</span><span class="n">price</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="n">products</span><span class="w"> </span><span class="n">t</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">jsonb_path_query</span><span class="p">(</span><span class="n">t</span><span class="p">.</span><span class="n">metadata</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;$.variants[*]&#39;</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">v</span><span class="p">;</span></span></span></code></pre></div></li>
</ol>
<p><strong>核心差異</strong>：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>MySQL JSON_TABLE</th>
          <th>PG JSONB operator</th>
          <th>PG jsonb_path_query</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Index</td>
          <td>必須對 JSON column 建 <em>generated column + 一般 index</em>、不能直接 GIN index JSON path</td>
          <td><strong>GIN index 直接 over JSONB</strong>（業界唯一）</td>
          <td>可以走 GIN expression index</td>
      </tr>
      <tr>
          <td>Storage</td>
          <td>JSON column = LONGTEXT 包裝</td>
          <td>JSONB = binary、壓縮、index 友善</td>
          <td>同左</td>
      </tr>
      <tr>
          <td>Query 效率（複雜 path）</td>
          <td>中等（需要 generated column 加速）</td>
          <td>高（GIN index 直接）</td>
          <td>高</td>
      </tr>
      <tr>
          <td>SQL standard 對齊</td>
          <td>高（JSON_TABLE 是 standard）</td>
          <td>低（JSONB operator 是 PG 專有）</td>
          <td>中（jsonpath 是 standard）</td>
      </tr>
      <tr>
          <td>大 JSON（&gt; 1 MB）</td>
          <td>LONGTEXT 仍可、但 query 慢</td>
          <td>JSONB 壓縮 + 部分 read</td>
          <td>同左</td>
      </tr>
  </tbody>
</table>
<p><strong>選型結論</strong>：</p>
<ul>
<li><strong>MySQL 是 JSON-storage 角色</strong>（document 順手存進關聯 DB）：JSON_TABLE 夠用、配 generated column + index、production-ready</li>
<li><strong>MySQL 是 document-heavy workload</strong>（大量 JSON-driven query / 複雜 path / 高 selectivity）：PG JSONB GIN index 仍是 <em>clearly winner</em>、或直接用 MongoDB</li>
<li><strong>MySQL 8.0 JSON 不是 PG JSONB 替代</strong>：JSON_TABLE 是 <em>SQL standard 對齊</em>、好 portable、但 <em>index 跟 storage 仍弱</em></li>
</ul>
<p>對「JSON 是 PG 主要賣點」的判斷：JSONB binary storage + GIN index 是 PG 在 JSON workload 的 <em>結構性優勢</em>、MySQL 8.0 補了 SQL_TABLE 但 <em>index 那層沒補</em>。8.0 後 JSON 議題 <em>不是 deal-breaker for MySQL</em>（不像 5.7 時代直接 disqualify）、但仍不是 MySQL 主場。</p>
<h3 id="特性-4lateral-derived-table">特性 4：Lateral Derived Table</h3>
<p>MySQL 8.0.14+ / PG 9.3+ 都支援。</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">-- 對每個 user、找他最近 5 個 order
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">u</span><span class="p">.</span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="n">recent</span><span class="p">.</span><span class="o">*</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="n">users</span><span class="w"> </span><span class="n">u</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">LEFT</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="k">LATERAL</span><span class="w"> </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">SELECT</span><span class="w"> </span><span class="n">order_id</span><span class="p">,</span><span class="w"> </span><span class="n">amount</span><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w">    </span><span class="k">FROM</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="n">o</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">WHERE</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="n">user_id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">u</span><span class="p">.</span><span class="n">id</span><span class="w">
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="w">    </span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">created_at</span><span class="w"> </span><span class="k">DESC</span><span class="w"> </span><span class="k">LIMIT</span><span class="w"> </span><span class="mi">5</span><span class="w">
</span></span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="w"></span><span class="p">)</span><span class="w"> </span><span class="n">recent</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="k">true</span><span class="p">;</span></span></span></code></pre></div><p>Lateral 讓 subquery 可以 <em>引用外部 reference column</em>（<code>u.id</code>）、不可能用 plain subquery 寫出來。</p>
<p><strong>行為差異</strong>：</p>
<ul>
<li>MySQL 8.0：lateral 後加、optimizer plan 仍在演進、複雜 lateral query 可能 plan 次優</li>
<li>PostgreSQL：lateral 早就成熟、plan 跟 join 直接 fuse、效率高</li>
</ul>
<p>對 PG-experienced 使用 lateral 寫 reporting query 的 user：MySQL 8.0 可以、但有時候要 hint optimizer 達到最佳 plan。</p>
<h3 id="特性-5hash-join">特性 5：Hash Join</h3>
<p>MySQL 8.0.18+ / PG 早已有。</p>
<p><strong>MySQL 8.0 之前</strong>：只有 <em>nested loop join</em>、大表 JOIN 完全失控（n × m row scan）。8.0.18 加 hash join、optimizer 在預估 row count 大時自動切。</p>
<p><strong>注意</strong>：MySQL 8.0 hash join 預設 <em>不對所有 join 開</em>、只在 <code>optimizer_switch='hash_join=on'</code> 且 join condition 是 <em>equality on indexed column</em> 時觸發。常見錯估：複雜 join 條件不觸發 hash join、optimizer fallback nested loop、query 永遠跑不完。</p>
<p><strong>PG 對應</strong>：PG 一直有 hash join、optimizer 預設 cover 廣、且有 <em>parallel hash join</em>（PG 11+）大表 JOIN 並行加速。</p>
<p>MySQL hash join 是 <em>補洞</em>、不是 <em>並肩特性</em>。複雜 OLAP query MySQL 仍弱於 PG。</p>
<h2 id="其他-80-特性一句話帶過">其他 8.0 特性（一句話帶過）</h2>
<ul>
<li><strong>Atomic DDL</strong>：CREATE TABLE / DROP / ALTER 變 transactional、crash recovery 不會留 orphan table（PG 早就 atomic）</li>
<li><strong>Role-based authentication</strong>：role 取代 group-level grant、user 可繼承 role（PG 早就 role 系統）</li>
<li><strong>CHECK constraint enforcement</strong>：5.7 可寫但不執行、8.0 真的 enforce（PG 一直執行）</li>
<li><strong>invisible index</strong>：建 index 但 optimizer 暫不用、適合 staging query plan 測試（PG 沒原生對應）</li>
<li><strong>Resource Group</strong>：query 跑時可分配 CPU thread 給特定 user group（PG 沒原生對應）</li>
<li><strong>Generated column</strong>：MySQL 5.7 已有、8.0 強化、可作為 JSON path 加速的 workaround</li>
</ul>
<h2 id="配置-step-by-step從-57--80-sql-feature-升級">配置 step-by-step（從 5.7 → 8.0 SQL feature 升級）</h2>
<p>如果已經是 8.0、所有特性都可以用、不必額外配置。如果是 5.7 → 8.0、需要：</p>
<ol>
<li><strong><code>character_set_server=utf8mb4</code></strong>：8.0 預設 utf8mb4（5.7 預設 latin1）、character set 不一致導致 query 行為微差</li>
<li><strong><code>default_authentication_plugin=mysql_native_password</code></strong>：8.0 預設 caching_sha2_password、舊 client 連不上、cluster upgrade 期間用 native_password 保兼容</li>
<li><strong><code>optimizer_switch='hash_join=on'</code></strong>：確認 hash join 啟用、預設應該已 ON</li>
<li><strong><code>cte_max_recursion_depth=10000</code></strong>：複雜 recursive CTE 需要時提高</li>
<li><strong>重新 review 所有 ORM-generated SQL</strong>：8.0 keywords 變多（WINDOW、RANK、LATERAL 等變成 reserved word）、5.7 識別碼可能變 syntax error</li>
</ol>
<h2 id="5-個-production-踩雷">5 個 Production 踩雷</h2>
<h3 id="1-cte-引用兩次--跑兩次">1. CTE 引用兩次 = 跑兩次</h3>





<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">WITH</span><span class="w"> </span><span class="n">expensive</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="p">(</span><span class="k">SELECT</span><span class="w"> </span><span class="p">...</span><span class="w"> </span><span class="n">heavy</span><span class="w"> </span><span class="n">aggregation</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="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">expensive</span><span class="w"> </span><span class="k">WHERE</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="k">UNION</span><span class="w"> </span><span class="k">ALL</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">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">expensive</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">other_condition</span><span class="p">;</span></span></span></code></pre></div><p>預期 CTE 跑一次、實際 MySQL 跑兩次。Query 時間 doubled。</p>
<p>修法：</p>
<ul>
<li>把 CTE 結果先 INSERT 進 <em>temporary table</em>、SELECT 兩次走 temp table（手動 materialize）</li>
<li>或 PG 用 <code>MATERIALIZED</code> keyword（MySQL 沒對應 hint、要手動 temp table）</li>
</ul>
<h3 id="2-window-function-大-partition-spill-到-disk">2. Window function 大 partition spill 到 disk</h3>





<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">SELECT</span><span class="w"> </span><span class="n">order_id</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="k">SUM</span><span class="p">(</span><span class="n">amount</span><span class="p">)</span><span class="w"> </span><span class="n">OVER</span><span class="w"> </span><span class="p">(</span><span class="n">PARTITION</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">user_id</span><span class="w"> </span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">created_at</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="k">FROM</span><span class="w"> </span><span class="n">orders</span><span class="p">;</span><span class="w">  </span><span class="c1">-- 1 億 row</span></span></span></code></pre></div><p><code>sort_buffer_size=256K</code> 預設、單 partition &gt; 256K row 開始 spill disk、執行從秒級變分鐘級。</p>
<p>修法：</p>
<ul>
<li>提高 <code>sort_buffer_size</code>（per-connection、不要設太大、connection × buffer 會吃 RAM）</li>
<li>加 INDEX 包含 <code>user_id, created_at</code>、optimizer 可直接用 sorted index、不必額外 sort</li>
</ul>
<h3 id="3-json_table-跟-generated-column-取捨錯誤">3. JSON_TABLE 跟 generated column 取捨錯誤</h3>
<p>直接 JSON_TABLE on every query：</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">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">products</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">JSON_TABLE</span><span class="p">(</span><span class="n">metadata</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;$.variants[*]&#39;</span><span class="w"> </span><span class="n">COLUMNS</span><span class="w"> </span><span class="p">(...));</span></span></span></code></pre></div><p>每次 query 跑 JSON parse、無 index 加速、大表 query 慢。</p>
<p>修法：</p>
<ul>
<li>
<p>對 <em>常 query 的 JSON path</em> 建 generated column：</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">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">products</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">ADD</span><span class="w"> </span><span class="k">COLUMN</span><span class="w"> </span><span class="n">category</span><span class="w"> </span><span class="nb">VARCHAR</span><span class="p">(</span><span class="mi">50</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="k">GENERATED</span><span class="w"> </span><span class="n">ALWAYS</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="p">(</span><span class="n">JSON_UNQUOTE</span><span class="p">(</span><span class="n">metadata</span><span class="o">-&gt;</span><span class="s1">&#39;$.category&#39;</span><span class="p">))</span><span class="w"> </span><span class="n">STORED</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">ADD</span><span class="w"> </span><span class="k">INDEX</span><span class="w"> </span><span class="n">idx_category</span><span class="w"> </span><span class="p">(</span><span class="n">category</span><span class="p">);</span></span></span></code></pre></div></li>
<li>
<p>JSON_TABLE 用於 <em>ad-hoc query</em>、不要當熱 path</p>
</li>
<li>
<p>跟 PG JSONB GIN 對比：PG 不必預先建 generated column、GIN index 直接 over JSONB</p>
</li>
</ul>
<h3 id="4-hash-join-沒觸發--optimizer-預估錯-row-count">4. Hash join 沒觸發 — Optimizer 預估錯 row count</h3>
<p>JOIN 大表預期 hash join、實際 MySQL 跑 nested loop、query 跑不完。常見原因：</p>
<ul>
<li>Table statistics 過時（沒跑 <code>ANALYZE TABLE</code>）</li>
<li>Join condition 不是 pure equality（<code>a.id = b.id + 1</code> 等）</li>
<li>一邊有 LIMIT、optimizer 估 small set、選 nested loop</li>
</ul>
<p>修法：</p>
<ul>
<li>跑 <code>ANALYZE TABLE</code> 更新 statistics</li>
<li>用 <code>EXPLAIN ANALYZE</code> 看實際 row count vs 估計</li>
<li>用 <code>optimizer_hint</code>（如 <code>/*+ HASH_JOIN(t1 t2) */</code>）強制</li>
</ul>
<h3 id="5-recursive-cte-深度上限--production-query-突然-fail">5. Recursive CTE 深度上限 — Production query 突然 fail</h3>
<p><code>cte_max_recursion_depth=1000</code> 預設、organization hierarchy / tree query 超過 1000 層直接 fail（<code>ER_CTE_MAX_RECURSION_DEPTH_EXCEEDED</code>）。</p>
<p>修法：</p>
<ul>
<li>評估真實 hierarchy 深度、設 <code>cte_max_recursion_depth=10000</code> 或更高</li>
<li>或 query 加 <code>WHERE depth &lt; N</code> 提前停（不依賴 implicit limit）</li>
<li>對極大 hierarchy（社群 follow graph 等）改用 <em>graph DB</em>（Neo4j）— MySQL recursive CTE 不是 graph workload 主場</li>
</ul>
<h2 id="mysql-80-vs-pg-sql-特性-cross-reference">MySQL 8.0 vs PG SQL 特性 cross-reference</h2>
<table>
  <thead>
      <tr>
          <th>特性</th>
          <th>MySQL 8.0</th>
          <th>PostgreSQL</th>
          <th>差異</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>CTE</td>
          <td>8.0+</td>
          <td>8.4+</td>
          <td>PG 2009 即支援、MySQL 2018 才支援、約晚 9 年</td>
      </tr>
      <tr>
          <td>Recursive CTE</td>
          <td>8.0+（depth 限）</td>
          <td>8.4+（unlimited）</td>
          <td>PG 無深度上限</td>
      </tr>
      <tr>
          <td>Window function</td>
          <td>8.0+</td>
          <td>8.4+</td>
          <td>Frame spec 兩家略不同（GROUPS frame 推出時點）</td>
      </tr>
      <tr>
          <td>Lateral</td>
          <td>8.0.14+</td>
          <td>9.3+</td>
          <td>PG plan 較成熟</td>
      </tr>
      <tr>
          <td>JSON_TABLE</td>
          <td>8.0+</td>
          <td>17+</td>
          <td>MySQL 早 6 年（SQL:2016 standard）</td>
      </tr>
      <tr>
          <td>JSONB index</td>
          <td>無原生</td>
          <td>GIN index over JSONB</td>
          <td><strong>PG 結構優勢</strong></td>
      </tr>
      <tr>
          <td>Hash join</td>
          <td>8.0.18+</td>
          <td>早</td>
          <td>PG parallel hash join</td>
      </tr>
      <tr>
          <td>Atomic DDL</td>
          <td>8.0+</td>
          <td>早</td>
          <td>PG 一直 atomic</td>
      </tr>
      <tr>
          <td>Common keyword</td>
          <td>補齊</td>
          <td>完整</td>
          <td>-</td>
      </tr>
      <tr>
          <td>Role-based auth</td>
          <td>8.0+</td>
          <td>早</td>
          <td>-</td>
      </tr>
      <tr>
          <td>Materialized view</td>
          <td>無原生</td>
          <td>9.3+</td>
          <td><strong>PG 結構優勢</strong>（MySQL 用 trigger / scheduled refresh 模擬）</td>
      </tr>
      <tr>
          <td>Partial index</td>
          <td>無</td>
          <td>早</td>
          <td><strong>PG 結構優勢</strong></td>
      </tr>
      <tr>
          <td>Expression index</td>
          <td>8.0.13+</td>
          <td>早</td>
          <td>MySQL 後加</td>
      </tr>
      <tr>
          <td>Full-text search</td>
          <td>內建（InnoDB 5.6+）</td>
          <td>內建（tsvector）</td>
          <td>PG full-text 更成熟</td>
      </tr>
      <tr>
          <td>Foreign data wrapper</td>
          <td>無原生</td>
          <td>早（FDW）</td>
          <td><strong>PG 結構優勢</strong></td>
      </tr>
  </tbody>
</table>
<p>8.0 補了 <em>語法層</em> 大部分缺漏、<em>storage / index / extensibility 層</em> 仍是 PG 結構優勢。對「先選 SQL 工程深度」的 org、PG 仍領先；對「先選 ecosystem / replication / sharding」的 org、MySQL 已不是 disqualifier。</p>
<h2 id="跟其他模組整合">跟其他模組整合</h2>
<h3 id="跟-innodb-tuning">跟 InnoDB Tuning</h3>
<p>JSON column 在 InnoDB 是 LONGTEXT 包裝、大 JSON 進 off-page storage（<code>innodb_default_row_format=DYNAMIC</code> 才行、Antelope format 不支援）。Buffer pool 對 LONGTEXT 較不友善、大 JSON workload 可能要更大 buffer pool。詳見 <a href="/blog/backend/01-database/vendors/mysql/innodb-tuning/" data-link-title="MySQL InnoDB Tuning：為什麼一個 100 GB DB 在 64 GB RAM server 上 query 慢 5 倍" data-link-desc="InnoDB 是 MySQL 預設 storage engine、預設值給 256 MB buffer pool（早期 default）。本文從一個常見痛點開場（DB &gt; RAM 但 server 仍 swap）、走 4 個 critical knob（buffer pool / redo log / flush method / IO capacity）、各自如何影響讀寫吞吐、配置 step-by-step、5 production 踩雷（buffer pool warm-up / log file 大小 / 設 sync_binlog=0 換速度 / IO scheduler / undo log 膨脹）、跟 SSD / NVMe / EBS 的 IO 假設">InnoDB Tuning</a>。</p>
<h3 id="跟-query-optimization">跟 Query Optimization</h3>
<p>8.0 新 hash join + lateral derived 讓 <em>EXPLAIN ANALYZE</em> 結果更複雜。優化複雜 query 需要熟 <em>新 plan node 類型</em>。詳見 <em>Query Optimization deep dive</em> 篇（待寫）。</p>
<h3 id="跟-online-schema-change">跟 Online Schema Change</h3>
<p>JSON column 跟 generated column 的 schema change 走 gh-ost / pt-osc 沒問題、但 JSON 大表 ALTER 速度比一般 column 慢（每 row 重 serialize）。詳見 <a href="/blog/backend/01-database/vendors/mysql/online-schema-change-tools/" data-link-title="MySQL Online Schema Change：gh-ost 跟 pt-online-schema-change 兩條完全不同的 ghost table 路徑" data-link-desc="MySQL ALTER TABLE 可能鎖整張表，production 需要 online schema change 流程。gh-ost（GitHub）跟 pt-online-schema-change（Percona）都用 ghost table 解決、但底層機制完全不同：pt-osc 用 trigger 同步、gh-ost 用 binlog stream 同步。本文走兩工具機制對照表 → trigger vs binlog 各自取捨 → 配置 step-by-step → 5 production 踩雷（trigger overhead / binlog 延遲 / FK constraint / hot trigger lock / 切換瞬間 deadlock）→ 何時用哪一個">Online Schema Change Tools</a>。</p>
<h3 id="跟-replication">跟 Replication</h3>
<p>Window function / CTE / JSON_TABLE 的 query <em>結果</em> replicate（row-level binlog 紀錄結果）、不 replicate <em>query 本身</em>。所以 replica apply 不會重新跑 window function、效率 OK。詳見 <a href="/blog/backend/01-database/vendors/mysql/replication-topology/" data-link-title="MySQL Replication Topology：async / semi-sync / GTID 不是三選一、是三個 trade-off 軸的疊加" data-link-desc="MySQL replication 不是「選 async 還是 semi-sync」、是 *durability / latency / consistency* 三個 trade-off 軸的疊加；GTID 是跨 mode 的 infrastructure layer、不是第三種 mode。本文走 3 軸取捨模型 → async / semi-sync 行為對比 → GTID 替代 binlog-position 的好處 → 配置 step-by-step → 5 production 踩雷（lag 暴衝 / semi-sync 退回 async / GTID gap / Loss-Less semi-sync 真的 loss-less / chained replication 雪崩）→ 跟 Aurora MySQL / Vitess / ProxySQL / Orchestrator 整合">Replication Topology</a>。</p>
<h2 id="何時-sql-特性是-mysql-選型-driver">何時 SQL 特性是 MySQL 選型 driver</h2>
<ul>
<li><strong>想要 SQL standard 對齊跨 vendor portable</strong>：MySQL 8.0 JSON_TABLE / window 都對齊 standard、PG 部分能力（JSONB operator）是 PG-only、portability MySQL 略好</li>
<li><strong>JSON workload &lt; 20% query</strong>：MySQL 8.0 + generated column 夠用、不必為 JSON 換 PG</li>
<li><strong>JSON workload &gt; 50% query + 複雜 path / aggregation</strong>：PG JSONB GIN 仍 winner、考慮 PG 或 MongoDB</li>
<li><strong>需要 materialized view / FDW / partial index</strong>：PG 仍領先、不要因為 SQL feature parity 假設 MySQL 全 cover</li>
<li><strong>既有 MySQL 投資 + SQL 工程深度上升</strong>：升 8.0 + 訓練團隊用新特性、不是換 vendor</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/mysql/" data-link-title="MySQL" data-link-desc="高併發網路服務常用關聯式資料庫、Vitess / PlanetScale 分片生態、GitHub / Shopify / Facebook 規模驗證">MySQL vendor overview</a></li>
<li><a href="/blog/backend/01-database/vendors/mysql/innodb-tuning/" data-link-title="MySQL InnoDB Tuning：為什麼一個 100 GB DB 在 64 GB RAM server 上 query 慢 5 倍" data-link-desc="InnoDB 是 MySQL 預設 storage engine、預設值給 256 MB buffer pool（早期 default）。本文從一個常見痛點開場（DB &gt; RAM 但 server 仍 swap）、走 4 個 critical knob（buffer pool / redo log / flush method / IO capacity）、各自如何影響讀寫吞吐、配置 step-by-step、5 production 踩雷（buffer pool warm-up / log file 大小 / 設 sync_binlog=0 換速度 / IO scheduler / undo log 膨脹）、跟 SSD / NVMe / EBS 的 IO 假設">InnoDB Tuning</a>（JSON column 對 buffer pool 影響）</li>
<li><a href="/blog/backend/01-database/vendors/mysql/online-schema-change-tools/" data-link-title="MySQL Online Schema Change：gh-ost 跟 pt-online-schema-change 兩條完全不同的 ghost table 路徑" data-link-desc="MySQL ALTER TABLE 可能鎖整張表，production 需要 online schema change 流程。gh-ost（GitHub）跟 pt-online-schema-change（Percona）都用 ghost table 解決、但底層機制完全不同：pt-osc 用 trigger 同步、gh-ost 用 binlog stream 同步。本文走兩工具機制對照表 → trigger vs binlog 各自取捨 → 配置 step-by-step → 5 production 踩雷（trigger overhead / binlog 延遲 / FK constraint / hot trigger lock / 切換瞬間 deadlock）→ 何時用哪一個">Online Schema Change Tools</a>（JSON column 大表 ALTER）</li>
<li><a href="/blog/backend/01-database/vendors/mysql/replication-topology/" data-link-title="MySQL Replication Topology：async / semi-sync / GTID 不是三選一、是三個 trade-off 軸的疊加" data-link-desc="MySQL replication 不是「選 async 還是 semi-sync」、是 *durability / latency / consistency* 三個 trade-off 軸的疊加；GTID 是跨 mode 的 infrastructure layer、不是第三種 mode。本文走 3 軸取捨模型 → async / semi-sync 行為對比 → GTID 替代 binlog-position 的好處 → 配置 step-by-step → 5 production 踩雷（lag 暴衝 / semi-sync 退回 async / GTID gap / Loss-Less semi-sync 真的 loss-less / chained replication 雪崩）→ 跟 Aurora MySQL / Vitess / ProxySQL / Orchestrator 整合">Replication Topology</a>（ROW-format binlog 對 window function）</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/sql-features-baseline/" data-link-title="PostgreSQL SQL Features：PG 早就有的、MySQL 8.0 才補的、PG 仍領先的" data-link-desc="PG 在 SQL features 上長期領先 MySQL — CTE / window function / lateral / partial index / FTS / JSONB / GIN index / materialized view 在 PG 早 5-15 年。MySQL 8.0（2018）補多數但 *index / storage / extension* 層仍是 PG 結構優勢。本文整理 PG 早期就有的特性、MySQL 8.0 補的差異、PG 仍領先的、跟 MySQL modern-sql-features sibling 反向視角">PostgreSQL SQL Features Baseline</a>（PG 反向視角、哪些特性 PG 早 5-15 年、MySQL 8.0 補齊後 PG 仍領先）</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/jsonb-deep-dive/" data-link-title="PostgreSQL JSONB Deep Dive：Binary Storage &#43; GIN Index 為什麼是結構性優勢" data-link-desc="PG JSONB（9.4&#43;）是 *binary 儲存的 JSON*、可直接 GIN index、是 PG 在 JSON workload 的結構性優勢、跟 MongoDB / MySQL 8.0 JSON_TABLE 比仍領先。本文走 JSON vs JSONB 差異、GIN index 機制（jsonb_ops vs jsonb_path_ops）、operator &#43; path query、partial JSONB indexing、5 production 踩雷（大 JSONB 跟 TOAST / nested update / index 選錯 op class / jsonb_path_query 跟 jsonb_path_exists 行為差 / partial index 條件搞錯）、何時用 JSONB vs 拆 column">PostgreSQL JSONB Deep Dive</a>（PG sibling、binary storage + GIN index 跟 MySQL JSON_TABLE 對比）</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 vendor page</a>（JSON / SQL feature 對比 source）</li>
<li><a href="/blog/backend/01-database/vendors/mongodb/" data-link-title="MongoDB" data-link-desc="Document database 代表、Atlas managed、跨雲可用、許多大規模平台從 MongoDB 起家">MongoDB vendor page</a>（document-heavy workload 替代）</li>
<li>官方：<a href="https://dev.mysql.com/doc/refman/8.0/en/mysql-nutshell.html">MySQL 8.0 What&rsquo;s New</a></li>
</ul>
]]></content:encoded></item><item><title>PostgreSQL SQL Features：PG 早就有的、MySQL 8.0 才補的、PG 仍領先的</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/sql-features-baseline/</link><pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/sql-features-baseline/</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>SQL features baseline&lt;/em> — PG 早期就有的、MySQL 8.0 才補的、PG 仍領先的、給從 MySQL 評估 PG 的讀者 reference。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="pg-sql-工程深度的歷史錨點">PG SQL 工程深度的歷史錨點&lt;/h2>
&lt;p>PG 在 SQL feature 上長期領先 MySQL：&lt;/p>
&lt;ul>
&lt;li>2009 (PG 8.4)：CTE / window function / recursive query&lt;/li>
&lt;li>2013 (PG 9.3)：lateral derived table / materialized view&lt;/li>
&lt;li>2014 (PG 9.4)：JSONB / partial index 早就有 / GIN index&lt;/li>
&lt;li>2015 (PG 9.5)：UPSERT (&lt;code>ON CONFLICT&lt;/code>)&lt;/li>
&lt;li>2017 (PG 10)：declarative partitioning / logical replication / multi-column statistics&lt;/li>
&lt;/ul>
&lt;p>MySQL 8.0（2018）才補 CTE / window / lateral / JSON_TABLE / hash join — &lt;em>PG 早 9 年起步&lt;/em>。&lt;/p>
&lt;p>對 &lt;em>從 MySQL 評估 PG&lt;/em> 的讀者來說、PG 的 SQL 工程深度不只是「該有的都有」、更多是「PG 結構性領先的特性 + MySQL 8.0 補了哪些 + PG 仍領先哪些」。&lt;/p>
&lt;p>跟 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/modern-sql-features/" data-link-title="MySQL 8.0 Modern SQL：CTE / window function / JSON_TABLE 不是「終於跟上 PG」、是進入 SQL 工程深度的入場券" data-link-desc="MySQL 8.0 在 SQL 特性上 *終於補齊* CTE、window function、lateral derived table、JSON_TABLE、hash join 等現代 SQL 特性。本文走 5 個關鍵特性、各自實際 production 場景、跟 PostgreSQL 對應特性的行為差異（特別是 JSON_TABLE vs PG JSONB / jsonb_path_query）、配置 / migration 注意事項、5 production 踩雷（CTE 不 materialize / window function 大量 sort spill / JSON_TABLE 跟 generated column 取捨 / hash join 預設沒開 / recursive CTE 深度上限）">MySQL Modern SQL Features&lt;/a> 對比視角：&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>SQL features baseline</em> — PG 早期就有的、MySQL 8.0 才補的、PG 仍領先的、給從 MySQL 評估 PG 的讀者 reference。</p></blockquote>
<hr>
<h2 id="pg-sql-工程深度的歷史錨點">PG SQL 工程深度的歷史錨點</h2>
<p>PG 在 SQL feature 上長期領先 MySQL：</p>
<ul>
<li>2009 (PG 8.4)：CTE / window function / recursive query</li>
<li>2013 (PG 9.3)：lateral derived table / materialized view</li>
<li>2014 (PG 9.4)：JSONB / partial index 早就有 / GIN index</li>
<li>2015 (PG 9.5)：UPSERT (<code>ON CONFLICT</code>)</li>
<li>2017 (PG 10)：declarative partitioning / logical replication / multi-column statistics</li>
</ul>
<p>MySQL 8.0（2018）才補 CTE / window / lateral / JSON_TABLE / hash join — <em>PG 早 9 年起步</em>。</p>
<p>對 <em>從 MySQL 評估 PG</em> 的讀者來說、PG 的 SQL 工程深度不只是「該有的都有」、更多是「PG 結構性領先的特性 + MySQL 8.0 補了哪些 + PG 仍領先哪些」。</p>
<p>跟 <a href="/blog/backend/01-database/vendors/mysql/modern-sql-features/" data-link-title="MySQL 8.0 Modern SQL：CTE / window function / JSON_TABLE 不是「終於跟上 PG」、是進入 SQL 工程深度的入場券" data-link-desc="MySQL 8.0 在 SQL 特性上 *終於補齊* CTE、window function、lateral derived table、JSON_TABLE、hash join 等現代 SQL 特性。本文走 5 個關鍵特性、各自實際 production 場景、跟 PostgreSQL 對應特性的行為差異（特別是 JSON_TABLE vs PG JSONB / jsonb_path_query）、配置 / migration 注意事項、5 production 踩雷（CTE 不 materialize / window function 大量 sort spill / JSON_TABLE 跟 generated column 取捨 / hash join 預設沒開 / recursive CTE 深度上限）">MySQL Modern SQL Features</a> 對比視角：</p>
<ul>
<li>MySQL 8.0 視角：「我終於補齊 + 跟 PG 對比」</li>
<li>PG 視角：「我長期領先 + MySQL 8.0 才追上某些、其他我仍領先」</li>
</ul>
<h2 id="pg-結構性領先特性mysql-沒對應--弱對應">PG 結構性領先特性（MySQL 沒對應 / 弱對應）</h2>
<h3 id="1-materialized-view">1. Materialized View</h3>
<p>PG 9.3+ 內建 materialized view：</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="n">MATERIALIZED</span><span class="w"> </span><span class="k">VIEW</span><span class="w"> </span><span class="n">orders_summary</span><span class="w"> </span><span class="k">AS</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">user_id</span><span class="p">,</span><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">AS</span><span class="w"> </span><span class="n">order_count</span><span class="p">,</span><span class="w"> </span><span class="k">SUM</span><span class="p">(</span><span class="n">amount</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">total</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">user_id</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></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="c1">-- 手動 refresh
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span><span class="n">REFRESH</span><span class="w"> </span><span class="n">MATERIALIZED</span><span class="w"> </span><span class="k">VIEW</span><span class="w"> </span><span class="n">orders_summary</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="c1">-- 或 concurrent refresh（PG 9.4+、不 lock read）
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1"></span><span class="n">REFRESH</span><span class="w"> </span><span class="n">MATERIALIZED</span><span class="w"> </span><span class="k">VIEW</span><span class="w"> </span><span class="n">CONCURRENTLY</span><span class="w"> </span><span class="n">orders_summary</span><span class="p">;</span></span></span></code></pre></div><p>用途：</p>
<ul>
<li>預計算複雜 aggregation、查詢時極快</li>
<li>Concurrent refresh 不 lock read</li>
<li>可建 index on materialized view</li>
</ul>
<p><strong>MySQL 對應</strong>：沒原生 materialized view。常見替代：</p>
<ul>
<li>Trigger + summary table（手動維護）</li>
<li>Application 層 caching layer</li>
<li>用 view + cache layer（不是 materialization）</li>
</ul>
<p>MySQL 8.0+ 仍無原生 materialized view。</p>
<h3 id="2-partial-index">2. Partial Index</h3>
<p>PG 預設支援 partial index — 對 <em>滿足條件的 row</em> 才建 index：</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">-- 只對 active user 建 index
</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_users_active_email</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">users</span><span class="p">(</span><span class="n">email</span><span class="p">)</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">status</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;active&#39;</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></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="c1">-- Index size 比 full index 小很多、query 性能跟 full index 一樣
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">users</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">status</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;active&#39;</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="n">email</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;x@y.com&#39;</span><span class="p">;</span></span></span></code></pre></div><p>用途：</p>
<ul>
<li><em>Soft-delete</em> 場景：對 <code>deleted_at IS NULL</code> 建 partial index</li>
<li><em>Hot subset</em> 場景：對 <code>status = 'pending'</code> 等熱資料建 partial</li>
<li>Index 大小 / 寫入成本大降</li>
</ul>
<p><strong>MySQL 對應</strong>：MySQL 沒原生 partial index。MySQL 8.0+ 有 <em>functional index</em> 但跟 partial 不同。MySQL 替代：</p>
<ul>
<li>Generated column + index（接近、但維護複雜）</li>
<li>或接受 full index cost</li>
</ul>
<h3 id="3-foreign-data-wrapper-fdw">3. Foreign Data Wrapper (FDW)</h3>
<p>PG FDW 讓 query 跨外部資料源：</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="n">EXTENSION</span><span class="w"> </span><span class="n">postgres_fdw</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></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w"></span><span class="k">CREATE</span><span class="w"> </span><span class="n">SERVER</span><span class="w"> </span><span class="n">remote_db</span><span class="w"> </span><span class="k">FOREIGN</span><span class="w"> </span><span class="k">DATA</span><span class="w"> </span><span class="n">WRAPPER</span><span class="w"> </span><span class="n">postgres_fdw</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">OPTIONS</span><span class="w"> </span><span class="p">(</span><span class="k">host</span><span class="w"> </span><span class="s1">&#39;remote.example.com&#39;</span><span class="p">,</span><span class="w"> </span><span class="n">dbname</span><span class="w"> </span><span class="s1">&#39;analytics&#39;</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></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">USER</span><span class="w"> </span><span class="n">MAPPING</span><span class="w"> </span><span class="k">FOR</span><span class="w"> </span><span class="n">localuser</span><span class="w"> </span><span class="n">SERVER</span><span class="w"> </span><span class="n">remote_db</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">OPTIONS</span><span class="w"> </span><span class="p">(</span><span class="k">user</span><span class="w"> </span><span class="s1">&#39;remoteuser&#39;</span><span class="p">,</span><span class="w"> </span><span class="n">password</span><span class="w"> </span><span class="s1">&#39;...&#39;</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></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">FOREIGN</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">remote_orders</span><span class="w"> </span><span class="p">(</span><span class="n">id</span><span class="w"> </span><span class="nb">INT</span><span class="p">,</span><span class="w"> </span><span class="p">...)</span><span class="w"> </span><span class="n">SERVER</span><span class="w"> </span><span class="n">remote_db</span><span class="w"> </span><span class="k">OPTIONS</span><span class="w"> </span><span class="p">(</span><span class="k">table_name</span><span class="w"> </span><span class="s1">&#39;orders&#39;</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></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w"></span><span class="c1">-- 在 local PG query remote table
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">remote_orders</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">100</span><span class="p">;</span></span></span></code></pre></div><p>支援 FDW：<code>postgres_fdw</code> / <code>mysql_fdw</code> / <code>oracle_fdw</code> / <code>mongo_fdw</code> / <code>file_fdw</code> / <code>redis_fdw</code> 等。</p>
<p><strong>MySQL 對應</strong>：MySQL 8.0+ 有 FEDERATED engine（受限、不推薦）。實務上 MySQL 跨 DB query 用 application 層處理。</p>
<h3 id="4-jsonb--gin-indexpg-結構性優勢">4. JSONB + GIN Index（PG 結構性優勢）</h3>
<p>PG JSONB 是 <em>binary 儲存</em> + 可 <em>直接 GIN index</em>：</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">products</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">SERIAL</span><span class="w"> </span><span class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</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">metadata</span><span class="w"> </span><span class="n">JSONB</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w"></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></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w"></span><span class="c1">-- GIN index over JSONB
</span></span></span><span class="line"><span class="ln"> 7</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_products_metadata</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">products</span><span class="w"> </span><span class="k">USING</span><span class="w"> </span><span class="n">GIN</span><span class="w"> </span><span class="p">(</span><span class="n">metadata</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></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w"></span><span class="c1">-- 快 query
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">products</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">metadata</span><span class="w"> </span><span class="o">@&gt;</span><span class="w"> </span><span class="s1">&#39;{&#34;category&#34;: &#34;shoes&#34;}&#39;</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="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">products</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">metadata</span><span class="w"> </span><span class="o">@?</span><span class="w"> </span><span class="s1">&#39;$.variants[*].price &gt; 100&#39;</span><span class="p">;</span></span></span></code></pre></div><p><strong>MySQL 對應</strong>：MySQL 8.0 JSON_TABLE 是 SQL standard、但 <em>index 必須 generated column workaround</em>（不能 GIN index over JSON）。</p>
<p>詳見 <a href="/blog/backend/01-database/vendors/mysql/modern-sql-features/" data-link-title="MySQL 8.0 Modern SQL：CTE / window function / JSON_TABLE 不是「終於跟上 PG」、是進入 SQL 工程深度的入場券" data-link-desc="MySQL 8.0 在 SQL 特性上 *終於補齊* CTE、window function、lateral derived table、JSON_TABLE、hash join 等現代 SQL 特性。本文走 5 個關鍵特性、各自實際 production 場景、跟 PostgreSQL 對應特性的行為差異（特別是 JSON_TABLE vs PG JSONB / jsonb_path_query）、配置 / migration 注意事項、5 production 踩雷（CTE 不 materialize / window function 大量 sort spill / JSON_TABLE 跟 generated column 取捨 / hash join 預設沒開 / recursive CTE 深度上限）">MySQL Modern SQL Features</a> JSON_TABLE vs PG JSONB 對比段。</p>
<h3 id="5-range-types--exclusion-constraints">5. Range Types + Exclusion Constraints</h3>
<p>PG range types + exclusion constraints 防止 <em>時間範圍重疊</em>：</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">reservations</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">SERIAL</span><span class="w"> </span><span class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</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">room_id</span><span class="w"> </span><span class="nb">INT</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="n">during</span><span class="w"> </span><span class="n">TSRANGE</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">EXCLUDE</span><span class="w"> </span><span class="k">USING</span><span class="w"> </span><span class="n">GIST</span><span class="w"> </span><span class="p">(</span><span class="n">room_id</span><span class="w"> </span><span class="k">WITH</span><span class="w"> </span><span class="o">=</span><span class="p">,</span><span class="w"> </span><span class="n">during</span><span class="w"> </span><span class="k">WITH</span><span class="w"> </span><span class="o">&amp;&amp;</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></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w"></span><span class="c1">-- INSERT 重疊 booking 自動 reject
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="n">reservations</span><span class="w"> </span><span class="p">(</span><span class="n">room_id</span><span class="p">,</span><span class="w"> </span><span class="n">during</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">VALUES</span><span class="w"> </span><span class="p">(</span><span class="mi">1</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;[2026-05-19 10:00, 2026-05-19 12:00)&#39;</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="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="n">reservations</span><span class="w"> </span><span class="p">(</span><span class="n">room_id</span><span class="p">,</span><span class="w"> </span><span class="n">during</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="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="mi">1</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;[2026-05-19 11:00, 2026-05-19 13:00)&#39;</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="c1">-- ERROR: conflicting key value violates exclusion constraint</span></span></span></code></pre></div><p><strong>MySQL 對應</strong>：完全沒對應、必須 application 層 enforce。</p>
<h3 id="6-check-constraint--domain-type">6. CHECK Constraint + Domain Type</h3>
<p>PG <code>CHECK</code> constraint 真執行（MySQL 8.0 才補）+ user-defined <code>DOMAIN</code>：</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">DOMAIN</span><span class="w"> </span><span class="n">positive_int</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="nb">INT</span><span class="w"> </span><span class="k">CHECK</span><span class="w"> </span><span class="p">(</span><span class="n">VALUE</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="mi">0</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="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">orders</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">id</span><span class="w"> </span><span class="nb">SERIAL</span><span class="w"> </span><span class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</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="n">quantity</span><span class="w"> </span><span class="n">positive_int</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">amount</span><span class="w"> </span><span class="nb">DECIMAL</span><span class="w"> </span><span class="k">CHECK</span><span class="w"> </span><span class="p">(</span><span class="n">amount</span><span class="w"> </span><span class="o">&gt;=</span><span class="w"> </span><span class="mi">0</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></span></code></pre></div><p><strong>MySQL 對應</strong>：8.0+ 有 CHECK constraint enforcement（5.7 可寫但不執行）。沒 user-defined DOMAIN。</p>
<h3 id="7-extension-ecosystem">7. Extension Ecosystem</h3>
<p>PG extension 是 <em>結構優勢</em>：</p>
<ul>
<li><code>pg_partman</code>：自動 partition lifecycle</li>
<li><code>pg_repack</code>：online table rewrite</li>
<li><code>pg_stat_statements</code>：query stats</li>
<li><code>pgvector</code>：vector similarity search</li>
<li><code>pg_cron</code>：scheduled job</li>
<li><code>PostGIS</code>：GIS</li>
<li><code>TimescaleDB</code>：time-series</li>
<li><code>Citus</code>：sharding</li>
</ul>
<p><strong>MySQL 對應</strong>：MySQL plugin 機制有、生態遠遠不如。詳見 <em>PG Extension Ecosystem</em> 篇（待寫）。</p>
<h2 id="mysql-80-補齊的-pg-既有特性">MySQL 8.0 補齊的 PG 既有特性</h2>
<table>
  <thead>
      <tr>
          <th>特性</th>
          <th>PG 推出</th>
          <th>MySQL 推出</th>
          <th>差異後說明</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>CTE</td>
          <td>8.4 (2009)</td>
          <td>8.0 (2018)</td>
          <td>MySQL 補語法、行為 PG 12+ 跟 MySQL 接近</td>
      </tr>
      <tr>
          <td>Window function</td>
          <td>8.4 (2009)</td>
          <td>8.0 (2018)</td>
          <td>兩家都標準、frame spec 細節有差</td>
      </tr>
      <tr>
          <td>Lateral derived table</td>
          <td>9.3 (2013)</td>
          <td>8.0.14 (2019)</td>
          <td>MySQL 後加、planner 不如 PG 成熟</td>
      </tr>
      <tr>
          <td>Hash join</td>
          <td>早就有</td>
          <td>8.0.18 (2019)</td>
          <td>MySQL 受限（equality on indexed column）</td>
      </tr>
      <tr>
          <td>JSON_TABLE</td>
          <td>17 (2024)</td>
          <td>8.0 (2018)</td>
          <td>MySQL 較早、PG 17+ 補進、PG 自己有 JSONB 路線</td>
      </tr>
      <tr>
          <td>CHECK constraint</td>
          <td>早就有</td>
          <td>8.0 (2018)</td>
          <td>MySQL 5.7 可寫但不執行</td>
      </tr>
      <tr>
          <td>Role-based auth</td>
          <td>早就有</td>
          <td>8.0 (2018)</td>
          <td>-</td>
      </tr>
      <tr>
          <td>Atomic DDL</td>
          <td>早就有</td>
          <td>8.0 (2018)</td>
          <td>-</td>
      </tr>
      <tr>
          <td>Common keyword</td>
          <td>完整</td>
          <td>8.0 補</td>
          <td>MySQL 5.7 缺很多 (window/rank/lateral 等)</td>
      </tr>
  </tbody>
</table>
<p>MySQL 8.0 是 <em>補齊 9 年 SQL standard 落後</em>、不是 <em>新領先 PG</em>。</p>
<h2 id="pg-仍領先的特性">PG 仍領先的特性</h2>
<p>對應「MySQL 8.0 補了 → PG 仍沒輸」的視角。以下 14 條中、<em>production 影響最大</em> 的是 Materialized view / Partial index / JSONB GIN / Full-text search 跟 Range / Exclusion constraints（schema-level expressiveness）；<em>次要但常用</em> 的是 Multi-column statistics 跟 Procedural language；<em>非典型但 niche 重要</em> 的是 User-defined DOMAIN / Generic table inheritance（讀者不必然知道、但 ORM 跟 schema migration 工具會用）：</p>
<table>
  <thead>
      <tr>
          <th>PG 領先特性</th>
          <th>MySQL 對應狀態</th>
          <th>補充</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Materialized view</td>
          <td>無原生</td>
          <td>application-side 重算成本高</td>
      </tr>
      <tr>
          <td>Partial index</td>
          <td>無（functional index 不等同）</td>
          <td>對 boolean / status column 救 storage</td>
      </tr>
      <tr>
          <td>FDW</td>
          <td>弱（FEDERATED engine 不推薦）</td>
          <td>跨 DB query escape hatch</td>
      </tr>
      <tr>
          <td>JSONB GIN index</td>
          <td>無（generated column workaround）</td>
          <td>JSON workload 結構性差</td>
      </tr>
      <tr>
          <td>Range types</td>
          <td>無</td>
          <td>booking / availability schema 救命</td>
      </tr>
      <tr>
          <td>Exclusion constraints</td>
          <td>無</td>
          <td>range overlap 防護</td>
      </tr>
      <tr>
          <td>User-defined DOMAIN</td>
          <td>無</td>
          <td>column-level type constraint</td>
      </tr>
      <tr>
          <td>Extension ecosystem</td>
          <td>弱</td>
          <td>pgvector / TimescaleDB / PostGIS</td>
      </tr>
      <tr>
          <td>Full-text search 成熟</td>
          <td>InnoDB FTS 較弱</td>
          <td>tsvector + GIN + pg_trgm 三層</td>
      </tr>
      <tr>
          <td>Multi-column statistics</td>
          <td>8.0 histograms 部分對應、PG 更廣</td>
          <td>planner 更準</td>
      </tr>
      <tr>
          <td>Procedural language</td>
          <td>PL/pgSQL + 多語言（PL/Python / PL/Perl 等）</td>
          <td>Stored procedure（不擴語言）</td>
      </tr>
      <tr>
          <td>Recursive CTE 深度</td>
          <td>Unlimited</td>
          <td>1000（cte_max_recursion_depth）</td>
      </tr>
      <tr>
          <td>LSN-based replication</td>
          <td>簡潔</td>
          <td>binlog file+position（GTID 緩解）</td>
      </tr>
      <tr>
          <td>Generic table inheritance</td>
          <td>早就有</td>
          <td>無（multi-tenant schema 結構用）</td>
      </tr>
  </tbody>
</table>
<h2 id="對從-mysql-評估-pg的讀者">對「從 MySQL 評估 PG」的讀者</h2>
<p>讀者通常從 MySQL 8.0 過來、問題是 <em>「PG 比 MySQL 強在哪、弱在哪」</em>：</p>
<h3 id="pg-比-mysql-強">PG 比 MySQL 強</h3>
<ul>
<li><em>SQL 工程深度</em>：上面列的 7 個結構優勢</li>
<li><em>Extension ecosystem</em>：pgvector / TimescaleDB / Citus / pg_partman 等</li>
<li><em>Optimizer</em>：planner 對複雜 query 更成熟</li>
<li><em>Concurrency model</em>：MVCC + 少 lock（<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>）</li>
</ul>
<h3 id="pg-比-mysql-弱">PG 比 MySQL 弱</h3>
<ul>
<li><em>Replication 機制簡潔度</em>：MySQL GTID 比 PG WAL + replication slot 配置簡單（<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>）</li>
<li><em>Sharding ecosystem</em>：Vitess / PlanetScale 比 Citus 規模驗證高</li>
<li><em>Operational tooling 廣度</em>：pt-toolkit / gh-ost / Orchestrator 等</li>
<li><em>VACUUM 維護</em>：PG MVCC 必須 VACUUM、autovacuum 配錯議題多（<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>）</li>
</ul>
<h3 id="選-pg-的核心-driver">選 PG 的核心 driver</h3>
<p>對 SQL 工程深度、extension、複雜 query / OLAP-style workload 的場景、PG 仍是首選。對純簡單 OLTP + 大規模 sharding、MySQL + Vitess 仍 competitive。</p>
<h2 id="跟其他模組整合">跟其他模組整合</h2>
<ul>
<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>：PG MVCC 是 SQL feature 的並行控制基礎</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>：PG planner 對 window / CTE / hash join 成熟</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/citus-distributed/" data-link-title="PostgreSQL Citus Distributed：用 extension 把 PG 變成 sharded cluster" data-link-desc="Citus 是 PG extension、把單機 PG 變成 *coordinator &#43; worker* sharded cluster、保留 PG SQL &#43; 加 distributed table &#43; reference table &#43; columnar storage。本文走 Citus 架構（coordinator / worker / distribution column）、3 種 table type（distributed / reference / local）、配置 step-by-step、5 production 踩雷（distribution column 選錯 / cross-shard transaction / reference table 過大 / colocate 不對齊 / worker failover）、跟 MySQL Vitess sharding sibling 對比">Citus Distributed</a>：extension 之一、體現 extension 生態</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>：MVCC 代價、跟 SQL feature 並行控制相關</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<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 vendor overview</a></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 對比">PG MVCC + Lock Model</a>（concurrency 基礎）</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 對比">PG Query Optimization</a>（planner 成熟度）</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/citus-distributed/" data-link-title="PostgreSQL Citus Distributed：用 extension 把 PG 變成 sharded cluster" data-link-desc="Citus 是 PG extension、把單機 PG 變成 *coordinator &#43; worker* sharded cluster、保留 PG SQL &#43; 加 distributed table &#43; reference table &#43; columnar storage。本文走 Citus 架構（coordinator / worker / distribution column）、3 種 table type（distributed / reference / local）、配置 step-by-step、5 production 踩雷（distribution column 選錯 / cross-shard transaction / reference table 過大 / colocate 不對齊 / worker failover）、跟 MySQL Vitess sharding sibling 對比">PG Citus Distributed</a>（extension example）</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 整合">PG Autovacuum Tuning</a>（MVCC 維護）</li>
<li><a href="/blog/backend/01-database/vendors/mysql/modern-sql-features/" data-link-title="MySQL 8.0 Modern SQL：CTE / window function / JSON_TABLE 不是「終於跟上 PG」、是進入 SQL 工程深度的入場券" data-link-desc="MySQL 8.0 在 SQL 特性上 *終於補齊* CTE、window function、lateral derived table、JSON_TABLE、hash join 等現代 SQL 特性。本文走 5 個關鍵特性、各自實際 production 場景、跟 PostgreSQL 對應特性的行為差異（特別是 JSON_TABLE vs PG JSONB / jsonb_path_query）、配置 / migration 注意事項、5 production 踩雷（CTE 不 materialize / window function 大量 sort spill / JSON_TABLE 跟 generated column 取捨 / hash join 預設沒開 / recursive CTE 深度上限）">MySQL Modern SQL Features</a>（sibling、反向視角）</li>
<li>官方：<a href="https://www.postgresql.org/about/featurematrix/">PostgreSQL Features</a></li>
</ul>
]]></content:encoded></item><item><title>MySQL Group Replication / InnoDB Cluster：single-primary vs multi-primary mode 對 transaction certification 的影響</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/group-replication/</link><pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/group-replication/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/" data-link-title="MySQL" data-link-desc="高併發網路服務常用關聯式資料庫、Vitess / PlanetScale 分片生態、GitHub / Shopify / Facebook 規模驗證">MySQL&lt;/a> overview 的 implementation-layer deep article。Overview 已說明 MySQL 在 OLTP 譜系的定位、本文聚焦 &lt;em>Group Replication + InnoDB Cluster&lt;/em> — synchronous multi-primary 的 transaction model + 部署模型。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;p>把「Group Replication multi-primary mode」當成「multi-primary 直接線性 scale write」是常見誤解。&lt;/p>
&lt;p>Single-primary 跟 multi-primary 共用同一套 GR 機制（GCE atomic broadcast + certification + applier）— 切換 mode 是 &lt;em>配置變更&lt;/em>。但 &lt;em>性能效果&lt;/em> 經常跟讀者預期不同：在 single-primary cluster 上加開 &lt;code>group_replication_single_primary_mode=OFF&lt;/code>、預期 &lt;em>3 個 instance 都可以接受 write&lt;/em> 帶來吞吐倍增、實際上每個寫入仍要全 cluster GCE broadcast + certification、寫吞吐沒爆增 / latency 飆高 / certification 衝突回退增加。&lt;/p>
&lt;p>這篇 deep article 把 GR 的 &lt;em>certification 流程&lt;/em> 講清楚 — 為什麼「multi-primary」聽起來像「線性 scale」、實際是「保 strong consistency 的 multi-entry」。然後展開 InnoDB Cluster（GR + MySQL Shell + MySQL Router）作為 production deployment 工具。&lt;/p>
&lt;h2 id="group-replication-的-transaction-model">Group Replication 的 transaction model&lt;/h2>
&lt;p>GR 用 &lt;em>Group Communication Engine (GCE)&lt;/em>（Paxos 變種）達成 &lt;em>atomic broadcast&lt;/em> — 任何 write transaction 必須先 broadcast 到所有 member、所有 member 確認 &lt;em>certification pass&lt;/em> 才 commit。&lt;/p>
&lt;p>每個 transaction 的 GR lifecycle：&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">1. Client → Member A: BEGIN; UPDATE ...; COMMIT;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">2. Member A: 先 local execute、收集 write_set（被改的 row + PK + transaction GTID）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">3. Member A: write_set + binlog event → GCE broadcast to all members
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">4. GCE: Paxos consensus、所有 member 收到 broadcast、按 *相同順序*
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">5. Each Member: certification phase — 看 write_set 跟 *尚未 apply 的 incoming transactions* 是否有 PK 衝突
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">6. 若無衝突 → apply 該 transaction（local + remote member 都 apply）、回 client COMMIT OK
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">7. 若衝突 → certification fail、Member A 對 client 回 ERR_LOCK_DEADLOCK / GR_CONFLICT、application 必須 retry&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>核心結論&lt;/strong>：&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/mysql/" data-link-title="MySQL" data-link-desc="高併發網路服務常用關聯式資料庫、Vitess / PlanetScale 分片生態、GitHub / Shopify / Facebook 規模驗證">MySQL</a> overview 的 implementation-layer deep article。Overview 已說明 MySQL 在 OLTP 譜系的定位、本文聚焦 <em>Group Replication + InnoDB Cluster</em> — synchronous multi-primary 的 transaction model + 部署模型。</p></blockquote>
<hr>
<p>把「Group Replication multi-primary mode」當成「multi-primary 直接線性 scale write」是常見誤解。</p>
<p>Single-primary 跟 multi-primary 共用同一套 GR 機制（GCE atomic broadcast + certification + applier）— 切換 mode 是 <em>配置變更</em>。但 <em>性能效果</em> 經常跟讀者預期不同：在 single-primary cluster 上加開 <code>group_replication_single_primary_mode=OFF</code>、預期 <em>3 個 instance 都可以接受 write</em> 帶來吞吐倍增、實際上每個寫入仍要全 cluster GCE broadcast + certification、寫吞吐沒爆增 / latency 飆高 / certification 衝突回退增加。</p>
<p>這篇 deep article 把 GR 的 <em>certification 流程</em> 講清楚 — 為什麼「multi-primary」聽起來像「線性 scale」、實際是「保 strong consistency 的 multi-entry」。然後展開 InnoDB Cluster（GR + MySQL Shell + MySQL Router）作為 production deployment 工具。</p>
<h2 id="group-replication-的-transaction-model">Group Replication 的 transaction model</h2>
<p>GR 用 <em>Group Communication Engine (GCE)</em>（Paxos 變種）達成 <em>atomic broadcast</em> — 任何 write transaction 必須先 broadcast 到所有 member、所有 member 確認 <em>certification pass</em> 才 commit。</p>
<p>每個 transaction 的 GR lifecycle：</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">1. Client → Member A: BEGIN; UPDATE ...; COMMIT;
</span></span><span class="line"><span class="ln">2</span><span class="cl">2. Member A: 先 local execute、收集 write_set（被改的 row + PK + transaction GTID）
</span></span><span class="line"><span class="ln">3</span><span class="cl">3. Member A: write_set + binlog event → GCE broadcast to all members
</span></span><span class="line"><span class="ln">4</span><span class="cl">4. GCE: Paxos consensus、所有 member 收到 broadcast、按 *相同順序*
</span></span><span class="line"><span class="ln">5</span><span class="cl">5. Each Member: certification phase — 看 write_set 跟 *尚未 apply 的 incoming transactions* 是否有 PK 衝突
</span></span><span class="line"><span class="ln">6</span><span class="cl">6. 若無衝突 → apply 該 transaction（local + remote member 都 apply）、回 client COMMIT OK
</span></span><span class="line"><span class="ln">7</span><span class="cl">7. 若衝突 → certification fail、Member A 對 client 回 ERR_LOCK_DEADLOCK / GR_CONFLICT、application 必須 retry</span></span></code></pre></div><p><strong>核心結論</strong>：</p>
<ul>
<li><em>Single-primary mode</em>：只有指定 member 接受 write、其他 member 純 apply、certification 仍跑（但衝突極少、因只有一個寫入源）</li>
<li><em>Multi-primary mode</em>：所有 member 都接受 write、certification 衝突常見、application 必須處理 conflict retry</li>
</ul>
<p><strong>「multi-primary 不會線性 scale write」的原因</strong>：</p>
<ul>
<li>每個 write 仍要全 cluster GCE broadcast + certification</li>
<li>寫吞吐 ceiling 受 <em>最慢 member + 網路延遲</em> 限制（不是「N members × M throughput」）</li>
<li>多寫入源增加 certification 衝突機率、衝突 retry 反而拖 throughput</li>
</ul>
<p><strong>「multi-primary 真實價值」</strong>：</p>
<ul>
<li><em>跨 region multi-active deploy</em>（每個 region local member 接受 local write、無 cross-region write latency）— 但需求極少、多數場景 single-primary + Aurora DSQL / Spanner 更實際</li>
<li><em>零停機 maintenance</em>（任一 member 下線、其他繼續接 write、不必 failover）— 但 single-primary mode 也提供同等 HA</li>
</ul>
<p>對 99% production case：<strong>single-primary mode</strong> 才是正確選擇。Multi-primary 是 <em>特殊 use case 工具</em>、不是 <em>預設 mode</em>。</p>
<h2 id="group-communication-enginegce">Group Communication Engine（GCE）</h2>
<p>GR 內建 GCE、基於 <em>XCom</em> protocol（Paxos 變種）。GCE 責任：</p>
<ul>
<li>Atomic broadcast：保證 message 到所有 member、按相同順序</li>
<li>Group membership：偵測 member join / leave / fail、reconfigure consensus</li>
<li>Network partition handling：minority partition 自動 fence（read-only）、majority 繼續服務</li>
</ul>
<p><strong>GCE 跟 Raft 對比</strong>：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>GR XCom (Paxos-like)</th>
          <th>Raft</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Leader</td>
          <td>沒固定 leader、每個 message 選一個 sender</td>
          <td>固定 leader、其他 follower</td>
      </tr>
      <tr>
          <td>配置複雜度</td>
          <td>高（cluster member 列表 + IP allowlist）</td>
          <td>中（更易理解）</td>
      </tr>
      <tr>
          <td>Member 數量</td>
          <td>預設 3 (max 9)</td>
          <td>預設 3-5</td>
      </tr>
      <tr>
          <td>Performance</td>
          <td>高吞吐、低延遲（不必每次選 leader）</td>
          <td>Leader bottleneck 偶有</td>
      </tr>
      <tr>
          <td>工程實作</td>
          <td>XCom 在 MySQL 內部、不暴露 API</td>
          <td>etcd / Consul / TiKV 等獨立工具</td>
      </tr>
  </tbody>
</table>
<p>GR 的設計取捨：<em>緊耦合 MySQL</em>（不必外部 DCS）、<em>Paxos-like consensus</em>（不像 Raft 那麼簡單但效率更高）。trade-off 是 <em>對 ops 的 transparency 較低</em> — XCom 內部行為對 DBA 是 black box。</p>
<h2 id="innodb-clustergr--mysql-shell--mysql-router">InnoDB Cluster：GR + MySQL Shell + MySQL Router</h2>
<p>純 GR 是 <em>底層 replication mechanism</em>、要組成 production deployment 需要：</p>
<ul>
<li><em>MySQL Shell</em> (<code>mysqlsh</code>)：CLI 工具、提供 <code>dba.createCluster()</code> / <code>cluster.addInstance()</code> 等 cluster 管理 API</li>
<li><em>MySQL Router</em>：connection routing layer、自動發現 cluster topology、寫入 routing 給 primary、讀取 routing replica</li>
<li><em>MySQL Group Replication plugin</em>：在每個 MySQL instance 啟用</li>
</ul>
<p><strong>InnoDB Cluster = GR + Shell + Router</strong>、是 Oracle 推薦的 production GR deployment 方式。</p>
<h3 id="起始部署3-member-single-primary-cluster">起始部署（3 member single-primary cluster）</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># Step 1: 在每個 instance 啟 GR plugin + 配 my.cnf</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="o">[</span>mysqld<span class="o">]</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="nv">server_id</span> <span class="o">=</span> <span class="m">1</span>                          <span class="c1"># 各 instance 不同</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="nv">gtid_mode</span> <span class="o">=</span> ON
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="nv">enforce_gtid_consistency</span> <span class="o">=</span> ON
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="nv">log_bin</span> <span class="o">=</span> mysql-bin
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="nv">binlog_format</span> <span class="o">=</span> ROW
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="nv">master_info_repository</span> <span class="o">=</span> TABLE
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="nv">relay_log_info_repository</span> <span class="o">=</span> TABLE
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="nv">transaction_write_set_extraction</span> <span class="o">=</span> XXHASH64
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="nv">plugin_load_add</span> <span class="o">=</span> <span class="s1">&#39;group_replication.so&#39;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="nv">group_replication_group_name</span> <span class="o">=</span> <span class="s2">&#34;aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee&#34;</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="nv">group_replication_start_on_boot</span> <span class="o">=</span> OFF
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="nv">group_replication_local_address</span> <span class="o">=</span> <span class="s2">&#34;node1.example.com:33061&#34;</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="nv">group_replication_group_seeds</span> <span class="o">=</span> <span class="s2">&#34;node1:33061,node2:33061,node3:33061&#34;</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="nv">group_replication_bootstrap_group</span> <span class="o">=</span> OFF
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="nv">group_replication_single_primary_mode</span> <span class="o">=</span> ON       <span class="c1"># 99% 場景用 ON</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="nv">group_replication_enforce_update_everywhere_checks</span> <span class="o">=</span> OFF
</span></span><span class="line"><span class="ln">20</span><span class="cl">
</span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="c1"># Step 2: 用 MySQL Shell 從第一個 member bootstrap cluster</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">mysqlsh --user<span class="o">=</span>root --host<span class="o">=</span>node1.example.com
</span></span><span class="line"><span class="ln">23</span><span class="cl">&gt; dba.configureInstance<span class="o">(</span><span class="s1">&#39;root@node1:3306&#39;</span><span class="o">)</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">&gt; var <span class="nv">cluster</span> <span class="o">=</span> dba.createCluster<span class="o">(</span><span class="s1">&#39;prodCluster&#39;</span><span class="o">)</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">&gt; cluster.addInstance<span class="o">(</span><span class="s1">&#39;root@node2:3306&#39;</span><span class="o">)</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">&gt; cluster.addInstance<span class="o">(</span><span class="s1">&#39;root@node3:3306&#39;</span><span class="o">)</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">&gt; cluster.status<span class="o">()</span>  <span class="c1"># 應該顯示 3 member、1 PRIMARY + 2 SECONDARY</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">
</span></span><span class="line"><span class="ln">29</span><span class="cl"><span class="c1"># Step 3: 部署 MySQL Router</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">mysqlrouter --bootstrap root@node1:3306 --directory /etc/mysql-router --user<span class="o">=</span>mysqlrouter
</span></span><span class="line"><span class="ln">31</span><span class="cl">systemctl start mysql-router
</span></span><span class="line"><span class="ln">32</span><span class="cl">
</span></span><span class="line"><span class="ln">33</span><span class="cl"><span class="c1"># 完成 — application 連 mysql-router:6446 (R/W) 或 :6447 (R/O)</span></span></span></code></pre></div><p>Application 連 Router、Router 自動發現 cluster topology + 自動 failover routing。Application 不必知道哪個 instance 是 primary。</p>
<h2 id="5-個-production-踩雷">5 個 Production 踩雷</h2>
<h3 id="1-certification-lag--multi-primary-模式-retry-storm">1. Certification lag — Multi-primary 模式 retry storm</h3>
<p>Multi-primary mode 下、3 個 instance 同時收到 <em>相同 row</em> 的 conflicting write、certification 階段必有 N-1 個 transaction 被退回。Application 看到 <code>ER_GR_CONFLICT_TRANSACTION_ABORTED</code>、retry、若不智能 retry（exponential backoff）會 retry storm、整個 cluster 寫吞吐暴降。</p>
<p>修法：</p>
<ul>
<li>99% 場景用 <em>single-primary mode</em>、避開 conflict</li>
<li>真的需要 multi-primary：application 必須 sharding-aware（不同 entry 寫不同 row range）、本質上跟 Vitess sharding 同概念但用 GR 機制</li>
<li>Application retry 用 <em>jitter exponential backoff</em>、不直接 retry</li>
</ul>
<h3 id="2-certification-queue-爆炸--single-primary-mode-仍受-cert-backlog-影響">2. Certification queue 爆炸 — Single-primary mode 仍受 cert backlog 影響</h3>
<p>Single-primary mode 下 primary 接受 write、broadcast 到 secondary。Secondary 跟 primary network latency / 處理速度差時、cert queue 累積。Cert queue 滿 → primary write 也被卡（GR 設計：所有 member 同步前不接受新 write、保 consistency）。</p>
<p>修法：</p>
<ul>
<li>監控 <code>group_replication_member_stats</code> view：<code>COUNT_TRANSACTIONS_IN_QUEUE</code> 持續 &gt; 0 是警訊</li>
<li>提高 <code>group_replication_message_cache_size</code>（預設 1 GB）給 large transaction 緩衝</li>
<li>確認 <em>所有 member 同 instance class</em>、不要混 spec</li>
<li>跨 region GR：完全不推薦（network latency 殺 cert throughput）</li>
</ul>
<h3 id="3-large-transaction--全-cluster-卡住">3. Large transaction — 全 cluster 卡住</h3>
<p>GR 必須把整個 transaction（含所有 write_set）一次 broadcast。10 GB transaction（大批量 UPDATE）必須一次塞滿 GCE buffer、cluster 內所有 member 都暫停接受新 transaction 直到 broadcast / apply 完成。常見場景：批次 archive / 大 backfill / <code>INSERT ... SELECT 1 億 row</code>。</p>
<p>修法：</p>
<ul>
<li><code>group_replication_transaction_size_limit</code>（預設 150 MB）超過直接 reject、不要設 unlimited</li>
<li>大批量寫入拆 chunk（每 chunk &lt; 100 MB）、用 application 層 loop</li>
<li>對 archive / backfill 用 <code>INSERT INTO archive SELECT ... LIMIT 10000</code> chunked、不是一個 transaction</li>
</ul>
<h3 id="4-network-partition--minority-partition-自動-read-only">4. Network partition — Minority partition 自動 read-only</h3>
<p>3 member cluster、network partition 把 1 個 member 隔離。被隔離 member 是 <em>minority</em>、自動進入 <em>read-only mode</em>（不接受 write）、防 split-brain。Application 連到 minority member 寫入會失敗。</p>
<p>修法：</p>
<ul>
<li>MySQL Router 自動發現 cluster topology、自動 route write 到 majority partition primary</li>
<li>Application 必須處理 connection error + retry（甚至 connection string 改成 <em>Router endpoint</em> 而非個別 instance）</li>
<li>監控 <code>group_replication_primary_member</code> UDF、確認哪個是真 primary</li>
</ul>
<h3 id="5-member-加入-catch-up--大量-binlog-阻擋-cluster-service">5. Member 加入 catch-up — 大量 binlog 阻擋 cluster service</h3>
<p>新 member 加入 cluster（new instance / 復原 failed member）必須 <em>catch-up</em> — apply 從 GR cluster start 到當前所有 binlog 才能 join consensus。如果 cluster 已運作 1 個月、binlog 累積 100 GB、catch-up 可能 6-12 小時、catch-up 期間 <em>該 member 不投票、其他 member 仍 service</em>、但 majority 安全邊界縮小（3 → 2 member working）。</p>
<p>修法：</p>
<ul>
<li>
<p>用 <em>MySQL Shell clone plugin</em> 直接 physical-snapshot 一個 existing member、跳過 binlog replay：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">&gt; cluster.addInstance<span class="o">(</span><span class="s1">&#39;root@node4:3306&#39;</span>, <span class="o">{</span>recoveryMethod: <span class="s1">&#39;clone&#39;</span><span class="o">})</span></span></span></code></pre></div></li>
<li>
<p>Clone 期間原 member 暫不接 write traffic（用 Router temporarily 排除）</p>
</li>
<li>
<p>規劃 maintenance window 加 member、不要在 peak load 期間</p>
</li>
</ul>
<h2 id="何時用-gr--innodb-cluster">何時用 GR / InnoDB Cluster</h2>
<table>
  <thead>
      <tr>
          <th>條件</th>
          <th>建議</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>需要 <em>zero-data-loss HA</em>（不容忍任何 binlog gap）</td>
          <td>GR single-primary</td>
      </tr>
      <tr>
          <td>需要 <em>自動 failover 而不必 Orchestrator + fence script</em></td>
          <td>GR / InnoDB Cluster</td>
      </tr>
      <tr>
          <td>需要 <em>跨 region multi-active</em>（且 conflict 可接受 / sharding-aware）</td>
          <td>GR multi-primary</td>
      </tr>
      <tr>
          <td>流量 &lt; 50K WPS、無嚴格 zero-loss 需求</td>
          <td>傳統 Orchestrator + Semi-sync 更簡單</td>
      </tr>
      <tr>
          <td>已用 Aurora / Cloud SQL 等 managed</td>
          <td>不用 GR、用 managed offering</td>
      </tr>
      <tr>
          <td>需要分散式 SQL（跨 region linearizable）</td>
          <td>Spanner / CockroachDB / Aurora DSQL（GR 不解決這個）</td>
      </tr>
  </tbody>
</table>
<h2 id="跟其他模組整合">跟其他模組整合</h2>
<h3 id="跟-replication-topology">跟 Replication topology</h3>
<p>GR 取代傳統 async / semi-sync replication、不是 <em>加在上面</em>。啟用 GR 後不要再配 <code>master-slave</code> style replication。詳見 <a href="/blog/backend/01-database/vendors/mysql/replication-topology/" data-link-title="MySQL Replication Topology：async / semi-sync / GTID 不是三選一、是三個 trade-off 軸的疊加" data-link-desc="MySQL replication 不是「選 async 還是 semi-sync」、是 *durability / latency / consistency* 三個 trade-off 軸的疊加；GTID 是跨 mode 的 infrastructure layer、不是第三種 mode。本文走 3 軸取捨模型 → async / semi-sync 行為對比 → GTID 替代 binlog-position 的好處 → 配置 step-by-step → 5 production 踩雷（lag 暴衝 / semi-sync 退回 async / GTID gap / Loss-Less semi-sync 真的 loss-less / chained replication 雪崩）→ 跟 Aurora MySQL / Vitess / ProxySQL / Orchestrator 整合">Replication Topology</a>。</p>
<h3 id="跟-orchestrator">跟 Orchestrator</h3>
<p>Orchestrator 跟 InnoDB Cluster 不該 <em>同時用</em> — 兩者都會 trigger failover、會打架。GR / InnoDB Cluster 內建 failover、不需要 Orchestrator。詳見 <a href="/blog/backend/01-database/vendors/mysql/orchestrator-failover/" data-link-title="MySQL Orchestrator Failover：HA 工具自己怎麼 HA？raft cluster &#43; GTID-based promotion 的兩段 paradox" data-link-desc="Orchestrator 是 MySQL HA 自動 failover 的 de facto standard、但讀者第一個問題往往是「HA 工具自己會壞嗎」。本文走 Orchestrator 的雙層架構（管 MySQL 的 raft cluster &#43; 被 raft 管的 orchestrator instance）→ topology discovery → failure detection → failover decision tree → promote action → 5 production 踩雷（split-brain 跟 fencing / pre-failover hook 失敗 / anti-flapping window / GTID errant transaction / VIP 跟 ProxySQL 整合斷層）→ 跟 ProxySQL / Patroni / RDS 對比">Orchestrator Failover</a>。</p>
<h3 id="跟-proxysql--mysql-router">跟 ProxySQL / MySQL Router</h3>
<p>ProxySQL 可以連 GR cluster（自動偵測 read_only flag）、但 <em>MySQL Router</em> 是 GR 原生的 routing layer、跟 InnoDB Cluster 緊耦合（透過 MySQL Shell metadata）。</p>
<p>選擇邏輯：</p>
<ul>
<li><em>純 MySQL stack, 想 Oracle-supported 整套</em> → MySQL Router</li>
<li><em>已用 ProxySQL（包含其他非 GR cluster）+ 統一 routing</em> → 仍用 ProxySQL</li>
</ul>
<p>詳見 <a href="/blog/backend/01-database/vendors/mysql/proxysql-config/" data-link-title="MySQL ProxySQL 配置：connection / query / route / response 四段 lifecycle 跟 query rule 設計" data-link-desc="ProxySQL 是 MySQL 生態的 connection pool &#43; query routing 標準。本文走 connection → query parse → route → response 四段 lifecycle、query rule engine 的 rule chain 設計、Hostgroup / Server / User 三層 schema、配置 step-by-step（讀寫分離 &#43; replica lag-aware routing）、5 production 踩雷（query rule 順序錯亂 / connection 漂移 / write 路由到 replica / runtime / disk schema drift / mirror traffic 副作用）、跟 Replication / Orchestrator / HAProxy 整合">ProxySQL 配置</a>。</p>
<h3 id="跟-innodb-tuning">跟 InnoDB Tuning</h3>
<p>GR 對 <code>innodb_flush_log_at_trx_commit</code> / <code>sync_binlog</code> 行為更敏感 — GR 要求 binlog 必須 <em>fsync to disk</em>（<code>sync_binlog=1</code>）保 zero-loss、不能用 <code>sync_binlog=0</code> 換速度。詳見 <a href="/blog/backend/01-database/vendors/mysql/innodb-tuning/" data-link-title="MySQL InnoDB Tuning：為什麼一個 100 GB DB 在 64 GB RAM server 上 query 慢 5 倍" data-link-desc="InnoDB 是 MySQL 預設 storage engine、預設值給 256 MB buffer pool（早期 default）。本文從一個常見痛點開場（DB &gt; RAM 但 server 仍 swap）、走 4 個 critical knob（buffer pool / redo log / flush method / IO capacity）、各自如何影響讀寫吞吐、配置 step-by-step、5 production 踩雷（buffer pool warm-up / log file 大小 / 設 sync_binlog=0 換速度 / IO scheduler / undo log 膨脹）、跟 SSD / NVMe / EBS 的 IO 假設">InnoDB Tuning</a>。</p>
<h3 id="跟-postgresql-patroni-對比">跟 PostgreSQL Patroni 對比</h3>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>InnoDB Cluster</th>
          <th>Patroni + PostgreSQL</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Consensus</td>
          <td>GCE (Paxos-like) 內建</td>
          <td>依賴外部 DCS (etcd / Consul)</td>
      </tr>
      <tr>
          <td>Multi-primary</td>
          <td>支援（但少用）</td>
          <td>不支援（PG single-primary）</td>
      </tr>
      <tr>
          <td>HA tooling</td>
          <td>MySQL Shell + Router 整套</td>
          <td>Patroni + HAProxy + pgBouncer</td>
      </tr>
      <tr>
          <td>Setup 複雜度</td>
          <td>中（MySQL Shell 帶很多 abstraction）</td>
          <td>中（Patroni config + DCS）</td>
      </tr>
      <tr>
          <td>5-year production maturity</td>
          <td>Oracle-backed</td>
          <td>community-driven、廣用</td>
      </tr>
  </tbody>
</table>
<p>兩者角色相同、設計取捨不同。詳見 <a href="/blog/backend/01-database/vendors/postgresql/patroni-ha/" data-link-title="PostgreSQL Patroni HA：從 leader 失聯到 client 重連的 5 段 failover lifecycle" data-link-desc="Patroni 把 PostgreSQL HA 拆成 detection / election / promotion / reconfiguration / recovery 五段 lifecycle、每段都有獨立配置跟 failure mode；DCS quorum &#43; watchdog 防 split-brain、async/sync replication 取捨、5 個 production 踩雷、跟 PgBouncer / HAProxy / cert-manager 整合">PostgreSQL Patroni HA</a>。</p>
<h2 id="容量規劃要點">容量規劃要點</h2>
<table>
  <thead>
      <tr>
          <th>元件</th>
          <th>配置建議</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Member 數量</td>
          <td>3 (預設、容忍 1 failure)、5 (容忍 2 failure)</td>
      </tr>
      <tr>
          <td>Member 間 network latency</td>
          <td>&lt; 5ms（同 region 同 AZ 或跨 AZ）</td>
      </tr>
      <tr>
          <td>Network bandwidth</td>
          <td>至少 1 Gbps、broadcast traffic 重</td>
      </tr>
      <tr>
          <td>Transaction size limit</td>
          <td><code>group_replication_transaction_size_limit=150M</code></td>
      </tr>
      <tr>
          <td>Message cache</td>
          <td><code>group_replication_message_cache_size=1G</code>（預設）+ 看 lag 調</td>
      </tr>
      <tr>
          <td>MySQL Router instance</td>
          <td>至少 2 個（HA）、放 application 同 LB 後</td>
      </tr>
  </tbody>
</table>
<p>Member 跨 region：<em>不推薦</em>。GR 對 latency 敏感、跨 region 50-200ms RTT 嚴重影響 cert throughput。multi-region 需求用 Aurora Global Database / Spanner 等專為跨 region 設計的方案。</p>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/mysql/" data-link-title="MySQL" data-link-desc="高併發網路服務常用關聯式資料庫、Vitess / PlanetScale 分片生態、GitHub / Shopify / Facebook 規模驗證">MySQL vendor overview</a></li>
<li><a href="/blog/backend/01-database/vendors/mysql/replication-topology/" data-link-title="MySQL Replication Topology：async / semi-sync / GTID 不是三選一、是三個 trade-off 軸的疊加" data-link-desc="MySQL replication 不是「選 async 還是 semi-sync」、是 *durability / latency / consistency* 三個 trade-off 軸的疊加；GTID 是跨 mode 的 infrastructure layer、不是第三種 mode。本文走 3 軸取捨模型 → async / semi-sync 行為對比 → GTID 替代 binlog-position 的好處 → 配置 step-by-step → 5 production 踩雷（lag 暴衝 / semi-sync 退回 async / GTID gap / Loss-Less semi-sync 真的 loss-less / chained replication 雪崩）→ 跟 Aurora MySQL / Vitess / ProxySQL / Orchestrator 整合">MySQL Replication Topology</a>（GR 取代傳統 replication）</li>
<li><a href="/blog/backend/01-database/vendors/mysql/orchestrator-failover/" data-link-title="MySQL Orchestrator Failover：HA 工具自己怎麼 HA？raft cluster &#43; GTID-based promotion 的兩段 paradox" data-link-desc="Orchestrator 是 MySQL HA 自動 failover 的 de facto standard、但讀者第一個問題往往是「HA 工具自己會壞嗎」。本文走 Orchestrator 的雙層架構（管 MySQL 的 raft cluster &#43; 被 raft 管的 orchestrator instance）→ topology discovery → failure detection → failover decision tree → promote action → 5 production 踩雷（split-brain 跟 fencing / pre-failover hook 失敗 / anti-flapping window / GTID errant transaction / VIP 跟 ProxySQL 整合斷層）→ 跟 ProxySQL / Patroni / RDS 對比">MySQL Orchestrator Failover</a>（GR / InnoDB Cluster 不必 Orchestrator）</li>
<li><a href="/blog/backend/01-database/vendors/mysql/proxysql-config/" data-link-title="MySQL ProxySQL 配置：connection / query / route / response 四段 lifecycle 跟 query rule 設計" data-link-desc="ProxySQL 是 MySQL 生態的 connection pool &#43; query routing 標準。本文走 connection → query parse → route → response 四段 lifecycle、query rule engine 的 rule chain 設計、Hostgroup / Server / User 三層 schema、配置 step-by-step（讀寫分離 &#43; replica lag-aware routing）、5 production 踩雷（query rule 順序錯亂 / connection 漂移 / write 路由到 replica / runtime / disk schema drift / mirror traffic 副作用）、跟 Replication / Orchestrator / HAProxy 整合">MySQL ProxySQL 配置</a>（routing layer 對比）</li>
<li><a href="/blog/backend/01-database/vendors/mysql/innodb-tuning/" data-link-title="MySQL InnoDB Tuning：為什麼一個 100 GB DB 在 64 GB RAM server 上 query 慢 5 倍" data-link-desc="InnoDB 是 MySQL 預設 storage engine、預設值給 256 MB buffer pool（早期 default）。本文從一個常見痛點開場（DB &gt; RAM 但 server 仍 swap）、走 4 個 critical knob（buffer pool / redo log / flush method / IO capacity）、各自如何影響讀寫吞吐、配置 step-by-step、5 production 踩雷（buffer pool warm-up / log file 大小 / 設 sync_binlog=0 換速度 / IO scheduler / undo log 膨脹）、跟 SSD / NVMe / EBS 的 IO 假設">MySQL InnoDB Tuning</a>（GR durability 需求）</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/bdr-multi-master/" data-link-title="PostgreSQL BDR / Multi-Master：active-active 寫入的 3 種路徑跟 conflict 治理" data-link-desc="PG 預設是 single-primary、active-active 多寫入入口需要 *BDR (EDB)* / *pgEdge* / *Bucardo* 等 extension。本文走 3 種 multi-master 方案對比、conflict detection &#43; resolution model、async vs sync 取捨、配置 step-by-step（pgEdge 為主）、5 production 踩雷（last-write-wins data loss / sequence collision / DDL replication / conflict log 治理 / failover 後 timeline 分歧）、跟 MySQL Group Replication sibling 對比">PostgreSQL BDR / Multi-Master</a>（PG sibling、active-active 寫入 3 種路徑跟 conflict 治理）</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/patroni-ha/" data-link-title="PostgreSQL Patroni HA：從 leader 失聯到 client 重連的 5 段 failover lifecycle" data-link-desc="Patroni 把 PostgreSQL HA 拆成 detection / election / promotion / reconfiguration / recovery 五段 lifecycle、每段都有獨立配置跟 failure mode；DCS quorum &#43; watchdog 防 split-brain、async/sync replication 取捨、5 個 production 踩雷、跟 PgBouncer / HAProxy / cert-manager 整合">PostgreSQL Patroni HA</a>（PG sibling、不同 consensus）</li>
<li><a href="/blog/backend/knowledge-cards/quorum/" data-link-title="Quorum" data-link-desc="分散式系統以多數節點同意作為提交或讀取有效性的門檻">quorum 卡片</a> / <a href="/blog/backend/knowledge-cards/quorum/" data-link-title="Quorum" data-link-desc="分散式系統以多數節點同意作為提交或讀取有效性的門檻">Paxos / Raft 對比</a></li>
<li>官方：<a href="https://dev.mysql.com/doc/refman/8.0/en/group-replication.html">MySQL Group Replication</a> / <a href="https://dev.mysql.com/doc/mysql-shell/8.0/en/mysql-innodb-cluster.html">InnoDB Cluster</a></li>
</ul>
]]></content:encoded></item><item><title>PostgreSQL BDR / Multi-Master：active-active 寫入的 3 種路徑跟 conflict 治理</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/bdr-multi-master/</link><pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/bdr-multi-master/</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>multi-master / active-active replication&lt;/em> — 不是 PG 預設、需要 extension。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="pg-預設沒-multi-master得用-extension">PG 預設沒 multi-master、得用 extension&lt;/h2>
&lt;p>PG core 是 &lt;em>single-primary streaming replication&lt;/em>：&lt;/p>
&lt;ul>
&lt;li>寫入只能進 primary&lt;/li>
&lt;li>Standby 接受 read（hot_standby）但拒絕 write&lt;/li>
&lt;li>Failover 後新 primary 接管、不能多入口&lt;/li>
&lt;/ul>
&lt;p>對需要 &lt;em>active-active&lt;/em>（多 region 各自接受 local write）的場景、PG 提供 3 條 extension 路徑：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>方案&lt;/th>
 &lt;th>來源&lt;/th>
 &lt;th>機制&lt;/th>
 &lt;th>License&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;strong>BDR&lt;/strong>&lt;/td>
 &lt;td>EDB（Enterprise）&lt;/td>
 &lt;td>Logical replication-based、雙向&lt;/td>
 &lt;td>商業（EDB 訂閱）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>pgEdge&lt;/strong>&lt;/td>
 &lt;td>pgEdge Inc.&lt;/td>
 &lt;td>基於 BDR、開源、加 Spock extension&lt;/td>
 &lt;td>開源（Spock）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Bucardo&lt;/strong>&lt;/td>
 &lt;td>community&lt;/td>
 &lt;td>Trigger-based、async、Perl 寫&lt;/td>
 &lt;td>開源（BSD）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>每條路徑有不同 trade-off。對 99% PG production case、&lt;em>不需要 multi-master&lt;/em> — single-primary streaming replication + read replica scaling 已夠。Multi-master 是 &lt;em>特殊需求&lt;/em>（跨 region active-active write / 不可中斷 maintenance）才上。&lt;/p>
&lt;p>跟 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/group-replication/" data-link-title="MySQL Group Replication / InnoDB Cluster：single-primary vs multi-primary mode 對 transaction certification 的影響" data-link-desc="MySQL Group Replication 提供 synchronous multi-primary replication、用 Paxos-like Group Communication Engine（GCE）達成 quorum-based commit。但「multi-primary」不是「single-primary 多開幾個 write 入口」、是 *transaction conflict detection &amp;#43; certification* 整個機制不同。本文走 GR 機制（GCE &amp;#43; certification &amp;#43; applier）、single-primary vs multi-primary mode、InnoDB Cluster 跟 MySQL Shell / Router 整合、5 production 踩雷（cert lag / write conflict / large transaction / network partition / member 加入 catch-up）、何時用 GR 何時用傳統 replication">MySQL Group Replication&lt;/a> 對比：MySQL GR 是 &lt;em>官方內建&lt;/em>（5.7+）、PG 沒對應內建選項。MySQL 用戶 GR / InnoDB Cluster 直接套、PG 用戶要選 extension + license trade-off。&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>multi-master / active-active replication</em> — 不是 PG 預設、需要 extension。</p></blockquote>
<hr>
<h2 id="pg-預設沒-multi-master得用-extension">PG 預設沒 multi-master、得用 extension</h2>
<p>PG core 是 <em>single-primary streaming replication</em>：</p>
<ul>
<li>寫入只能進 primary</li>
<li>Standby 接受 read（hot_standby）但拒絕 write</li>
<li>Failover 後新 primary 接管、不能多入口</li>
</ul>
<p>對需要 <em>active-active</em>（多 region 各自接受 local write）的場景、PG 提供 3 條 extension 路徑：</p>
<table>
  <thead>
      <tr>
          <th>方案</th>
          <th>來源</th>
          <th>機制</th>
          <th>License</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>BDR</strong></td>
          <td>EDB（Enterprise）</td>
          <td>Logical replication-based、雙向</td>
          <td>商業（EDB 訂閱）</td>
      </tr>
      <tr>
          <td><strong>pgEdge</strong></td>
          <td>pgEdge Inc.</td>
          <td>基於 BDR、開源、加 Spock extension</td>
          <td>開源（Spock）</td>
      </tr>
      <tr>
          <td><strong>Bucardo</strong></td>
          <td>community</td>
          <td>Trigger-based、async、Perl 寫</td>
          <td>開源（BSD）</td>
      </tr>
  </tbody>
</table>
<p>每條路徑有不同 trade-off。對 99% PG production case、<em>不需要 multi-master</em> — single-primary streaming replication + read replica scaling 已夠。Multi-master 是 <em>特殊需求</em>（跨 region active-active write / 不可中斷 maintenance）才上。</p>
<p>跟 <a href="/blog/backend/01-database/vendors/mysql/group-replication/" data-link-title="MySQL Group Replication / InnoDB Cluster：single-primary vs multi-primary mode 對 transaction certification 的影響" data-link-desc="MySQL Group Replication 提供 synchronous multi-primary replication、用 Paxos-like Group Communication Engine（GCE）達成 quorum-based commit。但「multi-primary」不是「single-primary 多開幾個 write 入口」、是 *transaction conflict detection &#43; certification* 整個機制不同。本文走 GR 機制（GCE &#43; certification &#43; applier）、single-primary vs multi-primary mode、InnoDB Cluster 跟 MySQL Shell / Router 整合、5 production 踩雷（cert lag / write conflict / large transaction / network partition / member 加入 catch-up）、何時用 GR 何時用傳統 replication">MySQL Group Replication</a> 對比：MySQL GR 是 <em>官方內建</em>（5.7+）、PG 沒對應內建選項。MySQL 用戶 GR / InnoDB Cluster 直接套、PG 用戶要選 extension + license trade-off。</p>
<h2 id="multi-master-三方案對比">Multi-master 三方案對比</h2>
<h3 id="方案-1bdr-edb-postgres-distributed">方案 1：BDR (EDB Postgres Distributed)</h3>
<p>EDB 商業 distributed 方案、跑在 EDB Postgres Advanced Server 或 PG community 上。</p>
<p><strong>特性</strong>：</p>
<ul>
<li>雙向 logical replication、N-way active-active</li>
<li>Built-in conflict detection + resolution（LWW / column-level / user-defined）</li>
<li>Eager（sync）跟 async 兩種 mode</li>
<li>Tightly integrated with EDB tooling</li>
</ul>
<p><strong>Trade-off</strong>：</p>
<ul>
<li>商業 license、EDB 訂閱</li>
<li>對 cross-region multi-master 成熟（北美 enterprise 廣用）</li>
<li>對 <em>新 PG version</em> 通常滯後幾個月</li>
</ul>
<h3 id="方案-2pgedge基於-spock-extension">方案 2：pgEdge（基於 Spock extension）</h3>
<p>pgEdge 開源 multi-master、基於 <em>Spock</em> extension（從 BDR 衍生）：</p>
<p><strong>特性</strong>：</p>
<ul>
<li>開源、可自管</li>
<li>跟 BDR 架構接近、無 license fee</li>
<li>Conflict resolution 用 LWW + column-level</li>
<li>對 <em>edge / 地理分散</em> 場景設計</li>
</ul>
<p><strong>Trade-off</strong>：</p>
<ul>
<li>較新（2023+）、社群驗證度低於 BDR</li>
<li>Conflict resolution policy 比 BDR 簡單</li>
<li>部分 EDB 商業 feature 沒對應</li>
</ul>
<h3 id="方案-3bucardo">方案 3：Bucardo</h3>
<p>PG community async multi-master、Perl 寫、trigger-based：</p>
<p><strong>特性</strong>：</p>
<ul>
<li>完全開源</li>
<li>Trigger-based（不依賴 logical replication）</li>
<li>支援 multi-source replication（fan-in / fan-out）</li>
</ul>
<p><strong>Trade-off</strong>：</p>
<ul>
<li>Async only — <em>higher latency conflict</em></li>
<li>Trigger overhead（影響 primary 寫吞吐）</li>
<li>維護 Perl + tools chain 不普及</li>
<li>對 <em>Sync 一致性</em> 需求不適用</li>
</ul>
<h2 id="multi-master-conflict-model">Multi-Master Conflict Model</h2>
<p>任何 multi-master 方案都要解決 <em>同一 row 兩地同時改</em> 的 conflict：</p>
<h3 id="conflict-來源">Conflict 來源</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">Region A (primary 1)          Region B (primary 2)
</span></span><span class="line"><span class="ln">2</span><span class="cl">UPDATE orders                 UPDATE orders
</span></span><span class="line"><span class="ln">3</span><span class="cl">SET status=&#39;shipped&#39;          SET status=&#39;cancelled&#39;
</span></span><span class="line"><span class="ln">4</span><span class="cl">WHERE id=100                  WHERE id=100
</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></code></pre></div><p>跨 region 兩地各自 commit、replication lag 期間發現 conflict、必須 <em>自動 resolve</em>（不能丟給 application）。</p>
<h3 id="conflict-resolution-strategies">Conflict Resolution Strategies</h3>
<p><strong>1. Last-Write-Wins (LWW)</strong> — 最常見：</p>
<ul>
<li>比較 transaction commit timestamp、晚的贏</li>
<li>簡單但 <em>data loss</em>（前一個 commit 的變更被覆蓋）</li>
<li>需要 <em>clock 同步</em>（NTP）—  clock skew 造成不可預測</li>
</ul>
<p><strong>2. Column-level conflict resolution</strong>：</p>
<ul>
<li>不同 column 各自 LWW（status column 跟 amount column 獨立解）</li>
<li>比 row-level LWW 細、但需 application semantics 配合</li>
</ul>
<p><strong>3. User-defined trigger</strong>：</p>
<ul>
<li>寫 PG function 解 conflict</li>
<li>對 <em>特殊 business logic</em>（如：金額相加、不是覆蓋）有用</li>
<li>維護成本高</li>
</ul>
<p><strong>4. Manual reconciliation</strong>：</p>
<ul>
<li>Conflict 寫進 log table、application / DBA 手動處理</li>
<li>對 <em>無法自動 resolve</em> 場景（如金融）</li>
<li>高 ops cost</li>
</ul>
<p>對 99% case 用 LWW、接受 small data loss、application 設計 <em>idempotent / commutative</em> 操作避免衝突。</p>
<h3 id="conflict-機率取決於-application-pattern">Conflict 機率取決於 application pattern</h3>
<ul>
<li><em>Tenant-isolated</em> application（user_id 各自寫自己的 row）：基本無 conflict</li>
<li><em>Shared counter / inventory</em> application：高 conflict、multi-master 不適合</li>
<li><em>Append-only event log</em>：conflict 低、適合 multi-master</li>
</ul>
<h2 id="配置-step-by-steppgedge-為主">配置 step-by-step（pgEdge 為主）</h2>
<p>pgEdge 開源、最常見的 self-hosted 選擇。</p>
<h3 id="step-1在每個-region-node-裝-pgedge">Step 1：在每個 region node 裝 pgEdge</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># Install pgEdge CLI</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">curl -fsSL https://pgedge-upstream.s3.amazonaws.com/REPO/install.py <span class="p">|</span> python3
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># Setup PG + Spock + pgEdge</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">./pgedge install pg16
</span></span><span class="line"><span class="ln">6</span><span class="cl">./pgedge install spock</span></span></code></pre></div><h3 id="step-2配置每個-node">Step 2：配置每個 node</h3>





<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">-- 在 node1（us-east） 跑
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">spock</span><span class="p">.</span><span class="n">node_create</span><span class="p">(</span><span class="n">node_name</span><span class="w"> </span><span class="p">:</span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;node1&#39;</span><span class="p">,</span><span class="w"> </span><span class="n">dsn</span><span class="w"> </span><span class="p">:</span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;host=node1.example.com port=5432 dbname=production&#39;</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></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="c1">-- 在 node2（eu-west）跑
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">spock</span><span class="p">.</span><span class="n">node_create</span><span class="p">(</span><span class="n">node_name</span><span class="w"> </span><span class="p">:</span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;node2&#39;</span><span class="p">,</span><span class="w"> </span><span class="n">dsn</span><span class="w"> </span><span class="p">:</span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;host=node2.example.com port=5432 dbname=production&#39;</span><span class="p">);</span></span></span></code></pre></div><h3 id="step-3建-replication-set--subscribe">Step 3：建 replication set + subscribe</h3>





<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">-- 在 node1 建 default replication set + 加 tables
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">spock</span><span class="p">.</span><span class="n">repset_add_all_tables</span><span class="p">(</span><span class="s1">&#39;default&#39;</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></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w"></span><span class="c1">-- 在 node1 subscribe node2
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">spock</span><span class="p">.</span><span class="n">sub_create</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">subscription_name</span><span class="w"> </span><span class="p">:</span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;sub_n1_n2&#39;</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">provider_dsn</span><span class="w"> </span><span class="p">:</span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;host=node2.example.com port=5432 dbname=production&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w"></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></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w"></span><span class="c1">-- 在 node2 subscribe node1（雙向）
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">spock</span><span class="p">.</span><span class="n">sub_create</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">subscription_name</span><span class="w"> </span><span class="p">:</span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;sub_n2_n1&#39;</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">provider_dsn</span><span class="w"> </span><span class="p">:</span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;host=node1.example.com port=5432 dbname=production&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w"></span><span class="p">);</span></span></span></code></pre></div><h3 id="step-4設-conflict-resolution">Step 4：設 conflict resolution</h3>





<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">-- 設 LWW（預設）
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">spock</span><span class="p">.</span><span class="n">conflict_resolution_setting_set</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">conflict_type</span><span class="w"> </span><span class="p">:</span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;update_origin_change&#39;</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="n">resolution_setting</span><span class="w"> </span><span class="p">:</span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;apply_remote&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="p">);</span></span></span></code></pre></div><h3 id="step-5驗證">Step 5：驗證</h3>





<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">-- 看 subscription 狀態
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">spock</span><span class="p">.</span><span class="n">subscription</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></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="c1">-- 看 replication lag
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">pg_stat_replication</span><span class="p">;</span></span></span></code></pre></div><h2 id="5-個-production-踩雷">5 個 Production 踩雷</h2>
<h3 id="1-lww-data-loss--application-沒設計-commutative">1. LWW data loss — Application 沒設計 commutative</h3>
<p>LWW 預設、兩 region 同時 UPDATE 同 row → 晚的 commit 贏、早的丟失。Application 看不到「我寫的不見了」、debug 困難。</p>
<p>修法：</p>
<ul>
<li>Application schema 設計 <em>tenant-isolated</em>（user_id 各自寫自己 row）</li>
<li>對 <em>shared counter / inventory</em> 用 <em>commutative operation</em>（INCREMENT not SET）</li>
<li>重要寫入加 <em>audit log</em> — conflict 仍寫到 audit、application 看 audit 知道發生過</li>
<li>真的需要 strict consistency 別用 multi-master、用 single-primary + reader 或 distributed SQL</li>
</ul>
<h3 id="2-sequence-collision--two-region-各自-next-同號">2. Sequence collision — Two region 各自 next 同號</h3>
<p><code>SERIAL</code> / <code>IDENTITY</code> 用 sequence、兩 region 各自 nextval 可能拿到同 number、INSERT 衝突（PK duplicate）。</p>
<p>修法：</p>
<ul>
<li>用 <em>staggered sequence range</em>：node1 用 1-1M、node2 用 1M+1 到 2M（用 <code>setval</code>）</li>
<li>或用 <em>UUID</em>（v4 / v7）作 PK、跨 node 無 collision</li>
<li>或 <em>sequence per-node namespace</em>：<code>CREATE SEQUENCE orders_id_node1 START 1 INCREMENT 2</code>（odd vs even）</li>
</ul>
<h3 id="3-ddl-replication-不自動">3. DDL replication 不自動</h3>
<p>PG logical replication（pgEdge / BDR 基礎）<em>不自動 replicate DDL</em>。每 node <code>CREATE TABLE</code> / <code>ALTER TABLE</code> 必須 <em>分別跑</em>。</p>
<p>修法：</p>
<ul>
<li>用 <em>deployment automation</em>（Ansible / Terraform）對所有 node 同時跑 DDL</li>
<li>pgEdge 提供 <code>spock.replicate_ddl(...)</code> 把 DDL 轉成可 replicate event</li>
<li>BDR Enterprise 有 <em>DDL replication</em>（商業 feature）</li>
<li>DDL 變更前確認 <em>所有 node 都健康</em>、減少 partial state</li>
</ul>
<h3 id="4-conflict-log-治理--log-table-爆滿">4. Conflict log 治理 — Log table 爆滿</h3>
<p>每個 conflict 寫進 <code>spock.conflict_log</code> / <code>bdr.conflict_history</code> 等 table、log 累積 disk 爆。</p>
<p>修法：</p>
<ul>
<li>設 <em>log retention</em>：cron 定期 archive + delete 老 conflict log</li>
<li>監控 conflict rate — 高 conflict rate 是 application 設計問題（不是 ops 問題）</li>
<li>對 <em>strict business</em> conflict 寫進 application-level audit table、不只 system log</li>
</ul>
<h3 id="5-failover-後-timeline-分歧">5. Failover 後 timeline 分歧</h3>
<p>Multi-master 設計上 <em>每 region 是 primary</em>、Region A 掛了 Region B 接管 — 但 Region A 復活後 <em>仍認為自己是 primary</em>。如果 Region A 復活前已有寫入沒 replicate 出去、resolution 跟 LWW 衝突。</p>
<p>修法：</p>
<ul>
<li><em>Fence Region A 復活</em>：物理 fence（network firewall）+ 手動 unfence 流程</li>
<li>用 <em>etcd / Consul</em> 跟 BDR / Spock 整合 leader election（避免 split-brain）</li>
<li>對 cross-region multi-master、必須有 <em>runbook</em> 處理 region 復活流程、不靠自動</li>
</ul>
<h2 id="何時用-multi-master-vs-不用">何時用 multi-master vs 不用</h2>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>建議</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>真正 cross-region active-active write 需求</td>
          <td>BDR / pgEdge</td>
      </tr>
      <tr>
          <td>不可中斷 maintenance（zero downtime upgrade）</td>
          <td>BDR / pgEdge</td>
      </tr>
      <tr>
          <td>高 conflict rate（shared counter / inventory）</td>
          <td>不要 multi-master、用 distributed SQL</td>
      </tr>
      <tr>
          <td>Read scaling 為主、可接受 stale read</td>
          <td>streaming replication + read replica（更簡單）</td>
      </tr>
      <tr>
          <td>Strict consistency 需求</td>
          <td>single-primary + sync replication 或 Aurora DSQL / Spanner</td>
      </tr>
      <tr>
          <td>預算敏感 + 不想養 BDR / pgEdge ops</td>
          <td>不要 multi-master、用 managed distributed SQL</td>
      </tr>
  </tbody>
</table>
<h2 id="跟-mysql-group-replication-對比">跟 MySQL Group Replication 對比</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>PG Multi-Master</th>
          <th>MySQL Group Replication</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>內建？</td>
          <td>否、需 extension</td>
          <td>是、5.7+ 內建</td>
      </tr>
      <tr>
          <td>商業 vs 開源</td>
          <td>BDR 商業 / pgEdge 開源</td>
          <td>Oracle 商業 / community 都行</td>
      </tr>
      <tr>
          <td>Sync mode</td>
          <td>可（BDR eager）</td>
          <td>是（certification-based）</td>
      </tr>
      <tr>
          <td>Conflict resolution</td>
          <td>LWW / column / user-defined</td>
          <td>Certification-based（distributed transaction）</td>
      </tr>
      <tr>
          <td>Production maturity</td>
          <td>BDR 高、pgEdge 中</td>
          <td>高（Oracle 推）</td>
      </tr>
      <tr>
          <td>Use case 比例</td>
          <td>少（PG 多用 single-primary）</td>
          <td>較多（MySQL 推 InnoDB Cluster）</td>
      </tr>
  </tbody>
</table>
<p>MySQL GR 內建 + Oracle 推、PG 沒對應內建。對 multi-master 需求重的 org、MySQL 走 GR 路徑更直接。</p>
<h2 id="跟其他模組整合">跟其他模組整合</h2>
<h3 id="跟-replication-topology">跟 Replication Topology</h3>
<p>Multi-master 是 <em>streaming replication 之上的 logical replication 加雙向</em>、不取代 streaming。Streaming 仍給 standby / failover、multi-master 給 active-active write。詳見 <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>。</p>
<h3 id="跟-logical-replication">跟 Logical Replication</h3>
<p>pgEdge / BDR 都基於 logical replication slot、跟 <a href="/blog/backend/01-database/vendors/postgresql/logical-replication-debezium/" data-link-title="PostgreSQL Logical Replication &#43; Debezium CDC：replication slot × failure × recovery 對照" data-link-desc="PostgreSQL logical replication slot 跟 Debezium CDC 的失效模式對照表：slot lag 撐爆 primary disk / schema change 斷流 / 初始 COPY 鎖表 / zombie slot 不釋放 / replay storm 後 offset reset；publication / subscription / pgoutput 配置、跟 Kafka outbox pattern 整合">Logical Replication + Debezium</a> 共用 PG logical decoding infrastructure、但 <em>配置 + tooling</em> 不同。</p>
<h3 id="跟-mvcc">跟 MVCC</h3>
<p>Multi-master 的 conflict 在 <em>commit 後</em> 偵測（async）、不在 transaction 內。跟單機 MVCC（同 cluster 內 transaction snapshot）不同層。詳見 <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>。</p>
<h2 id="相關連結">相關連結</h2>
<ul>
<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 vendor overview</a></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 整合">PG Replication Topology</a>（streaming + multi-master 共存）</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/logical-replication-debezium/" data-link-title="PostgreSQL Logical Replication &#43; Debezium CDC：replication slot × failure × recovery 對照" data-link-desc="PostgreSQL logical replication slot 跟 Debezium CDC 的失效模式對照表：slot lag 撐爆 primary disk / schema change 斷流 / 初始 COPY 鎖表 / zombie slot 不釋放 / replay storm 後 offset reset；publication / subscription / pgoutput 配置、跟 Kafka outbox pattern 整合">PG Logical Replication + Debezium</a>（logical decoding 基礎）</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 對比">PG MVCC + Lock Model</a>（multi-master conflict vs 單機 MVCC）</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/patroni-ha/" data-link-title="PostgreSQL Patroni HA：從 leader 失聯到 client 重連的 5 段 failover lifecycle" data-link-desc="Patroni 把 PostgreSQL HA 拆成 detection / election / promotion / reconfiguration / recovery 五段 lifecycle、每段都有獨立配置跟 failure mode；DCS quorum &#43; watchdog 防 split-brain、async/sync replication 取捨、5 個 production 踩雷、跟 PgBouncer / HAProxy / cert-manager 整合">PG Patroni HA</a>（single-primary HA 替代方案）</li>
<li><a href="/blog/backend/01-database/global-distributed-oltp/" data-link-title="1.11 全球分散式 OLTP" data-link-desc="Spanner / Aurora DSQL / Cosmos DB multi-region write / CockroachDB / TiDB 的全球一致性取捨">1.11 全球分散式 OLTP</a>（multi-master vs distributed SQL）</li>
<li><a href="/blog/backend/01-database/vendors/mysql/group-replication/" data-link-title="MySQL Group Replication / InnoDB Cluster：single-primary vs multi-primary mode 對 transaction certification 的影響" data-link-desc="MySQL Group Replication 提供 synchronous multi-primary replication、用 Paxos-like Group Communication Engine（GCE）達成 quorum-based commit。但「multi-primary」不是「single-primary 多開幾個 write 入口」、是 *transaction conflict detection &#43; certification* 整個機制不同。本文走 GR 機制（GCE &#43; certification &#43; applier）、single-primary vs multi-primary mode、InnoDB Cluster 跟 MySQL Shell / Router 整合、5 production 踩雷（cert lag / write conflict / large transaction / network partition / member 加入 catch-up）、何時用 GR 何時用傳統 replication">MySQL Group Replication</a>（sibling、不同實作）</li>
<li>官方：<a href="https://www.enterprisedb.com/products/edb-postgres-distributed-bdr">EDB BDR</a> / <a href="https://www.pgedge.com/">pgEdge</a> / <a href="https://github.com/pgEdge/spock">Spock GitHub</a> / <a href="https://bucardo.org/">Bucardo</a></li>
</ul>
]]></content:encoded></item><item><title>MySQL Query Optimization：從 EXPLAIN 看到實際執行、5 條 query 從 5 秒變 50ms 的 anatomy</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/query-optimization/</link><pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/query-optimization/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/" data-link-title="MySQL" data-link-desc="高併發網路服務常用關聯式資料庫、Vitess / PlanetScale 分片生態、GitHub / Shopify / Facebook 規模驗證">MySQL&lt;/a> overview 的 implementation-layer deep article。Overview 已說明 MySQL 在 OLTP 譜系的定位、本文聚焦 &lt;em>query optimization&lt;/em> — EXPLAIN / optimizer trace / hint 三層工具跟 5 個實際 case。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="5-個常見-production-case">5 個常見 production case&lt;/h2>
&lt;p>production 上 query 慢、root cause 幾乎都是 &lt;em>optimizer 選錯 plan&lt;/em>。從以下 5 個 case 進入 query optimization：&lt;/p>
&lt;h3 id="case-15-秒--50ms--join-順序選錯">Case 1：5 秒 → 50ms — JOIN 順序選錯&lt;/h3>





&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="c1">-- 慢 (5 秒)：optimizer 選 customers 為 outer table、scan 全 1M row
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">o&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">o&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">amount&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">name&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="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">orders&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">o&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">customers&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">c&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">o&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">customer_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">id&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">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">o&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">created_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;2026-05-01&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">region&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;TW&amp;#39;&lt;/span>&lt;span class="p">;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>EXPLAIN 顯示：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">+----+-------------+-------+------+---------------+--------+
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">| id | select_type | table | type | possible_keys | rows |
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">+----+-------------+-------+------+---------------+--------+
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">| 1 | SIMPLE | c | ALL | NULL | 1000000|
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">| 1 | SIMPLE | o | ref | idx_cust_id | 100 |
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">+----+-------------+-------+------+---------------+--------+&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>c&lt;/code> table type=ALL（full scan）、rows=1M。問題：&lt;code>customers&lt;/code> 沒在 &lt;code>region&lt;/code> 上的 index、optimizer 預估「region=TW filter 沒效率、就 full scan」、但 region=TW 只佔 10% row（100K row）。&lt;/p>
&lt;p>修法：&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">ALTER&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">customers&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ADD&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">INDEX&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">idx_region&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">region&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="k">ANALYZE&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">customers&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- 更新 statistics&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>加 index 後 optimizer 切 plan：先 scan &lt;code>customers&lt;/code> 用 &lt;code>idx_region&lt;/code> 篩 100K row、再 join &lt;code>orders&lt;/code>。從 5 秒降到 50ms。&lt;/p>
&lt;h3 id="case-230-秒--200ms--range-scan-退化-all">Case 2：30 秒 → 200ms — Range scan 退化 ALL&lt;/h3>





&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">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">events&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="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">created_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BETWEEN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;2026-05-01&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;2026-05-02&amp;#39;&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="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">user_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">12345&lt;/span>&lt;span class="p">;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>events&lt;/code> 有 &lt;code>idx_user_id&lt;/code> 跟 &lt;code>idx_created_at&lt;/code> 兩個 index、optimizer 應該選一個 + 二級 filter、但實際 &lt;code>type=ALL&lt;/code>（full scan）。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/mysql/" data-link-title="MySQL" data-link-desc="高併發網路服務常用關聯式資料庫、Vitess / PlanetScale 分片生態、GitHub / Shopify / Facebook 規模驗證">MySQL</a> overview 的 implementation-layer deep article。Overview 已說明 MySQL 在 OLTP 譜系的定位、本文聚焦 <em>query optimization</em> — EXPLAIN / optimizer trace / hint 三層工具跟 5 個實際 case。</p></blockquote>
<hr>
<h2 id="5-個常見-production-case">5 個常見 production case</h2>
<p>production 上 query 慢、root cause 幾乎都是 <em>optimizer 選錯 plan</em>。從以下 5 個 case 進入 query optimization：</p>
<h3 id="case-15-秒--50ms--join-順序選錯">Case 1：5 秒 → 50ms — JOIN 順序選錯</h3>





<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">-- 慢 (5 秒)：optimizer 選 customers 為 outer table、scan 全 1M row
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="n">amount</span><span class="p">,</span><span class="w"> </span><span class="k">c</span><span class="p">.</span><span class="n">name</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="n">o</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="n">customers</span><span class="w"> </span><span class="k">c</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="n">customer_id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">c</span><span class="p">.</span><span class="n">id</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">WHERE</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="n">created_at</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="s1">&#39;2026-05-01&#39;</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="k">c</span><span class="p">.</span><span class="n">region</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;TW&#39;</span><span class="p">;</span></span></span></code></pre></div><p>EXPLAIN 顯示：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">+----+-------------+-------+------+---------------+--------+
</span></span><span class="line"><span class="ln">2</span><span class="cl">| id | select_type | table | type | possible_keys | rows   |
</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">|  1 | SIMPLE      | c     | ALL  | NULL          | 1000000|
</span></span><span class="line"><span class="ln">5</span><span class="cl">|  1 | SIMPLE      | o     | ref  | idx_cust_id   | 100    |
</span></span><span class="line"><span class="ln">6</span><span class="cl">+----+-------------+-------+------+---------------+--------+</span></span></code></pre></div><p><code>c</code> table type=ALL（full scan）、rows=1M。問題：<code>customers</code> 沒在 <code>region</code> 上的 index、optimizer 預估「region=TW filter 沒效率、就 full scan」、但 region=TW 只佔 10% row（100K row）。</p>
<p>修法：</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">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">customers</span><span class="w"> </span><span class="k">ADD</span><span class="w"> </span><span class="k">INDEX</span><span class="w"> </span><span class="n">idx_region</span><span class="w"> </span><span class="p">(</span><span class="n">region</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="k">ANALYZE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">customers</span><span class="p">;</span><span class="w">  </span><span class="c1">-- 更新 statistics</span></span></span></code></pre></div><p>加 index 後 optimizer 切 plan：先 scan <code>customers</code> 用 <code>idx_region</code> 篩 100K row、再 join <code>orders</code>。從 5 秒降到 50ms。</p>
<h3 id="case-230-秒--200ms--range-scan-退化-all">Case 2：30 秒 → 200ms — Range scan 退化 ALL</h3>





<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">SELECT</span><span class="w"> </span><span class="o">*</span><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">2</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="n">created_at</span><span class="w"> </span><span class="k">BETWEEN</span><span class="w"> </span><span class="s1">&#39;2026-05-01&#39;</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="s1">&#39;2026-05-02&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">AND</span><span class="w"> </span><span class="n">user_id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">12345</span><span class="p">;</span></span></span></code></pre></div><p><code>events</code> 有 <code>idx_user_id</code> 跟 <code>idx_created_at</code> 兩個 index、optimizer 應該選一個 + 二級 filter、但實際 <code>type=ALL</code>（full scan）。</p>
<p>EXPLAIN ANALYZE 顯示：</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">-&gt; Filter: ((events.user_id = 12345) and (events.created_at between ...))  (cost=2M rows=100)
</span></span><span class="line"><span class="ln">2</span><span class="cl">    -&gt; Table scan on events  (cost=2M rows=10000000)  (actual time=0.1..30s ...)</span></span></code></pre></div><p>問題：optimizer estimated rows=100、實際 <em>cardinality estimation</em> 失準（distribution skew）、選了 ALL。</p>
<p>修法：</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">-- 用 composite index 直接 cover 兩個條件
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">ALTER</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="k">ADD</span><span class="w"> </span><span class="k">INDEX</span><span class="w"> </span><span class="n">idx_user_created</span><span class="w"> </span><span class="p">(</span><span class="n">user_id</span><span class="p">,</span><span class="w"> </span><span class="n">created_at</span><span class="p">);</span></span></span></code></pre></div><p>Composite index 讓 optimizer 看到 <em>單一 index 直接 satisfy 兩個 predicate</em>、走 range scan + index condition pushdown。30 秒降到 200ms。</p>
<h3 id="case-38-秒--30ms--subquery-沒-unnest">Case 3：8 秒 → 30ms — Subquery 沒 unnest</h3>





<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">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">orders</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="n">customer_id</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">3</span><span class="cl"><span class="w">    </span><span class="k">SELECT</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">customers</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">region</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;TW&#39;</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="n">vip_level</span><span class="w"> </span><span class="o">&gt;=</span><span class="w"> </span><span class="mi">3</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="p">);</span></span></span></code></pre></div><p>5.6 之前 MySQL 把 <code>IN (subquery)</code> 寫成 <em>correlated subquery</em>、外表每 row 都 re-run subquery、極慢。5.6+ 加 subquery unnesting、轉換成 JOIN，但某些情況 unnest 失敗。</p>
<p>EXPLAIN 顯示：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">+----+--------------------+-----------+-------+
</span></span><span class="line"><span class="ln">2</span><span class="cl">| id | select_type        | table     | type  |
</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">|  1 | PRIMARY            | orders    | ALL   |
</span></span><span class="line"><span class="ln">5</span><span class="cl">|  2 | DEPENDENT SUBQUERY | customers | unique_subquery |
</span></span><span class="line"><span class="ln">6</span><span class="cl">+----+--------------------+-----------+-------+</span></span></code></pre></div><p><code>DEPENDENT SUBQUERY</code> 是危險訊號。修法：</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">-- 手動改寫成 JOIN
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="n">o</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">JOIN</span><span class="w"> </span><span class="n">customers</span><span class="w"> </span><span class="k">c</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="n">customer_id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">c</span><span class="p">.</span><span class="n">id</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">WHERE</span><span class="w"> </span><span class="k">c</span><span class="p">.</span><span class="n">region</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;TW&#39;</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="k">c</span><span class="p">.</span><span class="n">vip_level</span><span class="w"> </span><span class="o">&gt;=</span><span class="w"> </span><span class="mi">3</span><span class="p">;</span></span></span></code></pre></div><p>或用 <code>EXISTS</code>（部分 case 比 <code>IN</code> plan 好）：</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">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="n">o</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="k">EXISTS</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="k">SELECT</span><span class="w"> </span><span class="mi">1</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">customers</span><span class="w"> </span><span class="k">c</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">WHERE</span><span class="w"> </span><span class="k">c</span><span class="p">.</span><span class="n">id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="n">customer_id</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="k">c</span><span class="p">.</span><span class="n">region</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;TW&#39;</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="k">c</span><span class="p">.</span><span class="n">vip_level</span><span class="w"> </span><span class="o">&gt;=</span><span class="w"> </span><span class="mi">3</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="p">);</span></span></span></code></pre></div><p>不同寫法 plan 差異需用 EXPLAIN 驗證、不能假設「JOIN 一定比 IN 快」。</p>
<h3 id="case-42-秒--100ms--derived-table-沒-materialize">Case 4：2 秒 → 100ms — Derived table 沒 materialize</h3>





<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">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="n">o</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">JOIN</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="k">SELECT</span><span class="w"> </span><span class="n">customer_id</span><span class="p">,</span><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">AS</span><span class="w"> </span><span class="n">order_count</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">FROM</span><span class="w"> </span><span class="n">orders</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">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">customer_id</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 class="k">AS</span><span class="w"> </span><span class="n">counts</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="n">customer_id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">counts</span><span class="p">.</span><span class="n">customer_id</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">WHERE</span><span class="w"> </span><span class="n">counts</span><span class="p">.</span><span class="n">order_count</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="mi">10</span><span class="p">;</span></span></span></code></pre></div><p>5.6 之前 derived table（FROM subquery）每次 query 都 re-run、慢。5.7+ 有 <em>derived table materialization</em>、但 optimizer 有時不觸發。</p>
<p>EXPLAIN 顯示：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">+----+-------------+-------+------+
</span></span><span class="line"><span class="ln">2</span><span class="cl">| id | select_type | table | type |
</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">|  1 | PRIMARY     | o     | ALL  |
</span></span><span class="line"><span class="ln">5</span><span class="cl">|  2 | DERIVED     | orders| ALL  |  -- 沒 materialize、每次 join 都跑
</span></span><span class="line"><span class="ln">6</span><span class="cl">+----+-------------+-------+------+</span></span></code></pre></div><p>修法：</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">-- 顯式用 CTE + 改寫
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">WITH</span><span class="w"> </span><span class="n">counts</span><span class="w"> </span><span class="k">AS</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="k">SELECT</span><span class="w"> </span><span class="n">customer_id</span><span class="p">,</span><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">AS</span><span class="w"> </span><span class="n">order_count</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">FROM</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">customer_id</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></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="k">SELECT</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="n">o</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">JOIN</span><span class="w"> </span><span class="n">counts</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="n">customer_id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">counts</span><span class="p">.</span><span class="n">customer_id</span><span class="w">
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="n">counts</span><span class="p">.</span><span class="n">order_count</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="mi">10</span><span class="p">;</span></span></span></code></pre></div><p>但記得 MySQL CTE 也不 materialize 預設、可能要 <em>temporary table</em> 才強制 cache：</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">TEMPORARY</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">counts</span><span class="w"> </span><span class="k">AS</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">customer_id</span><span class="p">,</span><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">AS</span><span class="w"> </span><span class="n">order_count</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">customer_id</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="k">SELECT</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="n">o</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="n">counts</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="n">customer_id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">counts</span><span class="p">.</span><span class="n">customer_id</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">WHERE</span><span class="w"> </span><span class="n">counts</span><span class="p">.</span><span class="n">order_count</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="mi">10</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">DROP</span><span class="w"> </span><span class="k">TEMPORARY</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">counts</span><span class="p">;</span></span></span></code></pre></div><h3 id="case-510-秒--100ms--optimizer-選-index-不對">Case 5：10 秒 → 100ms — Optimizer 選 index 不對</h3>





<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">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">users</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">age</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="mi">30</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="n">active</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">1</span><span class="p">;</span></span></span></code></pre></div><p><code>users</code> 有 <code>idx_active</code> (selectivity 高) 跟 <code>idx_age</code> (selectivity 低)。Optimizer 選 <code>idx_age</code>、scan 60% rows、慢。</p>
<p>EXPLAIN：<code>key: idx_age</code> — 但 active=1 filter 後 row 量 &lt; 5%。</p>
<p>修法選一：</p>
<ol>
<li>
<p><strong>Index hint 強制</strong>：</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">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">users</span><span class="w"> </span><span class="n">USE</span><span class="w"> </span><span class="k">INDEX</span><span class="w"> </span><span class="p">(</span><span class="n">idx_active</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="k">WHERE</span><span class="w"> </span><span class="n">age</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="mi">30</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="n">active</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">1</span><span class="p">;</span></span></span></code></pre></div></li>
<li>
<p><strong>Composite index 取代</strong>：</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">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">users</span><span class="w"> </span><span class="k">ADD</span><span class="w"> </span><span class="k">INDEX</span><span class="w"> </span><span class="n">idx_active_age</span><span class="w"> </span><span class="p">(</span><span class="n">active</span><span class="p">,</span><span class="w"> </span><span class="n">age</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="k">DROP</span><span class="w"> </span><span class="k">INDEX</span><span class="w"> </span><span class="n">idx_age</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">users</span><span class="p">;</span></span></span></code></pre></div></li>
<li>
<p><strong>Optimizer hint (8.0+)</strong>：</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">SELECT</span><span class="w"> </span><span class="cm">/*+ INDEX(users idx_active) */</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">users</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="n">age</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="mi">30</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="n">active</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">1</span><span class="p">;</span></span></span></code></pre></div></li>
</ol>
<p>Composite index 是最持久解（不依賴 hint）。Index hint 是 quick fix、但對 future schema change 脆弱。</p>
<h2 id="explain-三層工具">EXPLAIN 三層工具</h2>
<h3 id="tool-1explain--query-plan-preview">Tool 1：EXPLAIN — query plan preview</h3>





<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">EXPLAIN</span><span class="w"> </span><span class="k">SELECT</span><span class="w"> </span><span class="p">...;</span></span></span></code></pre></div><p>輸出每個 step 的 <em>估計</em> cost / row count / key used。<strong>用於 quick check plan 形狀</strong>。</p>
<p>關鍵欄位：</p>
<ul>
<li><code>type</code>：access type（ALL &lt; index &lt; range &lt; ref &lt; eq_ref &lt; const）、ALL / index 是警訊</li>
<li><code>key</code>：實際選的 index、可能跟 <code>possible_keys</code> 不同</li>
<li><code>rows</code>：估計 scan row 數</li>
<li><code>Extra</code>：<code>Using filesort</code> / <code>Using temporary</code> / <code>Using index condition</code> 等行為標記</li>
</ul>
<h3 id="tool-2explain-analyze--實際執行統計">Tool 2：EXPLAIN ANALYZE — 實際執行統計</h3>
<p>8.0+ 加的。差別：實際 run query、回實際 row count / time、跟 estimate 對比。</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">EXPLAIN</span><span class="w"> </span><span class="k">ANALYZE</span><span class="w"> </span><span class="k">SELECT</span><span class="w"> </span><span class="p">...;</span></span></span></code></pre></div><p>輸出格式（tree format）：</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">-&gt; Nested loop inner join  (cost=2.4e6 rows=100000) (actual time=0.05..3.2 rows=10000 loops=1)
</span></span><span class="line"><span class="ln">2</span><span class="cl">    -&gt; Index range scan on orders using idx_created (cost=2.4e6 rows=10000) (actual time=0.04..3.0 rows=10000 loops=1)
</span></span><span class="line"><span class="ln">3</span><span class="cl">    -&gt; Single-row index lookup on customers using PRIMARY (cost=1 rows=1) (actual time=0.0001..0.0001 rows=1 loops=10000)</span></span></code></pre></div><p>關鍵：對比 <code>cost / rows</code>（estimate） vs <code>actual time / rows</code>。如果 estimate=100K / actual=10M、optimizer 嚴重低估、可能選錯 plan。</p>
<h3 id="tool-3optimizer-trace--看-optimizer-為何選這個-plan">Tool 3：Optimizer Trace — 看 optimizer 為何選這個 plan</h3>





<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">SET</span><span class="w"> </span><span class="n">optimizer_trace</span><span class="o">=</span><span class="s1">&#39;enabled=on&#39;</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="k">SELECT</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="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">information_schema</span><span class="p">.</span><span class="n">optimizer_trace</span><span class="p">;</span></span></span></code></pre></div><p>輸出 JSON、列每個 step optimizer 考慮過的 plan + cost estimate + 為什麼選最終 plan。<strong>用於：optimizer 行為跟你預期不符時、debug 為什麼</strong>。</p>
<p>複雜 query 的 optimizer trace 可能 100+ KB、要熟讀 JSON 結構。production debug tool、不是常規 tool。</p>
<h2 id="optimizer-hint-vs-index-hint">Optimizer hint vs Index hint</h2>
<p>兩種 hint、語法不同、行為不同：</p>
<h3 id="index-hint5x-就有">Index hint（5.x 就有）</h3>





<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">SELECT</span><span class="w"> </span><span class="p">...</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="k">table</span><span class="w"> </span><span class="n">USE</span><span class="w"> </span><span class="k">INDEX</span><span class="w"> </span><span class="p">(</span><span class="n">idx_name</span><span class="p">)</span><span class="w"> </span><span class="k">WHERE</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="k">SELECT</span><span class="w"> </span><span class="p">...</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="k">table</span><span class="w"> </span><span class="k">FORCE</span><span class="w"> </span><span class="k">INDEX</span><span class="w"> </span><span class="p">(</span><span class="n">idx_name</span><span class="p">)</span><span class="w"> </span><span class="k">WHERE</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="k">SELECT</span><span class="w"> </span><span class="p">...</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="k">table</span><span class="w"> </span><span class="k">IGNORE</span><span class="w"> </span><span class="k">INDEX</span><span class="w"> </span><span class="p">(</span><span class="n">idx_name</span><span class="p">)</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="p">...;</span></span></span></code></pre></div><ul>
<li><code>USE INDEX</code>：建議 optimizer 用這 index、但 optimizer 仍可拒絕</li>
<li><code>FORCE INDEX</code>：強制用、optimizer 不能拒絕</li>
<li><code>IGNORE INDEX</code>：禁止用</li>
</ul>
<p><strong>問題</strong>：</p>
<ul>
<li>對 table name 寫死、refactor / partition 時容易斷</li>
<li><code>FORCE</code> 太強、可能讓 optimizer 跑得比沒 hint 更慢（forced index 不是最佳 plan）</li>
</ul>
<h3 id="optimizer-hint80">Optimizer hint（8.0+）</h3>





<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">SELECT</span><span class="w"> </span><span class="cm">/*+ INDEX(table_name idx_name) */</span><span class="w"> </span><span class="p">...</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="k">table</span><span class="w"> </span><span class="k">WHERE</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="k">SELECT</span><span class="w"> </span><span class="cm">/*+ JOIN_ORDER(t1, t2, t3) */</span><span class="w"> </span><span class="p">...</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">t1</span><span class="p">,</span><span class="w"> </span><span class="n">t2</span><span class="p">,</span><span class="w"> </span><span class="n">t3</span><span class="w"> </span><span class="k">WHERE</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="k">SELECT</span><span class="w"> </span><span class="cm">/*+ HASH_JOIN(t1 t2) */</span><span class="w"> </span><span class="p">...</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">t1</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="n">t2</span><span class="w"> </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">SELECT</span><span class="w"> </span><span class="cm">/*+ NO_INDEX_MERGE(table) */</span><span class="w"> </span><span class="p">...</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="k">table</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="p">...;</span></span></span></code></pre></div><ul>
<li>更細粒度（join order / join method / index 選擇分開）</li>
<li>注入 query comment 內、不污染 SQL syntax</li>
<li>比 index hint 安全：optimizer 看 hint 但仍走 plan space search</li>
</ul>
<p><strong>推薦</strong>：</p>
<ul>
<li>8.0+ 用 optimizer hint</li>
<li>5.7 仍用 index hint、但謹慎 — 觀察 hint 加上去後 <em>實際 plan</em> 是否真的好</li>
</ul>
<h2 id="5-個-production-踩雷">5 個 Production 踩雷</h2>
<h3 id="1-statistics-過時--optimizer-估錯-row-count">1. Statistics 過時 — optimizer 估錯 row count</h3>
<p><code>information_schema.STATISTICS</code> 紀錄每個 index 的 cardinality。如果 <em>過 1 個月沒 ANALYZE</em>、statistics 跟實際資料 distribution 嚴重偏差、optimizer 估計錯。</p>
<p>修法：</p>
<ul>
<li>定期跑 <code>ANALYZE TABLE</code>（大表改 nightly cron）</li>
<li>8.0+ <code>innodb_stats_auto_recalc=ON</code> 預設、但變更超過 10% row 才觸發</li>
<li>設 <code>innodb_stats_persistent=ON</code>（預設、把 statistics 存 disk）+ <code>innodb_stats_persistent_sample_pages=20</code>（提高 sample 精度）</li>
</ul>
<h3 id="2-forced-index-用錯--hint-比沒-hint-還慢">2. Forced index 用錯 — Hint 比沒 hint 還慢</h3>
<p><code>FORCE INDEX (idx)</code> 強制 optimizer 用、但 <em>idx 不是最佳</em> 時、query 變慢。常見：開發 staging 試出 <code>FORCE INDEX</code> 有效、production 資料 distribution 不同、forced index 反而慢。</p>
<p>修法：</p>
<ul>
<li>用 <code>USE INDEX</code> 而不是 <code>FORCE INDEX</code>（optimizer 仍可換）</li>
<li>不依賴 hint、用 composite index / 重寫 query 達到目的</li>
<li>已用 hint 的 query 進 <em>staging review 機制</em>、確認 plan 仍合理</li>
</ul>
<h3 id="3-hash-join-沒觸發--equality-是-expression">3. Hash join 沒觸發 — Equality 是 expression</h3>





<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">SELECT</span><span class="w"> </span><span class="p">...</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">a</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="n">b</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">a</span><span class="p">.</span><span class="n">id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">b</span><span class="p">.</span><span class="n">parent_id</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="mi">1</span><span class="p">;</span></span></span></code></pre></div><p><code>b.parent_id + 1</code> 是 expression、不是 raw column、optimizer 不選 hash join、用 nested loop。</p>
<p>修法：</p>
<ul>
<li>Schema 改：把 <code>parent_id + 1</code> 變成 <em>generated column</em></li>
<li>Query 改：JOIN 之前 <em>預計算 expression</em> 存 temp table</li>
<li>或 <code>/*+ HASH_JOIN(a b) */</code> 顯式（但 plan 仍可能拒絕）</li>
</ul>
<h3 id="4-range-scan-退化-all--cardinality-估計太低">4. Range scan 退化 ALL — Cardinality 估計太低</h3>





<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">SELECT</span><span class="w"> </span><span class="p">...</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">t</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">col</span><span class="w"> </span><span class="k">IN</span><span class="w"> </span><span class="p">(</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 class="p">...,</span><span class="w"> </span><span class="mi">1000</span><span class="p">);</span></span></span></code></pre></div><p><code>IN</code> 1000 value、optimizer 預估「range scan 太多 lookup、不如 ALL」、選 full table scan。對 <em>中型表</em>（1M row）通常 IN 仍快、但 optimizer 估錯。</p>
<p>修法：</p>
<ul>
<li>
<p><code>IN</code> 拆成 <em>temp table JOIN</em>：</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">TEMPORARY</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">in_values</span><span class="w"> </span><span class="p">(</span><span class="n">val</span><span class="w"> </span><span class="nb">INT</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="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="n">in_values</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="mi">1</span><span class="p">),</span><span class="w"> </span><span class="p">(</span><span class="mi">2</span><span class="p">),</span><span class="w"> </span><span class="p">...,</span><span class="w"> </span><span class="p">(</span><span class="mi">1000</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="k">SELECT</span><span class="w"> </span><span class="n">t</span><span class="p">.</span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">t</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="n">in_values</span><span class="w"> </span><span class="n">iv</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">t</span><span class="p">.</span><span class="n">col</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">iv</span><span class="p">.</span><span class="n">val</span><span class="p">;</span></span></span></code></pre></div></li>
<li>
<p>或 <code>optimizer_switch='index_merge=on'</code>（multi-value IN 可能走 index merge）</p>
</li>
<li>
<p>或大 <code>IN</code> 改 application 層拆批 query</p>
</li>
</ul>
<h3 id="5-derived-table-materialization-off--重複-scan">5. Derived table materialization off — 重複 scan</h3>
<p><code>optimizer_switch='derived_merge=on'</code>（預設 ON、derived table 自動 inline merge）某些 query 反而慢（merge 後 plan 變複雜）。或 <em>反向問題</em>：derived table <em>沒</em> materialize、每次都 re-run。</p>
<p>修法：</p>
<ul>
<li>看 EXPLAIN 是否有 <code>DERIVED</code> row、確認 materialization 行為</li>
<li>可 <code>optimizer_switch='derived_merge=off'</code> 強制 materialize（影響整個 connection、謹慎用）</li>
<li>大 derived table 改 explicit <em>temporary table</em> 完全控制</li>
</ul>
<h2 id="跟-postgresql-explain-對比">跟 PostgreSQL EXPLAIN 對比</h2>
<table>
  <thead>
      <tr>
          <th>工具</th>
          <th>MySQL</th>
          <th>PostgreSQL</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Query plan preview</td>
          <td><code>EXPLAIN</code></td>
          <td><code>EXPLAIN</code></td>
      </tr>
      <tr>
          <td>實際執行統計</td>
          <td><code>EXPLAIN ANALYZE</code> (8.0+)</td>
          <td><code>EXPLAIN ANALYZE</code></td>
      </tr>
      <tr>
          <td>Optimizer 內部 trace</td>
          <td>optimizer_trace (JSON)</td>
          <td><code>auto_explain</code> extension</td>
      </tr>
      <tr>
          <td>Format</td>
          <td>TABLE / JSON / TREE</td>
          <td>TEXT / JSON / XML / YAML</td>
      </tr>
      <tr>
          <td>Parallel query plan</td>
          <td>受限（8.0 限 hash join）</td>
          <td>Full（PG 10+ parallel scan / aggregate / join）</td>
      </tr>
      <tr>
          <td>Index merge</td>
          <td>有</td>
          <td>有 (<code>bitmap index scan</code>)</td>
      </tr>
      <tr>
          <td>Genetic Query Optimizer</td>
          <td>無</td>
          <td>PG 有（適合 &gt; 12 table JOIN）</td>
      </tr>
      <tr>
          <td>Cost estimate accuracy</td>
          <td>中（histograms 8.0+）</td>
          <td>高（成熟 statistics）</td>
      </tr>
  </tbody>
</table>
<p>PG optimizer 整體更成熟、複雜 OLAP-style query plan 更穩定。MySQL 8.0 補了不少（histograms、hash join、derived table merge）、簡單 OLTP query 已 OK、複雜 query 仍弱。</p>
<h2 id="跟其他模組整合">跟其他模組整合</h2>
<h3 id="跟-modern-sql-features">跟 Modern SQL Features</h3>
<p>CTE / window function / lateral / hash join 都改變 query plan space、optimizer 跟著要識別新 pattern。8.0 optimizer 對新 SQL feature plan 仍有改進空間。詳見 <a href="/blog/backend/01-database/vendors/mysql/modern-sql-features/" data-link-title="MySQL 8.0 Modern SQL：CTE / window function / JSON_TABLE 不是「終於跟上 PG」、是進入 SQL 工程深度的入場券" data-link-desc="MySQL 8.0 在 SQL 特性上 *終於補齊* CTE、window function、lateral derived table、JSON_TABLE、hash join 等現代 SQL 特性。本文走 5 個關鍵特性、各自實際 production 場景、跟 PostgreSQL 對應特性的行為差異（特別是 JSON_TABLE vs PG JSONB / jsonb_path_query）、配置 / migration 注意事項、5 production 踩雷（CTE 不 materialize / window function 大量 sort spill / JSON_TABLE 跟 generated column 取捨 / hash join 預設沒開 / recursive CTE 深度上限）">Modern SQL Features</a>。</p>
<h3 id="跟-innodb-tuning">跟 InnoDB Tuning</h3>
<p>Query plan 受 <em>buffer pool hit rate</em> 影響 — optimizer 假設 random IO cost、實際資料在 buffer pool 內讀取快。Buffer pool 不夠時 plan estimate 失真。詳見 <a href="/blog/backend/01-database/vendors/mysql/innodb-tuning/" data-link-title="MySQL InnoDB Tuning：為什麼一個 100 GB DB 在 64 GB RAM server 上 query 慢 5 倍" data-link-desc="InnoDB 是 MySQL 預設 storage engine、預設值給 256 MB buffer pool（早期 default）。本文從一個常見痛點開場（DB &gt; RAM 但 server 仍 swap）、走 4 個 critical knob（buffer pool / redo log / flush method / IO capacity）、各自如何影響讀寫吞吐、配置 step-by-step、5 production 踩雷（buffer pool warm-up / log file 大小 / 設 sync_binlog=0 換速度 / IO scheduler / undo log 膨脹）、跟 SSD / NVMe / EBS 的 IO 假設">InnoDB Tuning</a>。</p>
<h3 id="跟-proxysql">跟 ProxySQL</h3>
<p>ProxySQL query rule 不影響 optimizer plan、但可以 <em>rewrite query</em>（rule engine 的 <code>replace_pattern</code>）— 用於把 application 寫不好的 query 改成 optimizer-friendly 形式、application 不必改。詳見 <a href="/blog/backend/01-database/vendors/mysql/proxysql-config/" data-link-title="MySQL ProxySQL 配置：connection / query / route / response 四段 lifecycle 跟 query rule 設計" data-link-desc="ProxySQL 是 MySQL 生態的 connection pool &#43; query routing 標準。本文走 connection → query parse → route → response 四段 lifecycle、query rule engine 的 rule chain 設計、Hostgroup / Server / User 三層 schema、配置 step-by-step（讀寫分離 &#43; replica lag-aware routing）、5 production 踩雷（query rule 順序錯亂 / connection 漂移 / write 路由到 replica / runtime / disk schema drift / mirror traffic 副作用）、跟 Replication / Orchestrator / HAProxy 整合">ProxySQL 配置</a>。</p>
<h3 id="跟-lock-contention">跟 Lock Contention</h3>
<p>Slow query 持有 lock 久、其他 query wait、整個 cluster lock contention 爆。Query optimization 不只是 latency 問題、也是 <em>lock 影響範圍</em> 問題。詳見 <em>Lock Contention deep dive</em> 篇（待寫）。</p>
<h3 id="跟-partitioning">跟 Partitioning</h3>
<p>Partition pruning 是 optimizer 決定的、<code>EXPLAIN PARTITIONS</code> 看 partition 命中。partition + index 組合可能比 single big table + index 慢（cross-partition query overhead）。詳見 <em>Partitioning</em> 篇（待寫）。</p>
<h2 id="觀測-metric">觀測 metric</h2>
<p>Production 持續 monitor：</p>
<ul>
<li><code>Performance_schema.events_statements_summary_by_digest</code>：每個 query digest 的累計 time / row examined / row sent</li>
<li><code>slow_query_log</code>：slow query 進 log 檔（<code>long_query_time=1</code>）</li>
<li><code>sys.statements_with_full_table_scans</code>：列 query 用 full scan 的歷史</li>
<li><code>sys.schema_unused_indexes</code>：列從未用過的 index、可以 drop 省 write cost</li>
</ul>
<p>把這些丟進 Datadog / Percona Monitoring &amp; Management 做 trend analysis。</p>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/mysql/" data-link-title="MySQL" data-link-desc="高併發網路服務常用關聯式資料庫、Vitess / PlanetScale 分片生態、GitHub / Shopify / Facebook 規模驗證">MySQL vendor overview</a></li>
<li><a href="/blog/backend/01-database/vendors/mysql/modern-sql-features/" data-link-title="MySQL 8.0 Modern SQL：CTE / window function / JSON_TABLE 不是「終於跟上 PG」、是進入 SQL 工程深度的入場券" data-link-desc="MySQL 8.0 在 SQL 特性上 *終於補齊* CTE、window function、lateral derived table、JSON_TABLE、hash join 等現代 SQL 特性。本文走 5 個關鍵特性、各自實際 production 場景、跟 PostgreSQL 對應特性的行為差異（特別是 JSON_TABLE vs PG JSONB / jsonb_path_query）、配置 / migration 注意事項、5 production 踩雷（CTE 不 materialize / window function 大量 sort spill / JSON_TABLE 跟 generated column 取捨 / hash join 預設沒開 / recursive CTE 深度上限）">MySQL Modern SQL Features</a>（hash join / window / CTE 的 plan 議題）</li>
<li><a href="/blog/backend/01-database/vendors/mysql/innodb-tuning/" data-link-title="MySQL InnoDB Tuning：為什麼一個 100 GB DB 在 64 GB RAM server 上 query 慢 5 倍" data-link-desc="InnoDB 是 MySQL 預設 storage engine、預設值給 256 MB buffer pool（早期 default）。本文從一個常見痛點開場（DB &gt; RAM 但 server 仍 swap）、走 4 個 critical knob（buffer pool / redo log / flush method / IO capacity）、各自如何影響讀寫吞吐、配置 step-by-step、5 production 踩雷（buffer pool warm-up / log file 大小 / 設 sync_binlog=0 換速度 / IO scheduler / undo log 膨脹）、跟 SSD / NVMe / EBS 的 IO 假設">MySQL InnoDB Tuning</a>（buffer pool 對 plan estimate）</li>
<li><a href="/blog/backend/01-database/vendors/mysql/proxysql-config/" data-link-title="MySQL ProxySQL 配置：connection / query / route / response 四段 lifecycle 跟 query rule 設計" data-link-desc="ProxySQL 是 MySQL 生態的 connection pool &#43; query routing 標準。本文走 connection → query parse → route → response 四段 lifecycle、query rule engine 的 rule chain 設計、Hostgroup / Server / User 三層 schema、配置 step-by-step（讀寫分離 &#43; replica lag-aware routing）、5 production 踩雷（query rule 順序錯亂 / connection 漂移 / write 路由到 replica / runtime / disk schema drift / mirror traffic 副作用）、跟 Replication / Orchestrator / HAProxy 整合">MySQL ProxySQL 配置</a>（query rewrite 整合）</li>
<li><a href="/blog/backend/01-database/vendors/mysql/online-schema-change-tools/" data-link-title="MySQL Online Schema Change：gh-ost 跟 pt-online-schema-change 兩條完全不同的 ghost table 路徑" data-link-desc="MySQL ALTER TABLE 可能鎖整張表，production 需要 online schema change 流程。gh-ost（GitHub）跟 pt-online-schema-change（Percona）都用 ghost table 解決、但底層機制完全不同：pt-osc 用 trigger 同步、gh-ost 用 binlog stream 同步。本文走兩工具機制對照表 → trigger vs binlog 各自取捨 → 配置 step-by-step → 5 production 踩雷（trigger overhead / binlog 延遲 / FK constraint / hot trigger lock / 切換瞬間 deadlock）→ 何時用哪一個">MySQL Online Schema Change Tools</a>（add index 走 OSC）</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 對比">PostgreSQL Query Optimization</a>（PG sibling、EXPLAIN ANALYZE / pg_hint_plan / auto_explain 三層工具）</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/index-selection/" data-link-title="PostgreSQL Index Selection：B-tree / GIN / GiST / BRIN / Hash 對應 workload 的決策樹" data-link-desc="PG 有 6 種 index method（B-tree / Hash / GIN / GiST / SP-GiST / BRIN）跟 partial / expression / covering 三種變體、不是「都用 B-tree 就好」。每種 index 有自己的 query pattern、儲存代價、write amplification 跟 maintenance 成本。本文走 6 種 index 的適用 workload 對照、決策樹、partial / expression / covering / multi-column 變體、5 production 踩雷（過度 index / partial 條件不對 / B-tree 對 JSON 無效 / BRIN 對非 correlated 資料無效 / multi-column 順序錯）、跟 query-optimization 的 EXPLAIN 互補">PostgreSQL Index Selection</a>（B-tree / GIN / GiST / BRIN 決策樹 vs MySQL B-tree only）</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 vendor page</a>（EXPLAIN ANALYZE 對比）</li>
<li>官方：<a href="https://dev.mysql.com/doc/refman/8.0/en/optimization.html">MySQL Optimization</a> / <a href="https://dev.mysql.com/doc/refman/8.0/en/optimizer-hints.html">Optimizer Hints</a></li>
</ul>
]]></content:encoded></item><item><title>PostgreSQL Query Optimization：EXPLAIN ANALYZE / pg_hint_plan / auto_explain 三層工具跟 4 個 case</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/query-optimization/</link><pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/query-optimization/</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>query optimization&lt;/em> — EXPLAIN ANALYZE / auto_explain / pg_hint_plan 三層工具跟 4 個實際 case。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="4-個常見-production-case">4 個常見 production case&lt;/h2>
&lt;p>PG query 慢的 root cause 多數是 &lt;em>planner 選錯 plan&lt;/em>。從以下 4 個 case 進入 query optimization：&lt;/p>
&lt;h3 id="case-15-秒--50ms--seq-scan-vs-index">Case 1：5 秒 → 50ms — Seq scan vs index&lt;/h3>





&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="c1">-- 慢 (5 秒)
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">o&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">o&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">amount&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">name&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="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">orders&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">o&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">customers&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">c&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">o&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">customer_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">id&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">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">region&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;TW&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">o&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">created_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;2026-05-01&amp;#39;&lt;/span>&lt;span class="p">;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>EXPLAIN (ANALYZE, BUFFERS)&lt;/code>：&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">Hash Join (cost=20000..50000 rows=100 width=...) (actual time=4900..5000 rows=10000)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> -&amp;gt; Seq Scan on customers c (cost=0..20000 rows=1000000 width=...)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> Filter: (region = &amp;#39;TW&amp;#39;)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> Rows Removed by Filter: 900000
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> -&amp;gt; Hash (cost=...)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> -&amp;gt; Index Scan on orders_created_idx&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>問題：&lt;code>customers.region&lt;/code> 沒 index、planner 選 seq scan、實際 region=TW 只 10% row。修法：&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">INDEX&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">CONCURRENTLY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">idx_customers_region&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">customers&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">region&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="k">ANALYZE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">customers&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- 更新 statistics、讓 planner 看到新 index&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>加完 5 秒降 50ms。&lt;/p>
&lt;h3 id="case-230-秒--200ms--hash-join-沒觸發用-nested-loop">Case 2：30 秒 → 200ms — Hash join 沒觸發、用 nested loop&lt;/h3>





&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">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">u&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">name&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">count&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">o&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">id&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="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">users&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">u&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">LEFT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">orders&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">o&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">o&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">user_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">u&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">id&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="k">GROUP&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">u&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">name&lt;/span>&lt;span class="p">;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>EXPLAIN ANALYZE 顯示 &lt;em>Nested Loop&lt;/em> 跑 1M 次 inner loop、執行 30 秒。Planner 估錯 row count、選 nested loop。Hash join 應該 &amp;lt; 200ms。&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>query optimization</em> — EXPLAIN ANALYZE / auto_explain / pg_hint_plan 三層工具跟 4 個實際 case。</p></blockquote>
<hr>
<h2 id="4-個常見-production-case">4 個常見 production case</h2>
<p>PG query 慢的 root cause 多數是 <em>planner 選錯 plan</em>。從以下 4 個 case 進入 query optimization：</p>
<h3 id="case-15-秒--50ms--seq-scan-vs-index">Case 1：5 秒 → 50ms — Seq scan vs index</h3>





<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">-- 慢 (5 秒)
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="n">amount</span><span class="p">,</span><span class="w"> </span><span class="k">c</span><span class="p">.</span><span class="n">name</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="n">o</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="n">customers</span><span class="w"> </span><span class="k">c</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="n">customer_id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">c</span><span class="p">.</span><span class="n">id</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">WHERE</span><span class="w"> </span><span class="k">c</span><span class="p">.</span><span class="n">region</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;TW&#39;</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="n">created_at</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="s1">&#39;2026-05-01&#39;</span><span class="p">;</span></span></span></code></pre></div><p><code>EXPLAIN (ANALYZE, BUFFERS)</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">Hash Join  (cost=20000..50000 rows=100 width=...) (actual time=4900..5000 rows=10000)
</span></span><span class="line"><span class="ln">2</span><span class="cl">  -&gt;  Seq Scan on customers c  (cost=0..20000 rows=1000000 width=...)
</span></span><span class="line"><span class="ln">3</span><span class="cl">      Filter: (region = &#39;TW&#39;)
</span></span><span class="line"><span class="ln">4</span><span class="cl">      Rows Removed by Filter: 900000
</span></span><span class="line"><span class="ln">5</span><span class="cl">  -&gt;  Hash  (cost=...)
</span></span><span class="line"><span class="ln">6</span><span class="cl">      -&gt;  Index Scan on orders_created_idx</span></span></code></pre></div><p>問題：<code>customers.region</code> 沒 index、planner 選 seq scan、實際 region=TW 只 10% row。修法：</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">CONCURRENTLY</span><span class="w"> </span><span class="n">idx_customers_region</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">customers</span><span class="p">(</span><span class="n">region</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="k">ANALYZE</span><span class="w"> </span><span class="n">customers</span><span class="p">;</span><span class="w">  </span><span class="c1">-- 更新 statistics、讓 planner 看到新 index</span></span></span></code></pre></div><p>加完 5 秒降 50ms。</p>
<h3 id="case-230-秒--200ms--hash-join-沒觸發用-nested-loop">Case 2：30 秒 → 200ms — Hash join 沒觸發、用 nested loop</h3>





<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">SELECT</span><span class="w"> </span><span class="n">u</span><span class="p">.</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="n">o</span><span class="p">.</span><span class="n">id</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="k">FROM</span><span class="w"> </span><span class="n">users</span><span class="w"> </span><span class="n">u</span><span class="w"> </span><span class="k">LEFT</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="n">o</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="n">user_id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">u</span><span class="p">.</span><span class="n">id</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</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="n">u</span><span class="p">.</span><span class="n">name</span><span class="p">;</span></span></span></code></pre></div><p>EXPLAIN ANALYZE 顯示 <em>Nested Loop</em> 跑 1M 次 inner loop、執行 30 秒。Planner 估錯 row count、選 nested loop。Hash join 應該 &lt; 200ms。</p>
<p>修法：</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">ANALYZE</span><span class="w"> </span><span class="n">users</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="k">ANALYZE</span><span class="w"> </span><span class="n">orders</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="c1">-- 提高 default_statistics_target 對 critical column
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="k">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="k">ALTER</span><span class="w"> </span><span class="k">COLUMN</span><span class="w"> </span><span class="n">user_id</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="k">STATISTICS</span><span class="w"> </span><span class="mi">1000</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">ANALYZE</span><span class="w"> </span><span class="n">orders</span><span class="p">;</span></span></span></code></pre></div><p>統計精度提升、planner 估 row count 準、自動切 hash join。</p>
<h3 id="case-38-秒--100ms--multi-column-統計缺">Case 3：8 秒 → 100ms — Multi-column 統計缺</h3>





<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">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">status</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;pending&#39;</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="n">region</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;TW&#39;</span><span class="p">;</span></span></span></code></pre></div><p><code>status = 'pending'</code> 5% row、<code>region = 'TW'</code> 10% row。Planner 假設兩 column 獨立、估 0.5% (5K row)。實際 status=&lsquo;pending&rsquo; 跟 region=&lsquo;TW&rsquo; 強相關（TW 訂單多 pending）、實際 4% (40K row)。Planner 估錯 8x、選錯 plan。</p>
<p>修法（PG 10+）：</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">STATISTICS</span><span class="w"> </span><span class="n">stats_orders_status_region</span><span class="w"> </span><span class="p">(</span><span class="n">dependencies</span><span class="p">,</span><span class="w"> </span><span class="n">ndistinct</span><span class="p">,</span><span class="w"> </span><span class="n">mcv</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="k">ON</span><span class="w"> </span><span class="n">status</span><span class="p">,</span><span class="w"> </span><span class="n">region</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">orders</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="k">ANALYZE</span><span class="w"> </span><span class="n">orders</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="c1">-- 之後 planner 知道 status+region 相關度、估準</span></span></span></code></pre></div><h3 id="case-420-秒--5-秒--parallel-query-沒觸發">Case 4：20 秒 → 5 秒 — Parallel query 沒觸發</h3>





<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">SELECT</span><span class="w"> </span><span class="n">region</span><span class="p">,</span><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="n">amount</span><span class="p">)</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">region</span><span class="p">;</span></span></span></code></pre></div><p><code>orders</code> 100M row、預期 PG parallel scan + parallel aggregate、實際 single worker 跑 20 秒。</p>
<p>EXPLAIN：<code>Workers Planned: 0</code>。</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="c1"># postgresql.conf</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="na">max_parallel_workers_per_gather</span> <span class="o">=</span> <span class="s">4</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="na">max_parallel_workers</span> <span class="o">=</span> <span class="s">8</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="na">max_worker_processes</span> <span class="o">=</span> <span class="s">16</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="na">parallel_setup_cost</span> <span class="o">=</span> <span class="s">100        # 預設 1000、降低讓 planner 更敢 parallel</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="na">parallel_tuple_cost</span> <span class="o">=</span> <span class="s">0.01       # 預設 0.1</span></span></span></code></pre></div><p>並行後 5 秒。</p>
<h2 id="explain-三層工具">EXPLAIN 三層工具</h2>
<h3 id="tool-1explain--plan-preview">Tool 1：EXPLAIN — Plan preview</h3>





<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">EXPLAIN</span><span class="w"> </span><span class="k">SELECT</span><span class="w"> </span><span class="p">...;</span></span></span></code></pre></div><p>輸出每個 node 的 <em>估計</em> cost / row count / width。<strong>用於 quick plan check</strong>。</p>
<p>關鍵欄位：</p>
<ul>
<li><code>Plan node 類型</code>：<code>Seq Scan</code> &lt; <code>Index Scan</code> &lt; <code>Index Only Scan</code>、警訊看 <em>unexpected</em> node type</li>
<li><code>cost=START..END</code>：planner 估的 cost、START 是 startup cost、END 是 total</li>
<li><code>rows</code>：估計 output row 數</li>
<li><code>width</code>：每 row average byte（影響 sort / hash memory）</li>
</ul>
<h3 id="tool-2explain-analyze--實際執行--對比-estimate">Tool 2：EXPLAIN ANALYZE — 實際執行 + 對比 estimate</h3>





<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">EXPLAIN</span><span class="w"> </span><span class="p">(</span><span class="k">ANALYZE</span><span class="p">,</span><span class="w"> </span><span class="n">BUFFERS</span><span class="p">,</span><span class="w"> </span><span class="k">VERBOSE</span><span class="p">)</span><span class="w"> </span><span class="k">SELECT</span><span class="w"> </span><span class="p">...;</span></span></span></code></pre></div><p>差別：實際 <em>跑 query</em>、輸出實際 row count / time、跟 estimate 對比：</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">Hash Join  (cost=20000..50000 rows=100) (actual time=400..500 rows=10000 loops=1)</span></span></code></pre></div><p><code>rows=100 (estimate)</code> vs <code>rows=10000 (actual)</code> — 估錯 100x、planner 可能選錯 plan。<code>BUFFERS</code> 顯示 disk read vs buffer cache hit。</p>
<p><strong>注意</strong>：EXPLAIN ANALYZE <em>實際跑 query</em>、修改性 query（UPDATE / DELETE）會真的改 data。讀 query 安全。修改性 query 包 transaction：</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">BEGIN</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="k">EXPLAIN</span><span class="w"> </span><span class="k">ANALYZE</span><span class="w"> </span><span class="k">UPDATE</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="n">status</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;x&#39;</span><span class="w"> </span><span class="k">WHERE</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="k">ROLLBACK</span><span class="p">;</span></span></span></code></pre></div><h3 id="tool-3auto_explain--production-query-自動-capture">Tool 3：auto_explain — Production query 自動 capture</h3>
<p><code>auto_explain</code> extension 自動 log slow query 的 plan：</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="c1"># postgresql.conf</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="na">shared_preload_libraries</span> <span class="o">=</span> <span class="s">&#39;auto_explain&#39;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="na">auto_explain.log_min_duration</span> <span class="o">=</span> <span class="s">&#39;1s&#39;    # 超過 1 秒 log plan</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="na">auto_explain.log_analyze</span> <span class="o">=</span> <span class="s">on            # 含 ANALYZE 統計</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="na">auto_explain.log_buffers</span> <span class="o">=</span> <span class="s">on</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="na">auto_explain.log_format</span> <span class="o">=</span> <span class="s">&#39;json&#39;         # JSON 格式給工具消費</span></span></span></code></pre></div><p>Production slow query 自動進 log、不必手動 EXPLAIN。組合 pg_stat_statements + auto_explain 是 PG 標準 query observability。</p>
<h2 id="pg_hint_plan-vs-planner-guc">pg_hint_plan vs Planner GUC</h2>
<p>PG 兩種方式 nudge planner：</p>
<h3 id="planner-gucglobal">Planner GUC（global）</h3>
<p><code>postgresql.conf</code> 內：</p>
<ul>
<li><code>enable_seqscan = off</code> — 禁用 seq scan（force index）</li>
<li><code>enable_nestloop = off</code> — 禁用 nested loop（force hash/merge join）</li>
<li><code>random_page_cost = 1.1</code> — SSD 設低（預設 4 是 HDD assumption）</li>
<li><code>effective_cache_size = '16GB'</code> — buffer pool + OS cache 估、影響 planner</li>
</ul>
<p>GUC 是 <em>global</em> — 影響所有 query。對 <em>單一 query 用 hint</em>：</p>
<h3 id="pg_hint_plan-extensionper-query-hint">pg_hint_plan extension（per-query hint）</h3>





<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">-- 強制特定 plan
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="cm">/*+ IndexScan(orders idx_orders_status) NestLoop(orders customers) */</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">SELECT</span><span class="w"> </span><span class="p">...</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="n">customers</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="p">...;</span></span></span></code></pre></div><p>Hint 形態：</p>
<ul>
<li><code>IndexScan(t1 idx_name)</code> — 強制 index scan</li>
<li><code>SeqScan(t1)</code> — 強制 seq scan</li>
<li><code>HashJoin(t1 t2)</code> / <code>NestLoop(t1 t2)</code> / <code>MergeJoin(t1 t2)</code></li>
<li><code>Leading(t1 t2 t3)</code> — 強制 join order</li>
<li><code>Rows(t1 t2 #100)</code> — 強制 row 估計</li>
</ul>
<p><strong>推薦</strong>：</p>
<ul>
<li>全 cluster 行為：用 GUC（如 <code>random_page_cost</code>）</li>
<li>單 query 行為：用 pg_hint_plan（不污染其他 query）</li>
<li>不要過度 hint — planner 多數時候 <em>是對的</em>、hint 是 last resort</li>
</ul>
<h2 id="5-個-production-踩雷">5 個 Production 踩雷</h2>
<h3 id="1-statistics-過時--planner-估錯-row-count">1. Statistics 過時 — Planner 估錯 row count</h3>
<p><code>ANALYZE</code> 是 autovacuum 一部分、預設 <em>autovacuum_analyze_scale_factor=0.1</em>（10% row 變動才 analyze）。對 <em>快速 grow 的表</em>（log / event）、ANALYZE 跟不上、planner 用過時 statistics。</p>
<p>修法：</p>
<ul>
<li>
<p>對 critical table 設 <em>較 aggressive autovacuum_analyze_scale_factor</em>：</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">ALTER</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="k">SET</span><span class="w"> </span><span class="p">(</span><span class="n">autovacuum_analyze_scale_factor</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">0</span><span class="p">.</span><span class="mi">02</span><span class="p">);</span></span></span></code></pre></div></li>
<li>
<p>對 <em>大批量寫入後</em>、手動 <code>ANALYZE events;</code></p>
</li>
<li>
<p>監控 <code>pg_stat_user_tables.last_analyze</code> — 跟 row count 比、判定是否需手動 trigger</p>
</li>
</ul>
<h3 id="2-multi-column-statistics--planner-假設-column-獨立">2. Multi-column statistics — Planner 假設 column 獨立</h3>
<p>如 Case 3、單 column statistics 對 <em>相關 column</em> 估錯。</p>
<p>修法：</p>
<ul>
<li>對 <em>常一起 query 的 column 組合</em>、建 <code>CREATE STATISTICS</code>（PG 10+）</li>
<li>3 種 type：<code>dependencies</code>（functional dependency）、<code>ndistinct</code>（multi-column distinct count）、<code>mcv</code>（most common value combinations）</li>
<li>設完 <em>必須跑 ANALYZE</em> 才生效</li>
</ul>
<h3 id="3-cost-base-setting-不對齊硬體--planner-偏-seq-scan">3. Cost-base setting 不對齊硬體 — Planner 偏 seq scan</h3>
<p>預設 <code>random_page_cost = 4</code>、<code>seq_page_cost = 1</code> 是 <em>HDD assumption</em>（random IO 比 sequential 慢 4x）。SSD / NVMe random / seq IO 差別小、planner 不該 4x penalty random。</p>
<p>修法：</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">-- SSD
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">ALTER</span><span class="w"> </span><span class="k">SYSTEM</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="n">random_page_cost</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">1</span><span class="p">.</span><span class="mi">1</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></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="c1">-- NVMe
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="k">ALTER</span><span class="w"> </span><span class="k">SYSTEM</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="n">random_page_cost</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">1</span><span class="p">.</span><span class="mi">0</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></span><span class="line"><span class="ln">7</span><span class="cl"><span class="w"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">pg_reload_conf</span><span class="p">();</span></span></span></code></pre></div><p><code>random_page_cost</code> 改了 planner 對 index scan 的 cost 估計更準、自動選 index 更積極。</p>
<h3 id="4-effective_cache_size-不對齊實際-ram">4. <code>effective_cache_size</code> 不對齊實際 RAM</h3>
<p><code>effective_cache_size</code> 預設 4 GB、planner 假設 buffer pool + OS cache 共 4 GB。實際 server 64 GB RAM、<code>shared_buffers = 16GB</code>、OS page cache ~30 GB、實際可用 cache 46 GB。</p>
<p>修法：</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">ALTER</span><span class="w"> </span><span class="k">SYSTEM</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="n">effective_cache_size</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;46GB&#39;</span><span class="p">;</span><span class="w">  </span><span class="c1">-- shared_buffers + OS cache 估</span></span></span></code></pre></div><p>提升後 planner 估 query 多數 page 在 cache、降低 <em>估計 random IO cost</em>、選 index 更積極。</p>
<h3 id="5-parallel-query-不觸發">5. Parallel query 不觸發</h3>
<p>預設 <code>max_parallel_workers_per_gather = 2</code>、有些 workload 不夠。或 <em>table size 太小</em>、<code>min_parallel_table_scan_size = 8MB</code> 預設、小表不 parallel。</p>
<p>修法：</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">ALTER</span><span class="w"> </span><span class="k">SYSTEM</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="n">max_parallel_workers_per_gather</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">4</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="k">ALTER</span><span class="w"> </span><span class="k">SYSTEM</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="n">parallel_setup_cost</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">100</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="k">ALTER</span><span class="w"> </span><span class="k">SYSTEM</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="n">parallel_tuple_cost</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">0</span><span class="p">.</span><span class="mi">01</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">ALTER</span><span class="w"> </span><span class="k">SYSTEM</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="n">min_parallel_table_scan_size</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;0&#39;</span><span class="p">;</span><span class="w">  </span><span class="c1">-- 任何 size 都 parallel</span></span></span></code></pre></div><p>監控 <code>EXPLAIN</code> 的 <code>Workers Planned</code> 數量、看是否真 parallel。</p>
<h2 id="觀測-metric">觀測 metric</h2>
<p>Production 持續 monitor：</p>
<ul>
<li><code>pg_stat_statements</code>：每個 query digest 累計 calls / time / rows / IO</li>
<li><code>auto_explain</code> log：slow query 的實際 plan + ANALYZE 統計</li>
<li><code>pg_stat_user_tables.last_analyze</code> / <code>last_autoanalyze</code>：statistics 新鮮度</li>
<li><code>pg_stat_user_indexes.idx_scan</code>：每個 index 使用次數 — 0 表示沒用、可考慮 drop</li>
</ul>
<p>把這些丟進 Datadog / Prometheus（用 <code>postgres_exporter</code> / <code>pg_exporter</code>）做 trend analysis。</p>
<h2 id="跟-mysql-query-optimization-對照">跟 MySQL Query Optimization 對照</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>PG</th>
          <th>MySQL</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Query plan preview</td>
          <td><code>EXPLAIN</code></td>
          <td><code>EXPLAIN</code></td>
      </tr>
      <tr>
          <td>實際執行統計</td>
          <td><code>EXPLAIN ANALYZE</code></td>
          <td><code>EXPLAIN ANALYZE</code> (8.0+)</td>
      </tr>
      <tr>
          <td>Auto-capture</td>
          <td><code>auto_explain</code> extension</td>
          <td><code>slow_query_log</code> + <code>pt-query-digest</code></td>
      </tr>
      <tr>
          <td>Optimizer trace</td>
          <td>log_planner_stats / log_executor_stats</td>
          <td><code>optimizer_trace</code> (JSON)</td>
      </tr>
      <tr>
          <td>Per-query hint</td>
          <td><code>pg_hint_plan</code> extension</td>
          <td>optimizer hint comment (<code>/*+ */</code>)</td>
      </tr>
      <tr>
          <td>Multi-column statistics</td>
          <td><code>CREATE STATISTICS</code></td>
          <td>無原生（依賴 index 統計）</td>
      </tr>
      <tr>
          <td>Parallel query</td>
          <td>Full (scan / agg / join, PG 9.6+)</td>
          <td>受限 (8.0 hash join)</td>
      </tr>
      <tr>
          <td>Cost-base setting</td>
          <td>random_page_cost / effective_cache_size</td>
          <td>隱性、optimizer 預設</td>
      </tr>
  </tbody>
</table>
<p>PG planner 整體成熟、複雜 OLAP-style query 處理較好。MySQL 8.0 補了不少（histograms / hash join）但複雜 query 仍弱於 PG。詳見 <a href="/blog/backend/01-database/vendors/mysql/query-optimization/" data-link-title="MySQL Query Optimization：從 EXPLAIN 看到實際執行、5 條 query 從 5 秒變 50ms 的 anatomy" data-link-desc="MySQL query 慢的根因不在「SQL 寫法」、在「optimizer 選錯 plan」。本文從 5 個常見 production case 開場（5 秒 → 50ms / 30 秒 → 200ms / 8 秒 → 30ms 等）、走 EXPLAIN / EXPLAIN ANALYZE / optimizer trace 三層分析工具、index hint vs optimizer hint 取捨、cardinality estimation 失效時的修法、5 production 踩雷（statistics 過時 / forced index 用錯 / hash join 沒觸發 / range scan 退化 ALL / derived table materialization）">MySQL Query Optimization</a>。</p>
<h2 id="跟其他模組整合">跟其他模組整合</h2>
<h3 id="跟-autovacuum-tuning">跟 Autovacuum Tuning</h3>
<p>ANALYZE 是 autovacuum 一部分、autovacuum 跟不上 → statistics 過時 → planner 估錯。詳見 <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>。</p>
<h3 id="跟-replication-topology">跟 Replication Topology</h3>
<p>Standby 上跑 query 用同 statistics（streaming replication copy 整個 system catalog）、planner 行為一致。但 <em>standby 有 hot_standby_feedback</em> 影響 primary autovacuum / ANALYZE 行為。詳見 <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>。</p>
<h3 id="跟-partitioning">跟 Partitioning</h3>
<p>Partition pruning 跟 query plan 緊密 — <code>EXPLAIN</code> 看是否 prune 對的 partition。詳見 <a href="/blog/backend/01-database/vendors/postgresql/declarative-partitioning/" data-link-title="PostgreSQL declarative partitioning：partition 不是切表、是讓 planner pruning" data-link-desc="Declarative partitioning 的真實價值是 query planner pruning &#43; maintenance scope 縮小、不是「把大表切小」；RANGE / LIST / HASH 取捨、partition key 選法、5 個 production 踩雷（key 選錯不 prune / unique 不 enforce 跨 partition / ATTACH 鎖太久 / partition 數爆 / DETACH 不 reclaim 空間）、跟 autovacuum &#43; index 設計整合">Declarative Partitioning</a>。</p>
<h2 id="何時用-pg_hint_plan-vs-guc">何時用 pg_hint_plan vs GUC</h2>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>選擇</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>全 cluster 行為（如 SSD random_page_cost）</td>
          <td>GUC</td>
      </tr>
      <tr>
          <td>單一 critical query 強制特定 plan</td>
          <td>pg_hint_plan</td>
      </tr>
      <tr>
          <td>暫時 disable 某類 plan 給 debug</td>
          <td><code>SET enable_xxx=off</code> per-session</td>
      </tr>
      <tr>
          <td>Production stable use</td>
          <td>GUC + multi-column statistics 為主、hint 為 last resort</td>
      </tr>
  </tbody>
</table>
<h2 id="相關連結">相關連結</h2>
<ul>
<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 vendor overview</a></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 整合">PG Autovacuum Tuning</a>（ANALYZE 跟 statistics 新鮮度）</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 整合">PG Replication Topology</a>（standby planner 行為）</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/declarative-partitioning/" data-link-title="PostgreSQL declarative partitioning：partition 不是切表、是讓 planner pruning" data-link-desc="Declarative partitioning 的真實價值是 query planner pruning &#43; maintenance scope 縮小、不是「把大表切小」；RANGE / LIST / HASH 取捨、partition key 選法、5 個 production 踩雷（key 選錯不 prune / unique 不 enforce 跨 partition / ATTACH 鎖太久 / partition 數爆 / DETACH 不 reclaim 空間）、跟 autovacuum &#43; index 設計整合">PG Declarative Partitioning</a>（partition pruning）</li>
<li><a href="/blog/backend/01-database/vendors/mysql/query-optimization/" data-link-title="MySQL Query Optimization：從 EXPLAIN 看到實際執行、5 條 query 從 5 秒變 50ms 的 anatomy" data-link-desc="MySQL query 慢的根因不在「SQL 寫法」、在「optimizer 選錯 plan」。本文從 5 個常見 production case 開場（5 秒 → 50ms / 30 秒 → 200ms / 8 秒 → 30ms 等）、走 EXPLAIN / EXPLAIN ANALYZE / optimizer trace 三層分析工具、index hint vs optimizer hint 取捨、cardinality estimation 失效時的修法、5 production 踩雷（statistics 過時 / forced index 用錯 / hash join 沒觸發 / range scan 退化 ALL / derived table materialization）">MySQL Query Optimization</a>（sibling、不同 optimizer 成熟度）</li>
<li>官方：<a href="https://www.postgresql.org/docs/current/sql-explain.html">EXPLAIN</a> / <a href="https://github.com/ossc-db/pg_hint_plan">pg_hint_plan</a> / <a href="https://www.postgresql.org/docs/current/auto-explain.html">auto_explain</a></li>
</ul>
]]></content:encoded></item><item><title>MySQL Partitioning：partition lifecycle 五段、跟 Vitess sharding 不同的「同 instance 內水平切割」</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/partitioning/</link><pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/partitioning/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/" data-link-title="MySQL" data-link-desc="高併發網路服務常用關聯式資料庫、Vitess / PlanetScale 分片生態、GitHub / Shopify / Facebook 規模驗證">MySQL&lt;/a> overview 的 implementation-layer deep article。Overview 已說明 MySQL 在 OLTP 譜系的定位、本文聚焦 &lt;em>native partitioning&lt;/em> — 5 段 lifecycle + 4 種 type + 跟 Vitess sharding / PG partitioning 對比。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="partition-lifecycle-五段">Partition lifecycle 五段&lt;/h2>
&lt;p>MySQL native partitioning 是 &lt;em>同 instance 內把一個邏輯 table 拆成多個 physical sub-table&lt;/em>、optimizer 可選擇只 scan 相關 partition。整個 partition lifecycle 5 段：&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">Design 決定 partition key / type / 數量
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> ↓
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">Create CREATE TABLE ... PARTITION BY ...
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> ↓
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">Query WHERE clause + partition pruning
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> ↓
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">Maintenance ADD / DROP / REORGANIZE / EXCHANGE
&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">Drop 整個 partition 一次刪（比 DELETE FROM 快 1000x）&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>每段都有獨立工程決策。設計階段選錯 partition key、後續 maintenance + query 全部 broken。&lt;/p>
&lt;p>跟 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/vitess-sharding/" data-link-title="MySQL Vitess Sharding：VTGate / VTTablet / VReplication / VSchema 四件套協作" data-link-desc="Vitess 不只是 MySQL sharding proxy、是 4 個 component 協作的完整 sharding 系統 — VTGate（query routing layer）、VTTablet（per-MySQL agent）、VReplication（跨 shard 資料移動）、VSchema（sharding metadata）。本文走 4 件套各自責任、keyspace / shard / tablet 架構、shard key 設計（Vindex）、配置 step-by-step、5 production 踩雷（cross-shard transaction / VStream lag / Vindex 不均勻 / resharding 切流 / VReplication 卡住）、跟自管 sharding 跟 PlanetScale 的對比">Vitess sharding&lt;/a> 對比：&lt;/p>
&lt;ul>
&lt;li>&lt;em>MySQL partitioning&lt;/em>：同 instance、optimizer 自動 pruning、無 cross-instance network cost&lt;/li>
&lt;li>&lt;em>Vitess sharding&lt;/em>：跨 instance、application 透過 VTGate routing、可線性 scale&lt;/li>
&lt;/ul>
&lt;p>兩者不衝突、可組合：Vitess shard 內部 &lt;em>再&lt;/em> 用 MySQL partition（例如：shard 切 16 個、每個 shard 的 table 再按月份 partition）。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/mysql/" data-link-title="MySQL" data-link-desc="高併發網路服務常用關聯式資料庫、Vitess / PlanetScale 分片生態、GitHub / Shopify / Facebook 規模驗證">MySQL</a> overview 的 implementation-layer deep article。Overview 已說明 MySQL 在 OLTP 譜系的定位、本文聚焦 <em>native partitioning</em> — 5 段 lifecycle + 4 種 type + 跟 Vitess sharding / PG partitioning 對比。</p></blockquote>
<hr>
<h2 id="partition-lifecycle-五段">Partition lifecycle 五段</h2>
<p>MySQL native partitioning 是 <em>同 instance 內把一個邏輯 table 拆成多個 physical sub-table</em>、optimizer 可選擇只 scan 相關 partition。整個 partition lifecycle 5 段：</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">Design       決定 partition key / type / 數量
</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">Create       CREATE TABLE ... PARTITION BY ...
</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">Query        WHERE clause + partition pruning
</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">Maintenance  ADD / DROP / REORGANIZE / EXCHANGE
</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">Drop         整個 partition 一次刪（比 DELETE FROM 快 1000x）</span></span></code></pre></div><p>每段都有獨立工程決策。設計階段選錯 partition key、後續 maintenance + query 全部 broken。</p>
<p>跟 <a href="/blog/backend/01-database/vendors/mysql/vitess-sharding/" data-link-title="MySQL Vitess Sharding：VTGate / VTTablet / VReplication / VSchema 四件套協作" data-link-desc="Vitess 不只是 MySQL sharding proxy、是 4 個 component 協作的完整 sharding 系統 — VTGate（query routing layer）、VTTablet（per-MySQL agent）、VReplication（跨 shard 資料移動）、VSchema（sharding metadata）。本文走 4 件套各自責任、keyspace / shard / tablet 架構、shard key 設計（Vindex）、配置 step-by-step、5 production 踩雷（cross-shard transaction / VStream lag / Vindex 不均勻 / resharding 切流 / VReplication 卡住）、跟自管 sharding 跟 PlanetScale 的對比">Vitess sharding</a> 對比：</p>
<ul>
<li><em>MySQL partitioning</em>：同 instance、optimizer 自動 pruning、無 cross-instance network cost</li>
<li><em>Vitess sharding</em>：跨 instance、application 透過 VTGate routing、可線性 scale</li>
</ul>
<p>兩者不衝突、可組合：Vitess shard 內部 <em>再</em> 用 MySQL partition（例如：shard 切 16 個、每個 shard 的 table 再按月份 partition）。</p>
<h2 id="4-種-partition-type">4 種 partition type</h2>
<h3 id="range-partitioning--連續區間切割">RANGE partitioning — 連續區間切割</h3>
<p>最常見、適合 time-series / 連續數字：</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">orders</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">BIGINT</span><span class="w"> </span><span class="n">AUTO_INCREMENT</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">user_id</span><span class="w"> </span><span class="nb">BIGINT</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"> 4</span><span class="cl"><span class="w">    </span><span class="n">amount</span><span class="w"> </span><span class="nb">DECIMAL</span><span class="p">(</span><span class="mi">10</span><span class="p">,</span><span class="mi">2</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">created_at</span><span class="w"> </span><span class="n">DATETIME</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="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</span><span class="w"> </span><span class="p">(</span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="n">created_at</span><span class="p">)</span><span class="w">              </span><span class="c1">-- PK 必須含 partition key
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"></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">PARTITION</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">RANGE</span><span class="w"> </span><span class="p">(</span><span class="n">TO_DAYS</span><span class="p">(</span><span class="n">created_at</span><span class="p">))</span><span class="w"> </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">PARTITION</span><span class="w"> </span><span class="n">p202601</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="k">LESS</span><span class="w"> </span><span class="k">THAN</span><span class="w"> </span><span class="p">(</span><span class="n">TO_DAYS</span><span class="p">(</span><span class="s1">&#39;2026-02-01&#39;</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">PARTITION</span><span class="w"> </span><span class="n">p202602</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="k">LESS</span><span class="w"> </span><span class="k">THAN</span><span class="w"> </span><span class="p">(</span><span class="n">TO_DAYS</span><span class="p">(</span><span class="s1">&#39;2026-03-01&#39;</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">PARTITION</span><span class="w"> </span><span class="n">p202603</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="k">LESS</span><span class="w"> </span><span class="k">THAN</span><span class="w"> </span><span class="p">(</span><span class="n">TO_DAYS</span><span class="p">(</span><span class="s1">&#39;2026-04-01&#39;</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">PARTITION</span><span class="w"> </span><span class="n">p_future</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="k">LESS</span><span class="w"> </span><span class="k">THAN</span><span class="w"> </span><span class="k">MAXVALUE</span><span class="w">  </span><span class="c1">-- 未來資料 fallback
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1"></span><span class="p">);</span></span></span></code></pre></div><p>優點：</p>
<ul>
<li>Partition pruning 高效（時間 range query）</li>
<li>整個月 archive 直接 <code>ALTER TABLE orders DROP PARTITION p202601</code>、毫秒級</li>
</ul>
<p>缺點：</p>
<ul>
<li>必須 <em>預先建</em> 未來 partition（或用 <code>p_future</code> fallback、但 fallback partition 變大就失去 pruning 意義）</li>
<li><em>Hot partition</em> — 最新 partition 接收所有 INSERT、其他 partition 純歷史</li>
</ul>
<h3 id="list-partitioning--離散值切割">LIST partitioning — 離散值切割</h3>
<p>適合 enum-like value：</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">users</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">BIGINT</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">name</span><span class="w"> </span><span class="nb">VARCHAR</span><span class="p">(</span><span class="mi">100</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="n">region</span><span class="w"> </span><span class="nb">VARCHAR</span><span class="p">(</span><span class="mi">10</span><span class="p">)</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="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</span><span class="w"> </span><span class="p">(</span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="n">region</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="n">PARTITION</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">LIST</span><span class="w"> </span><span class="n">COLUMNS</span><span class="w"> </span><span class="p">(</span><span class="n">region</span><span class="p">)</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="n">PARTITION</span><span class="w"> </span><span class="n">p_asia</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="k">IN</span><span class="w"> </span><span class="p">(</span><span class="s1">&#39;TW&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;JP&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;KR&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;CN&#39;</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">PARTITION</span><span class="w"> </span><span class="n">p_americas</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="k">IN</span><span class="w"> </span><span class="p">(</span><span class="s1">&#39;US&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;CA&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;BR&#39;</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">PARTITION</span><span class="w"> </span><span class="n">p_emea</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="k">IN</span><span class="w"> </span><span class="p">(</span><span class="s1">&#39;GB&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;DE&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;FR&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;IT&#39;</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></span></code></pre></div><p>優點：對 enum-like value 直接命中、pruning 簡單。</p>
<p>缺點：value list 不能變更（不 supported <code>ALTER PARTITION ADD VALUE</code>）、新國家代碼必須 REORGANIZE。</p>
<h3 id="hash-partitioning--均勻分布">HASH partitioning — 均勻分布</h3>
<p>對 numeric / string column 取 hash、均勻分布：</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">BIGINT</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">user_id</span><span class="w"> </span><span class="nb">BIGINT</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">4</span><span class="cl"><span class="w">    </span><span class="n">event_type</span><span class="w"> </span><span class="nb">VARCHAR</span><span class="p">(</span><span class="mi">50</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">PRIMARY</span><span class="w"> </span><span class="k">KEY</span><span class="w"> </span><span class="p">(</span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="n">user_id</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="n">PARTITION</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">HASH</span><span class="w"> </span><span class="p">(</span><span class="n">user_id</span><span class="p">)</span><span class="w"> </span><span class="n">PARTITIONS</span><span class="w"> </span><span class="mi">8</span><span class="p">;</span></span></span></code></pre></div><p>優點：均勻分布、沒有 hot partition。</p>
<p>缺點：</p>
<ul>
<li><em>Range query 沒效</em> — <code>WHERE user_id BETWEEN 100 AND 200</code> 不能 pruning、scan 全部 partition</li>
<li>Partition 數量改變需要 REORGANIZE 整張表</li>
</ul>
<h3 id="key-partitioning--mysql-內部-hash">KEY partitioning — MySQL 內部 hash</h3>
<p>跟 HASH 類似、但用 MySQL 內部 hash function（不依賴 column 是否 integer）：</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">sessions</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">session_id</span><span class="w"> </span><span class="nb">VARCHAR</span><span class="p">(</span><span class="mi">64</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">user_id</span><span class="w"> </span><span class="nb">BIGINT</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">4</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">5</span><span class="cl"><span class="w">    </span><span class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</span><span class="w"> </span><span class="p">(</span><span class="n">session_id</span><span class="p">,</span><span class="w"> </span><span class="n">user_id</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="n">PARTITION</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="k">KEY</span><span class="w"> </span><span class="p">(</span><span class="n">user_id</span><span class="p">)</span><span class="w"> </span><span class="n">PARTITIONS</span><span class="w"> </span><span class="mi">16</span><span class="p">;</span></span></span></code></pre></div><p>用於 <em>string column 或 composite column</em> 的均勻分布。一般場景跟 HASH 效果接近。</p>
<h3 id="sub-partitioning--兩層切割">Sub-partitioning — 兩層切割</h3>
<p>RANGE + HASH 組合、深化分隔：</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">big_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">BIGINT</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">user_id</span><span class="w"> </span><span class="nb">BIGINT</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="n">created_at</span><span class="w"> </span><span class="n">DATETIME</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">PRIMARY</span><span class="w"> </span><span class="k">KEY</span><span class="w"> </span><span class="p">(</span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="n">created_at</span><span class="p">,</span><span class="w"> </span><span class="n">user_id</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="n">PARTITION</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">RANGE</span><span class="w"> </span><span class="p">(</span><span class="n">TO_DAYS</span><span class="p">(</span><span class="n">created_at</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">SUBPARTITION</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">HASH</span><span class="w"> </span><span class="p">(</span><span class="n">user_id</span><span class="p">)</span><span class="w"> </span><span class="n">SUBPARTITIONS</span><span class="w"> </span><span class="mi">4</span><span class="w"> </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">PARTITION</span><span class="w"> </span><span class="n">p202601</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="k">LESS</span><span class="w"> </span><span class="k">THAN</span><span class="w"> </span><span class="p">(</span><span class="n">TO_DAYS</span><span class="p">(</span><span class="s1">&#39;2026-02-01&#39;</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">PARTITION</span><span class="w"> </span><span class="n">p202602</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="k">LESS</span><span class="w"> </span><span class="k">THAN</span><span class="w"> </span><span class="p">(</span><span class="n">TO_DAYS</span><span class="p">(</span><span class="s1">&#39;2026-03-01&#39;</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></span></code></pre></div><p>每個 RANGE partition 又拆 4 個 HASH sub-partition、共 8 個 physical storage location。適合 <em>時間 range + user_id hash</em> 兩維度。</p>
<p>實務罕用、複雜性高、調 query plan 困難。多數 case 用 single-level partition 即可。</p>
<h2 id="partition-pruning--optimizer-怎麼選-partition">Partition Pruning — Optimizer 怎麼選 partition</h2>
<p><code>EXPLAIN PARTITIONS SELECT ...</code> 顯示 query 命中哪些 partition：</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">EXPLAIN</span><span class="w"> </span><span class="n">PARTITIONS</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">created_at</span><span class="w"> </span><span class="k">BETWEEN</span><span class="w"> </span><span class="s1">&#39;2026-02-15&#39;</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="s1">&#39;2026-02-20&#39;</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></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="o">+</span><span class="c1">----+-------------+--------+------------+-------+
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="o">|</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">select_type</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="k">table</span><span class="w">  </span><span class="o">|</span><span class="w"> </span><span class="n">partitions</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="k">type</span><span class="w">  </span><span class="o">|</span><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w"></span><span class="o">+</span><span class="c1">----+-------------+--------+------------+-------+
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"></span><span class="o">|</span><span class="w">  </span><span class="mi">1</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="k">SIMPLE</span><span class="w">      </span><span class="o">|</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">p202602</span><span class="w">    </span><span class="o">|</span><span class="w"> </span><span class="n">range</span><span class="w"> </span><span class="o">|</span><span class="w">
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="w"></span><span class="o">+</span><span class="c1">----+-------------+--------+------------+-------+</span></span></span></code></pre></div><p>只命中 <code>p202602</code>、其他 partition 不 scan。</p>
<p><strong>Pruning 失效場景</strong>：</p>
<ol>
<li>
<p><strong>Function on partition key</strong>：</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">WHERE</span><span class="w"> </span><span class="k">YEAR</span><span class="p">(</span><span class="n">created_at</span><span class="p">)</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">2026</span><span class="w">  </span><span class="c1">-- 沒 pruning、scan 全部</span></span></span></code></pre></div><p>應該寫成：</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">WHERE</span><span class="w"> </span><span class="n">created_at</span><span class="w"> </span><span class="o">&gt;=</span><span class="w"> </span><span class="s1">&#39;2026-01-01&#39;</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="n">created_at</span><span class="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="s1">&#39;2027-01-01&#39;</span></span></span></code></pre></div></li>
<li>
<p><strong>Implicit conversion</strong>：</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">WHERE</span><span class="w"> </span><span class="n">created_at</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;2026-02-15&#39;</span><span class="w">  </span><span class="c1">-- 字串 vs DATETIME、可能失效</span></span></span></code></pre></div><p>應該：</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">WHERE</span><span class="w"> </span><span class="n">created_at</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">TIMESTAMP</span><span class="w"> </span><span class="s1">&#39;2026-02-15 00:00:00&#39;</span></span></span></code></pre></div></li>
<li>
<p><strong>OR 跨 partition</strong>：</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">WHERE</span><span class="w"> </span><span class="n">created_at</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;2026-02-15&#39;</span><span class="w"> </span><span class="k">OR</span><span class="w"> </span><span class="n">user_id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">100</span><span class="w">  </span><span class="c1">-- partition + non-partition column OR、scan 全部</span></span></span></code></pre></div></li>
<li>
<p><strong>JOIN 不直接 filter partition key</strong>：JOIN 條件不含 partition key、optimizer 估計無法 pruning。</p>
</li>
</ol>
<h2 id="partition-maintenance--add--drop--reorganize--exchange">Partition Maintenance — ADD / DROP / REORGANIZE / EXCHANGE</h2>
<h3 id="add-partition">ADD partition</h3>





<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">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="k">ADD</span><span class="w"> </span><span class="n">PARTITION</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">PARTITION</span><span class="w"> </span><span class="n">p202604</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="k">LESS</span><span class="w"> </span><span class="k">THAN</span><span class="w"> </span><span class="p">(</span><span class="n">TO_DAYS</span><span class="p">(</span><span class="s1">&#39;2026-05-01&#39;</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="p">);</span></span></span></code></pre></div><p>對 RANGE 簡單、但要 <em>排在 MAXVALUE partition 之前</em>（如果有 <code>p_future</code>、要先 REORGANIZE）。</p>
<h3 id="drop-partition">DROP partition</h3>





<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">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="k">DROP</span><span class="w"> </span><span class="n">PARTITION</span><span class="w"> </span><span class="n">p202601</span><span class="p">;</span></span></span></code></pre></div><p>直接刪 partition file、毫秒級完成。是 <em>time-series archive 的最大優勢</em> — 對比 <code>DELETE FROM orders WHERE created_at &lt; '...'</code> 跑 hours。</p>
<h3 id="reorganize-partition">REORGANIZE partition</h3>
<p>切分 / 合併 partition：</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">-- 切：把 p_future 切成 p202604 + new p_future
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="n">REORGANIZE</span><span class="w"> </span><span class="n">PARTITION</span><span class="w"> </span><span class="n">p_future</span><span class="w"> </span><span class="k">INTO</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">PARTITION</span><span class="w"> </span><span class="n">p202604</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="k">LESS</span><span class="w"> </span><span class="k">THAN</span><span class="w"> </span><span class="p">(</span><span class="n">TO_DAYS</span><span class="p">(</span><span class="s1">&#39;2026-05-01&#39;</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="n">PARTITION</span><span class="w"> </span><span class="n">p_future</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="k">LESS</span><span class="w"> </span><span class="k">THAN</span><span class="w"> </span><span class="k">MAXVALUE</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="p">);</span></span></span></code></pre></div><p>REORGANIZE <em>rewrites partition data</em>、跟 OSC 一樣慢、大 partition 走 gh-ost / pt-osc 模擬（用 ghost table）。</p>
<h3 id="exchange-partition">EXCHANGE partition</h3>
<p>把 partition 跟 <em>獨立 table</em> swap（不複製資料）：</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">-- 建一個 staging table 跟 partition 同 schema
</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">orders_staging</span><span class="w"> </span><span class="k">LIKE</span><span class="w"> </span><span class="n">orders</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="k">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">orders_staging</span><span class="w"> </span><span class="n">REMOVE</span><span class="w"> </span><span class="n">PARTITIONING</span><span class="p">;</span><span class="w">  </span><span class="c1">-- staging 必須是 non-partitioned
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="c1">-- 把 archive partition 的資料 atomic swap 給 staging
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span><span class="k">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="n">EXCHANGE</span><span class="w"> </span><span class="n">PARTITION</span><span class="w"> </span><span class="n">p202601</span><span class="w"> </span><span class="k">WITH</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">orders_staging</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></span><span class="line"><span class="ln">8</span><span class="cl"><span class="w"></span><span class="c1">-- 現在 orders_staging 有 p202601 的資料、orders 的 p202601 變空
</span></span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="c1">-- 可以 dump staging 到 S3、或 INSERT 進 archive DB</span></span></span></code></pre></div><p><code>EXCHANGE PARTITION</code> 是 <em>metadata operation</em>、毫秒級完成、不複製資料。Time-series archive 工作流的核心工具。</p>
<h2 id="5-個-production-踩雷">5 個 Production 踩雷</h2>
<h3 id="1-pk-必須含-partition-key--schema-設計受限">1. PK 必須含 partition key — Schema 設計受限</h3>
<p>MySQL partition 規則：<strong>PK 必須包含所有 partition key column</strong>。</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">-- 錯：PK 沒包含 partition key
</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">orders</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">id</span><span class="w"> </span><span class="nb">BIGINT</span><span class="w"> </span><span class="n">AUTO_INCREMENT</span><span class="w"> </span><span class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</span><span class="p">,</span><span class="w">  </span><span class="c1">-- 只有 id
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="w">    </span><span class="n">created_at</span><span class="w"> </span><span class="n">DATETIME</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="p">)</span><span class="w"> </span><span class="n">PARTITION</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">RANGE</span><span class="w"> </span><span class="p">(</span><span class="n">TO_DAYS</span><span class="p">(</span><span class="n">created_at</span><span class="p">))</span><span class="w"> </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="c1">-- ERROR 1503: A PRIMARY KEY must include all columns in the table&#39;s partitioning function</span></span></span></code></pre></div>




<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">-- 對：PK 包含 partition key
</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">orders</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">id</span><span class="w"> </span><span class="nb">BIGINT</span><span class="w"> </span><span class="n">AUTO_INCREMENT</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="n">created_at</span><span class="w"> </span><span class="n">DATETIME</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="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</span><span class="w"> </span><span class="p">(</span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="n">created_at</span><span class="p">)</span><span class="w">  </span><span class="c1">-- 兩 column 都進 PK
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span><span class="p">)</span><span class="w"> </span><span class="n">PARTITION</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">RANGE</span><span class="w"> </span><span class="p">(</span><span class="n">TO_DAYS</span><span class="p">(</span><span class="n">created_at</span><span class="p">))</span><span class="w"> </span><span class="p">(...);</span></span></span></code></pre></div><p>修法：</p>
<ul>
<li>接受 PK 是 composite（id + partition_key column）</li>
<li>AUTO_INCREMENT 仍 work、但 INSERT 必須給定 created_at</li>
<li><em>Unique constraint 也受影響</em> — 所有 UNIQUE index 必須含 partition key</li>
</ul>
<p>對 application：原本 <code>WHERE id = X</code> 仍 work、但慢（沒 partition pruning）、必須 <code>WHERE id = X AND created_at &gt;= ...</code> 才高效。</p>
<h3 id="2-global-index-沒原生支援">2. Global index 沒原生支援</h3>
<p>MySQL partitioning <em>沒 global secondary index</em>（PG 有）。每個 partition 各自有自己的 local index、跨 partition 的 unique constraint 必須 <em>包含 partition key</em>。</p>
<p>例：希望 <code>user_id</code> 全表 unique、但 partition by <code>created_at</code>：</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">-- MySQL 不允許這樣 — UNIQUE 必須含 created_at
</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">orders</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">id</span><span class="w"> </span><span class="nb">BIGINT</span><span class="w"> </span><span class="n">AUTO_INCREMENT</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="n">user_id</span><span class="w"> </span><span class="nb">BIGINT</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">created_at</span><span class="w"> </span><span class="n">DATETIME</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="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</span><span class="w"> </span><span class="p">(</span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="n">created_at</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">UNIQUE</span><span class="w"> </span><span class="k">KEY</span><span class="w"> </span><span class="p">(</span><span class="n">user_id</span><span class="p">,</span><span class="w"> </span><span class="n">created_at</span><span class="p">)</span><span class="w">  </span><span class="c1">-- 必須含 created_at、不是純 user_id
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1"></span><span class="p">);</span></span></span></code></pre></div><p>對 application：跨 partition 的 unique 需要 <em>application 層處理</em>（INSERT 前 SELECT 檢查）或改用 Vitess <code>lookup_hash</code> Vindex。</p>
<h3 id="3-exchange-partition--schema-必須完全一致">3. EXCHANGE partition — schema 必須完全一致</h3>
<p>EXCHANGE 失敗常見：staging table 跟 partition 的 <em>index / column 順序差一個</em>、<code>ERROR 1736: Tables have different definitions</code>。</p>
<p>修法：</p>
<ul>
<li>建 staging 用 <code>CREATE TABLE staging LIKE orders</code> 而非手寫</li>
<li><code>REMOVE PARTITIONING</code> 後立即 verify schema</li>
<li>跑 OSC 改 schema 時、partition + staging table 同時改、不能漏一個</li>
</ul>
<h3 id="4-orphan-partition--future-partition-預先建忘記延展">4. Orphan partition — Future partition 預先建忘記延展</h3>
<p>部署 cron 每月建下個月 partition、cron 失敗 / pause、下個月 INSERT 無對應 partition、寫入 <code>p_future</code>。<code>p_future</code> 一年累積後變超大、partition pruning 沒效、查最近資料 scan 全表。</p>
<p>修法：</p>
<ul>
<li>監控 <code>p_future</code> partition size、超過 threshold alert</li>
<li>Cron 失敗 alert（不是 silent fail）</li>
<li>不依賴 cron、改成 <em>application 層在 INSERT 前 ensure partition exists</em>（lazy create）</li>
</ul>
<h3 id="5-cross-partition-query-慢">5. Cross-partition query 慢</h3>





<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">SELECT</span><span class="w"> </span><span class="n">user_id</span><span class="p">,</span><span class="w"> </span><span class="k">SUM</span><span class="p">(</span><span class="n">amount</span><span class="p">)</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">user_id</span><span class="p">;</span></span></span></code></pre></div><p>沒 partition key filter、optimizer 不能 pruning、scan 全部 partition。比 <em>single big table without partition</em> 還慢（因為跨 partition aggregation overhead）。</p>
<p>修法：</p>
<ul>
<li>接受 partition 不是 <em>讀效能</em> 工具、是 <em>write + archive 效能</em> 工具</li>
<li>跨 partition aggregation 改 <em>materialized aggregation table</em>（trigger / scheduled job 維護）</li>
<li>跨 partition reporting 改丟 OLAP DB（BigQuery / Snowflake / ClickHouse）</li>
</ul>
<h2 id="跟-vitess-sharding-對比">跟 Vitess sharding 對比</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>MySQL partitioning</th>
          <th>Vitess sharding</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>切割範圍</td>
          <td>同 instance 內</td>
          <td>跨 instance（無上限）</td>
      </tr>
      <tr>
          <td>Cross-shard query</td>
          <td>不適用</td>
          <td>VTGate 自動 split + aggregate</td>
      </tr>
      <tr>
          <td>Resharding</td>
          <td>REORGANIZE（rewrite data）</td>
          <td>VReplication 自動</td>
      </tr>
      <tr>
          <td>Operational cost</td>
          <td>低（單 instance 內）</td>
          <td>高（4 component Vitess stack）</td>
      </tr>
      <tr>
          <td>可線性 scale write</td>
          <td>否（單 instance 寫吞吐限）</td>
          <td>是（加 shard）</td>
      </tr>
      <tr>
          <td>Archive 效率</td>
          <td>DROP PARTITION 毫秒級</td>
          <td>不是 archive 工具</td>
      </tr>
  </tbody>
</table>
<p>兩者不衝突、適用不同問題。Partitioning 解決 <em>單 instance archive + write 集中</em>、sharding 解決 <em>跨 instance scale</em>。</p>
<h2 id="跟-postgresql-declarative-partitioning-對比">跟 PostgreSQL declarative-partitioning 對比</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>MySQL partitioning</th>
          <th>PostgreSQL declarative-partitioning</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Partition type</td>
          <td>RANGE / LIST / HASH / KEY</td>
          <td>RANGE / LIST / HASH</td>
      </tr>
      <tr>
          <td>Sub-partitioning</td>
          <td>RANGE + HASH</td>
          <td>多層 nested 支援更廣</td>
      </tr>
      <tr>
          <td>Global index</td>
          <td>無</td>
          <td>PG 11+ 有</td>
      </tr>
      <tr>
          <td>Partition wise join</td>
          <td>受限</td>
          <td>PG 11+ 強</td>
      </tr>
      <tr>
          <td>Cross-partition unique</td>
          <td>必須含 partition key</td>
          <td>PG 11+ 同限制、但 PG 17+ 部分解除</td>
      </tr>
      <tr>
          <td>Partition attach</td>
          <td>EXCHANGE PARTITION</td>
          <td>ATTACH PARTITION</td>
      </tr>
      <tr>
          <td>操作工具</td>
          <td>gh-ost / pt-osc 對 partition</td>
          <td>pg_partman（成熟）</td>
      </tr>
      <tr>
          <td>Production maturity</td>
          <td>中（5.x 開始有、8.0 強化）</td>
          <td>高（11+ declarative 後成熟）</td>
      </tr>
  </tbody>
</table>
<p>PG partitioning 對 <em>跨 partition unique</em> 跟 <em>partition-wise join</em> 處理較好、是 reporting workload 的優勢。MySQL partitioning 對 <em>archive workflow</em>（DROP / EXCHANGE）較成熟。詳見 <a href="/blog/backend/01-database/vendors/postgresql/declarative-partitioning/" data-link-title="PostgreSQL declarative partitioning：partition 不是切表、是讓 planner pruning" data-link-desc="Declarative partitioning 的真實價值是 query planner pruning &#43; maintenance scope 縮小、不是「把大表切小」；RANGE / LIST / HASH 取捨、partition key 選法、5 個 production 踩雷（key 選錯不 prune / unique 不 enforce 跨 partition / ATTACH 鎖太久 / partition 數爆 / DETACH 不 reclaim 空間）、跟 autovacuum &#43; index 設計整合">PostgreSQL Declarative Partitioning</a>。</p>
<h2 id="何時用-native-partitioning">何時用 native partitioning</h2>
<table>
  <thead>
      <tr>
          <th>場景</th>
          <th>建議</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Time-series workload + archive needs（log / event / order history）</td>
          <td>用 RANGE</td>
      </tr>
      <tr>
          <td>大表 &gt; 1 TB 且 query 多有 time filter</td>
          <td>用 RANGE 加速 prune</td>
      </tr>
      <tr>
          <td>跨 region / 跨業務切分</td>
          <td>用 LIST</td>
      </tr>
      <tr>
          <td>需要 <em>線性 scale write throughput</em></td>
          <td>不用 partition、用 Vitess sharding</td>
      </tr>
      <tr>
          <td>需要 <em>全表 unique constraint</em></td>
          <td>不用 partition、影響太大</td>
      </tr>
      <tr>
          <td>主要做 ad-hoc analytical query</td>
          <td>不用 partition、OLAP DB（ClickHouse / BigQuery）</td>
      </tr>
      <tr>
          <td>小表 &lt; 100 GB</td>
          <td>不必 partition、index 夠用</td>
      </tr>
  </tbody>
</table>
<h2 id="跟其他模組整合">跟其他模組整合</h2>
<h3 id="跟-online-schema-change">跟 Online Schema Change</h3>
<p>對 partitioned table 的 schema change（ALTER COLUMN）必須 <em>每個 partition 都改</em>。gh-ost / pt-osc 對 partitioned table 仍 work、但複雜性增加。詳見 <a href="/blog/backend/01-database/vendors/mysql/online-schema-change-tools/" data-link-title="MySQL Online Schema Change：gh-ost 跟 pt-online-schema-change 兩條完全不同的 ghost table 路徑" data-link-desc="MySQL ALTER TABLE 可能鎖整張表，production 需要 online schema change 流程。gh-ost（GitHub）跟 pt-online-schema-change（Percona）都用 ghost table 解決、但底層機制完全不同：pt-osc 用 trigger 同步、gh-ost 用 binlog stream 同步。本文走兩工具機制對照表 → trigger vs binlog 各自取捨 → 配置 step-by-step → 5 production 踩雷（trigger overhead / binlog 延遲 / FK constraint / hot trigger lock / 切換瞬間 deadlock）→ 何時用哪一個">Online Schema Change Tools</a>。</p>
<h3 id="跟-vitess">跟 Vitess</h3>
<p>Vitess shard 內部可再 partition、單 shard 對應一個 MySQL instance、partition 是 instance 內優化。Vitess <code>vtctldclient PartitionTablet</code> 命令處理 shard-aware partition 操作。詳見 <a href="/blog/backend/01-database/vendors/mysql/vitess-sharding/" data-link-title="MySQL Vitess Sharding：VTGate / VTTablet / VReplication / VSchema 四件套協作" data-link-desc="Vitess 不只是 MySQL sharding proxy、是 4 個 component 協作的完整 sharding 系統 — VTGate（query routing layer）、VTTablet（per-MySQL agent）、VReplication（跨 shard 資料移動）、VSchema（sharding metadata）。本文走 4 件套各自責任、keyspace / shard / tablet 架構、shard key 設計（Vindex）、配置 step-by-step、5 production 踩雷（cross-shard transaction / VStream lag / Vindex 不均勻 / resharding 切流 / VReplication 卡住）、跟自管 sharding 跟 PlanetScale 的對比">Vitess sharding</a>。</p>
<h3 id="跟-innodb-tuning">跟 InnoDB Tuning</h3>
<p>每個 partition 是獨立 InnoDB tablespace（<code>innodb_file_per_table=ON</code> 預設）、buffer pool 內 cache 行為跟 single big table 不同。Partition 多時 buffer pool warm-up 時間更長。詳見 <a href="/blog/backend/01-database/vendors/mysql/innodb-tuning/" data-link-title="MySQL InnoDB Tuning：為什麼一個 100 GB DB 在 64 GB RAM server 上 query 慢 5 倍" data-link-desc="InnoDB 是 MySQL 預設 storage engine、預設值給 256 MB buffer pool（早期 default）。本文從一個常見痛點開場（DB &gt; RAM 但 server 仍 swap）、走 4 個 critical knob（buffer pool / redo log / flush method / IO capacity）、各自如何影響讀寫吞吐、配置 step-by-step、5 production 踩雷（buffer pool warm-up / log file 大小 / 設 sync_binlog=0 換速度 / IO scheduler / undo log 膨脹）、跟 SSD / NVMe / EBS 的 IO 假設">InnoDB Tuning</a>。</p>
<h3 id="跟-replication">跟 Replication</h3>
<p>Partition operation（ADD / DROP / EXCHANGE）是 DDL、走 binlog、replica apply 時可能 <em>locking issue</em>（特別是 EXCHANGE 跟 replica running query 衝突）。詳見 <a href="/blog/backend/01-database/vendors/mysql/replication-topology/" data-link-title="MySQL Replication Topology：async / semi-sync / GTID 不是三選一、是三個 trade-off 軸的疊加" data-link-desc="MySQL replication 不是「選 async 還是 semi-sync」、是 *durability / latency / consistency* 三個 trade-off 軸的疊加；GTID 是跨 mode 的 infrastructure layer、不是第三種 mode。本文走 3 軸取捨模型 → async / semi-sync 行為對比 → GTID 替代 binlog-position 的好處 → 配置 step-by-step → 5 production 踩雷（lag 暴衝 / semi-sync 退回 async / GTID gap / Loss-Less semi-sync 真的 loss-less / chained replication 雪崩）→ 跟 Aurora MySQL / Vitess / ProxySQL / Orchestrator 整合">Replication Topology</a>。</p>
<h3 id="跟-query-optimization">跟 Query Optimization</h3>
<p><code>EXPLAIN PARTITIONS</code> 是 partition-aware query optimization 的關鍵工具、看 query 真的命中哪些 partition。詳見 <a href="/blog/backend/01-database/vendors/mysql/query-optimization/" data-link-title="MySQL Query Optimization：從 EXPLAIN 看到實際執行、5 條 query 從 5 秒變 50ms 的 anatomy" data-link-desc="MySQL query 慢的根因不在「SQL 寫法」、在「optimizer 選錯 plan」。本文從 5 個常見 production case 開場（5 秒 → 50ms / 30 秒 → 200ms / 8 秒 → 30ms 等）、走 EXPLAIN / EXPLAIN ANALYZE / optimizer trace 三層分析工具、index hint vs optimizer hint 取捨、cardinality estimation 失效時的修法、5 production 踩雷（statistics 過時 / forced index 用錯 / hash join 沒觸發 / range scan 退化 ALL / derived table materialization）">Query Optimization</a>。</p>
<h2 id="容量規劃要點">容量規劃要點</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>建議</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Partition 數量上限</td>
          <td>8.0 預設 8192、實務建議 &lt; 1000（管理成本上升）</td>
      </tr>
      <tr>
          <td>單 partition 大小</td>
          <td>10 GB - 100 GB（太小無 partition value、太大 prune 沒效）</td>
      </tr>
      <tr>
          <td>RANGE 時間 partition</td>
          <td>月 / 週 / 日（依資料量）</td>
      </tr>
      <tr>
          <td>HASH partition 數量</td>
          <td>通常 power of 2（8 / 16 / 32 / 64）</td>
      </tr>
      <tr>
          <td>Future partition pre-create</td>
          <td>至少 6 個月 buffer、cron 每月 add 1 個</td>
      </tr>
  </tbody>
</table>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/mysql/" data-link-title="MySQL" data-link-desc="高併發網路服務常用關聯式資料庫、Vitess / PlanetScale 分片生態、GitHub / Shopify / Facebook 規模驗證">MySQL vendor overview</a></li>
<li><a href="/blog/backend/01-database/vendors/mysql/vitess-sharding/" data-link-title="MySQL Vitess Sharding：VTGate / VTTablet / VReplication / VSchema 四件套協作" data-link-desc="Vitess 不只是 MySQL sharding proxy、是 4 個 component 協作的完整 sharding 系統 — VTGate（query routing layer）、VTTablet（per-MySQL agent）、VReplication（跨 shard 資料移動）、VSchema（sharding metadata）。本文走 4 件套各自責任、keyspace / shard / tablet 架構、shard key 設計（Vindex）、配置 step-by-step、5 production 踩雷（cross-shard transaction / VStream lag / Vindex 不均勻 / resharding 切流 / VReplication 卡住）、跟自管 sharding 跟 PlanetScale 的對比">MySQL Vitess sharding</a>（跨 instance 切割對比）</li>
<li><a href="/blog/backend/01-database/vendors/mysql/online-schema-change-tools/" data-link-title="MySQL Online Schema Change：gh-ost 跟 pt-online-schema-change 兩條完全不同的 ghost table 路徑" data-link-desc="MySQL ALTER TABLE 可能鎖整張表，production 需要 online schema change 流程。gh-ost（GitHub）跟 pt-online-schema-change（Percona）都用 ghost table 解決、但底層機制完全不同：pt-osc 用 trigger 同步、gh-ost 用 binlog stream 同步。本文走兩工具機制對照表 → trigger vs binlog 各自取捨 → 配置 step-by-step → 5 production 踩雷（trigger overhead / binlog 延遲 / FK constraint / hot trigger lock / 切換瞬間 deadlock）→ 何時用哪一個">MySQL Online Schema Change</a>（partition table 的 schema change）</li>
<li><a href="/blog/backend/01-database/vendors/mysql/query-optimization/" data-link-title="MySQL Query Optimization：從 EXPLAIN 看到實際執行、5 條 query 從 5 秒變 50ms 的 anatomy" data-link-desc="MySQL query 慢的根因不在「SQL 寫法」、在「optimizer 選錯 plan」。本文從 5 個常見 production case 開場（5 秒 → 50ms / 30 秒 → 200ms / 8 秒 → 30ms 等）、走 EXPLAIN / EXPLAIN ANALYZE / optimizer trace 三層分析工具、index hint vs optimizer hint 取捨、cardinality estimation 失效時的修法、5 production 踩雷（statistics 過時 / forced index 用錯 / hash join 沒觸發 / range scan 退化 ALL / derived table materialization）">MySQL Query Optimization</a>（EXPLAIN PARTITIONS）</li>
<li><a href="/blog/backend/01-database/vendors/mysql/innodb-tuning/" data-link-title="MySQL InnoDB Tuning：為什麼一個 100 GB DB 在 64 GB RAM server 上 query 慢 5 倍" data-link-desc="InnoDB 是 MySQL 預設 storage engine、預設值給 256 MB buffer pool（早期 default）。本文從一個常見痛點開場（DB &gt; RAM 但 server 仍 swap）、走 4 個 critical knob（buffer pool / redo log / flush method / IO capacity）、各自如何影響讀寫吞吐、配置 step-by-step、5 production 踩雷（buffer pool warm-up / log file 大小 / 設 sync_binlog=0 換速度 / IO scheduler / undo log 膨脹）、跟 SSD / NVMe / EBS 的 IO 假設">MySQL InnoDB Tuning</a>（partition + buffer pool 互動）</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/declarative-partitioning/" data-link-title="PostgreSQL declarative partitioning：partition 不是切表、是讓 planner pruning" data-link-desc="Declarative partitioning 的真實價值是 query planner pruning &#43; maintenance scope 縮小、不是「把大表切小」；RANGE / LIST / HASH 取捨、partition key 選法、5 個 production 踩雷（key 選錯不 prune / unique 不 enforce 跨 partition / ATTACH 鎖太久 / partition 數爆 / DETACH 不 reclaim 空間）、跟 autovacuum &#43; index 設計整合">PostgreSQL Declarative Partitioning</a>（PG sibling 對比）</li>
<li><a href="/blog/backend/knowledge-cards/partition/" data-link-title="Partition" data-link-desc="說明事件流如何切分成多個可並行處理的有序片段">Partition 卡片</a></li>
<li>官方：<a href="https://dev.mysql.com/doc/refman/8.0/en/partitioning.html">MySQL Partitioning</a></li>
</ul>
]]></content:encoded></item><item><title>MySQL PITR + Backup Strategy：備份不是「拷貝資料」、是 N 點任意 restore 的能力</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/pitr-backup/</link><pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/pitr-backup/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/" data-link-title="MySQL" data-link-desc="高併發網路服務常用關聯式資料庫、Vitess / PlanetScale 分片生態、GitHub / Shopify / Facebook 規模驗證">MySQL&lt;/a> overview 的 implementation-layer deep article。Overview 已說明 MySQL 在 OLTP 譜系的定位、本文聚焦 &lt;em>backup + PITR&lt;/em> — 不是「拷貝資料」、是「N 點任意 restore 的能力」。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;p>「我們每天 mysqldump 一次、放 S3、沒問題吧」是個常見錯誤。問「能不能 restore 到 5 分鐘前」、答案會是 &lt;em>不能&lt;/em>。Dump-based backup 只能 restore 到 &lt;em>dump 那個瞬間&lt;/em>、5 分鐘前的事故無法 recover、必須等下次 dump。&lt;/p>
&lt;p>&lt;strong>真正的 backup strategy 是 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/point-in-time-recovery/" data-link-title="Point-in-Time Recovery" data-link-desc="說明如何用完整備份加上後續變更日誌，把資料庫還原到任意時間點">PITR（point-in-time recovery）&lt;/a>&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>&lt;em>能 restore 到任意過去時間點&lt;/em>（RPO 取決於 binlog flush 頻率、可接近 0）&lt;/li>
&lt;li>由 &lt;em>full backup 基線&lt;/em> + &lt;em>binlog 連續流&lt;/em>（從 backup 點到目標時間點的 incremental delta）組成&lt;/li>
&lt;li>Restore 過程：先 restore full backup → 再 apply binlog 到目標 timestamp 或 GTID&lt;/li>
&lt;/ul>
&lt;p>這篇 deep article 把 backup &lt;em>拆解成能力&lt;/em>、然後展開達到此能力需要的工具鏈跟工程紀律。&lt;/p>
&lt;h2 id="backup-三層責任">Backup 三層責任&lt;/h2>
&lt;p>PITR 的 &lt;em>能力&lt;/em> 由三層工程責任達成、任一層失效則 PITR 不成立：&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">Layer 1: Full Backup（基線）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> ↓ (mysqldump / XtraBackup / MyDumper / LVM snapshot / EBS snapshot)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> ↓
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">Layer 2: Binlog Stream（incremental）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> ↓ (sync_binlog=1 + binlog 持續流到 backup storage)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> ↓
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">Layer 3: Restore + Replay 流程
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl"> (能 restore full + 能 apply binlog 到目標時間點)&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>每層的 &lt;em>backup&lt;/em> 不夠 — 必須有 &lt;em>測試 restore 流程&lt;/em> 才算真的有 backup。「dump 在 S3」加「沒有 verified restore」= no backup。&lt;/p>
&lt;h2 id="tool-1mysqldump--邏輯備份最廣容最慢">Tool 1：mysqldump — 邏輯備份、最廣容、最慢&lt;/h2>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">mysqldump --single-transaction --master-data&lt;span class="o">=&lt;/span>&lt;span class="m">2&lt;/span> --gtid-purged&lt;span class="o">=&lt;/span>ON &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --triggers --routines --events &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --all-databases &amp;gt; full-backup.sql&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>輸出&lt;/strong>：SQL statement、純文字、可 grep / 編輯。&lt;/p>
&lt;p>&lt;strong>Trade-off&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>優點：跨 MySQL 版本（5.7 → 8.0 也讀）、跨 cloud / 跨 OS、可選 dump 部分 table&lt;/li>
&lt;li>缺點：&lt;em>極慢&lt;/em>（rebuild 整 DB 從 SQL execute）、大 DB（&amp;gt; 100 GB）不適用、restore 時長 hours+&lt;/li>
&lt;li>&lt;code>--single-transaction&lt;/code>：InnoDB only、用 REPEATABLE READ 拿 consistent snapshot、不 lock 表&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>適合&lt;/strong>：&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/mysql/" data-link-title="MySQL" data-link-desc="高併發網路服務常用關聯式資料庫、Vitess / PlanetScale 分片生態、GitHub / Shopify / Facebook 規模驗證">MySQL</a> overview 的 implementation-layer deep article。Overview 已說明 MySQL 在 OLTP 譜系的定位、本文聚焦 <em>backup + PITR</em> — 不是「拷貝資料」、是「N 點任意 restore 的能力」。</p></blockquote>
<hr>
<p>「我們每天 mysqldump 一次、放 S3、沒問題吧」是個常見錯誤。問「能不能 restore 到 5 分鐘前」、答案會是 <em>不能</em>。Dump-based backup 只能 restore 到 <em>dump 那個瞬間</em>、5 分鐘前的事故無法 recover、必須等下次 dump。</p>
<p><strong>真正的 backup strategy 是 <a href="/blog/backend/knowledge-cards/point-in-time-recovery/" data-link-title="Point-in-Time Recovery" data-link-desc="說明如何用完整備份加上後續變更日誌，把資料庫還原到任意時間點">PITR（point-in-time recovery）</a></strong>：</p>
<ul>
<li><em>能 restore 到任意過去時間點</em>（RPO 取決於 binlog flush 頻率、可接近 0）</li>
<li>由 <em>full backup 基線</em> + <em>binlog 連續流</em>（從 backup 點到目標時間點的 incremental delta）組成</li>
<li>Restore 過程：先 restore full backup → 再 apply binlog 到目標 timestamp 或 GTID</li>
</ul>
<p>這篇 deep article 把 backup <em>拆解成能力</em>、然後展開達到此能力需要的工具鏈跟工程紀律。</p>
<h2 id="backup-三層責任">Backup 三層責任</h2>
<p>PITR 的 <em>能力</em> 由三層工程責任達成、任一層失效則 PITR 不成立：</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">Layer 1: Full Backup（基線）
</span></span><span class="line"><span class="ln">2</span><span class="cl">   ↓     (mysqldump / XtraBackup / MyDumper / LVM snapshot / EBS snapshot)
</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">Layer 2: Binlog Stream（incremental）
</span></span><span class="line"><span class="ln">5</span><span class="cl">   ↓     (sync_binlog=1 + binlog 持續流到 backup storage)
</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">Layer 3: Restore + Replay 流程
</span></span><span class="line"><span class="ln">8</span><span class="cl">         (能 restore full + 能 apply binlog 到目標時間點)</span></span></code></pre></div><p>每層的 <em>backup</em> 不夠 — 必須有 <em>測試 restore 流程</em> 才算真的有 backup。「dump 在 S3」加「沒有 verified restore」= no backup。</p>
<h2 id="tool-1mysqldump--邏輯備份最廣容最慢">Tool 1：mysqldump — 邏輯備份、最廣容、最慢</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">mysqldump --single-transaction --master-data<span class="o">=</span><span class="m">2</span> --gtid-purged<span class="o">=</span>ON <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --triggers --routines --events <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --all-databases &gt; full-backup.sql</span></span></code></pre></div><p><strong>輸出</strong>：SQL statement、純文字、可 grep / 編輯。</p>
<p><strong>Trade-off</strong>：</p>
<ul>
<li>優點：跨 MySQL 版本（5.7 → 8.0 也讀）、跨 cloud / 跨 OS、可選 dump 部分 table</li>
<li>缺點：<em>極慢</em>（rebuild 整 DB 從 SQL execute）、大 DB（&gt; 100 GB）不適用、restore 時長 hours+</li>
<li><code>--single-transaction</code>：InnoDB only、用 REPEATABLE READ 拿 consistent snapshot、不 lock 表</li>
</ul>
<p><strong>適合</strong>：</p>
<ul>
<li>&lt; 100 GB DB</li>
<li>Schema dump（migration / 給 dev clone DB）</li>
<li>跨版本 migrate</li>
<li>配 binlog 做 PITR baseline</li>
</ul>
<p><strong>不適合</strong>：</p>
<ul>
<li>
<blockquote>
<p>500 GB DB（restore 跑 days）</p></blockquote>
</li>
<li>高吞吐 production（dump 跑時 hold MVCC read view、bloat）</li>
</ul>
<h2 id="tool-2percona-xtrabackup--物理備份快production-標準">Tool 2：Percona XtraBackup — 物理備份、快、production 標準</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">xtrabackup --backup --target-dir<span class="o">=</span>/backup/full-2026-05-19 <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --user<span class="o">=</span>backup --password<span class="o">=</span>... <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --slave-info --safe-slave-backup
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># Prepare（apply 內部 redo log、變成可 restore 狀態）</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">xtrabackup --prepare --target-dir<span class="o">=</span>/backup/full-2026-05-19</span></span></code></pre></div><p><strong>輸出</strong>：InnoDB 資料檔案的 binary copy。</p>
<p><strong>Trade-off</strong>：</p>
<ul>
<li>優點：<em>極快</em>（直接 copy file、無 SQL execute）、適合 TB-scale DB、restore 跑時間跟 copy file 同</li>
<li>缺點：MySQL 版本綁定（XtraBackup 8.0 不能 restore 5.7 backup）、有 storage engine 限制（只 InnoDB）</li>
<li><em>Incremental backup</em> 支援：基於 LSN（log sequence number）只 copy 變更 page</li>
</ul>
<p><strong>Incremental flow</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># Day 1: Full backup</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">xtrabackup --backup --target-dir<span class="o">=</span>/backup/full-day1
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"># Day 2: Incremental（only changes since day 1）</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">xtrabackup --backup --target-dir<span class="o">=</span>/backup/inc-day2 <span class="se">\
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="se"></span>  --incremental-basedir<span class="o">=</span>/backup/full-day1
</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 class="c1"># Restore: Apply incremental on top of full</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">xtrabackup --prepare --apply-log-only --target-dir<span class="o">=</span>/backup/full-day1
</span></span><span class="line"><span class="ln">10</span><span class="cl">xtrabackup --prepare --apply-log-only --target-dir<span class="o">=</span>/backup/full-day1 <span class="se">\
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="se"></span>  --incremental-dir<span class="o">=</span>/backup/inc-day2
</span></span><span class="line"><span class="ln">12</span><span class="cl">xtrabackup --prepare --target-dir<span class="o">=</span>/backup/full-day1</span></span></code></pre></div><p><strong>適合</strong>：</p>
<ul>
<li>
<blockquote>
<p>100 GB production DB</p></blockquote>
</li>
<li>每日 incremental + 週一次 full（典型 enterprise schedule）</li>
<li>從自管 MySQL 遷 cloud（XtraBackup + rsync 到 cloud restore）</li>
</ul>
<p><strong>不適合</strong>：</p>
<ul>
<li>Schema-only dump（用 mysqldump 更簡單）</li>
<li>跨 major version restore</li>
</ul>
<h2 id="tool-3mydumper--並行邏輯備份">Tool 3：MyDumper — 並行邏輯備份</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">mydumper --user<span class="o">=</span>backup --password<span class="o">=</span>... <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --threads<span class="o">=</span><span class="m">8</span> --rows<span class="o">=</span><span class="m">100000</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --outputdir<span class="o">=</span>/backup/mydumper-2026-05-19 <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --less-locking</span></span></code></pre></div><p><strong>輸出</strong>：每張 table 一個 <code>.sql</code> file（schema） + 多個 chunked <code>.dat</code> file（資料）。</p>
<p><strong>Trade-off</strong>：</p>
<ul>
<li>優點：<em>並行 dump</em>（per-table thread）、比 mysqldump 快 5-10x、可恢復斷點（resume）</li>
<li>缺點：tooling 不如 mysqldump 普及、需要單獨裝</li>
<li>對應的 <code>myloader</code> restore：也並行、比 mysqldump restore 快 5-10x</li>
</ul>
<p><strong>適合</strong>：</p>
<ul>
<li>100 GB - 1 TB 範圍</li>
<li>中型 production、想要邏輯備份的可讀性 + 並行加速</li>
</ul>
<h2 id="tool-4lvm--ebs-snapshot--物理-file-system-層">Tool 4：LVM / EBS Snapshot — 物理 file system 層</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 1. Freeze MySQL（讓 write 暫停）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">mysql&gt; FLUSH TABLES WITH READ LOCK<span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"># 2. Trigger snapshot（EBS / LVM）</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">aws ec2 create-snapshot --volume-id vol-xxx --description <span class="s2">&#34;mysql-2026-05-19&#34;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># 3. Unfreeze</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">mysql&gt; UNLOCK TABLES<span class="p">;</span></span></span></code></pre></div><p><strong>Trade-off</strong>：</p>
<ul>
<li>優點：超快（file system 層）、適合 <em>VM-based MySQL</em>（EC2 / on-prem）</li>
<li>缺點：必須 <em>暫停 write</em>（短時間 lock）、不能跨 OS / cloud 移植</li>
<li>AWS RDS / Aurora 全部走這條路（自動 snapshot）</li>
</ul>
<p><strong>適合</strong>：</p>
<ul>
<li>AWS RDS / Aurora（自動）</li>
<li>自管 MySQL on EC2 with EBS（EBS snapshot 結合 mysql freeze）</li>
<li>大 DB 想要 fast backup + fast restore</li>
</ul>
<h2 id="binlog-based-pitr">Binlog-based PITR</h2>
<p>Full backup 加上 binlog 才能達到 PITR。Binlog 是 MySQL replication / CDC / PITR 共用的 source。</p>
<p><strong>配置</strong>：</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="k">[mysqld]</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="na">log_bin</span> <span class="o">=</span> <span class="s">mysql-bin</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="na">binlog_format</span> <span class="o">=</span> <span class="s">ROW                  # ROW 必須</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="na">binlog_row_image</span> <span class="o">=</span> <span class="s">FULL              # 完整 row image</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="na">sync_binlog</span> <span class="o">=</span> <span class="s">1                      # 每次 commit fsync binlog（zero loss）</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="na">binlog_expire_logs_seconds</span> <span class="o">=</span> <span class="s">1209600 # 14 天 retention（依需求調）</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="na">gtid_mode</span> <span class="o">=</span> <span class="s">ON                       # GTID 必須、PITR 用 GTID 識別 transaction</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="na">enforce_gtid_consistency</span> <span class="o">=</span> <span class="s">ON</span></span></span></code></pre></div><p><strong>Binlog backup</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 持續 stream binlog 到 backup storage</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">mysqlbinlog --read-from-remote-server --raw --stop-never <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --user<span class="o">=</span>replication --password<span class="o">=</span>... <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --host<span class="o">=</span>primary.example.com <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  --result-file<span class="o">=</span>/backup/binlog/ mysql-bin.000001 <span class="p">&amp;</span></span></span></code></pre></div><p><code>--read-from-remote-server</code> + <code>--stop-never</code> 持續從 primary tail binlog、不間斷 stream 到 backup directory。每個 binlog file 寫滿後 close + 開新 file。</p>
<h2 id="restore--pitr-流程">Restore + PITR 流程</h2>
<p>完整 PITR 流程（restore 到 2026-05-19 14:30:00）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># Step 1: Restore full backup</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">xtrabackup --copy-back --target-dir<span class="o">=</span>/backup/full-2026-05-18  <span class="c1"># 前一天 full</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"># Step 2: 啟動 MySQL（會看到 backup 拿那刻的 GTID set）</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">systemctl start mysqld
</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 class="c1"># Step 3: 查 full backup 結束時的 GTID</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">mysql&gt; SHOW MASTER STATUS<span class="p">;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">+------------------+----------+------------------------------------------+
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">|</span> File             <span class="p">|</span> Position <span class="p">|</span> Executed_Gtid_Set                        <span class="p">|</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">+------------------+----------+------------------------------------------+
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">|</span> mysql-bin.000150 <span class="p">|</span>     <span class="m">1234</span> <span class="p">|</span> server-uuid:1-12345                      <span class="p">|</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">+------------------+----------+------------------------------------------+
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="c1"># Step 4: Apply binlog 從 backup 之後到目標時間</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">mysqlbinlog --start-datetime<span class="o">=</span><span class="s2">&#34;2026-05-18 03:00:00&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="se"></span>            --stop-datetime<span class="o">=</span><span class="s2">&#34;2026-05-19 14:30:00&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="se"></span>            /backup/binlog/mysql-bin.000150 <span class="se">\
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="se"></span>            /backup/binlog/mysql-bin.000151 <span class="se">\
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="se"></span>            ...                                <span class="c1"># 列所有需要的 binlog</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">            <span class="p">|</span> mysql -u root -p
</span></span><span class="line"><span class="ln">22</span><span class="cl">
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="c1"># Step 5: 驗證 GTID set 到目標時間點對應的位置</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">mysql&gt; SHOW MASTER STATUS<span class="p">;</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="c1"># Executed_Gtid_Set 應包含到目標時間點的 transaction</span></span></span></code></pre></div><p>對 <em>精確 GTID-based PITR</em>（停在特定 transaction、不是 timestamp）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">mysqlbinlog --include-gtids<span class="o">=</span><span class="s1">&#39;server-uuid:1-50000&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>            /backup/binlog/mysql-bin.000150 ... <span class="p">|</span> mysql -u root -p</span></span></code></pre></div><h2 id="5-個-production-踩雷">5 個 Production 踩雷</h2>
<h3 id="1-gtid-處理不一致--restore-後-replication-broken">1. GTID 處理不一致 — Restore 後 replication broken</h3>
<p>XtraBackup restore 時 <code>--slave-info</code> 紀錄 GTID purged set、mysqldump 用 <code>--gtid-purged=ON</code>。如果 restore 後沒正確 set <code>gtid_purged</code>、replica re-attach 時 GTID gap error。</p>
<p>修法：</p>
<ul>
<li>XtraBackup restore：用 <code>xtrabackup_binlog_info</code> 內的 GTID set 設 <code>SET GLOBAL gtid_purged='...';</code></li>
<li>mysqldump：dump file 內已有 <code>SET @@GLOBAL.GTID_PURGED='...';</code>、執行 dump 自動 set</li>
<li>Restore 後 <em>先驗證 <code>Executed_Gtid_Set</code></em> 跟 source 預期對齊、再 START SLAVE</li>
</ul>
<h3 id="2-binlog-gap--中間遺漏-file-直接-restore-fail">2. Binlog gap — 中間遺漏 file 直接 restore fail</h3>
<p>Binlog stream 失聯（network blip / disk full）+ binlog rotate、<code>mysql-bin.000156</code> 不在 backup storage 內。PITR 試圖跨過該 file restore、跳過已 commit transaction、結果 <em>資料不一致</em>（不是錯誤、是 <em>silently incorrect</em>）。</p>
<p>修法：</p>
<ul>
<li><em>Binlog stream 必須持續</em>、失聯 → alert</li>
<li>監控 backup storage 內 binlog 連續性（file name 連號、無 gap）</li>
<li>Restore 前 <em>先驗證 binlog 完整性</em>：<code>mysqlbinlog --verify-binlog-checksum *.bin &gt; /dev/null</code></li>
<li>對 missing binlog <em>中止 PITR</em>、不繼續 partial restore</li>
</ul>
<h3 id="3-backup-沒-verify--真事故時才發現-restore-broken">3. Backup 沒 verify — 真事故時才發現 restore broken</h3>
<p>每天備份成功、storage 用了 5 TB、實際 <em>從未 restore 過</em>。事故發生 restore 才知道 backup file corrupt / GTID 錯 / binlog gap、整套無用。</p>
<p>修法：</p>
<ul>
<li><em>自動化 restore test</em>：每週 / 每月在 staging server 跑完整 restore + PITR、跑完 SELECT 比對 production</li>
<li>驗證 restore 後 row count 跟 production 接近、<code>CHECKSUM TABLE</code> 比對主要 table</li>
<li>真的事故時 RTO 才不會 surprise</li>
</ul>
<h3 id="4-rpo-不到-1-分鐘的代價">4. RPO 不到 1 分鐘的代價</h3>
<p>「我要 RPO &lt; 1 分鐘」聽起來合理、但實現需要：</p>
<ul>
<li><code>sync_binlog=1</code>（每 commit fsync、寫吞吐降 10-30%）</li>
<li>Binlog stream 到 <em>獨立 storage</em>（不只是 primary local disk）、cross-region replication（額外 network cost）</li>
<li>Replica 也用 semi-sync 配合（zero binlog loss）</li>
<li>監控 + alert RPO 違反（&lt; 1 分鐘 stream lag）</li>
</ul>
<p><strong>TCO</strong>：~30% 寫吞吐 penalty + 額外 storage / network cost + 7x24 on-call。考慮 <em>real RPO requirement</em> — 多數 application 5 分鐘 RPO 已足夠、追求 1 分鐘 RPO 不划算。</p>
<p>修法：</p>
<ul>
<li>跟 product / business 確認 <em>真 RPO 要求</em></li>
<li><em>RPO budget = 寫吞吐 trade-off + ops cost</em>、不是 free</li>
<li>用 <a href="/blog/backend/01-database/vendors/aurora/" data-link-title="AWS Aurora" data-link-desc="AWS managed PostgreSQL / MySQL、storage / compute 分離、&#43;75% 效能改善的 production 證據">Aurora</a> / managed offering 把 RPO 議題 outsource（Aurora &lt; 1 秒 RPO + 自動 cross-AZ）</li>
</ul>
<h3 id="5-encryption-key-沒備份--restore-後解不開資料">5. Encryption key 沒備份 — Restore 後解不開資料</h3>
<p>啟用 <em>encryption at rest</em>（MySQL 8.0+ <code>default_table_encryption=ON</code> + keyring plugin / component；MariaDB 用 <code>innodb_encrypt_tables</code>）後、所有 InnoDB tablespace 都加密。Master key 在 <em>keyring file</em> 或 KMS-backed component。如果 backup 只 backup MySQL data file、沒備 keyring、restore 後資料 <em>encrypted 但無 key、無法讀</em>。</p>
<p>修法：</p>
<ul>
<li><em>Keyring file 跟 data file 分開儲存</em>、但兩者 <em>都要 backup</em></li>
<li>用 <em>KMS-based keyring</em>（AWS KMS / HashiCorp Vault）取代 file-based、key 不在 MySQL server 上</li>
<li>Disaster recovery runbook 紀錄 <em>key recovery 流程</em>、不要假設「重 install MySQL」就能解</li>
</ul>
<h2 id="容量規劃要點">容量規劃要點</h2>
<table>
  <thead>
      <tr>
          <th>項目</th>
          <th>建議</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Full backup 頻率</td>
          <td>週一次（XtraBackup）或日一次（小 DB）</td>
      </tr>
      <tr>
          <td>Incremental 頻率</td>
          <td>每日（XtraBackup incremental）</td>
      </tr>
      <tr>
          <td>Binlog retention</td>
          <td>14 天（給 PITR window）</td>
      </tr>
      <tr>
          <td>Backup retention</td>
          <td>Full × 4 週 + 月度 archive × 12 個月</td>
      </tr>
      <tr>
          <td>Storage cost</td>
          <td>約 2-3x DB size（full + incremental + binlog）</td>
      </tr>
      <tr>
          <td>Cross-region copy</td>
          <td>必要（local backup 失效時還有 disaster recovery）</td>
      </tr>
      <tr>
          <td>Restore test 頻率</td>
          <td>每週 staging 上跑、每月 production-like 跑</td>
      </tr>
  </tbody>
</table>
<h2 id="跟其他模組整合">跟其他模組整合</h2>
<h3 id="跟-replication-topology">跟 Replication topology</h3>
<p>Replication replica 不能取代 backup — replica 上的 DROP TABLE 也會被 replicate、replica 上資料同樣消失。Backup 是 <em>獨立保險</em>。詳見 <a href="/blog/backend/01-database/vendors/mysql/replication-topology/" data-link-title="MySQL Replication Topology：async / semi-sync / GTID 不是三選一、是三個 trade-off 軸的疊加" data-link-desc="MySQL replication 不是「選 async 還是 semi-sync」、是 *durability / latency / consistency* 三個 trade-off 軸的疊加；GTID 是跨 mode 的 infrastructure layer、不是第三種 mode。本文走 3 軸取捨模型 → async / semi-sync 行為對比 → GTID 替代 binlog-position 的好處 → 配置 step-by-step → 5 production 踩雷（lag 暴衝 / semi-sync 退回 async / GTID gap / Loss-Less semi-sync 真的 loss-less / chained replication 雪崩）→ 跟 Aurora MySQL / Vitess / ProxySQL / Orchestrator 整合">Replication Topology</a>。</p>
<h3 id="跟-innodb-tuning">跟 InnoDB Tuning</h3>
<p><code>innodb_flush_log_at_trx_commit=1</code> + <code>sync_binlog=1</code> 是 backup-friendly 的設定（zero loss）、但寫吞吐降。如果為了寫吞吐放寬 durability、必須接受 <em>PITR window</em> 也 widening。詳見 <a href="/blog/backend/01-database/vendors/mysql/innodb-tuning/" data-link-title="MySQL InnoDB Tuning：為什麼一個 100 GB DB 在 64 GB RAM server 上 query 慢 5 倍" data-link-desc="InnoDB 是 MySQL 預設 storage engine、預設值給 256 MB buffer pool（早期 default）。本文從一個常見痛點開場（DB &gt; RAM 但 server 仍 swap）、走 4 個 critical knob（buffer pool / redo log / flush method / IO capacity）、各自如何影響讀寫吞吐、配置 step-by-step、5 production 踩雷（buffer pool warm-up / log file 大小 / 設 sync_binlog=0 換速度 / IO scheduler / undo log 膨脹）、跟 SSD / NVMe / EBS 的 IO 假設">InnoDB Tuning</a>。</p>
<h3 id="跟-aurora-mysql">跟 Aurora MySQL</h3>
<p>Aurora 完全 outsource backup — automatic continuous backup + PITR &lt; 1 秒、不必管 mysqldump / XtraBackup / binlog stream。從 Aurora 遷出時、需要重新建 self-managed backup chain。詳見 <a href="/blog/backend/01-database/vendors/mysql/migrate-to-aurora/" data-link-title="MySQL → Aurora MySQL：storage layer 轉手到 AWS、replication / HA / backup 全部 outsource" data-link-desc="自管 MySQL → Aurora MySQL 是 Type C operational hybrid migration — wire protocol 一致、ops 責任轉到 AWS。本文走 6 維 audit（Operational High）、Aurora storage architecture 衝擊、4-phase migration、5 production 踩雷、何時維持原路線。">migrate-to-aurora</a>。</p>
<h3 id="跟-postgresql-pitr">跟 PostgreSQL PITR</h3>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>MySQL PITR</th>
          <th>PostgreSQL PITR</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Logical backup</td>
          <td>mysqldump / MyDumper</td>
          <td>pg_dump / pg_dumpall</td>
      </tr>
      <tr>
          <td>Physical backup</td>
          <td>XtraBackup</td>
          <td>pg_basebackup / pgBackRest</td>
      </tr>
      <tr>
          <td>Incremental log</td>
          <td>Binary log（binlog）</td>
          <td>WAL (Write-Ahead Log)</td>
      </tr>
      <tr>
          <td>Stream tool</td>
          <td>mysqlbinlog &ndash;read-from-remote-server</td>
          <td>pg_receivewal</td>
      </tr>
      <tr>
          <td>PITR command</td>
          <td>mysqlbinlog &ndash;stop-datetime</td>
          <td>pg_ctl + recovery.conf / standby.signal</td>
      </tr>
      <tr>
          <td>Identifier</td>
          <td>GTID 或 file:position</td>
          <td>LSN（Log Sequence Number）</td>
      </tr>
      <tr>
          <td>Cross-version</td>
          <td>mysqldump（廣容）</td>
          <td>pg_dump（廣容）</td>
      </tr>
  </tbody>
</table>
<p>兩家 PITR 概念類似（full + log replay）、tool name 不同、概念對等。詳見 <a href="/blog/backend/01-database/vendors/postgresql/pitr-wal-archiving/" data-link-title="PostgreSQL PITR &#43; WAL archiving：從 base backup 到 point-in-time recovery 的完整鏈" data-link-desc="Base backup &#43; WAL archive 構成 PITR 的雙軌資料、archive_command &#43; restore_command 配置、用 pgBackRest / WAL-G 替代手寫腳本、5 個 production 踩雷（archive 靜默失敗 / archive lag / 錯誤 target time / base backup 過期未清 / timeline 分歧 recovery 模糊）、跟 Patroni &#43; monitoring 整合">PostgreSQL PITR + WAL Archiving</a>。</p>
<h2 id="何時-outsource-backup">何時 outsource backup</h2>
<table>
  <thead>
      <tr>
          <th>場景</th>
          <th>建議</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>AWS 生態 + 不想管 backup ops</td>
          <td>Aurora MySQL（內建 PITR）</td>
      </tr>
      <tr>
          <td>GCP 生態</td>
          <td>Cloud SQL（內建 PITR）</td>
      </tr>
      <tr>
          <td>Azure 生態</td>
          <td>Azure DB for MySQL</td>
      </tr>
      <tr>
          <td>跨雲 + 想自管</td>
          <td>XtraBackup + binlog stream + S3</td>
      </tr>
      <tr>
          <td>規模小、可接受 mysqldump</td>
          <td>mysqldump cron + S3</td>
      </tr>
      <tr>
          <td>規模大、無 cloud</td>
          <td>Percona XtraBackup Enterprise + tape archive</td>
      </tr>
      <tr>
          <td>強合規（HIPAA / PCI-DSS）</td>
          <td>自管 + air-gap backup + audit trail</td>
      </tr>
  </tbody>
</table>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/mysql/" data-link-title="MySQL" data-link-desc="高併發網路服務常用關聯式資料庫、Vitess / PlanetScale 分片生態、GitHub / Shopify / Facebook 規模驗證">MySQL vendor overview</a></li>
<li><a href="/blog/backend/01-database/vendors/mysql/replication-topology/" data-link-title="MySQL Replication Topology：async / semi-sync / GTID 不是三選一、是三個 trade-off 軸的疊加" data-link-desc="MySQL replication 不是「選 async 還是 semi-sync」、是 *durability / latency / consistency* 三個 trade-off 軸的疊加；GTID 是跨 mode 的 infrastructure layer、不是第三種 mode。本文走 3 軸取捨模型 → async / semi-sync 行為對比 → GTID 替代 binlog-position 的好處 → 配置 step-by-step → 5 production 踩雷（lag 暴衝 / semi-sync 退回 async / GTID gap / Loss-Less semi-sync 真的 loss-less / chained replication 雪崩）→ 跟 Aurora MySQL / Vitess / ProxySQL / Orchestrator 整合">MySQL Replication Topology</a>（binlog 跟 PITR 共用 source）</li>
<li><a href="/blog/backend/01-database/vendors/mysql/innodb-tuning/" data-link-title="MySQL InnoDB Tuning：為什麼一個 100 GB DB 在 64 GB RAM server 上 query 慢 5 倍" data-link-desc="InnoDB 是 MySQL 預設 storage engine、預設值給 256 MB buffer pool（早期 default）。本文從一個常見痛點開場（DB &gt; RAM 但 server 仍 swap）、走 4 個 critical knob（buffer pool / redo log / flush method / IO capacity）、各自如何影響讀寫吞吐、配置 step-by-step、5 production 踩雷（buffer pool warm-up / log file 大小 / 設 sync_binlog=0 換速度 / IO scheduler / undo log 膨脹）、跟 SSD / NVMe / EBS 的 IO 假設">MySQL InnoDB Tuning</a>（durability + backup 互動）</li>
<li><a href="/blog/backend/01-database/vendors/mysql/migrate-to-aurora/" data-link-title="MySQL → Aurora MySQL：storage layer 轉手到 AWS、replication / HA / backup 全部 outsource" data-link-desc="自管 MySQL → Aurora MySQL 是 Type C operational hybrid migration — wire protocol 一致、ops 責任轉到 AWS。本文走 6 維 audit（Operational High）、Aurora storage architecture 衝擊、4-phase migration、5 production 踩雷、何時維持原路線。">migrate-to-aurora</a>（backup outsource）</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/pitr-wal-archiving/" data-link-title="PostgreSQL PITR &#43; WAL archiving：從 base backup 到 point-in-time recovery 的完整鏈" data-link-desc="Base backup &#43; WAL archive 構成 PITR 的雙軌資料、archive_command &#43; restore_command 配置、用 pgBackRest / WAL-G 替代手寫腳本、5 個 production 踩雷（archive 靜默失敗 / archive lag / 錯誤 target time / base backup 過期未清 / timeline 分歧 recovery 模糊）、跟 Patroni &#43; monitoring 整合">PostgreSQL PITR + WAL Archiving</a>（PG sibling）</li>
<li>官方：<a href="https://docs.percona.com/percona-xtrabackup/8.0/">Percona XtraBackup</a> / <a href="https://github.com/mydumper/mydumper">MyDumper</a> / <a href="https://dev.mysql.com/doc/refman/8.0/en/mysqldump.html">mysqldump</a></li>
</ul>
]]></content:encoded></item><item><title>MySQL Lock Contention：在 staging 重現的 deadlock、production 跑 6 個月才出現</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/lock-contention/</link><pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/lock-contention/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/" data-link-title="MySQL" data-link-desc="高併發網路服務常用關聯式資料庫、Vitess / PlanetScale 分片生態、GitHub / Shopify / Facebook 規模驗證">MySQL&lt;/a> overview 的 implementation-layer deep article。Overview 已說明 MySQL 在 OLTP 譜系的定位、本文聚焦 &lt;em>lock contention&lt;/em> — 5 種 lock type + isolation level 互動 + production debug。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="開場案例">開場案例&lt;/h2>
&lt;p>Application 跑了 6 個月、staging 100% 重現過的 deadlock 從來沒在 production 出現。某天 traffic 上升 30%、production 開始爆 &lt;code>ER_LOCK_DEADLOCK&lt;/code>、application retry 不夠快、order 大量失敗。&lt;/p>
&lt;p>&lt;code>SHOW ENGINE INNODB STATUS\G&lt;/code> 拉出 deadlock：&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">*** (1) TRANSACTION:
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">TRANSACTION 12345, ACTIVE 1 sec starting index read
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">mysql tables in use 1, locked 1
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">LOCK WAIT 4 lock struct(s), heap size 1136, 3 row lock(s)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">MySQL thread id 100, query id 5000 update orders
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">UPDATE orders SET status = &amp;#39;shipped&amp;#39; WHERE id = 500
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">RECORD LOCKS space id 50 page no 5 n bits 80 index PRIMARY of table `production`.`orders`
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">trx id 12345 lock_mode X locks rec but not gap waiting
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">*** (2) TRANSACTION:
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">TRANSACTION 12346, ACTIVE 1 sec starting index read
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">mysql tables in use 1, locked 1
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">4 lock struct(s), heap size 1136, 4 row lock(s)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">MySQL thread id 101, query id 5001 update payments
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">UPDATE payments SET captured = 1 WHERE order_id = 500
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl">*** (2) HOLDS THE LOCK(S):
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl">RECORD LOCKS space id 50 page no 5 n bits 80 index PRIMARY of table `production`.`orders`
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl">trx id 12346 lock_mode X locks rec but not gap
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl">*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl">RECORD LOCKS space id 51 page no 10 n bits 80 index idx_order_id of table `production`.`payments`
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">25&lt;/span>&lt;span class="cl">trx id 12346 lock_mode X waiting
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">26&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">27&lt;/span>&lt;span class="cl">*** WE ROLL BACK TRANSACTION (1)&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>兩個 transaction 各自拿了一邊 lock、互相等對方的、deadlock。為什麼 staging 重現過、production 6 個月才爆？因為 &lt;strong>lock contention 是 &lt;em>可能性&lt;/em> 不是 &lt;em>確定性&lt;/em>&lt;/strong> — staging 重現等於確認「程式邏輯有 deadlock risk」、production 6 個月平安等於「concurrency 還沒撞到」。Traffic 上升把 &lt;em>機率乘以 N&lt;/em>、原本每天 0 次變每分鐘 5 次。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/mysql/" data-link-title="MySQL" data-link-desc="高併發網路服務常用關聯式資料庫、Vitess / PlanetScale 分片生態、GitHub / Shopify / Facebook 規模驗證">MySQL</a> overview 的 implementation-layer deep article。Overview 已說明 MySQL 在 OLTP 譜系的定位、本文聚焦 <em>lock contention</em> — 5 種 lock type + isolation level 互動 + production debug。</p></blockquote>
<hr>
<h2 id="開場案例">開場案例</h2>
<p>Application 跑了 6 個月、staging 100% 重現過的 deadlock 從來沒在 production 出現。某天 traffic 上升 30%、production 開始爆 <code>ER_LOCK_DEADLOCK</code>、application retry 不夠快、order 大量失敗。</p>
<p><code>SHOW ENGINE INNODB STATUS\G</code> 拉出 deadlock：</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">*** (1) TRANSACTION:
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">TRANSACTION 12345, ACTIVE 1 sec starting index read
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">mysql tables in use 1, locked 1
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">LOCK WAIT 4 lock struct(s), heap size 1136, 3 row lock(s)
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">MySQL thread id 100, query id 5000 update orders
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">UPDATE orders SET status = &#39;shipped&#39; WHERE id = 500
</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">*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">RECORD LOCKS space id 50 page no 5 n bits 80 index PRIMARY of table `production`.`orders`
</span></span><span class="line"><span class="ln">10</span><span class="cl">trx id 12345 lock_mode X locks rec but not gap waiting
</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">*** (2) TRANSACTION:
</span></span><span class="line"><span class="ln">13</span><span class="cl">TRANSACTION 12346, ACTIVE 1 sec starting index read
</span></span><span class="line"><span class="ln">14</span><span class="cl">mysql tables in use 1, locked 1
</span></span><span class="line"><span class="ln">15</span><span class="cl">4 lock struct(s), heap size 1136, 4 row lock(s)
</span></span><span class="line"><span class="ln">16</span><span class="cl">MySQL thread id 101, query id 5001 update payments
</span></span><span class="line"><span class="ln">17</span><span class="cl">UPDATE payments SET captured = 1 WHERE order_id = 500
</span></span><span class="line"><span class="ln">18</span><span class="cl">
</span></span><span class="line"><span class="ln">19</span><span class="cl">*** (2) HOLDS THE LOCK(S):
</span></span><span class="line"><span class="ln">20</span><span class="cl">RECORD LOCKS space id 50 page no 5 n bits 80 index PRIMARY of table `production`.`orders`
</span></span><span class="line"><span class="ln">21</span><span class="cl">trx id 12346 lock_mode X locks rec but not gap
</span></span><span class="line"><span class="ln">22</span><span class="cl">
</span></span><span class="line"><span class="ln">23</span><span class="cl">*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
</span></span><span class="line"><span class="ln">24</span><span class="cl">RECORD LOCKS space id 51 page no 10 n bits 80 index idx_order_id of table `production`.`payments`
</span></span><span class="line"><span class="ln">25</span><span class="cl">trx id 12346 lock_mode X waiting
</span></span><span class="line"><span class="ln">26</span><span class="cl">
</span></span><span class="line"><span class="ln">27</span><span class="cl">*** WE ROLL BACK TRANSACTION (1)</span></span></code></pre></div><p>兩個 transaction 各自拿了一邊 lock、互相等對方的、deadlock。為什麼 staging 重現過、production 6 個月才爆？因為 <strong>lock contention 是 <em>可能性</em> 不是 <em>確定性</em></strong> — staging 重現等於確認「程式邏輯有 deadlock risk」、production 6 個月平安等於「concurrency 還沒撞到」。Traffic 上升把 <em>機率乘以 N</em>、原本每天 0 次變每分鐘 5 次。</p>
<p>這個 case 揭露 MySQL lock 教學的核心：理解 lock 不只是 <em>debug 跑 deadlock 報錯</em> 的能力、是 <em>讀 query 預測 lock pattern</em> 的能力。</p>
<h2 id="innodb-5-種-lock-類型">InnoDB 5 種 Lock 類型</h2>
<p>InnoDB 不是 <em>簡單 row lock</em>、有 5 個獨立 lock concept：</p>
<h3 id="1-record-lock--鎖-row">1. Record Lock — 鎖 row</h3>
<p><code>SELECT ... FOR UPDATE</code> / UPDATE / DELETE 對 <em>被 match 的 row</em> 加 record lock。</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">-- Transaction 1
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">BEGIN</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="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">100</span><span class="w"> </span><span class="k">FOR</span><span class="w"> </span><span class="k">UPDATE</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="c1">-- 對 id=100 的 row 加 record lock</span></span></span></code></pre></div><p>Transaction 2 試 <code>UPDATE orders WHERE id = 100</code> 必須等。</p>
<h3 id="2-gap-lock--鎖-row-之間的空隙">2. Gap Lock — 鎖 row 之間的「空隙」</h3>
<p>InnoDB 在 <em>REPEATABLE READ</em> (預設) 下、<code>SELECT ... FOR UPDATE WHERE col &gt; 100</code> 不只 lock 符合的 row、<em>也 lock 該 range 內的「空隙」</em>、防其他 transaction INSERT 進這個 range。</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">-- 已存在 orders: id=100, 200, 300
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">BEGIN</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="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="mi">100</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="mi">300</span><span class="w"> </span><span class="k">FOR</span><span class="w"> </span><span class="k">UPDATE</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="c1">-- Lock id=200 + gap lock (100, 200) + gap lock (200, 300)</span></span></span></code></pre></div><p>Transaction 2 試 <code>INSERT INTO orders (id) VALUES (150)</code> 必須等 — 即使 id=150 不存在、gap lock 阻擋 INSERT。</p>
<p><strong>Gap lock 是 deadlock 最常見來源</strong> — application logic 看 row、但 lock 卻 cover row 之外的空隙、難預測。</p>
<h3 id="3-next-key-lock--record--gap-組合">3. Next-Key Lock — Record + Gap 組合</h3>
<p>預設 lock 行為。<code>SELECT ... FOR UPDATE WHERE col = 100</code> 對 id=100 的 record lock + id=100 之前的 gap lock。</p>
<p>Lock 的範圍實際是 <em>半開區間</em> (previous_id, current_id]：</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">Records: 100, 200, 300
</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">WHERE id = 100 FOR UPDATE → next-key lock (-inf, 100]
</span></span><span class="line"><span class="ln">4</span><span class="cl">WHERE id = 200 FOR UPDATE → next-key lock (100, 200]
</span></span><span class="line"><span class="ln">5</span><span class="cl">WHERE id = 300 FOR UPDATE → next-key lock (200, 300]
</span></span><span class="line"><span class="ln">6</span><span class="cl">WHERE id BETWEEN 150 AND 250 FOR UPDATE → next-key lock (100, 200] + (200, 300]</span></span></code></pre></div><h3 id="4-insert-intention-lock--insert-之前的-gap-lock">4. Insert Intention Lock — INSERT 之前的 gap lock</h3>
<p><code>INSERT</code> 不直接 lock 整個 gap、而是 <em>insert intention lock</em> — 比 gap lock 弱、允許多個 INSERT 同 gap 並行（不同 id）。</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">-- Transaction 1
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="p">(</span><span class="n">id</span><span class="p">)</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="mi">150</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="c1">-- Transaction 2
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="p">(</span><span class="n">id</span><span class="p">)</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="mi">175</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="c1">-- 同 gap (100, 200)、兩個 INSERT 並行、不阻塞</span></span></span></code></pre></div><p>但如果 Transaction 1 已 hold gap lock（through SELECT FOR UPDATE）、Transaction 2 INSERT 必須等。</p>
<h3 id="5-auto-inc-lock--auto-increment-column-專用">5. Auto-Inc Lock — Auto-Increment column 專用</h3>
<p><code>INSERT INTO orders (id) VALUES (DEFAULT)</code> 取得 auto-increment value 時 lock。Mode：</p>
<ul>
<li><code>innodb_autoinc_lock_mode=0</code>（traditional）：lock 整個 INSERT statement 期間、其他 INSERT 必須等</li>
<li><code>innodb_autoinc_lock_mode=1</code>（consecutive）：lock 短時間（取值期間）、INSERT 1 row 不會阻塞其他</li>
<li><code>innodb_autoinc_lock_mode=2</code>（interleaved、8.0+ 預設（5.7 預設仍是 1））：完全並行、auto-inc value 不保證連續但可並行</li>
</ul>
<p>8.0+ 預設 mode=2、性能高、但 <em>binlog format 必須 ROW</em>（STATEMENT 行為錯）。</p>
<h2 id="isolation-level-對-lock-的決定性影響">Isolation Level 對 Lock 的決定性影響</h2>
<p>InnoDB 4 個 isolation level、lock 行為完全不同：</p>
<table>
  <thead>
      <tr>
          <th>Isolation</th>
          <th>Read 行為</th>
          <th>Lock 範圍</th>
          <th>Default?</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>READ UNCOMMITTED</td>
          <td>可讀 dirty data</td>
          <td>純 record lock、無 gap</td>
          <td>否</td>
      </tr>
      <tr>
          <td>READ COMMITTED</td>
          <td>每個 statement 看當下 committed</td>
          <td>純 record lock、無 gap</td>
          <td>否</td>
      </tr>
      <tr>
          <td>REPEATABLE READ</td>
          <td>Transaction 內 snapshot consistent</td>
          <td>Record + gap + next-key</td>
          <td><strong>是</strong></td>
      </tr>
      <tr>
          <td>SERIALIZABLE</td>
          <td>強制 SELECT 變 SELECT &hellip; FOR SHARE</td>
          <td>Record + gap + next-key 加重</td>
          <td>否</td>
      </tr>
  </tbody>
</table>
<p><strong>REPEATABLE READ + Gap lock 是 deadlock 主要來源</strong>：</p>
<ul>
<li>預設 isolation level</li>
<li>為了 <em>保證 repeatable read</em>（同 transaction 內讀同樣資料）、強制 gap lock 防 phantom row</li>
<li>但 gap lock 經常 lock 比預期廣的範圍、deadlock 機率上升</li>
</ul>
<p><strong>改成 READ COMMITTED 的取捨</strong>：</p>
<ul>
<li>優點：無 gap lock、deadlock 大降、寫吞吐上升</li>
<li>缺點：transaction 內讀同 query 結果可能不同（non-repeatable read）</li>
<li>重要：<em>binlog format 必須 ROW</em>（STATEMENT 在 READ COMMITTED 下 replication 行為不一致）</li>
<li>多數 MySQL production 用 READ COMMITTED 跑 OLTP、REPEATABLE READ 留給特殊 case</li>
</ul>
<p><strong>對比 PostgreSQL</strong>：</p>
<ul>
<li>PG 預設 isolation 是 <em>READ COMMITTED</em>（不是 RR）</li>
<li>PG 的 RR 用 <em>snapshot isolation</em>（不靠 gap lock）、deadlock 少</li>
<li>這是 MySQL 跟 PG 在 <em>並行控制 model</em> 的根本差異 — MySQL 用 lock-based、PG 用 MVCC-heavy</li>
</ul>
<h2 id="用-show-engine-innodb-status-讀-lock-狀態">用 SHOW ENGINE INNODB STATUS 讀 lock 狀態</h2>
<p><code>SHOW ENGINE INNODB STATUS\G</code> 是 production debug lock contention 的主要工具：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">------------
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">TRANSACTIONS
</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">Trx id counter 12350
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">Purge done for trx&#39;s n:o &lt; 12340 undo n:o &lt; 0 state: running but idle
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">History list length 5
</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">---TRANSACTION 12345, ACTIVE 30 sec  -- 長 transaction、警訊
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">3 lock struct(s), heap size 1136, 5 row lock(s)
</span></span><span class="line"><span class="ln">10</span><span class="cl">MySQL thread id 100, OS thread handle ..., query id ...
</span></span><span class="line"><span class="ln">11</span><span class="cl">SELECT * FROM orders WHERE id &gt; 100 FOR UPDATE
</span></span><span class="line"><span class="ln">12</span><span class="cl">------- TRX HAS BEEN WAITING 5 SEC FOR THIS LOCK:
</span></span><span class="line"><span class="ln">13</span><span class="cl">RECORD LOCKS space id 50 page no 5 n bits 80 index PRIMARY of table `production`.`orders`
</span></span><span class="line"><span class="ln">14</span><span class="cl">trx id 12345 lock_mode X locks gap before rec  -- gap lock</span></span></code></pre></div><p>關鍵欄位：</p>
<ul>
<li><code>ACTIVE N sec</code>：transaction 跑多久（長 transaction 嫌疑）</li>
<li><code>lock_mode X / S</code>：exclusive / shared lock</li>
<li><code>locks rec but not gap</code> / <code>locks gap before rec</code> / <code>locks rec</code>：是 record / gap / next-key</li>
<li><code>TRX HAS BEEN WAITING N SEC FOR THIS LOCK</code>：等多久、超過幾秒就是 lock contention</li>
</ul>
<p><code>SELECT * FROM information_schema.INNODB_TRX</code> / <code>INNODB_LOCKS</code> (5.7) / <code>performance_schema.data_locks</code> (8.0) 給 <em>structured</em> lock 視圖。</p>
<h2 id="5-個-production-踩雷">5 個 Production 踩雷</h2>
<h3 id="1-gap-lock-阻塞-insert--lock-不存在的-row">1. Gap lock 阻塞 INSERT — 「Lock 不存在的 row」</h3>





<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">-- Transaction 1
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">BEGIN</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="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">user_id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">100</span><span class="w"> </span><span class="k">FOR</span><span class="w"> </span><span class="k">UPDATE</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="c1">-- 假設 user_id=100 沒任何 order、預期沒 lock 任何 row
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w"></span><span class="c1">-- Transaction 2
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="p">(</span><span class="n">user_id</span><span class="p">,</span><span class="w"> </span><span class="n">amount</span><span class="p">)</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="mi">100</span><span class="p">,</span><span class="w"> </span><span class="mi">50</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="c1">-- 等！為什麼？</span></span></span></code></pre></div><p>問題：<code>WHERE user_id = 100</code> <em>沒有 record</em> 時、InnoDB 仍 lock <em>user_id=100 應該在的 gap</em>（防 phantom）、Transaction 2 INSERT 進這個 gap 被阻擋。</p>
<p>修法：</p>
<ul>
<li>改 READ COMMITTED isolation</li>
<li>或不用 <code>SELECT ... FOR UPDATE</code> on empty result、改 <em>application 層 check + INSERT</em> pattern</li>
<li>用 <code>INSERT ... ON DUPLICATE KEY UPDATE</code> 或 <code>INSERT IGNORE</code> 避免 SELECT FOR UPDATE</li>
</ul>
<h3 id="2-auto-inc-lock-contention--大量並行-insert">2. Auto-Inc Lock Contention — 大量並行 INSERT</h3>
<p><code>innodb_autoinc_lock_mode=0</code> 或 <code>=1</code> 模式下、大量並行 INSERT 撞 auto-inc lock、寫吞吐 cap。</p>
<p>修法：</p>
<ul>
<li>設 <code>innodb_autoinc_lock_mode=2</code>（interleaved、8.0+ 預設（5.7 預設仍是 1））</li>
<li>確認 <code>binlog_format=ROW</code>（mode=2 必須）</li>
<li>接受 auto-inc value 不連續（id 可能跳號）</li>
</ul>
<h3 id="3-fk-lock-cascading--父子-transaction-互鎖">3. FK Lock Cascading — 父子 transaction 互鎖</h3>





<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">-- orders 表有 customer_id FK → customers.id
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1">-- Transaction 1
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span><span class="k">UPDATE</span><span class="w"> </span><span class="n">customers</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="n">name</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;...&#39;</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">100</span><span class="p">;</span><span class="w">  </span><span class="c1">-- lock customers row
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="c1">-- Transaction 2
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="p">(</span><span class="n">customer_id</span><span class="p">,</span><span class="w"> </span><span class="n">amount</span><span class="p">)</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="mi">100</span><span class="p">,</span><span class="w"> </span><span class="mi">50</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="c1">-- FK check 需要 lock customers row id=100、等 Transaction 1</span></span></span></code></pre></div><p>FK 強制 <em>每個 INSERT child 都要 shared lock parent</em>、parent 的任何 UPDATE 都會 lock 所有 child INSERT。</p>
<p>修法：</p>
<ul>
<li>評估 FK 是否真的需要（high-write 場景考慮 application-level enforcement）</li>
<li>短 transaction 縮短 lock 時間</li>
<li>FK 設計時讓 <em>parent UPDATE 少</em> / <em>child INSERT 多</em>（parent 是穩定資料）</li>
</ul>
<h3 id="4-large-transaction-lock-holding--1-個-transaction-拖全-cluster">4. Large Transaction Lock Holding — 1 個 transaction 拖全 cluster</h3>





<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">BEGIN</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="c1">-- 100K row 的 batch UPDATE
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span><span class="k">UPDATE</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="n">status</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;archived&#39;</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">created_at</span><span class="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="s1">&#39;2024-01-01&#39;</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="c1">-- 跑 5 分鐘、持 100K row 的 lock
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1">-- 其他 transaction 撞到任何被 lock 的 row 都等 5 分鐘
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span><span class="k">COMMIT</span><span class="p">;</span></span></span></code></pre></div><p>長 transaction 是 <em>lock contention 災難</em>。</p>
<p>修法：</p>
<ul>
<li>
<p>把 batch operation <em>拆 chunk</em>（每 chunk 1000 row、commit、繼續）：</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">DO</span><span class="w"> </span><span class="err">{</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w">  </span><span class="k">START</span><span class="w"> </span><span class="k">TRANSACTION</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="k">UPDATE</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="n">status</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;archived&#39;</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">WHERE</span><span class="w"> </span><span class="n">created_at</span><span class="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="s1">&#39;2024-01-01&#39;</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="n">status</span><span class="w"> </span><span class="o">!=</span><span class="w"> </span><span class="s1">&#39;archived&#39;</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">LIMIT</span><span class="w"> </span><span class="mi">1000</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="k">COMMIT</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="err">}</span><span class="w"> </span><span class="n">WHILE</span><span class="w"> </span><span class="n">rows_affected</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="mi">0</span><span class="p">;</span></span></span></code></pre></div></li>
<li>
<p>用 <em>pt-archiver</em> tool（Percona）對 batch UPDATE / DELETE 自動 chunked</p>
</li>
<li>
<p>監控 <code>information_schema.innodb_trx</code> 找出 long-running transaction</p>
</li>
</ul>
<h3 id="5-read-committed--binlog-row-interaction">5. READ COMMITTED + Binlog ROW Interaction</h3>
<p>READ COMMITTED isolation 改善 deadlock、但對 <em>binlog format</em> 有要求：</p>
<ul>
<li><code>binlog_format=STATEMENT</code>：READ COMMITTED 下 transaction 看到不同 snapshot、replicate 後 replica 結果可能 <em>不同於 primary</em>（broken replication semantically）</li>
<li><code>binlog_format=ROW</code>：每個 row event 都 explicit、READ COMMITTED 跟 ROW 兼容、replica 結果一致</li>
<li><code>binlog_format=MIXED</code>：部分 case 仍可能 fall back STATEMENT、不推薦</li>
</ul>
<p>修法：</p>
<ul>
<li>用 READ COMMITTED 時、強制 <code>binlog_format=ROW</code></li>
<li>全 cluster server（primary + replica + Group Replication members）統一 binlog_format</li>
<li>Migration 5.7 STATEMENT → 8.0 ROW 時、isolation 跟 binlog format 一起 review</li>
</ul>
<h2 id="跟其他模組整合">跟其他模組整合</h2>
<h3 id="跟-replication">跟 Replication</h3>
<p><code>binlog_format=ROW</code> 跟 isolation level 互動已述。Replica apply ROW binlog 時、replica 上 <em>也 acquire 同樣 lock</em>、replica 上的 long query 跟 replication lag 互動。詳見 <a href="/blog/backend/01-database/vendors/mysql/replication-topology/" data-link-title="MySQL Replication Topology：async / semi-sync / GTID 不是三選一、是三個 trade-off 軸的疊加" data-link-desc="MySQL replication 不是「選 async 還是 semi-sync」、是 *durability / latency / consistency* 三個 trade-off 軸的疊加；GTID 是跨 mode 的 infrastructure layer、不是第三種 mode。本文走 3 軸取捨模型 → async / semi-sync 行為對比 → GTID 替代 binlog-position 的好處 → 配置 step-by-step → 5 production 踩雷（lag 暴衝 / semi-sync 退回 async / GTID gap / Loss-Less semi-sync 真的 loss-less / chained replication 雪崩）→ 跟 Aurora MySQL / Vitess / ProxySQL / Orchestrator 整合">Replication Topology</a>。</p>
<h3 id="跟-group-replication">跟 Group Replication</h3>
<p>GR certification phase 跟 row lock 衝突 — write conflict 檢測在 certification、不是 lock。但 <em>local row lock</em> 仍存在、影響 single-instance write throughput。詳見 <a href="/blog/backend/01-database/vendors/mysql/group-replication/" data-link-title="MySQL Group Replication / InnoDB Cluster：single-primary vs multi-primary mode 對 transaction certification 的影響" data-link-desc="MySQL Group Replication 提供 synchronous multi-primary replication、用 Paxos-like Group Communication Engine（GCE）達成 quorum-based commit。但「multi-primary」不是「single-primary 多開幾個 write 入口」、是 *transaction conflict detection &#43; certification* 整個機制不同。本文走 GR 機制（GCE &#43; certification &#43; applier）、single-primary vs multi-primary mode、InnoDB Cluster 跟 MySQL Shell / Router 整合、5 production 踩雷（cert lag / write conflict / large transaction / network partition / member 加入 catch-up）、何時用 GR 何時用傳統 replication">Group Replication</a>。</p>
<h3 id="跟-online-schema-change">跟 Online Schema Change</h3>
<p>gh-ost / pt-osc 在 cut-over 階段需要 metadata lock、跟 long-running transaction 衝突。Lock contention deep dive 跟 OSC cut-over 議題密切。詳見 <a href="/blog/backend/01-database/vendors/mysql/online-schema-change-tools/" data-link-title="MySQL Online Schema Change：gh-ost 跟 pt-online-schema-change 兩條完全不同的 ghost table 路徑" data-link-desc="MySQL ALTER TABLE 可能鎖整張表，production 需要 online schema change 流程。gh-ost（GitHub）跟 pt-online-schema-change（Percona）都用 ghost table 解決、但底層機制完全不同：pt-osc 用 trigger 同步、gh-ost 用 binlog stream 同步。本文走兩工具機制對照表 → trigger vs binlog 各自取捨 → 配置 step-by-step → 5 production 踩雷（trigger overhead / binlog 延遲 / FK constraint / hot trigger lock / 切換瞬間 deadlock）→ 何時用哪一個">Online Schema Change Tools</a>。</p>
<h3 id="跟-query-optimization">跟 Query Optimization</h3>
<p>Slow query 持 lock 久、放大 contention。<code>EXPLAIN ANALYZE</code> 看實際執行時間、跟 lock holding time 直接相關。詳見 <a href="/blog/backend/01-database/vendors/mysql/query-optimization/" data-link-title="MySQL Query Optimization：從 EXPLAIN 看到實際執行、5 條 query 從 5 秒變 50ms 的 anatomy" data-link-desc="MySQL query 慢的根因不在「SQL 寫法」、在「optimizer 選錯 plan」。本文從 5 個常見 production case 開場（5 秒 → 50ms / 30 秒 → 200ms / 8 秒 → 30ms 等）、走 EXPLAIN / EXPLAIN ANALYZE / optimizer trace 三層分析工具、index hint vs optimizer hint 取捨、cardinality estimation 失效時的修法、5 production 踩雷（statistics 過時 / forced index 用錯 / hash join 沒觸發 / range scan 退化 ALL / derived table materialization）">Query Optimization</a>。</p>
<h3 id="跟-innodb-tuning">跟 InnoDB Tuning</h3>
<p><code>innodb_lock_wait_timeout=50</code>（預設 50 秒）— lock wait 超時 transaction 自動 rollback、避免無限等。production 建議調短（10-20 秒）、快 fail 給 application retry。詳見 <a href="/blog/backend/01-database/vendors/mysql/innodb-tuning/" data-link-title="MySQL InnoDB Tuning：為什麼一個 100 GB DB 在 64 GB RAM server 上 query 慢 5 倍" data-link-desc="InnoDB 是 MySQL 預設 storage engine、預設值給 256 MB buffer pool（早期 default）。本文從一個常見痛點開場（DB &gt; RAM 但 server 仍 swap）、走 4 個 critical knob（buffer pool / redo log / flush method / IO capacity）、各自如何影響讀寫吞吐、配置 step-by-step、5 production 踩雷（buffer pool warm-up / log file 大小 / 設 sync_binlog=0 換速度 / IO scheduler / undo log 膨脹）、跟 SSD / NVMe / EBS 的 IO 假設">InnoDB Tuning</a>。</p>
<h2 id="跟-postgresql-lock-model-對比">跟 PostgreSQL Lock model 對比</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>MySQL InnoDB</th>
          <th>PostgreSQL</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Concurrency model</td>
          <td>Lock-based（rec / gap / next-key）</td>
          <td>MVCC-heavy（few explicit lock）</td>
      </tr>
      <tr>
          <td>預設 isolation</td>
          <td>REPEATABLE READ</td>
          <td>READ COMMITTED</td>
      </tr>
      <tr>
          <td>Gap lock</td>
          <td>有</td>
          <td>無對應（PG 用 predicate lock for SERIALIZABLE）</td>
      </tr>
      <tr>
          <td>Deadlock 機率</td>
          <td>中-高</td>
          <td>低</td>
      </tr>
      <tr>
          <td>Auto-inc</td>
          <td>內建 + auto-inc lock</td>
          <td>SEQUENCE（無對應 lock 議題）</td>
      </tr>
      <tr>
          <td>Snapshot isolation</td>
          <td>部分（RR 內）</td>
          <td>完整（MVCC 跑全 stack）</td>
      </tr>
  </tbody>
</table>
<p>PG 用 MVCC 跑大部分並行 control、少數 case 才用 explicit lock、整體 deadlock 機率低。MySQL 用 lock-based + MVCC mixed、production 必須懂 lock pattern。</p>
<h2 id="觀測-metric">觀測 metric</h2>
<p>Production 持續 monitor：</p>
<ul>
<li><code>Innodb_row_lock_waits</code> / <code>_time</code> → lock wait 累計</li>
<li><code>Innodb_deadlocks</code> → deadlock 次數（5.7+ 有、之前要 parse SHOW ENGINE）</li>
<li><code>performance_schema.data_lock_waits</code> → 即時 lock wait 視圖（8.0+）</li>
<li><code>information_schema.innodb_trx</code> → long-running transaction</li>
<li><code>slow_query_log</code> → 看 query 是否花太多 time 在 lock wait</li>
</ul>
<p>對 deadlock：把 <code>innodb_print_all_deadlocks=ON</code>、所有 deadlock 寫 error log、不用 <code>SHOW ENGINE</code> 才看到。</p>
<h2 id="何時改-isolation-level">何時改 isolation level</h2>
<table>
  <thead>
      <tr>
          <th>場景</th>
          <th>建議 isolation</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>典型 web OLTP、低-中寫吞吐</td>
          <td>REPEATABLE READ（預設）</td>
      </tr>
      <tr>
          <td>高寫吞吐、deadlock 頻繁</td>
          <td>READ COMMITTED</td>
      </tr>
      <tr>
          <td>金融 transaction、需要 strict isolation</td>
          <td>REPEATABLE READ + 仔細 review</td>
      </tr>
      <tr>
          <td>嚴格 serializable（小 case）</td>
          <td>SERIALIZABLE（performance penalty）</td>
      </tr>
      <tr>
          <td>跨 region replication + 強一致</td>
          <td>用 Group Replication / Spanner 而不是 isolation level</td>
      </tr>
  </tbody>
</table>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/mysql/" data-link-title="MySQL" data-link-desc="高併發網路服務常用關聯式資料庫、Vitess / PlanetScale 分片生態、GitHub / Shopify / Facebook 規模驗證">MySQL vendor overview</a></li>
<li><a href="/blog/backend/01-database/vendors/mysql/replication-topology/" data-link-title="MySQL Replication Topology：async / semi-sync / GTID 不是三選一、是三個 trade-off 軸的疊加" data-link-desc="MySQL replication 不是「選 async 還是 semi-sync」、是 *durability / latency / consistency* 三個 trade-off 軸的疊加；GTID 是跨 mode 的 infrastructure layer、不是第三種 mode。本文走 3 軸取捨模型 → async / semi-sync 行為對比 → GTID 替代 binlog-position 的好處 → 配置 step-by-step → 5 production 踩雷（lag 暴衝 / semi-sync 退回 async / GTID gap / Loss-Less semi-sync 真的 loss-less / chained replication 雪崩）→ 跟 Aurora MySQL / Vitess / ProxySQL / Orchestrator 整合">MySQL Replication Topology</a>（binlog format + isolation 互動）</li>
<li><a href="/blog/backend/01-database/vendors/mysql/query-optimization/" data-link-title="MySQL Query Optimization：從 EXPLAIN 看到實際執行、5 條 query 從 5 秒變 50ms 的 anatomy" data-link-desc="MySQL query 慢的根因不在「SQL 寫法」、在「optimizer 選錯 plan」。本文從 5 個常見 production case 開場（5 秒 → 50ms / 30 秒 → 200ms / 8 秒 → 30ms 等）、走 EXPLAIN / EXPLAIN ANALYZE / optimizer trace 三層分析工具、index hint vs optimizer hint 取捨、cardinality estimation 失效時的修法、5 production 踩雷（statistics 過時 / forced index 用錯 / hash join 沒觸發 / range scan 退化 ALL / derived table materialization）">MySQL Query Optimization</a>（slow query → lock contention）</li>
<li><a href="/blog/backend/01-database/vendors/mysql/innodb-tuning/" data-link-title="MySQL InnoDB Tuning：為什麼一個 100 GB DB 在 64 GB RAM server 上 query 慢 5 倍" data-link-desc="InnoDB 是 MySQL 預設 storage engine、預設值給 256 MB buffer pool（早期 default）。本文從一個常見痛點開場（DB &gt; RAM 但 server 仍 swap）、走 4 個 critical knob（buffer pool / redo log / flush method / IO capacity）、各自如何影響讀寫吞吐、配置 step-by-step、5 production 踩雷（buffer pool warm-up / log file 大小 / 設 sync_binlog=0 換速度 / IO scheduler / undo log 膨脹）、跟 SSD / NVMe / EBS 的 IO 假設">MySQL InnoDB Tuning</a>（lock_wait_timeout）</li>
<li><a href="/blog/backend/01-database/vendors/mysql/group-replication/" data-link-title="MySQL Group Replication / InnoDB Cluster：single-primary vs multi-primary mode 對 transaction certification 的影響" data-link-desc="MySQL Group Replication 提供 synchronous multi-primary replication、用 Paxos-like Group Communication Engine（GCE）達成 quorum-based commit。但「multi-primary」不是「single-primary 多開幾個 write 入口」、是 *transaction conflict detection &#43; certification* 整個機制不同。本文走 GR 機制（GCE &#43; certification &#43; applier）、single-primary vs multi-primary mode、InnoDB Cluster 跟 MySQL Shell / Router 整合、5 production 踩雷（cert lag / write conflict / large transaction / network partition / member 加入 catch-up）、何時用 GR 何時用傳統 replication">MySQL Group Replication</a>（cert vs lock）</li>
<li><a href="/blog/backend/01-database/vendors/mysql/online-schema-change-tools/" data-link-title="MySQL Online Schema Change：gh-ost 跟 pt-online-schema-change 兩條完全不同的 ghost table 路徑" data-link-desc="MySQL ALTER TABLE 可能鎖整張表，production 需要 online schema change 流程。gh-ost（GitHub）跟 pt-online-schema-change（Percona）都用 ghost table 解決、但底層機制完全不同：pt-osc 用 trigger 同步、gh-ost 用 binlog stream 同步。本文走兩工具機制對照表 → trigger vs binlog 各自取捨 → 配置 step-by-step → 5 production 踩雷（trigger overhead / binlog 延遲 / FK constraint / hot trigger lock / 切換瞬間 deadlock）→ 何時用哪一個">MySQL Online Schema Change</a>（metadata lock）</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 對比">PostgreSQL MVCC + Lock Model</a>（PG sibling、為什麼 PG 比 MySQL 少 deadlock — pure MVCC vs MVCC + gap lock）</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 vendor page</a>（MVCC vs lock model）</li>
<li><a href="/blog/backend/knowledge-cards/isolation-level/" data-link-title="Isolation Level" data-link-desc="說明資料庫交易隔離級別如何影響並發讀寫結果">Isolation Level 卡片</a></li>
<li>官方：<a href="https://dev.mysql.com/doc/refman/8.0/en/innodb-locking.html">InnoDB Locking</a></li>
</ul>
]]></content:encoded></item><item><title>PostgreSQL MVCC + Lock Model：為什麼 PG 比 MySQL 少 deadlock、但 vacuum 是別的代價</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/mvcc-lock-model/</link><pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/mvcc-lock-model/</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>MVCC + lock model&lt;/em> — PG 並行控制機制跟跟 MySQL lock-based 不同。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="pg-mvcc每次更新都-新增-tuple不改舊版">PG MVCC：每次更新都 &lt;em>新增 tuple&lt;/em>、不改舊版&lt;/h2>
&lt;p>PG 的並行控制核心是 &lt;em>Multi-Version Concurrency Control&lt;/em> — UPDATE 不修改原 row、是 &lt;em>新增&lt;/em> 一個 tuple version、舊 version 留在 table 直到 VACUUM 清理：&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">原 row: (id=1, status=&amp;#39;pending&amp;#39;, xmin=100, xmax=NULL)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> ↓ UPDATE status=&amp;#39;shipped&amp;#39;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">新 tuple: (id=1, status=&amp;#39;shipped&amp;#39;, xmin=200, xmax=NULL)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">舊 tuple 標 xmax=200（不刪、給其他 transaction 看舊 version）&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>xmin&lt;/code> / &lt;code>xmax&lt;/code> 是 &lt;em>creator transaction id&lt;/em> / &lt;em>destroyer transaction id&lt;/em>。每個 SELECT 用 &lt;em>snapshot&lt;/em>（含當下 active transaction list）判斷哪些 tuple 對自己可見：&lt;/p>
&lt;ul>
&lt;li>自己 transaction id &amp;gt; tuple.xmin 且 (tuple.xmax = NULL 或自己 transaction id &amp;lt; tuple.xmax) → 可見&lt;/li>
&lt;li>否則 → 看不到（過去 / 未來版本）&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>結果&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>&lt;em>Readers 不 lock writers&lt;/em>：SELECT 看 snapshot、不 block UPDATE&lt;/li>
&lt;li>&lt;em>Writers 不 lock readers&lt;/em>：UPDATE 寫新 tuple、不影響正在跑的 SELECT snapshot&lt;/li>
&lt;li>&lt;em>Writers 只 lock 同一 row 的 writers&lt;/em>：兩個 UPDATE 同 row 才 conflict&lt;/li>
&lt;/ul>
&lt;p>跟 MySQL InnoDB &lt;em>lock-based&lt;/em>（&lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/lock-contention/" data-link-title="MySQL Lock Contention：在 staging 重現的 deadlock、production 跑 6 個月才出現" data-link-desc="MySQL InnoDB 的 lock 是 row-level、但 *為什麼某些 row 莫名其妙也被 lock* 是 gap lock / next-key lock 設計造成的隱性行為。本文從一個 production case 開場（staging 重現 deadlock / production 6 個月後突然爆）、走 5 種 InnoDB lock 類型（record / gap / next-key / insert intention / auto-inc）、isolation level 對 lock 行為的決定性影響、deadlock detection / SHOW ENGINE INNODB STATUS 解讀、5 production 踩雷（gap lock 阻塞 INSERT / auto-inc lock contention / FK lock cascading / large transaction lock holding / READ COMMITTED 跟 binlog ROW 互動）">Lock Contention&lt;/a>）對比：&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>MVCC + lock model</em> — PG 並行控制機制跟跟 MySQL lock-based 不同。</p></blockquote>
<hr>
<h2 id="pg-mvcc每次更新都-新增-tuple不改舊版">PG MVCC：每次更新都 <em>新增 tuple</em>、不改舊版</h2>
<p>PG 的並行控制核心是 <em>Multi-Version Concurrency Control</em> — UPDATE 不修改原 row、是 <em>新增</em> 一個 tuple version、舊 version 留在 table 直到 VACUUM 清理：</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">原 row:    (id=1, status=&#39;pending&#39;, xmin=100, xmax=NULL)
</span></span><span class="line"><span class="ln">2</span><span class="cl">                 ↓ UPDATE status=&#39;shipped&#39;
</span></span><span class="line"><span class="ln">3</span><span class="cl">新 tuple:  (id=1, status=&#39;shipped&#39;, xmin=200, xmax=NULL)
</span></span><span class="line"><span class="ln">4</span><span class="cl">舊 tuple 標 xmax=200（不刪、給其他 transaction 看舊 version）</span></span></code></pre></div><p><code>xmin</code> / <code>xmax</code> 是 <em>creator transaction id</em> / <em>destroyer transaction id</em>。每個 SELECT 用 <em>snapshot</em>（含當下 active transaction list）判斷哪些 tuple 對自己可見：</p>
<ul>
<li>自己 transaction id &gt; tuple.xmin 且 (tuple.xmax = NULL 或自己 transaction id &lt; tuple.xmax) → 可見</li>
<li>否則 → 看不到（過去 / 未來版本）</li>
</ul>
<p><strong>結果</strong>：</p>
<ul>
<li><em>Readers 不 lock writers</em>：SELECT 看 snapshot、不 block UPDATE</li>
<li><em>Writers 不 lock readers</em>：UPDATE 寫新 tuple、不影響正在跑的 SELECT snapshot</li>
<li><em>Writers 只 lock 同一 row 的 writers</em>：兩個 UPDATE 同 row 才 conflict</li>
</ul>
<p>跟 MySQL InnoDB <em>lock-based</em>（<a href="/blog/backend/01-database/vendors/mysql/lock-contention/" data-link-title="MySQL Lock Contention：在 staging 重現的 deadlock、production 跑 6 個月才出現" data-link-desc="MySQL InnoDB 的 lock 是 row-level、但 *為什麼某些 row 莫名其妙也被 lock* 是 gap lock / next-key lock 設計造成的隱性行為。本文從一個 production case 開場（staging 重現 deadlock / production 6 個月後突然爆）、走 5 種 InnoDB lock 類型（record / gap / next-key / insert intention / auto-inc）、isolation level 對 lock 行為的決定性影響、deadlock detection / SHOW ENGINE INNODB STATUS 解讀、5 production 踩雷（gap lock 阻塞 INSERT / auto-inc lock contention / FK lock cascading / large transaction lock holding / READ COMMITTED 跟 binlog ROW 互動）">Lock Contention</a>）對比：</p>
<ul>
<li>MySQL：SELECT FOR UPDATE 用 gap lock 防 phantom、deadlock 機率高</li>
<li>PG：MVCC + snapshot 自然防 phantom（read 看 snapshot）、deadlock 少</li>
</ul>
<p>但 PG 代價是 <em>VACUUM 治理</em> — dead tuple 不清理會佔 disk + 影響 query 效率。詳見 <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>。</p>
<h2 id="pg-4-種-lock">PG 4 種 lock</h2>
<p>PG 仍有 lock、但場景跟 MySQL 不同：</p>
<h3 id="1-row-level-lock--主要由-update--delete--select-for-update-取">1. Row-level lock — 主要由 UPDATE / DELETE / SELECT FOR UPDATE 取</h3>





<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">BEGIN</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="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">100</span><span class="w"> </span><span class="k">FOR</span><span class="w"> </span><span class="k">UPDATE</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="c1">-- 對 id=100 row 加 ROW EXCLUSIVE lock
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">-- 其他 transaction 試 UPDATE / DELETE id=100 必須等</span></span></span></code></pre></div><p>Row-level lock <em>不 block reader</em>（SELECT 看 snapshot、不檢查 lock）。</p>
<h3 id="2-table-level-lock--ddl-跟少數-select-for-場景">2. Table-level lock — DDL 跟少數 SELECT FOR 場景</h3>
<p>PG 有 8 種 table lock mode、嚴重程度遞增：</p>
<table>
  <thead>
      <tr>
          <th>Mode</th>
          <th>行為</th>
          <th>衝突</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>ACCESS SHARE</td>
          <td>SELECT 跑</td>
          <td>跟 ACCESS EXCLUSIVE 衝突</td>
      </tr>
      <tr>
          <td>ROW SHARE</td>
          <td>SELECT FOR UPDATE / FOR SHARE</td>
          <td>跟 EXCLUSIVE 衝突</td>
      </tr>
      <tr>
          <td>ROW EXCLUSIVE</td>
          <td>UPDATE / DELETE / INSERT</td>
          <td>跟 SHARE 衝突</td>
      </tr>
      <tr>
          <td>SHARE UPDATE EXCLUSIVE</td>
          <td>VACUUM / ANALYZE / CREATE INDEX CONCURRENTLY</td>
          <td>跟同 mode + 高 mode 衝突</td>
      </tr>
      <tr>
          <td>SHARE</td>
          <td>CREATE INDEX（non-concurrent）</td>
          <td>跟 ROW EXCLUSIVE 衝突</td>
      </tr>
      <tr>
          <td>SHARE ROW EXCLUSIVE</td>
          <td>CREATE TRIGGER / 某些 ALTER</td>
          <td>跟 ROW EXCLUSIVE 衝突</td>
      </tr>
      <tr>
          <td>EXCLUSIVE</td>
          <td>REFRESH MATERIALIZED VIEW</td>
          <td>跟所有 + 自身衝突</td>
      </tr>
      <tr>
          <td>ACCESS EXCLUSIVE</td>
          <td>DROP / ALTER TABLE / VACUUM FULL</td>
          <td>跟所有衝突</td>
      </tr>
  </tbody>
</table>
<p>DDL（ALTER / DROP）拿 ACCESS EXCLUSIVE、跟所有衝突。Production 跑 ALTER 必須短時間或走 <a href="/blog/backend/01-database/vendors/postgresql/online-schema-change/" data-link-title="PostgreSQL Online Schema Change：先用 ALTER 內建特性、不能解才 pg_repack / pg-osc" data-link-desc="PostgreSQL ALTER TABLE 對多數變更已是 *fast catalog-only*（add column nullable / drop column / 改 default），不必走 ghost table tool。本文走 PG 內建 fast DDL 行為、何時必須走 pg_repack / pg-osc、兩工具機制對比（trigger-based vs WAL-shipping）、配置 step-by-step、5 production 踩雷（lock 升級 / VACUUM FULL 誤用 / pg_repack version mismatch / concurrent index 失敗清理 / generated stored column 不能 online）、跟 MySQL gh-ost / pt-osc sibling 對比">Online Schema Change</a>。</p>
<h3 id="3-advisory-lock--application-自己控">3. Advisory lock — Application 自己控</h3>
<p>PG 提供 <em>advisory lock</em> 給 application 用、不關 row / table 結構：</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">-- Session 1
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">pg_advisory_lock</span><span class="p">(</span><span class="mi">12345</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="c1">-- 跑 critical section
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">pg_advisory_unlock</span><span class="p">(</span><span class="mi">12345</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></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w"></span><span class="c1">-- Session 2
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">pg_try_advisory_lock</span><span class="p">(</span><span class="mi">12345</span><span class="p">);</span><span class="w">  </span><span class="c1">-- 試取、不阻塞、返回 false</span></span></span></code></pre></div><p>用途：</p>
<ul>
<li>Application-level 互斥（如：cron job 同時只跑一個）</li>
<li>跨 connection 同步（PG-managed mutex）</li>
<li>Distributed transaction coordinator（lightweight）</li>
</ul>
<p>跟 row lock 不同：advisory lock 不關 row、application 自定義 lock ID 語義。</p>
<h3 id="4-predicate-lock--serializable-isolation-才用">4. Predicate lock — SERIALIZABLE isolation 才用</h3>
<p>PG SERIALIZABLE 用 <em>Serializable Snapshot Isolation (SSI)</em>、追蹤 <em>predicate</em>（query 條件）而不是 <em>row</em>：</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">SET</span><span class="w"> </span><span class="k">TRANSACTION</span><span class="w"> </span><span class="k">ISOLATION</span><span class="w"> </span><span class="k">LEVEL</span><span class="w"> </span><span class="k">SERIALIZABLE</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="k">BEGIN</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="c1">-- Predicate lock 紀錄這個 query 看了哪些 predicate
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">status</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;pending&#39;</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="c1">-- 其他 transaction INSERT pending order
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1">-- 提交時：PG 偵測 anomaly、rollback 之一
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"></span><span class="k">COMMIT</span><span class="p">;</span></span></span></code></pre></div><p>跟 MySQL gap lock 不同：</p>
<ul>
<li>MySQL gap lock：<em>pre-lock</em>、防 phantom 在 query 期間</li>
<li>PG predicate lock：<em>post-detect</em>、commit 時偵測 anomaly、退回 transaction</li>
</ul>
<p>PG SSI 對 <em>寫入吞吐影響低</em>（不 pre-lock）、但 <em>transaction rollback 機率高</em>（要 application retry）。</p>
<h2 id="pg-預設-isolationread-committed">PG 預設 isolation：READ COMMITTED</h2>
<p>PG 預設 READ COMMITTED、跟 MySQL InnoDB 預設 REPEATABLE READ 不同：</p>
<table>
  <thead>
      <tr>
          <th>Isolation</th>
          <th>PG 行為</th>
          <th>MySQL InnoDB 對應</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>READ UNCOMMITTED</td>
          <td>PG 視為 READ COMMITTED（不真的支援 dirty read）</td>
          <td>MySQL 真支援</td>
      </tr>
      <tr>
          <td>READ COMMITTED</td>
          <td>每 statement 看當下 committed snapshot（PG 預設）</td>
          <td>一致</td>
      </tr>
      <tr>
          <td>REPEATABLE READ</td>
          <td>Transaction 內 fixed snapshot（純 MVCC）</td>
          <td>MVCC snapshot + gap lock 防 phantom（兩者都 MVCC、差在 phantom 防護機制：PG 靠 snapshot version visibility、InnoDB 加 gap lock pre-lock 範圍）</td>
      </tr>
      <tr>
          <td>SERIALIZABLE</td>
          <td>SSI、commit 時偵測 anomaly</td>
          <td>強 lock + gap</td>
      </tr>
  </tbody>
</table>
<p><strong>對 application code 含意</strong>：</p>
<ul>
<li>PG REPEATABLE READ 對 <em>寫入吞吐</em> 影響低（不 pre-lock、只 retry）</li>
<li>沒 gap lock → INSERT 不被 lock-induced 阻塞</li>
<li>Deadlock 機率比 MySQL 低數量級</li>
</ul>
<p>實務 PG production：用預設 READ COMMITTED 即可、SERIALIZABLE 留給 <em>strict consistency 需求</em>（金融 / 訂單）但接受 retry。</p>
<h2 id="5-個-production-踩雷">5 個 Production 踩雷</h2>
<h3 id="1-idle-transaction-卡-vacuum--bloat-暴增">1. Idle transaction 卡 vacuum — Bloat 暴增</h3>
<p>PG MVCC 仰賴 <em>VACUUM 清理 dead tuple</em>。VACUUM 只清理 <em>沒 active transaction 看得到的 dead tuple</em>。如果有 <em>idle in transaction</em> session 持續開著（application connection pool 連線忘關 transaction）、VACUUM 看不到 <em>該 transaction snapshot 之後的 dead tuple</em>、累積 bloat。</p>
<p>修法：</p>
<ul>
<li>監控 <code>pg_stat_activity</code> 看 <code>state = 'idle in transaction'</code> 持續時間</li>
<li>設 <code>idle_in_transaction_session_timeout = '5min'</code> — 超時 PG 自動 kill 該 session</li>
<li>Application connection pool 配置 <em>不留 transaction 開著</em>（如：pgBouncer transaction pool 自動 commit / rollback）</li>
</ul>
<h3 id="2-select-for-update-跨-transaction--application-retry-麻煩">2. SELECT FOR UPDATE 跨 transaction — Application retry 麻煩</h3>
<p>跟 MySQL 不同：PG SELECT FOR UPDATE 不會 <em>block 其他 SELECT</em>（讀仍可繼續）、但 <em>block 其他 UPDATE / FOR UPDATE</em>。若 application 在 transaction 內 SELECT FOR UPDATE、其他 transaction 等。</p>
<p>如果 application 設計 <em>跨 transaction 持 lock</em>（如：取 lock + return UI + 等用戶操作 + commit）、容易撞 idle in transaction 跟其他 transaction wait。</p>
<p>修法：</p>
<ul>
<li><em>Transaction 短</em>：取 FOR UPDATE → 立刻處理 → commit、不跨 user interaction</li>
<li>跨 user interaction 用 <em>advisory lock</em> 或 application-level state machine、不依賴 row lock</li>
</ul>
<h3 id="3-advisory-lock-沒釋放--session-結束才自動釋放">3. Advisory lock 沒釋放 — Session 結束才自動釋放</h3>
<p><code>pg_advisory_lock()</code> 拿了、沒 <code>pg_advisory_unlock()</code>、lock 直到 <em>session 結束</em> 才自動釋放。Connection pool 重複使用同 connection、可能繼承前面留的 lock。</p>
<p>修法：</p>
<ul>
<li>用 <code>pg_advisory_lock</code> 必 <code>try/finally pg_advisory_unlock</code></li>
<li>或用 <em>session-level</em> 用 transaction-scoped：<code>pg_advisory_xact_lock()</code> — commit / rollback 自動釋放</li>
<li>監控 <code>pg_locks</code> 看 advisory lock count、長期累積是警訊</li>
</ul>
<h3 id="4-bloat-不只是-vacuum-沒跑是-active-transaction-阻擋-vacuum">4. Bloat 不只是 vacuum 沒跑、是 <em>active transaction 阻擋 vacuum</em></h3>
<p>第 #1 點延伸：vacuum 已跑、但 bloat 仍持續成長、原因不是 vacuum 不夠、是 <em>active transaction 阻擋 vacuum 看 dead tuple</em>。</p>
<p>修法：</p>
<ul>
<li>不只看 <code>last_vacuum</code>、看 <em>VACUUM 跑了但沒收回多少</em></li>
<li><code>SELECT * FROM pg_stat_progress_vacuum</code> 看 VACUUM 進度</li>
<li><code>SELECT * FROM pg_stat_activity WHERE backend_xmin IS NOT NULL ORDER BY backend_xmin</code> — 看誰阻擋 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></li>
</ul>
<h3 id="5-serializable-下-transaction-rollback--application-必須-retry">5. SERIALIZABLE 下 transaction rollback — Application 必須 retry</h3>
<p><code>SET TRANSACTION ISOLATION LEVEL SERIALIZABLE</code> 後、PG SSI 偵測到 anomaly 會 <em>rollback transaction</em>、application 看到 <code>serialization failure</code>、必須 retry。</p>
<p>對 <em>不知道要 retry</em> 的 application、SERIALIZABLE 變 production bug。</p>
<p>修法：</p>
<ul>
<li>Application code 加 <em>retry middleware</em>：catch <code>SQLSTATE 40001 (serialization_failure)</code> → exponential backoff retry</li>
<li>不必所有 transaction 走 SERIALIZABLE — 只對 <em>strict consistency 需求</em> 場景 set</li>
<li>高並發 SERIALIZABLE workload 容易 rollback storm、考慮拆 transaction 縮短時間</li>
</ul>
<h2 id="觀測-metric">觀測 metric</h2>
<p>Production 監控：</p>
<ul>
<li><code>pg_stat_activity</code>：active session / idle in transaction / wait_event</li>
<li><code>pg_locks</code>：當前 lock 列表、用 join 看誰 block 誰</li>
<li><code>pg_stat_database.deadlocks</code>：deadlock 計數（PG 較低、但仍要監控）</li>
<li><code>pg_stat_user_tables.n_dead_tup</code> / <code>n_live_tup</code>：dead tuple 比例 — bloat 指標</li>
<li><code>pg_stat_progress_vacuum</code>：VACUUM 進度</li>
</ul>
<h2 id="跟-mysql-lock-model-對比">跟 MySQL Lock Model 對比</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>PG MVCC</th>
          <th>MySQL InnoDB Lock</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>主要機制</td>
          <td>MVCC + snapshot</td>
          <td>Lock-based + MVCC mixed</td>
      </tr>
      <tr>
          <td>Readers vs Writers</td>
          <td>不互 block</td>
          <td>預設 RR 下 gap lock 影響</td>
      </tr>
      <tr>
          <td>Deadlock 機率</td>
          <td>低（無 gap lock）</td>
          <td>中-高（gap lock 主要來源）</td>
      </tr>
      <tr>
          <td>Phantom 防護</td>
          <td>Snapshot 自然防 + SSI predicate lock</td>
          <td>Gap lock 預先 lock</td>
      </tr>
      <tr>
          <td>預設 isolation</td>
          <td>READ COMMITTED</td>
          <td>REPEATABLE READ</td>
      </tr>
      <tr>
          <td>成本</td>
          <td>Dead tuple + VACUUM 治理</td>
          <td>Lock contention 治理</td>
      </tr>
      <tr>
          <td>Application code</td>
          <td>SERIALIZABLE 需 retry</td>
          <td>寫得不錯多數時 OK</td>
      </tr>
  </tbody>
</table>
<p>兩者解決同一問題（並行控制）、用不同策略。PG 用 <em>空間換時間</em>（保留多版本 tuple、讀寫不互鎖、但需 VACUUM 清理）、MySQL 用 <em>時間換空間</em>（lock 等待、但不必清舊版本）。</p>
<p><strong>選擇判讀</strong>：</p>
<ul>
<li>High 並發 OLTP、寫 / 讀都重：PG MVCC 通常更好（讀不 block 寫）</li>
<li>簡單 OLTP + 不想管 VACUUM：MySQL InnoDB 對 ops 簡單</li>
<li>需要 SERIALIZABLE 強一致：PG SSI 對寫吞吐影響低</li>
<li>已有 MySQL 生態 / 工具鏈：MySQL Lock 知識可繼續用</li>
</ul>
<p>詳見 <a href="/blog/backend/01-database/vendors/mysql/lock-contention/" data-link-title="MySQL Lock Contention：在 staging 重現的 deadlock、production 跑 6 個月才出現" data-link-desc="MySQL InnoDB 的 lock 是 row-level、但 *為什麼某些 row 莫名其妙也被 lock* 是 gap lock / next-key lock 設計造成的隱性行為。本文從一個 production case 開場（staging 重現 deadlock / production 6 個月後突然爆）、走 5 種 InnoDB lock 類型（record / gap / next-key / insert intention / auto-inc）、isolation level 對 lock 行為的決定性影響、deadlock detection / SHOW ENGINE INNODB STATUS 解讀、5 production 踩雷（gap lock 阻塞 INSERT / auto-inc lock contention / FK lock cascading / large transaction lock holding / READ COMMITTED 跟 binlog ROW 互動）">MySQL Lock Contention</a> — 完整 MySQL lock 機制。</p>
<h2 id="跟其他模組整合">跟其他模組整合</h2>
<h3 id="跟-autovacuum-tuning">跟 Autovacuum Tuning</h3>
<p>MVCC 仰賴 VACUUM、autovacuum 是 PG 並行控制的 <em>維護成本</em>。VACUUM 跑慢 / 沒跑 → bloat → query 慢。詳見 <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>。</p>
<h3 id="跟-replication-topology">跟 Replication Topology</h3>
<p><code>hot_standby_feedback = on</code> 讓 standby 上 long-running query 不被 vacuum 取消、但 <em>standby 把 oldest xmin 推回 primary</em>、primary autovacuum 變保守、增加 bloat。詳見 <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>。</p>
<h3 id="跟-connection-pool">跟 Connection Pool</h3>
<p>pgBouncer transaction pooling 模式下、advisory lock / SELECT FOR UPDATE 跨 transaction 行為 <em>broken</em>（不同 transaction 可能進不同 backend connection）。詳見 <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>
<h3 id="跟-query-optimization">跟 Query Optimization</h3>
<p>長 transaction 跑慢 query 期間、其他 transaction 看到 snapshot bloat、planner 估錯 dead tuple ratio。詳見 <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>。</p>
<h2 id="相關連結">相關連結</h2>
<ul>
<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 vendor overview</a></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 整合">PG Autovacuum Tuning</a>（VACUUM 是 MVCC 必要成本）</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 整合">PG Replication Topology</a>（hot_standby_feedback 影響）</li>
<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）跟容量規劃">PG pgBouncer</a>（transaction pooling 跟 lock 互動）</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/online-schema-change/" data-link-title="PostgreSQL Online Schema Change：先用 ALTER 內建特性、不能解才 pg_repack / pg-osc" data-link-desc="PostgreSQL ALTER TABLE 對多數變更已是 *fast catalog-only*（add column nullable / drop column / 改 default），不必走 ghost table tool。本文走 PG 內建 fast DDL 行為、何時必須走 pg_repack / pg-osc、兩工具機制對比（trigger-based vs WAL-shipping）、配置 step-by-step、5 production 踩雷（lock 升級 / VACUUM FULL 誤用 / pg_repack version mismatch / concurrent index 失敗清理 / generated stored column 不能 online）、跟 MySQL gh-ost / pt-osc sibling 對比">PG Online Schema Change</a>（DDL lock 議題）</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 對比">PG Query Optimization</a>（snapshot bloat 影響 planner）</li>
<li><a href="/blog/backend/01-database/vendors/mysql/lock-contention/" data-link-title="MySQL Lock Contention：在 staging 重現的 deadlock、production 跑 6 個月才出現" data-link-desc="MySQL InnoDB 的 lock 是 row-level、但 *為什麼某些 row 莫名其妙也被 lock* 是 gap lock / next-key lock 設計造成的隱性行為。本文從一個 production case 開場（staging 重現 deadlock / production 6 個月後突然爆）、走 5 種 InnoDB lock 類型（record / gap / next-key / insert intention / auto-inc）、isolation level 對 lock 行為的決定性影響、deadlock detection / SHOW ENGINE INNODB STATUS 解讀、5 production 踩雷（gap lock 阻塞 INSERT / auto-inc lock contention / FK lock cascading / large transaction lock holding / READ COMMITTED 跟 binlog ROW 互動）">MySQL Lock Contention</a>（sibling、不同模型）</li>
<li><a href="/blog/backend/knowledge-cards/isolation-level/" data-link-title="Isolation Level" data-link-desc="說明資料庫交易隔離級別如何影響並發讀寫結果">Isolation Level 卡片</a></li>
<li>官方：<a href="https://www.postgresql.org/docs/current/mvcc.html">PG MVCC</a> / <a href="https://www.postgresql.org/docs/current/transaction-iso.html">PG Concurrency Control</a> / <a href="https://www.postgresql.org/docs/current/explicit-locking.html">Explicit Locking</a></li>
</ul>
]]></content:encoded></item><item><title>PostgreSQL JSONB Deep Dive：Binary Storage + GIN Index 為什麼是結構性優勢</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/jsonb-deep-dive/</link><pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/jsonb-deep-dive/</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>JSONB deep dive&lt;/em> — binary storage + GIN index 的結構性優勢。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="json-vs-jsonb選-jsonb">JSON vs JSONB：選 JSONB&lt;/h2>
&lt;p>PG 9.2 加 &lt;code>JSON&lt;/code> type、9.4 加 &lt;code>JSONB&lt;/code>。99% 場景用 JSONB：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>維度&lt;/th>
 &lt;th>JSON&lt;/th>
 &lt;th>JSONB&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>儲存&lt;/td>
 &lt;td>純文字（原樣保存）&lt;/td>
 &lt;td>Binary decomposed format&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Parse cost&lt;/td>
 &lt;td>每次 query parse&lt;/td>
 &lt;td>Insert 時 parse 一次&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Index 支援&lt;/td>
 &lt;td>Limited（functional index）&lt;/td>
 &lt;td>GIN / functional / partial 都行&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Operator 支援&lt;/td>
 &lt;td>有限（→ / →&amp;gt;）&lt;/td>
 &lt;td>完整（@&amp;gt; / ? / @? / ? 等）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Duplicate key&lt;/td>
 &lt;td>保留（原樣）&lt;/td>
 &lt;td>只保留最後一個（normalize）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Key order&lt;/td>
 &lt;td>保留&lt;/td>
 &lt;td>不保留&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Whitespace&lt;/td>
 &lt;td>保留&lt;/td>
 &lt;td>不保留&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>JSONB 唯一缺點是 &lt;em>binary 儲存（不保留 key order / whitespace / duplicate）&lt;/em>。99% application 不在意這些。&lt;/p>
&lt;p>從 &lt;em>application semantics&lt;/em> 視角、JSONB 是 PG JSON 的 &lt;em>the right type&lt;/em>、JSON 是 &lt;em>legacy / niche&lt;/em>。&lt;/p>
&lt;h2 id="jsonb-gin-index核心結構性優勢">JSONB GIN Index：核心結構性優勢&lt;/h2>
&lt;p>PG GIN（Generalized Inverted Index）可以對 JSONB 內所有 key/value pair 建 inverted index：&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">products&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">SERIAL&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="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">metadata&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">JSONB&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="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>&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="c1">-- GIN index
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">INDEX&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">idx_products_metadata&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">products&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">USING&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">GIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">metadata&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>加完後、JSONB query 用 GIN index 加速：&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="c1">-- @&amp;gt; (contains) 用 GIN
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">products&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">metadata&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">@&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;{&amp;#34;category&amp;#34;: &amp;#34;shoes&amp;#34;}&amp;#39;&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>&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="c1">-- ? (has key) 用 GIN
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">products&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">metadata&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">?&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;discount&amp;#39;&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>&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="c1">-- ?| (has any of these keys) 用 GIN
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">products&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">metadata&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">?|&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">array&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s1">&amp;#39;discount&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;promotion&amp;#39;&lt;/span>&lt;span class="p">];&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>跟 MongoDB index 對比、PG 不必 &lt;em>預先 define&lt;/em> JSON path index、&lt;code>USING GIN (metadata)&lt;/code> 對 &lt;em>整個 JSONB document 任意 path&lt;/em> 都有效。&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>JSONB deep dive</em> — binary storage + GIN index 的結構性優勢。</p></blockquote>
<hr>
<h2 id="json-vs-jsonb選-jsonb">JSON vs JSONB：選 JSONB</h2>
<p>PG 9.2 加 <code>JSON</code> type、9.4 加 <code>JSONB</code>。99% 場景用 JSONB：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>JSON</th>
          <th>JSONB</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>儲存</td>
          <td>純文字（原樣保存）</td>
          <td>Binary decomposed format</td>
      </tr>
      <tr>
          <td>Parse cost</td>
          <td>每次 query parse</td>
          <td>Insert 時 parse 一次</td>
      </tr>
      <tr>
          <td>Index 支援</td>
          <td>Limited（functional index）</td>
          <td>GIN / functional / partial 都行</td>
      </tr>
      <tr>
          <td>Operator 支援</td>
          <td>有限（→ / →&gt;）</td>
          <td>完整（@&gt; / ? / @? / ? 等）</td>
      </tr>
      <tr>
          <td>Duplicate key</td>
          <td>保留（原樣）</td>
          <td>只保留最後一個（normalize）</td>
      </tr>
      <tr>
          <td>Key order</td>
          <td>保留</td>
          <td>不保留</td>
      </tr>
      <tr>
          <td>Whitespace</td>
          <td>保留</td>
          <td>不保留</td>
      </tr>
  </tbody>
</table>
<p>JSONB 唯一缺點是 <em>binary 儲存（不保留 key order / whitespace / duplicate）</em>。99% application 不在意這些。</p>
<p>從 <em>application semantics</em> 視角、JSONB 是 PG JSON 的 <em>the right type</em>、JSON 是 <em>legacy / niche</em>。</p>
<h2 id="jsonb-gin-index核心結構性優勢">JSONB GIN Index：核心結構性優勢</h2>
<p>PG GIN（Generalized Inverted Index）可以對 JSONB 內所有 key/value pair 建 inverted index：</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">products</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">SERIAL</span><span class="w"> </span><span class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</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">metadata</span><span class="w"> </span><span class="n">JSONB</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></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></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w"></span><span class="c1">-- GIN index
</span></span></span><span class="line"><span class="ln">7</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_products_metadata</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">products</span><span class="w"> </span><span class="k">USING</span><span class="w"> </span><span class="n">GIN</span><span class="w"> </span><span class="p">(</span><span class="n">metadata</span><span class="p">);</span></span></span></code></pre></div><p>加完後、JSONB query 用 GIN index 加速：</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">-- @&gt; (contains) 用 GIN
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">products</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">metadata</span><span class="w"> </span><span class="o">@&gt;</span><span class="w"> </span><span class="s1">&#39;{&#34;category&#34;: &#34;shoes&#34;}&#39;</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></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="c1">-- ? (has key) 用 GIN
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">products</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">metadata</span><span class="w"> </span><span class="o">?</span><span class="w"> </span><span class="s1">&#39;discount&#39;</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></span><span class="line"><span class="ln">7</span><span class="cl"><span class="w"></span><span class="c1">-- ?| (has any of these keys) 用 GIN
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">products</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">metadata</span><span class="w"> </span><span class="o">?|</span><span class="w"> </span><span class="nb">array</span><span class="p">[</span><span class="s1">&#39;discount&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;promotion&#39;</span><span class="p">];</span></span></span></code></pre></div><p>跟 MongoDB index 對比、PG 不必 <em>預先 define</em> JSON path index、<code>USING GIN (metadata)</code> 對 <em>整個 JSONB document 任意 path</em> 都有效。</p>
<h3 id="jsonb_ops-vs-jsonb_path_ops"><code>jsonb_ops</code> vs <code>jsonb_path_ops</code></h3>
<p>PG GIN 對 JSONB 有兩種 <em>operator class</em>：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th><code>jsonb_ops</code>（預設）</th>
          <th><code>jsonb_path_ops</code></th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>索引內容</td>
          <td>Key + value 都索引</td>
          <td>只索引 path → value pair</td>
      </tr>
      <tr>
          <td>Index size</td>
          <td>大</td>
          <td>小（約一半）</td>
      </tr>
      <tr>
          <td>支援 operator</td>
          <td><code>@&gt; / ? / ?| / ?&amp;</code></td>
          <td>只 <code>@&gt;</code> (containment)</td>
      </tr>
      <tr>
          <td>適用</td>
          <td>多種 query pattern</td>
          <td>只用 <code>@&gt;</code> 的場景</td>
      </tr>
  </tbody>
</table>





<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">-- jsonb_ops（預設）
</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_meta_default</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">products</span><span class="w"> </span><span class="k">USING</span><span class="w"> </span><span class="n">GIN</span><span class="w"> </span><span class="p">(</span><span class="n">metadata</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></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="c1">-- jsonb_path_ops（小、快、但只支援 @&gt;）
</span></span></span><span class="line"><span class="ln">5</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_meta_path</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">products</span><span class="w"> </span><span class="k">USING</span><span class="w"> </span><span class="n">GIN</span><span class="w"> </span><span class="p">(</span><span class="n">metadata</span><span class="w"> </span><span class="n">jsonb_path_ops</span><span class="p">);</span></span></span></code></pre></div><p><strong>選擇</strong>：</p>
<ul>
<li>只跑 <code>@&gt;</code> containment query → <code>jsonb_path_ops</code>（index 小、快）</li>
<li>跑 <code>?</code> / <code>?|</code> / <code>?&amp;</code> key existence query → <code>jsonb_ops</code>（預設）</li>
</ul>
<h2 id="operator--path-query">Operator + Path Query</h2>
<p>JSONB 提供豐富 operator + jsonpath：</p>
<h3 id="operator">Operator</h3>





<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">-- Extract value（returns jsonb）
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">metadata</span><span class="w"> </span><span class="o">-&gt;</span><span class="w"> </span><span class="s1">&#39;name&#39;</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">products</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></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w"></span><span class="c1">-- Extract text（returns text）
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">metadata</span><span class="w"> </span><span class="o">-&gt;&gt;</span><span class="w"> </span><span class="s1">&#39;name&#39;</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">products</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></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w"></span><span class="c1">-- Path extract
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">metadata</span><span class="w"> </span><span class="o">#&gt;</span><span class="w"> </span><span class="s1">&#39;{variants, 0, price}&#39;</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">products</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">SELECT</span><span class="w"> </span><span class="n">metadata</span><span class="w"> </span><span class="o">#&gt;&gt;</span><span class="w"> </span><span class="s1">&#39;{variants, 0, price}&#39;</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">products</span><span class="p">;</span><span class="w">  </span><span class="c1">-- 返回 text
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w"></span><span class="c1">-- Containment（用 GIN index）
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">products</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">metadata</span><span class="w"> </span><span class="o">@&gt;</span><span class="w"> </span><span class="s1">&#39;{&#34;category&#34;: &#34;shoes&#34;, &#34;active&#34;: true}&#39;</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></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w"></span><span class="c1">-- Reverse containment
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">products</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="s1">&#39;{&#34;sub&#34;: &#34;value&#34;}&#39;</span><span class="w"> </span><span class="o">&lt;@</span><span class="w"> </span><span class="n">metadata</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></span><span class="line"><span class="ln">17</span><span class="cl"><span class="w"></span><span class="c1">-- Key existence
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">products</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">metadata</span><span class="w"> </span><span class="o">?</span><span class="w"> </span><span class="s1">&#39;discount&#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">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">products</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">metadata</span><span class="w"> </span><span class="o">?|</span><span class="w"> </span><span class="nb">array</span><span class="p">[</span><span class="s1">&#39;a&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;b&#39;</span><span class="p">];</span><span class="w">  </span><span class="c1">-- 任一 key
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">products</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">metadata</span><span class="w"> </span><span class="o">?&amp;</span><span class="w"> </span><span class="nb">array</span><span class="p">[</span><span class="s1">&#39;a&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;b&#39;</span><span class="p">];</span><span class="w">  </span><span class="c1">-- 全部 key</span></span></span></code></pre></div><h3 id="jsonpathpg-12">jsonpath（PG 12+）</h3>
<p>SQL/JSON jsonpath 是 SQL standard、PG 12+ 支援：</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">-- jsonb_path_query：展開 path 結果
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">jsonb_path_query</span><span class="p">(</span><span class="n">metadata</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;$.variants[*].price&#39;</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="k">FROM</span><span class="w"> </span><span class="n">products</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="o">=</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></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w"></span><span class="c1">-- jsonb_path_exists：返 boolean
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">products</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">WHERE</span><span class="w"> </span><span class="n">jsonb_path_exists</span><span class="p">(</span><span class="n">metadata</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;$.variants[*] ? (@.price &gt; 100)&#39;</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></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w"></span><span class="c1">-- jsonb_path_query_array：返 array of result
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">jsonb_path_query_array</span><span class="p">(</span><span class="n">metadata</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;$.tags[*]&#39;</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="k">FROM</span><span class="w"> </span><span class="n">products</span><span class="p">;</span></span></span></code></pre></div><p>jsonpath 比 PG-specific operator 標準化、跨 vendor portable。</p>
<h2 id="partial-jsonb-index">Partial JSONB Index</h2>
<p>對 <em>只 query subset row</em> 的場景、建 partial index：</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">-- 只對 active product 建 metadata index
</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_active_products_metadata</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">ON</span><span class="w"> </span><span class="n">products</span><span class="w"> </span><span class="k">USING</span><span class="w"> </span><span class="n">GIN</span><span class="w"> </span><span class="p">(</span><span class="n">metadata</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">WHERE</span><span class="w"> </span><span class="n">status</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;active&#39;</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></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w"></span><span class="c1">-- Query active products + JSONB filter
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">products</span><span class="w">
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="n">status</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;active&#39;</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="n">metadata</span><span class="w"> </span><span class="o">@&gt;</span><span class="w"> </span><span class="s1">&#39;{&#34;category&#34;: &#34;shoes&#34;}&#39;</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="c1">-- → planner 用 partial GIN index</span></span></span></code></pre></div><p>Partial index 比 full GIN 小很多、write cost 低、index hit rate 高。</p>
<h2 id="5-個-production-踩雷">5 個 Production 踩雷</h2>
<h3 id="1-大-jsonb--toast--性能崩潰">1. 大 JSONB + TOAST — 性能崩潰</h3>
<p>JSONB &gt; 2 KB 自動進 TOAST（PG 內外部 storage）、每次 query read 該 row 都要 <em>de-TOAST</em>（拉外部 storage 再合併）。大 JSONB（&gt; 50 KB）每次 query 慢 10-100x。</p>
<p>修法：</p>
<ul>
<li>把 <em>大 attribute 拆獨立 column</em>（如 <code>description TEXT</code> 不放 metadata）</li>
<li>用 <em>JSON path index</em> 對 hot path 加速、不必每次讀整個 JSONB</li>
<li>用 <code>pg_column_size(metadata)</code> 監控 JSONB size 分布、找 outlier</li>
<li>對 truly 大 document（&gt; 1 MB）考慮 separate table 或 object storage</li>
</ul>
<h3 id="2-nested-update--整個-jsonb-重寫">2. Nested update — 整個 JSONB 重寫</h3>
<p>PG 沒 <em>atomic partial update</em>。修改 nested key 必須讀整個 JSONB → 修改 → 寫回：</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">UPDATE</span><span class="w"> </span><span class="n">products</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">SET</span><span class="w"> </span><span class="n">metadata</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">jsonb_set</span><span class="p">(</span><span class="n">metadata</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;{discount}&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;0.2&#39;</span><span class="p">::</span><span class="n">jsonb</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="k">WHERE</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">100</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="c1">-- 等同於：讀 metadata、改 discount、寫回整個 metadata</span></span></span></code></pre></div><p>對 <em>大 JSONB + 高頻 update</em> 場景、寫吞吐受限。跟 MongoDB <code>$set</code> operator 對應 <em>partial document update</em> 不同。</p>
<p>修法：</p>
<ul>
<li>對 <em>high-update nested key</em> 拆獨立 column</li>
<li>Application 層 batch update（攢一批一次 update）</li>
<li>接受 PG JSONB <em>是 immutable-replace</em> 心智模型、不是 <em>mutable in-place</em></li>
</ul>
<h3 id="3-index-選錯-op-class---query-走-full-scan">3. Index 選錯 op class — <code>?</code> query 走 full scan</h3>
<p>對 <code>jsonb_path_ops</code> index、<code>?</code> key existence query 走 <em>full scan</em>（不用 index）。Application 看 query 慢、查 EXPLAIN 才發現 index 沒用。</p>
<p>修法：</p>
<ul>
<li>設計階段確認 <em>application query pattern</em>：只用 <code>@&gt;</code> 還是會用 <code>?</code></li>
<li>多 query pattern → <code>jsonb_ops</code>（預設）</li>
<li>純 containment → <code>jsonb_path_ops</code>（省 index size）</li>
<li>不確定先用預設、production 觀察後再優化</li>
</ul>
<h3 id="4-jsonb_path_query-跟-jsonb_path_exists-行為差">4. <code>jsonb_path_query</code> 跟 <code>jsonb_path_exists</code> 行為差</h3>
<ul>
<li><code>jsonb_path_query(metadata, '$.variants[*].price')</code> — 展開、每個 match return 一 row</li>
<li><code>jsonb_path_exists(metadata, '$.variants[*]')</code> — return boolean（true if any match）</li>
</ul>
<p>Application 想要「過濾 row」用前者寫成：</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">-- 錯：返多 row 給每個 product、結果 row count 暴增
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="n">jsonb_path_query</span><span class="p">(</span><span class="n">metadata</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;$.variants[*].price&#39;</span><span class="p">)</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">products</span><span class="p">;</span></span></span></code></pre></div><p>應該：</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">-- 對：只過濾 product
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">products</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">jsonb_path_exists</span><span class="p">(</span><span class="n">metadata</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;$.variants[*] ? (@.price &gt; 100)&#39;</span><span class="p">);</span></span></span></code></pre></div><p>修法：</p>
<ul>
<li>區分 <em>exists 過濾 row</em> vs <em>query 展開 row</em></li>
<li>過濾用 <code>jsonb_path_exists</code> 或 <code>@&gt;</code> operator</li>
<li>展開用 <code>jsonb_path_query</code> + 配合 <code>LATERAL</code> 或 subquery</li>
</ul>
<h3 id="5-partial-index-條件不對齊-query">5. Partial index 條件不對齊 query</h3>





<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_active_metadata</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">products</span><span class="w"> </span><span class="k">USING</span><span class="w"> </span><span class="n">GIN</span><span class="w"> </span><span class="p">(</span><span class="n">metadata</span><span class="p">)</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">status</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;active&#39;</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></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="c1">-- Application query 但 status 沒 explicit
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">products</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">metadata</span><span class="w"> </span><span class="o">@&gt;</span><span class="w"> </span><span class="s1">&#39;{&#34;category&#34;: &#34;shoes&#34;}&#39;</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="c1">-- → 不用 partial index（planner 不知道 status=&#39;active&#39; 條件）</span></span></span></code></pre></div><p>修法：</p>
<ul>
<li>
<p>Application query <em>必須包含 partial index 的 WHERE 條件</em>：</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">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">products</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">status</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;active&#39;</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="n">metadata</span><span class="w"> </span><span class="o">@&gt;</span><span class="w"> </span><span class="s1">&#39;...&#39;</span><span class="p">;</span></span></span></code></pre></div></li>
<li>
<p>確認 planner 用 partial index：<code>EXPLAIN</code> 看 <code>Index Scan using idx_active_metadata</code></p>
</li>
<li>
<p>不對齊 query pattern 的 partial index = waste</p>
</li>
</ul>
<h2 id="何時用-jsonb-vs-拆-column">何時用 JSONB vs 拆 column</h2>
<table>
  <thead>
      <tr>
          <th>場景</th>
          <th>選擇</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>不規則 schema（user-generated metadata / customization）</td>
          <td>JSONB</td>
      </tr>
      <tr>
          <td>半結構化 + 5-10 個常 query key</td>
          <td>JSONB + GIN partial index</td>
      </tr>
      <tr>
          <td>規則 schema、column 數量穩定</td>
          <td>拆 column（更快 / index 易）</td>
      </tr>
      <tr>
          <td>Nested 結構 + 經常需要展開 query</td>
          <td>JSONB + jsonb_path_query</td>
      </tr>
      <tr>
          <td>大 document（&gt; 1 KB）+ 高頻 update</td>
          <td>拆 column 或 separate table</td>
      </tr>
      <tr>
          <td>完全 schemaless workload</td>
          <td>考慮 MongoDB 而非 PG</td>
      </tr>
  </tbody>
</table>
<p>JSONB 是 <em>PG 適合 semi-structured data</em> 的工具、不是 <em>MongoDB 替代品</em>。對 <em>主要結構化 + 少量 JSON</em> 場景 JSONB 完美；對 <em>主要 JSON / 複雜 nested aggregation</em> 場景 MongoDB 仍是專業選擇。</p>
<h2 id="跟其他模組整合">跟其他模組整合</h2>
<h3 id="跟-query-optimization">跟 Query Optimization</h3>
<p>JSONB query 的 planner 行為：</p>
<ul>
<li><code>@&gt;</code> containment 對 jsonb_ops / jsonb_path_ops 都用 GIN</li>
<li><code>?</code> 只對 jsonb_ops 用 GIN</li>
<li>jsonb_path_exists 用 <em>functional index</em>（不是 GIN）</li>
<li>看 EXPLAIN 確認用對 index、詳見 <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></li>
</ul>
<h3 id="跟-sql-features-baseline">跟 SQL Features Baseline</h3>
<p>JSONB 是 PG 結構性領先特性之一、詳見 <a href="/blog/backend/01-database/vendors/postgresql/sql-features-baseline/" data-link-title="PostgreSQL SQL Features：PG 早就有的、MySQL 8.0 才補的、PG 仍領先的" data-link-desc="PG 在 SQL features 上長期領先 MySQL — CTE / window function / lateral / partial index / FTS / JSONB / GIN index / materialized view 在 PG 早 5-15 年。MySQL 8.0（2018）補多數但 *index / storage / extension* 層仍是 PG 結構優勢。本文整理 PG 早期就有的特性、MySQL 8.0 補的差異、PG 仍領先的、跟 MySQL modern-sql-features sibling 反向視角">SQL Features Baseline</a>。</p>
<h3 id="跟-mvcc--lock-model">跟 MVCC + Lock Model</h3>
<p>JSONB UPDATE 整個 column 重寫、每次 update 創新 tuple、跟 row update 相同 MVCC behavior。詳見 <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>。</p>
<h3 id="跟-mysql-json_table">跟 MySQL JSON_TABLE</h3>
<p>MySQL 8.0 JSON_TABLE 跟 PG jsonpath 類似（都 SQL standard）、但 <em>index 機制</em> 完全不同：</p>
<ul>
<li>PG：JSONB + GIN index over 整個 column</li>
<li>MySQL：JSON column + generated column + index over generated</li>
</ul>
<p>PG JSONB GIN 是 <em>結構性領先</em>、MySQL 短期內難對應。詳見 <a href="/blog/backend/01-database/vendors/mysql/modern-sql-features/" data-link-title="MySQL 8.0 Modern SQL：CTE / window function / JSON_TABLE 不是「終於跟上 PG」、是進入 SQL 工程深度的入場券" data-link-desc="MySQL 8.0 在 SQL 特性上 *終於補齊* CTE、window function、lateral derived table、JSON_TABLE、hash join 等現代 SQL 特性。本文走 5 個關鍵特性、各自實際 production 場景、跟 PostgreSQL 對應特性的行為差異（特別是 JSON_TABLE vs PG JSONB / jsonb_path_query）、配置 / migration 注意事項、5 production 踩雷（CTE 不 materialize / window function 大量 sort spill / JSON_TABLE 跟 generated column 取捨 / hash join 預設沒開 / recursive CTE 深度上限）">MySQL Modern SQL Features</a>。</p>
<h2 id="觀測-metric">觀測 metric</h2>
<ul>
<li><code>pg_column_size(metadata)</code> — 每 row JSONB size 分布</li>
<li><code>pg_relation_size('idx_name')</code> — JSONB GIN index 大小</li>
<li><code>pg_stat_user_indexes.idx_scan</code> — JSONB index 使用次數</li>
<li>TOAST table size：<code>SELECT pg_relation_size(reltoastrelid) FROM pg_class WHERE relname='products'</code></li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<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 vendor overview</a></li>
<li><a href="/blog/backend/01-database/vendors/postgresql/sql-features-baseline/" data-link-title="PostgreSQL SQL Features：PG 早就有的、MySQL 8.0 才補的、PG 仍領先的" data-link-desc="PG 在 SQL features 上長期領先 MySQL — CTE / window function / lateral / partial index / FTS / JSONB / GIN index / materialized view 在 PG 早 5-15 年。MySQL 8.0（2018）補多數但 *index / storage / extension* 層仍是 PG 結構優勢。本文整理 PG 早期就有的特性、MySQL 8.0 補的差異、PG 仍領先的、跟 MySQL modern-sql-features sibling 反向視角">PG SQL Features Baseline</a>（JSONB 是 PG 結構領先之一）</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 對比">PG Query Optimization</a>（JSONB index 用對）</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 對比">PG MVCC + Lock Model</a>（JSONB update 跟 MVCC）</li>
<li><a href="/blog/backend/01-database/vendors/mysql/modern-sql-features/" data-link-title="MySQL 8.0 Modern SQL：CTE / window function / JSON_TABLE 不是「終於跟上 PG」、是進入 SQL 工程深度的入場券" data-link-desc="MySQL 8.0 在 SQL 特性上 *終於補齊* CTE、window function、lateral derived table、JSON_TABLE、hash join 等現代 SQL 特性。本文走 5 個關鍵特性、各自實際 production 場景、跟 PostgreSQL 對應特性的行為差異（特別是 JSON_TABLE vs PG JSONB / jsonb_path_query）、配置 / migration 注意事項、5 production 踩雷（CTE 不 materialize / window function 大量 sort spill / JSON_TABLE 跟 generated column 取捨 / hash join 預設沒開 / recursive CTE 深度上限）">MySQL Modern SQL Features</a>（JSON_TABLE vs JSONB 對比）</li>
<li><a href="/blog/backend/01-database/vendors/mongodb/" data-link-title="MongoDB" data-link-desc="Document database 代表、Atlas managed、跨雲可用、許多大規模平台從 MongoDB 起家">MongoDB vendor</a>（純 document workload 替代）</li>
<li>官方：<a href="https://www.postgresql.org/docs/current/functions-json.html">PG JSON Functions</a> / <a href="https://www.postgresql.org/docs/current/datatype-json.html#JSON-INDEXING">JSONB Indexing</a></li>
</ul>
]]></content:encoded></item><item><title>PostgreSQL Extension Ecosystem：把 PG 變成 vector DB / time-series / sharded 的 plugin 生態</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/extension-ecosystem/</link><pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/extension-ecosystem/</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>extension ecosystem&lt;/em> — PG 結構性產品線擴張的機制。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="extension-不只是-plugin是產品線擴張">Extension 不只是 plugin、是產品線擴張&lt;/h2>
&lt;p>PG extension 機制讓 &lt;em>第三方加新 type / function / operator / index access method / planner hook&lt;/em>、深度整合到 PG core。對比其他 DB 的 plugin model（MySQL plugin / MongoDB plugin）、PG extension 是 &lt;em>更深的 SPI&lt;/em>。&lt;/p>
&lt;p>結果：&lt;/p>
&lt;ul>
&lt;li>pgvector → PG 變 vector similarity search DB（取代 Pinecone / Weaviate）&lt;/li>
&lt;li>TimescaleDB → PG 變 time-series DB（取代 InfluxDB）&lt;/li>
&lt;li>Citus → PG 變 sharded cluster&lt;/li>
&lt;li>PostGIS → PG 變 GIS DB&lt;/li>
&lt;li>pg_cron → PG 變 scheduled job runner&lt;/li>
&lt;li>pgvectorscale → 大規模 vector index&lt;/li>
&lt;/ul>
&lt;p>對 &lt;em>vendor lock-in 敏感&lt;/em> / &lt;em>想統一 stack&lt;/em> 的 org、PG extension 提供 &lt;em>用 PG 取代多個 specialized DB&lt;/em> 的可能。&lt;/p>
&lt;p>但 &lt;em>統一 stack 的代價&lt;/em>：PG 主庫 ops 風險集中（一個 PG 掛 = vector / time-series / GIS / cron 全掛）、extension 跟 PG version 對齊矩陣多一道升級顧慮、規模上限通常比專業 DB 低（pgvector 100M+ vs Pinecone 10B+ / TimescaleDB 100K rows/s vs InfluxDB 500K+）。決策框架：&lt;em>中小規模 + 已用 PG + 不想多管系統&lt;/em> → extension；&lt;em>大規模 + 純該 workload + 有專業 team&lt;/em> → specialized DB。&lt;/p>
&lt;h2 id="extension-lifecycle">Extension Lifecycle&lt;/h2>





&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="c1">-- 看可用 extension
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">pg_available_extensions&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>&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="c1">-- 安裝（在 OS 層、要有對應 package）
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="c1">-- apt install postgresql-14-pg-stat-statements
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="c1">&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="c1">-- Enable in DB
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">EXTENSION&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">pg_stat_statements&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>&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="c1">-- 確認
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">pg_extension&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>&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="c1">-- 升級 extension
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">ALTER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">EXTENSION&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">pg_stat_statements&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">UPDATE&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>&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="c1">-- 移除
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">DROP&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">EXTENSION&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">pg_stat_statements&lt;/span>&lt;span class="p">;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>每個 extension 有：&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>extension ecosystem</em> — PG 結構性產品線擴張的機制。</p></blockquote>
<hr>
<h2 id="extension-不只是-plugin是產品線擴張">Extension 不只是 plugin、是產品線擴張</h2>
<p>PG extension 機制讓 <em>第三方加新 type / function / operator / index access method / planner hook</em>、深度整合到 PG core。對比其他 DB 的 plugin model（MySQL plugin / MongoDB plugin）、PG extension 是 <em>更深的 SPI</em>。</p>
<p>結果：</p>
<ul>
<li>pgvector → PG 變 vector similarity search DB（取代 Pinecone / Weaviate）</li>
<li>TimescaleDB → PG 變 time-series DB（取代 InfluxDB）</li>
<li>Citus → PG 變 sharded cluster</li>
<li>PostGIS → PG 變 GIS DB</li>
<li>pg_cron → PG 變 scheduled job runner</li>
<li>pgvectorscale → 大規模 vector index</li>
</ul>
<p>對 <em>vendor lock-in 敏感</em> / <em>想統一 stack</em> 的 org、PG extension 提供 <em>用 PG 取代多個 specialized DB</em> 的可能。</p>
<p>但 <em>統一 stack 的代價</em>：PG 主庫 ops 風險集中（一個 PG 掛 = vector / time-series / GIS / cron 全掛）、extension 跟 PG version 對齊矩陣多一道升級顧慮、規模上限通常比專業 DB 低（pgvector 100M+ vs Pinecone 10B+ / TimescaleDB 100K rows/s vs InfluxDB 500K+）。決策框架：<em>中小規模 + 已用 PG + 不想多管系統</em> → extension；<em>大規模 + 純該 workload + 有專業 team</em> → specialized DB。</p>
<h2 id="extension-lifecycle">Extension Lifecycle</h2>





<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">-- 看可用 extension
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">pg_available_extensions</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></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w"></span><span class="c1">-- 安裝（在 OS 層、要有對應 package）
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1">-- apt install postgresql-14-pg-stat-statements
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w"></span><span class="c1">-- Enable in DB
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"></span><span class="k">CREATE</span><span class="w"> </span><span class="n">EXTENSION</span><span class="w"> </span><span class="n">pg_stat_statements</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></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w"></span><span class="c1">-- 確認
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">pg_extension</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">-- 升級 extension
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1"></span><span class="k">ALTER</span><span class="w"> </span><span class="n">EXTENSION</span><span class="w"> </span><span class="n">pg_stat_statements</span><span class="w"> </span><span class="k">UPDATE</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></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w"></span><span class="c1">-- 移除
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="c1"></span><span class="k">DROP</span><span class="w"> </span><span class="n">EXTENSION</span><span class="w"> </span><span class="n">pg_stat_statements</span><span class="p">;</span></span></span></code></pre></div><p>每個 extension 有：</p>
<ul>
<li><em>Version</em> — 跟 PG version 綁定（如 pg_stat_statements 14 / 15 / 16）</li>
<li><em>Schema</em> — 安裝到 <code>public</code> 或專屬 schema</li>
<li><em>Dependencies</em> — 部分 extension 依賴其他（如 PostGIS 依賴 pg_trgm）</li>
<li><em>Trusted vs untrusted</em> — trusted 可以 non-superuser 安裝（PG 13+）</li>
</ul>
<h2 id="6-個-production-critical-extension">6 個 Production-Critical Extension</h2>
<h3 id="1-pg_stat_statements--query-stats必裝">1. pg_stat_statements — Query stats（必裝）</h3>
<p>任何 production PG cluster 都該裝：</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="c1"># postgresql.conf</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="na">shared_preload_libraries</span> <span class="o">=</span> <span class="s">&#39;pg_stat_statements&#39;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="na">pg_stat_statements.max</span> <span class="o">=</span> <span class="s">5000</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="na">pg_stat_statements.track</span> <span class="o">=</span> <span class="s">all</span></span></span></code></pre></div>




<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="n">EXTENSION</span><span class="w"> </span><span class="n">pg_stat_statements</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></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="c1">-- Top 10 query by total time
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">query</span><span class="p">,</span><span class="w"> </span><span class="n">calls</span><span class="p">,</span><span class="w"> </span><span class="n">total_exec_time</span><span class="p">,</span><span class="w"> </span><span class="n">mean_exec_time</span><span class="p">,</span><span class="w"> </span><span class="k">rows</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">FROM</span><span class="w"> </span><span class="n">pg_stat_statements</span><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w"></span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">total_exec_time</span><span class="w"> </span><span class="k">DESC</span><span class="w"> </span><span class="k">LIMIT</span><span class="w"> </span><span class="mi">10</span><span class="p">;</span></span></span></code></pre></div><p>對應 MySQL <code>events_statements_summary_by_digest</code>。詳見 <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>。</p>
<h3 id="2-pg_partman--自動-partition-lifecycle">2. pg_partman — 自動 partition lifecycle</h3>
<p>PG declarative partitioning 需要 <em>手動建 / drop partition</em>。pg_partman 自動化：</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="n">EXTENSION</span><span class="w"> </span><span class="n">pg_partman</span><span class="w"> </span><span class="k">SCHEMA</span><span class="w"> </span><span class="n">partman</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></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w"></span><span class="c1">-- 設 events 表自動 monthly partition
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">partman</span><span class="p">.</span><span class="n">create_parent</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">p_parent_table</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="s1">&#39;public.events&#39;</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">p_control</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="s1">&#39;created_at&#39;</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">p_type</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="s1">&#39;range&#39;</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">p_interval</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="s1">&#39;1 month&#39;</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">p_premake</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="mi">6</span><span class="w">  </span><span class="c1">-- 預先建 6 個未來 partition
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"></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></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w"></span><span class="c1">-- 跑 maintenance（建未來 partition + drop 老 partition）
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">partman</span><span class="p">.</span><span class="n">run_maintenance</span><span class="p">(</span><span class="n">p_analyze</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="k">false</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="c1">-- 預設用 pg_cron 排程</span></span></span></code></pre></div><p>對 <em>time-series partition</em> workload 必裝。詳見 <a href="/blog/backend/01-database/vendors/postgresql/declarative-partitioning/" data-link-title="PostgreSQL declarative partitioning：partition 不是切表、是讓 planner pruning" data-link-desc="Declarative partitioning 的真實價值是 query planner pruning &#43; maintenance scope 縮小、不是「把大表切小」；RANGE / LIST / HASH 取捨、partition key 選法、5 個 production 踩雷（key 選錯不 prune / unique 不 enforce 跨 partition / ATTACH 鎖太久 / partition 數爆 / DETACH 不 reclaim 空間）、跟 autovacuum &#43; index 設計整合">Declarative Partitioning</a>。</p>
<h3 id="3-pg_repack--online-table-rewrite">3. pg_repack — Online table rewrite</h3>
<p>詳見 <a href="/blog/backend/01-database/vendors/postgresql/online-schema-change/" data-link-title="PostgreSQL Online Schema Change：先用 ALTER 內建特性、不能解才 pg_repack / pg-osc" data-link-desc="PostgreSQL ALTER TABLE 對多數變更已是 *fast catalog-only*（add column nullable / drop column / 改 default），不必走 ghost table tool。本文走 PG 內建 fast DDL 行為、何時必須走 pg_repack / pg-osc、兩工具機制對比（trigger-based vs WAL-shipping）、配置 step-by-step、5 production 踩雷（lock 升級 / VACUUM FULL 誤用 / pg_repack version mismatch / concurrent index 失敗清理 / generated stored column 不能 online）、跟 MySQL gh-ost / pt-osc sibling 對比">Online Schema Change</a>。</p>
<h3 id="4-pgvector--vector-similarity-search">4. pgvector — Vector similarity search</h3>
<p>LLM embedding / semantic search 場景必裝：</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="n">EXTENSION</span><span class="w"> </span><span class="n">vector</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></span><span class="line"><span class="ln"> 3</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">documents</span><span class="w"> </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="n">id</span><span class="w"> </span><span class="nb">SERIAL</span><span class="w"> </span><span class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</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">content</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"> 6</span><span class="cl"><span class="w">    </span><span class="n">embedding</span><span class="w"> </span><span class="n">VECTOR</span><span class="p">(</span><span class="mi">1536</span><span class="p">)</span><span class="w">  </span><span class="c1">-- OpenAI text-embedding-3-small 1536-dim
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"></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></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w"></span><span class="c1">-- HNSW index（pgvector 0.5+）
</span></span></span><span class="line"><span class="ln">10</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="k">ON</span><span class="w"> </span><span class="n">documents</span><span class="w"> </span><span class="k">USING</span><span class="w"> </span><span class="n">HNSW</span><span class="w"> </span><span class="p">(</span><span class="n">embedding</span><span class="w"> </span><span class="n">vector_cosine_ops</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></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w"></span><span class="c1">-- 找最相似的 5 個
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">documents</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">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">embedding</span><span class="w"> </span><span class="o">&lt;=&gt;</span><span class="w"> </span><span class="s1">&#39;[0.1, 0.2, ...]&#39;</span><span class="p">::</span><span class="n">vector</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">LIMIT</span><span class="w"> </span><span class="mi">5</span><span class="p">;</span></span></span></code></pre></div><p>對 <em>中小規模 RAG / semantic search</em> workload、pgvector 在 PG 內跑、不必跨 Pinecone / Weaviate / Qdrant 等獨立服務。</p>
<p>對 <em>超大規模</em> vector workload（&gt; 1 億 vector）考慮 pgvectorscale（pgvector 的 streaming variant）或專業 vector DB。</p>
<h3 id="5-timescaledb--time-series-擴展">5. TimescaleDB — Time-series 擴展</h3>
<p>把 PG 變 time-series DB：</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="n">EXTENSION</span><span class="w"> </span><span class="n">timescaledb</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></span><span class="line"><span class="ln"> 3</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">metrics</span><span class="w"> </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="n">time</span><span class="w"> </span><span class="n">TIMESTAMPTZ</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">device_id</span><span class="w"> </span><span class="nb">INT</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">value</span><span class="w"> </span><span class="n">DOUBLE</span><span class="w"> </span><span class="k">PRECISION</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><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></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w"></span><span class="c1">-- 轉成 hypertable（auto-partition by time）
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">create_hypertable</span><span class="p">(</span><span class="s1">&#39;metrics&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;time&#39;</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></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w"></span><span class="c1">-- Continuous aggregate（materialized view 自動 refresh）
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1"></span><span class="k">CREATE</span><span class="w"> </span><span class="n">MATERIALIZED</span><span class="w"> </span><span class="k">VIEW</span><span class="w"> </span><span class="n">metrics_5min</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">WITH</span><span class="w"> </span><span class="p">(</span><span class="n">timescaledb</span><span class="p">.</span><span class="n">continuous</span><span class="p">)</span><span class="w"> </span><span class="k">AS</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">time_bucket</span><span class="p">(</span><span class="s1">&#39;5 minutes&#39;</span><span class="p">,</span><span class="w"> </span><span class="n">time</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">bucket</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">device_id</span><span class="p">,</span><span class="w"> </span><span class="k">avg</span><span class="p">(</span><span class="n">value</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">metrics</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">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">bucket</span><span class="p">,</span><span class="w"> </span><span class="n">device_id</span><span class="p">;</span></span></span></code></pre></div><p>對 IoT / monitoring / financial tick data 場景、TimescaleDB 比純 PG 寫吞吐高 10x+。</p>
<h3 id="6-postgis--gis-extension">6. PostGIS — GIS extension</h3>
<p>地理 / 空間 query 業界標準：</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="n">EXTENSION</span><span class="w"> </span><span class="n">postgis</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></span><span class="line"><span class="ln"> 3</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">stores</span><span class="w"> </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="n">id</span><span class="w"> </span><span class="nb">SERIAL</span><span class="w"> </span><span class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</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="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="k">location</span><span class="w"> </span><span class="n">GEOGRAPHY</span><span class="p">(</span><span class="n">POINT</span><span class="p">,</span><span class="w"> </span><span class="mi">4326</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="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">INDEX</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">stores</span><span class="w"> </span><span class="k">USING</span><span class="w"> </span><span class="n">GIST</span><span class="w"> </span><span class="p">(</span><span class="k">location</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></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w"></span><span class="c1">-- 找 1 km 內的 store
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">stores</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="n">ST_DWithin</span><span class="p">(</span><span class="k">location</span><span class="p">,</span><span class="w"> </span><span class="n">ST_MakePoint</span><span class="p">(</span><span class="mi">121</span><span class="p">.</span><span class="mi">5</span><span class="p">,</span><span class="w"> </span><span class="mi">25</span><span class="p">.</span><span class="mi">05</span><span class="p">)::</span><span class="n">geography</span><span class="p">,</span><span class="w"> </span><span class="mi">1000</span><span class="p">);</span></span></span></code></pre></div><p>PostGIS 是 GIS workload 業界標準、其他 DB GIS 能力都對標 PostGIS。</p>
<h2 id="其他常用-extension">其他常用 extension</h2>
<p>除 6 個 production-critical 之外、以下是 <em>特定場景常用</em> 的 extension — 分四類：排程跟 utility（<code>pg_cron</code> / <code>pg_trgm</code> / <code>uuid-ossp</code>）、type 擴展（<code>hstore</code> / <code>citext</code> / <code>pgcrypto</code>）、跨 DB 整合（<code>postgres_fdw</code> / <code>mysql_fdw</code>）、observability / debug 工具（<code>pg_buffercache</code> / <code>pg_visibility</code> / <code>auto_explain</code>）：</p>
<table>
  <thead>
      <tr>
          <th>Extension</th>
          <th>用途</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>pg_cron</code></td>
          <td>排程 SQL job（不必外部 cron）</td>
      </tr>
      <tr>
          <td><code>pg_trgm</code></td>
          <td>Fuzzy string match / similarity</td>
      </tr>
      <tr>
          <td><code>uuid-ossp</code></td>
          <td>UUID 產生</td>
      </tr>
      <tr>
          <td><code>hstore</code></td>
          <td>Key-value pair type</td>
      </tr>
      <tr>
          <td><code>citext</code></td>
          <td>Case-insensitive text type</td>
      </tr>
      <tr>
          <td><code>pgcrypto</code></td>
          <td>加密 / hash function</td>
      </tr>
      <tr>
          <td><code>postgres_fdw</code></td>
          <td>PG → PG foreign table</td>
      </tr>
      <tr>
          <td><code>mysql_fdw</code></td>
          <td>PG → MySQL foreign table</td>
      </tr>
      <tr>
          <td><code>pg_buffercache</code></td>
          <td>Buffer pool 內容檢視</td>
      </tr>
      <tr>
          <td><code>pg_visibility</code></td>
          <td>Visibility map 檢視（debug bloat）</td>
      </tr>
      <tr>
          <td><code>auto_explain</code></td>
          <td>Slow query 自動 log plan</td>
      </tr>
      <tr>
          <td><code>wal2json</code></td>
          <td>Logical decoding output 為 JSON</td>
      </tr>
      <tr>
          <td><code>Citus</code></td>
          <td>Distributed PG</td>
      </tr>
      <tr>
          <td><code>pgvector</code></td>
          <td>Vector similarity</td>
      </tr>
      <tr>
          <td><code>pglogical</code></td>
          <td>Logical replication（功能比 native 強）</td>
      </tr>
      <tr>
          <td><code>pg_squeeze</code></td>
          <td>pg_repack 替代</td>
      </tr>
  </tbody>
</table>
<p>實務組合：observability 三件套（<code>pg_stat_statements</code> + <code>auto_explain</code> + <code>pg_buffercache</code>）幾乎是 production 標配；FDW 是「跨 DB query」的 escape hatch、但 cross-DB query 效能差、適合 reporting 不適合 OLTP。</p>
<h2 id="5-個-production-踩雷">5 個 Production 踩雷</h2>
<h3 id="1-extension-version-跟-pg-version-對齊">1. Extension version 跟 PG version 對齊</h3>
<p>PG cluster 升 14 → 15 後、extension（pg_stat_statements / pg_partman / pgvector 等）必須有對應 15 版本。早期升級 / niche extension 可能還沒釋出。</p>
<p>修法：</p>
<ul>
<li>升 PG cluster 前 <em>先確認所有 extension 都有對應 PG version 釋出版本</em></li>
<li>升完 PG cluster <em>立即跑 <code>ALTER EXTENSION xxx UPDATE</code></em></li>
<li>Upgrade runbook 紀錄每個 extension 的版本兼容狀態</li>
</ul>
<h3 id="2-managed-pg-限制-extension-列表">2. Managed PG 限制 extension 列表</h3>
<p>AWS RDS / Aurora PG / Cloud SQL / Azure DB for PostgreSQL 各自有 <em>支援 extension 白名單</em>：</p>
<ul>
<li>不在白名單的 extension 不能 install</li>
<li>部分 extension 限定特定 PG version</li>
<li>Untrusted extension 通常不允許</li>
</ul>
<p>常見 <em>managed 不支援</em> 的 extension：</p>
<ul>
<li><code>pg_repack</code>（Aurora 有限支援、RDS 部分 version 支援）</li>
<li><code>pglogical</code>（部分 cloud 不支援）</li>
<li><code>pg_cron</code>（cloud 通常用 managed scheduler 取代）</li>
<li>Custom extension（自寫 .so）</li>
</ul>
<p>修法：</p>
<ul>
<li>評估 managed PG 之前、先查 <em>vendor 支援 extension 列表</em></li>
<li>Self-hosted vs managed 的 <em>跨雲 portability</em> 議題：extension 是 lock-in source</li>
<li>如果 application 強依賴某 extension（如 PostGIS），確認 cloud 支援</li>
</ul>
<h3 id="3-extension-upgrade-order">3. Extension upgrade order</h3>
<p><code>pg_upgrade</code> 升 PG major version 後、extension 也要升。順序：</p>
<ol>
<li><em>pg_upgrade</em> PG binary + cluster</li>
<li>對每個 DB 跑 <code>ALTER EXTENSION xxx UPDATE</code></li>
<li>部分 extension（如 PostGIS）需要 <em>特殊升級程序</em>（<code>SELECT postgis_extensions_upgrade()</code>）</li>
</ol>
<p>修法：</p>
<ul>
<li>升 PG 後 <em>先測 staging cluster</em> 確認 extension upgrade 流程</li>
<li>PostGIS / TimescaleDB / Citus 有自己 upgrade 程序、必須遵循 vendor doc</li>
<li>升完跑 <code>\dx</code> 看每個 extension 版本</li>
</ul>
<h3 id="4-shared_preload_libraries-衝突">4. <code>shared_preload_libraries</code> 衝突</h3>
<p>部分 extension（pg_stat_statements / auto_explain / TimescaleDB / Citus / pg_cron）必須在 <code>shared_preload_libraries</code> 加進去、需要 <em>重啟 PG</em>。</p>
<p>衝突情境：</p>
<ul>
<li>pg_partman + TimescaleDB 都用 background worker、worker 上限不夠</li>
<li><code>max_worker_processes</code> 預設 8、不夠時某些 extension 起不起來</li>
</ul>
<p>修法：</p>
<ul>
<li>列出所有 shared_preload extension、確認 order（部分有 dependency）</li>
<li>提高 <code>max_worker_processes = 16</code> / <code>max_parallel_workers = 8</code> 等</li>
<li>重啟 PG 才生效、計入 maintenance window</li>
</ul>
<h3 id="5-extension-跟-logical-replication-互動">5. Extension 跟 logical replication 互動</h3>
<p>Logical replication（pglogical / native）不自動 replicate extension state（function / type definition）。Subscriber 沒裝對應 extension、replicate event 失敗。</p>
<p>修法：</p>
<ul>
<li>Subscriber 必須 <em>先安裝</em> publisher 用的 extension</li>
<li>Extension 版本 <em>publisher / subscriber 對齊</em></li>
<li>對 extension-heavy schema、考慮用 <em>streaming replication</em>（physical）而非 logical</li>
</ul>
<h2 id="cloud-vendor-對-extension-的支援">Cloud Vendor 對 Extension 的支援</h2>
<table>
  <thead>
      <tr>
          <th>Vendor</th>
          <th>常見 extension 支援</th>
          <th>限制</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>AWS RDS PostgreSQL</td>
          <td>pg_stat_statements / pg_partman / pgvector / pg_repack</td>
          <td>部分 version 限制 / 不能 install custom</td>
      </tr>
      <tr>
          <td>AWS Aurora PostgreSQL</td>
          <td>同 RDS、加 Aurora-specific</td>
          <td>pg_repack 限版本</td>
      </tr>
      <tr>
          <td>GCP Cloud SQL</td>
          <td>標準 extension 廣支援</td>
          <td>pg_cron / pgvector OK</td>
      </tr>
      <tr>
          <td>Azure DB for PostgreSQL</td>
          <td>廣泛支援 + Azure 整合</td>
          <td>Citus（managed 即 Cosmos DB for PG）</td>
      </tr>
      <tr>
          <td>Self-hosted</td>
          <td>全部</td>
          <td>自己維護</td>
      </tr>
  </tbody>
</table>
<p>對 <em>extension-heavy</em> application、self-hosted PG 仍是必要選擇。Managed PG 適合 <em>標準 extension</em> workload。</p>
<h2 id="何時用-pg-extension-取代專業-db">何時用 PG extension 取代專業 DB</h2>
<table>
  <thead>
      <tr>
          <th>場景</th>
          <th>用 extension 還是專業 DB</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>&lt; 100M vector + RAG / semantic search</td>
          <td>pgvector（單一 stack 省 ops）</td>
      </tr>
      <tr>
          <td>大規模 vector search &gt; 10M with high QPS</td>
          <td>專業 vector DB（Pinecone / Qdrant）</td>
      </tr>
      <tr>
          <td>Time-series &lt; 100 TB</td>
          <td>TimescaleDB</td>
      </tr>
      <tr>
          <td>Time-series &gt; 100 TB + high cardinality</td>
          <td>專業 TS DB（InfluxDB / VictoriaMetrics）</td>
      </tr>
      <tr>
          <td>GIS</td>
          <td>PostGIS（業界標準）</td>
      </tr>
      <tr>
          <td>Sharded &lt; 10 TB + multi-tenant</td>
          <td>Citus</td>
      </tr>
      <tr>
          <td>Sharded &gt; 100 TB</td>
          <td>distributed SQL（CockroachDB / TiDB）</td>
      </tr>
      <tr>
          <td>Scheduled job</td>
          <td>pg_cron（簡單）/ Airflow（複雜）</td>
      </tr>
  </tbody>
</table>
<p>對中小規模、PG + extension 是 <em>簡化 stack</em> 的有效路徑。規模超過時、專業 DB 仍是首選。</p>
<h2 id="跟其他模組整合">跟其他模組整合</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/postgresql/citus-distributed/" data-link-title="PostgreSQL Citus Distributed：用 extension 把 PG 變成 sharded cluster" data-link-desc="Citus 是 PG extension、把單機 PG 變成 *coordinator &#43; worker* sharded cluster、保留 PG SQL &#43; 加 distributed table &#43; reference table &#43; columnar storage。本文走 Citus 架構（coordinator / worker / distribution column）、3 種 table type（distributed / reference / local）、配置 step-by-step、5 production 踩雷（distribution column 選錯 / cross-shard transaction / reference table 過大 / colocate 不對齊 / worker failover）、跟 MySQL Vitess sharding sibling 對比">Citus Distributed</a>：extension 一例、可看 extension model</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>：pg_stat_statements + auto_explain 必用</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/online-schema-change/" data-link-title="PostgreSQL Online Schema Change：先用 ALTER 內建特性、不能解才 pg_repack / pg-osc" data-link-desc="PostgreSQL ALTER TABLE 對多數變更已是 *fast catalog-only*（add column nullable / drop column / 改 default），不必走 ghost table tool。本文走 PG 內建 fast DDL 行為、何時必須走 pg_repack / pg-osc、兩工具機制對比（trigger-based vs WAL-shipping）、配置 step-by-step、5 production 踩雷（lock 升級 / VACUUM FULL 誤用 / pg_repack version mismatch / concurrent index 失敗清理 / generated stored column 不能 online）、跟 MySQL gh-ost / pt-osc sibling 對比">Online Schema Change</a>：pg_repack 是 extension</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/declarative-partitioning/" data-link-title="PostgreSQL declarative partitioning：partition 不是切表、是讓 planner pruning" data-link-desc="Declarative partitioning 的真實價值是 query planner pruning &#43; maintenance scope 縮小、不是「把大表切小」；RANGE / LIST / HASH 取捨、partition key 選法、5 個 production 踩雷（key 選錯不 prune / unique 不 enforce 跨 partition / ATTACH 鎖太久 / partition 數爆 / DETACH 不 reclaim 空間）、跟 autovacuum &#43; index 設計整合">Declarative Partitioning</a>：pg_partman 是 extension</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/sql-features-baseline/" data-link-title="PostgreSQL SQL Features：PG 早就有的、MySQL 8.0 才補的、PG 仍領先的" data-link-desc="PG 在 SQL features 上長期領先 MySQL — CTE / window function / lateral / partial index / FTS / JSONB / GIN index / materialized view 在 PG 早 5-15 年。MySQL 8.0（2018）補多數但 *index / storage / extension* 層仍是 PG 結構優勢。本文整理 PG 早期就有的特性、MySQL 8.0 補的差異、PG 仍領先的、跟 MySQL modern-sql-features sibling 反向視角">SQL Features Baseline</a>：extension 是 PG 結構性領先之一</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<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 vendor overview</a></li>
<li><a href="/blog/backend/01-database/vendors/postgresql/sql-features-baseline/" data-link-title="PostgreSQL SQL Features：PG 早就有的、MySQL 8.0 才補的、PG 仍領先的" data-link-desc="PG 在 SQL features 上長期領先 MySQL — CTE / window function / lateral / partial index / FTS / JSONB / GIN index / materialized view 在 PG 早 5-15 年。MySQL 8.0（2018）補多數但 *index / storage / extension* 層仍是 PG 結構優勢。本文整理 PG 早期就有的特性、MySQL 8.0 補的差異、PG 仍領先的、跟 MySQL modern-sql-features sibling 反向視角">PG SQL Features Baseline</a>（extension 是結構優勢）</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/citus-distributed/" data-link-title="PostgreSQL Citus Distributed：用 extension 把 PG 變成 sharded cluster" data-link-desc="Citus 是 PG extension、把單機 PG 變成 *coordinator &#43; worker* sharded cluster、保留 PG SQL &#43; 加 distributed table &#43; reference table &#43; columnar storage。本文走 Citus 架構（coordinator / worker / distribution column）、3 種 table type（distributed / reference / local）、配置 step-by-step、5 production 踩雷（distribution column 選錯 / cross-shard transaction / reference table 過大 / colocate 不對齊 / worker failover）、跟 MySQL Vitess sharding sibling 對比">PG Citus Distributed</a>（extension example）</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/online-schema-change/" data-link-title="PostgreSQL Online Schema Change：先用 ALTER 內建特性、不能解才 pg_repack / pg-osc" data-link-desc="PostgreSQL ALTER TABLE 對多數變更已是 *fast catalog-only*（add column nullable / drop column / 改 default），不必走 ghost table tool。本文走 PG 內建 fast DDL 行為、何時必須走 pg_repack / pg-osc、兩工具機制對比（trigger-based vs WAL-shipping）、配置 step-by-step、5 production 踩雷（lock 升級 / VACUUM FULL 誤用 / pg_repack version mismatch / concurrent index 失敗清理 / generated stored column 不能 online）、跟 MySQL gh-ost / pt-osc sibling 對比">PG Online Schema Change</a>（pg_repack）</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/declarative-partitioning/" data-link-title="PostgreSQL declarative partitioning：partition 不是切表、是讓 planner pruning" data-link-desc="Declarative partitioning 的真實價值是 query planner pruning &#43; maintenance scope 縮小、不是「把大表切小」；RANGE / LIST / HASH 取捨、partition key 選法、5 個 production 踩雷（key 選錯不 prune / unique 不 enforce 跨 partition / ATTACH 鎖太久 / partition 數爆 / DETACH 不 reclaim 空間）、跟 autovacuum &#43; index 設計整合">PG Declarative Partitioning</a>（pg_partman）</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 對比">PG Query Optimization</a>（pg_stat_statements + auto_explain）</li>
<li>官方：<a href="https://www.postgresql.org/docs/current/extend-extensions.html">PG Extensions</a> / <a href="https://github.com/pgvector/pgvector">pgvector</a> / <a href="https://docs.timescale.com/">TimescaleDB</a> / <a href="https://postgis.net/">PostGIS</a></li>
</ul>
]]></content:encoded></item><item><title>PostgreSQL Full-Text Search：tsvector / tsquery / GIN index 跟 pg_trgm fuzzy 三層搜尋</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/full-text-search/</link><pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/full-text-search/</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>full-text search&lt;/em> — 內建 tsvector / tsquery + pg_trgm fuzzy match。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="pg-fts-機制tsvector--tsquery--gin-index">PG FTS 機制：tsvector + tsquery + GIN index&lt;/h2>
&lt;p>PG 內建 full-text search 三件組：&lt;/p>
&lt;ul>
&lt;li>&lt;code>tsvector&lt;/code>：document 轉成 &lt;em>lexeme&lt;/em>（字根 + position）vector、normalized 後存&lt;/li>
&lt;li>&lt;code>tsquery&lt;/code>：搜尋字串 parse 成 query 形式&lt;/li>
&lt;li>GIN index：對 tsvector 加 inverted index&lt;/li>
&lt;/ul>





&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="c1">-- Document
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">to_tsvector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;english&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;The quick brown fox jumps over the lazy dog&amp;#39;&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="c1">-- 結果：&amp;#39;brown&amp;#39;:3 &amp;#39;dog&amp;#39;:9 &amp;#39;fox&amp;#39;:4 &amp;#39;jump&amp;#39;:5 &amp;#39;lazi&amp;#39;:8 &amp;#39;quick&amp;#39;:2
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="c1">-- The/over 是 stop word 被過濾、jumps/lazy 轉字根、保留 position
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="c1">&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="c1">-- Query
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">to_tsquery&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;english&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;fox &amp;amp; dog&amp;#39;&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="c1">-- 結果：&amp;#39;fox&amp;#39; &amp;amp; &amp;#39;dog&amp;#39;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="c1">&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="c1">-- Match
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">to_tsvector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;english&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;The quick brown fox&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">@@&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">to_tsquery&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;english&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;fox &amp;amp; quick&amp;#39;&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="c1">-- → true&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Index&lt;/strong>：&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">articles&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">SERIAL&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="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">title&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"> 4&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">body&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"> 5&lt;/span>&lt;span class="cl">&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"> 6&lt;/span>&lt;span class="cl">&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="c1">-- GIN index over tsvector (動態 cast)
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">INDEX&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">idx_articles_fts&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">articles&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="k">USING&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">GIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">to_tsvector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;english&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">title&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">||&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39; &amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">||&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">body&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>&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="c1">-- Query 用 index
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">articles&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="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">to_tsvector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;english&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">title&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">||&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39; &amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">||&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">body&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">@@&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">to_tsquery&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;english&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;postgres &amp;amp; index&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>跟 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/jsonb-deep-dive/" data-link-title="PostgreSQL JSONB Deep Dive：Binary Storage &amp;#43; GIN Index 為什麼是結構性優勢" data-link-desc="PG JSONB（9.4&amp;#43;）是 *binary 儲存的 JSON*、可直接 GIN index、是 PG 在 JSON workload 的結構性優勢、跟 MongoDB / MySQL 8.0 JSON_TABLE 比仍領先。本文走 JSON vs JSONB 差異、GIN index 機制（jsonb_ops vs jsonb_path_ops）、operator &amp;#43; path query、partial JSONB indexing、5 production 踩雷（大 JSONB 跟 TOAST / nested update / index 選錯 op class / jsonb_path_query 跟 jsonb_path_exists 行為差 / partial index 條件搞錯）、何時用 JSONB vs 拆 column">JSONB GIN index&lt;/a> 同 GIN access method、不同 indexed expression。&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>full-text search</em> — 內建 tsvector / tsquery + pg_trgm fuzzy match。</p></blockquote>
<hr>
<h2 id="pg-fts-機制tsvector--tsquery--gin-index">PG FTS 機制：tsvector + tsquery + GIN index</h2>
<p>PG 內建 full-text search 三件組：</p>
<ul>
<li><code>tsvector</code>：document 轉成 <em>lexeme</em>（字根 + position）vector、normalized 後存</li>
<li><code>tsquery</code>：搜尋字串 parse 成 query 形式</li>
<li>GIN index：對 tsvector 加 inverted index</li>
</ul>





<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">-- Document
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">to_tsvector</span><span class="p">(</span><span class="s1">&#39;english&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;The quick brown fox jumps over the lazy dog&#39;</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="c1">-- 結果：&#39;brown&#39;:3 &#39;dog&#39;:9 &#39;fox&#39;:4 &#39;jump&#39;:5 &#39;lazi&#39;:8 &#39;quick&#39;:2
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1">-- The/over 是 stop word 被過濾、jumps/lazy 轉字根、保留 position
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w"></span><span class="c1">-- Query
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">to_tsquery</span><span class="p">(</span><span class="s1">&#39;english&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;fox &amp; dog&#39;</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="c1">-- 結果：&#39;fox&#39; &amp; &#39;dog&#39;
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w"></span><span class="c1">-- Match
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">to_tsvector</span><span class="p">(</span><span class="s1">&#39;english&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;The quick brown fox&#39;</span><span class="p">)</span><span class="w"> </span><span class="o">@@</span><span class="w"> </span><span class="n">to_tsquery</span><span class="p">(</span><span class="s1">&#39;english&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;fox &amp; quick&#39;</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="c1">-- → true</span></span></span></code></pre></div><p><strong>Index</strong>：</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">articles</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">SERIAL</span><span class="w"> </span><span class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</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">title</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="n">body</span><span class="w"> </span><span class="nb">TEXT</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w"></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></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w"></span><span class="c1">-- GIN index over tsvector (動態 cast)
</span></span></span><span class="line"><span class="ln"> 8</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_articles_fts</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">articles</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">USING</span><span class="w"> </span><span class="n">GIN</span><span class="w"> </span><span class="p">(</span><span class="n">to_tsvector</span><span class="p">(</span><span class="s1">&#39;english&#39;</span><span class="p">,</span><span class="w"> </span><span class="n">title</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="s1">&#39; &#39;</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="n">body</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></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w"></span><span class="c1">-- Query 用 index
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">articles</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="n">to_tsvector</span><span class="p">(</span><span class="s1">&#39;english&#39;</span><span class="p">,</span><span class="w"> </span><span class="n">title</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="s1">&#39; &#39;</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="n">body</span><span class="p">)</span><span class="w"> </span><span class="o">@@</span><span class="w"> </span><span class="n">to_tsquery</span><span class="p">(</span><span class="s1">&#39;english&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;postgres &amp; index&#39;</span><span class="p">);</span></span></span></code></pre></div><p>跟 <a href="/blog/backend/01-database/vendors/postgresql/jsonb-deep-dive/" data-link-title="PostgreSQL JSONB Deep Dive：Binary Storage &#43; GIN Index 為什麼是結構性優勢" data-link-desc="PG JSONB（9.4&#43;）是 *binary 儲存的 JSON*、可直接 GIN index、是 PG 在 JSON workload 的結構性優勢、跟 MongoDB / MySQL 8.0 JSON_TABLE 比仍領先。本文走 JSON vs JSONB 差異、GIN index 機制（jsonb_ops vs jsonb_path_ops）、operator &#43; path query、partial JSONB indexing、5 production 踩雷（大 JSONB 跟 TOAST / nested update / index 選錯 op class / jsonb_path_query 跟 jsonb_path_exists 行為差 / partial index 條件搞錯）、何時用 JSONB vs 拆 column">JSONB GIN index</a> 同 GIN access method、不同 indexed expression。</p>
<h2 id="generated-column-加速">Generated column 加速</h2>
<p>每次 query 都跑 <code>to_tsvector(...)</code> 浪費 CPU。用 <em>generated column</em> 預存：</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">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">articles</span><span class="w"> </span><span class="k">ADD</span><span class="w"> </span><span class="k">COLUMN</span><span class="w"> </span><span class="n">fts</span><span class="w"> </span><span class="n">tsvector</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">GENERATED</span><span class="w"> </span><span class="n">ALWAYS</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="p">(</span><span class="n">to_tsvector</span><span class="p">(</span><span class="s1">&#39;english&#39;</span><span class="p">,</span><span class="w"> </span><span class="n">coalesce</span><span class="p">(</span><span class="n">title</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;&#39;</span><span class="p">)</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="s1">&#39; &#39;</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="n">coalesce</span><span class="p">(</span><span class="n">body</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;&#39;</span><span class="p">)))</span><span class="w"> </span><span class="n">STORED</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></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">INDEX</span><span class="w"> </span><span class="n">idx_articles_fts</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">articles</span><span class="w"> </span><span class="k">USING</span><span class="w"> </span><span class="n">GIN</span><span class="w"> </span><span class="p">(</span><span class="n">fts</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></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w"></span><span class="c1">-- Query 簡化
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">articles</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">fts</span><span class="w"> </span><span class="o">@@</span><span class="w"> </span><span class="n">to_tsquery</span><span class="p">(</span><span class="s1">&#39;english&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;postgres&#39;</span><span class="p">);</span></span></span></code></pre></div><p>Stored generated column 是 PG 12+、自動跟 row update 同步。</p>
<h2 id="ranking--加權">Ranking + 加權</h2>
<p>PG FTS 提供 <code>ts_rank</code> / <code>ts_rank_cd</code> 給結果排序：</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">-- 簡單 ranking
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="n">title</span><span class="p">,</span><span class="w"> </span><span class="n">ts_rank</span><span class="p">(</span><span class="n">fts</span><span class="p">,</span><span class="w"> </span><span class="n">query</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">rank</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="n">articles</span><span class="p">,</span><span class="w"> </span><span class="n">to_tsquery</span><span class="p">(</span><span class="s1">&#39;english&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;postgres &amp; index&#39;</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">query</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">WHERE</span><span class="w"> </span><span class="n">fts</span><span class="w"> </span><span class="o">@@</span><span class="w"> </span><span class="n">query</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">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">rank</span><span class="w"> </span><span class="k">DESC</span><span class="w"> </span><span class="k">LIMIT</span><span class="w"> </span><span class="mi">10</span><span class="p">;</span></span></span></code></pre></div><p>加權（A &gt; B &gt; C &gt; D）：</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">-- Title 比 body 重要
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">UPDATE</span><span class="w"> </span><span class="n">articles</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="n">fts</span><span class="w"> </span><span class="o">=</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">setweight</span><span class="p">(</span><span class="n">to_tsvector</span><span class="p">(</span><span class="s1">&#39;english&#39;</span><span class="p">,</span><span class="w"> </span><span class="n">coalesce</span><span class="p">(</span><span class="n">title</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;&#39;</span><span class="p">)),</span><span class="w"> </span><span class="s1">&#39;A&#39;</span><span class="p">)</span><span class="w"> </span><span class="o">||</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">    </span><span class="n">setweight</span><span class="p">(</span><span class="n">to_tsvector</span><span class="p">(</span><span class="s1">&#39;english&#39;</span><span class="p">,</span><span class="w"> </span><span class="n">coalesce</span><span class="p">(</span><span class="n">body</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;&#39;</span><span class="p">)),</span><span class="w"> </span><span class="s1">&#39;B&#39;</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></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w"></span><span class="c1">-- Query 用加權 ranking
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="n">title</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">ts_rank</span><span class="p">(</span><span class="n">fts</span><span class="p">,</span><span class="w"> </span><span class="n">query</span><span class="p">,</span><span class="w"> </span><span class="mi">32</span><span class="w"> </span><span class="cm">/* normalize by document length */</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">rank</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">FROM</span><span class="w"> </span><span class="n">articles</span><span class="p">,</span><span class="w"> </span><span class="n">to_tsquery</span><span class="p">(</span><span class="s1">&#39;english&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;postgres&#39;</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">query</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">WHERE</span><span class="w"> </span><span class="n">fts</span><span class="w"> </span><span class="o">@@</span><span class="w"> </span><span class="n">query</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w"></span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">rank</span><span class="w"> </span><span class="k">DESC</span><span class="p">;</span></span></span></code></pre></div><p><code>ts_rank</code> 第三 parameter 是 normalization flag：</p>
<ul>
<li>0：no normalization</li>
<li>1：divide by document length</li>
<li>32：divide by uniqueness（避免短 doc 一律 rank 高）</li>
</ul>
<h2 id="multi-language-support">Multi-language Support</h2>
<p>PG 內建多種語言 dictionary：<code>english</code> / <code>french</code> / <code>german</code> / <code>spanish</code> / <code>simple</code>（不做 stemming）等。</p>
<p>對 <em>中文 / 日文 / 韓文</em>、PG 預設無支援、需要 extension：</p>
<ul>
<li><code>zhparser</code>（中文、用 SCWS 分詞）</li>
<li><code>pgroonga</code>（多語言、支援中日韓）</li>
<li><code>RUM index</code>（PG 自己 + 可選 dictionary）</li>
</ul>





<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">-- 中文用 zhparser
</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="n">EXTENSION</span><span class="w"> </span><span class="n">zhparser</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="k">CREATE</span><span class="w"> </span><span class="nb">TEXT</span><span class="w"> </span><span class="k">SEARCH</span><span class="w"> </span><span class="n">CONFIGURATION</span><span class="w"> </span><span class="n">chinese</span><span class="w"> </span><span class="p">(</span><span class="n">PARSER</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">zhparser</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">ALTER</span><span class="w"> </span><span class="nb">TEXT</span><span class="w"> </span><span class="k">SEARCH</span><span class="w"> </span><span class="n">CONFIGURATION</span><span class="w"> </span><span class="n">chinese</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">ADD</span><span class="w"> </span><span class="n">MAPPING</span><span class="w"> </span><span class="k">FOR</span><span class="w"> </span><span class="n">n</span><span class="p">,</span><span class="n">v</span><span class="p">,</span><span class="n">a</span><span class="p">,</span><span class="n">i</span><span class="p">,</span><span class="n">e</span><span class="p">,</span><span class="n">l</span><span class="w"> </span><span class="k">WITH</span><span class="w"> </span><span class="k">simple</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></span><span class="line"><span class="ln">7</span><span class="cl"><span class="w"></span><span class="c1">-- 使用
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">to_tsvector</span><span class="p">(</span><span class="s1">&#39;chinese&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;我愛 PostgreSQL 資料庫&#39;</span><span class="p">);</span></span></span></code></pre></div><p>對 <em>主要英文 search</em> 場景 PG built-in 夠用、對 <em>主要 CJK search</em> 需要 extension。</p>
<h2 id="pg_trgm--fuzzy-string-match">pg_trgm — Fuzzy String Match</h2>
<p>PG FTS 對 <em>精確字根 match</em> 強、對 <em>拼錯 / similar string</em> 弱。<code>pg_trgm</code> extension 提供 trigram-based fuzzy match：</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="n">EXTENSION</span><span class="w"> </span><span class="n">pg_trgm</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></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w"></span><span class="c1">-- 對 column 建 GIN trigram index
</span></span></span><span class="line"><span class="ln"> 4</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_users_name_trgm</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">users</span><span class="w"> </span><span class="k">USING</span><span class="w"> </span><span class="n">GIN</span><span class="w"> </span><span class="p">(</span><span class="n">name</span><span class="w"> </span><span class="n">gin_trgm_ops</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></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w"></span><span class="c1">-- Fuzzy match（similarity threshold 預設 0.3）
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">users</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">name</span><span class="w"> </span><span class="o">%</span><span class="w"> </span><span class="s1">&#39;jhon&#39;</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="c1">-- → 找到 &#39;John&#39;、&#39;Johan&#39;、&#39;Johnny&#39; 等 similar string
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w"></span><span class="c1">-- 顯式 similarity score
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">name</span><span class="p">,</span><span class="w"> </span><span class="n">similarity</span><span class="p">(</span><span class="n">name</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;jhon&#39;</span><span class="p">)</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">users</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w"></span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">similarity</span><span class="p">(</span><span class="n">name</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;jhon&#39;</span><span class="p">)</span><span class="w"> </span><span class="k">DESC</span><span class="w"> </span><span class="k">LIMIT</span><span class="w"> </span><span class="mi">5</span><span class="p">;</span></span></span></code></pre></div><p>用途：</p>
<ul>
<li>Autocomplete / typeahead suggestion</li>
<li>拼錯容錯（user 輸入 typo）</li>
<li>ILIKE 加速（<code>name ILIKE '%jhon%'</code> 走 GIN trigram index）</li>
</ul>
<p>跟 FTS 互補：</p>
<ul>
<li>FTS：full document search、tokenize / stemming / ranking</li>
<li>pg_trgm：short string similarity、typo tolerance</li>
</ul>
<h2 id="5-個-production-踩雷">5 個 Production 踩雷</h2>
<h3 id="1-dictionary-選錯--中文搜不到">1. Dictionary 選錯 — 中文搜不到</h3>
<p>對中文 column 用 <code>to_tsvector('english', text)</code>、不分詞、整段當一個 token、搜不到任何結果。</p>
<p>修法：</p>
<ul>
<li>中文用 <code>zhparser</code> / <code>pgroonga</code></li>
<li>多語言 column 拆 <em>per-language column</em> 或用 <code>simple</code> dictionary（不 stemming、字元級 match）</li>
<li>確認 dictionary 選對：<code>SELECT to_tsvector('chinese', '...')</code> 看分詞結果</li>
</ul>
<h3 id="2-gin-vs-gist-取捨選錯">2. GIN vs GiST 取捨選錯</h3>
<p>PG FTS 有兩種 index access method：</p>
<ul>
<li><em>GIN</em>：read fast、write slow、size 大、適合 <em>read-heavy</em></li>
<li><em>GiST</em>：read 慢、write fast、size 小、適合 <em>write-heavy 或 small doc</em></li>
</ul>
<p>預設選 GIN、適合 90% search workload。對 <em>寫入頻繁 + 文件小</em> 場景 GiST。</p>
<p>修法：</p>
<ul>
<li>預設 GIN</li>
<li>寫吞吐 &gt; 10K WPS 場景考慮 GiST 或 <em>bulk index</em>（先 disable index、bulk insert、重建 index）</li>
<li>GIN 有 <code>fastupdate</code> option、buffering 加速寫入（trade-off：read 慢）</li>
</ul>
<h3 id="3-ranking-評分權重不對齊-business">3. Ranking 評分權重不對齊 business</h3>
<p><code>ts_rank</code> 預設不考慮 <em>field weight</em>、<code>ts_rank_cd</code> 考慮 cover density、兩者結果不同。Application 不知道 <em>自己 query 對應哪個 rank function</em>、結果隨機。</p>
<p>修法：</p>
<ul>
<li>顯式選 ranking function：<code>ts_rank</code> 一般用、<code>ts_rank_cd</code> 對 <em>proximity 重要</em> 場景</li>
<li>設 <em>field weight</em>（A &gt; B &gt; C &gt; D）反映 business priority（title &gt; body &gt; tags）</li>
<li>對 <em>搜尋結果</em> 用 A/B test 評估 ranking 質量、不靠直覺</li>
</ul>
<h3 id="4-multi-language-column-處理">4. Multi-language column 處理</h3>
<p>Application 同表存多語言 row（user-generated content、不同 language）、用單一 <code>to_tsvector('english', ...)</code> 對中文 row 搜不到、對 french row 也 stem 錯。</p>
<p>修法：</p>
<ul>
<li>
<p>加 <code>language</code> column 標每 row 語言</p>
</li>
<li>
<p>用 dynamic dictionary：</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">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">articles</span><span class="w"> </span><span class="k">ADD</span><span class="w"> </span><span class="k">COLUMN</span><span class="w"> </span><span class="n">fts</span><span class="w"> </span><span class="n">tsvector</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">GENERATED</span><span class="w"> </span><span class="n">ALWAYS</span><span class="w"> </span><span class="k">AS</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">to_tsvector</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">CASE</span><span class="w"> </span><span class="k">WHEN</span><span class="w"> </span><span class="k">language</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;zh&#39;</span><span class="w"> </span><span class="k">THEN</span><span class="w"> </span><span class="s1">&#39;chinese&#39;</span><span class="p">::</span><span class="n">regconfig</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">WHEN</span><span class="w"> </span><span class="k">language</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;fr&#39;</span><span class="w"> </span><span class="k">THEN</span><span class="w"> </span><span class="s1">&#39;french&#39;</span><span class="p">::</span><span class="n">regconfig</span><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w">             </span><span class="k">ELSE</span><span class="w"> </span><span class="s1">&#39;english&#39;</span><span class="p">::</span><span class="n">regconfig</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">7</span><span class="cl"><span class="w">        </span><span class="n">coalesce</span><span class="p">(</span><span class="n">title</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;&#39;</span><span class="p">)</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="s1">&#39; &#39;</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="n">coalesce</span><span class="p">(</span><span class="n">body</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;&#39;</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="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="p">)</span><span class="w"> </span><span class="n">STORED</span><span class="p">;</span></span></span></code></pre></div></li>
<li>
<p>Query 時用對應語言 <code>to_tsquery</code></p>
</li>
</ul>
<h3 id="5-何時不該用-pg-fts--應該換-elasticsearch--opensearch">5. 何時不該用 PG FTS — 應該換 Elasticsearch / OpenSearch</h3>
<p>PG FTS 適合 <em>中小規模搜尋</em>、不適合：</p>
<ul>
<li><em>&gt; 100M document</em> high-QPS search</li>
<li>需要 <em>complex aggregation</em>（faceted search）</li>
<li>需要 <em>advanced ranking</em>（BM25 / learning to rank）</li>
<li>需要 <em>分散式 search</em>（PG FTS 是 single-node）</li>
<li>需要 <em>near-real-time indexing</em>（PG GIN update 較慢）</li>
</ul>
<p>對這些場景、用 Elasticsearch / OpenSearch / Meilisearch / Typesense 等專業 search engine。</p>
<p>PG FTS <em>優勢</em> 是 <em>跟 OLTP data 同 transaction</em> — 不需要 ETL 同步 search index、application 寫 PG 立即 searchable。對 application data + search 是 <em>同源</em> 的場景 PG FTS 比較適合。</p>
<h2 id="何時用-pg-fts">何時用 PG FTS</h2>
<table>
  <thead>
      <tr>
          <th>場景</th>
          <th>選擇</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Application internal search（admin / dashboard）</td>
          <td>PG FTS</td>
      </tr>
      <tr>
          <td>&lt; 10M document、低 QPS（&lt; 100/s）</td>
          <td>PG FTS</td>
      </tr>
      <tr>
          <td>Search 跟 OLTP data 同 transaction needed</td>
          <td>PG FTS</td>
      </tr>
      <tr>
          <td>Fuzzy / typo tolerance</td>
          <td>PG FTS + pg_trgm</td>
      </tr>
      <tr>
          <td>&gt; 100M document + high QPS</td>
          <td>Elasticsearch / OpenSearch</td>
      </tr>
      <tr>
          <td>Faceted aggregation</td>
          <td>Elasticsearch / OpenSearch</td>
      </tr>
      <tr>
          <td>Vector similarity（semantic search）</td>
          <td>pgvector（同 PG）</td>
      </tr>
  </tbody>
</table>
<p>PG FTS + pgvector 組合對 <em>中小規模 hybrid keyword + semantic search</em> 是強選擇。</p>
<h2 id="跟其他模組整合">跟其他模組整合</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/postgresql/jsonb-deep-dive/" data-link-title="PostgreSQL JSONB Deep Dive：Binary Storage &#43; GIN Index 為什麼是結構性優勢" data-link-desc="PG JSONB（9.4&#43;）是 *binary 儲存的 JSON*、可直接 GIN index、是 PG 在 JSON workload 的結構性優勢、跟 MongoDB / MySQL 8.0 JSON_TABLE 比仍領先。本文走 JSON vs JSONB 差異、GIN index 機制（jsonb_ops vs jsonb_path_ops）、operator &#43; path query、partial JSONB indexing、5 production 踩雷（大 JSONB 跟 TOAST / nested update / index 選錯 op class / jsonb_path_query 跟 jsonb_path_exists 行為差 / partial index 條件搞錯）、何時用 JSONB vs 拆 column">JSONB Deep Dive</a>：JSONB 跟 FTS 都用 GIN</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/extension-ecosystem/" data-link-title="PostgreSQL Extension Ecosystem：把 PG 變成 vector DB / time-series / sharded 的 plugin 生態" data-link-desc="PG 的 extension 機制不只是 plugin、是 *結構性產品線擴張* — pgvector 讓 PG 變 vector DB、TimescaleDB 變 time-series、Citus 變 sharded、PostGIS 變 GIS。本文走 PG extension lifecycle、6 個 production-critical extension（pg_stat_statements / pg_partman / pg_repack / pgvector / TimescaleDB / PostGIS）、5 production 踩雷（extension version 跟 PG version 對齊 / managed PG 限制 / upgrade order / shared_preload_libraries 衝突 / extension 跟 logical replication 互動）、cloud vendor 對 extension 的限制">Extension Ecosystem</a>：pg_trgm / pgroonga / zhparser 都是 extension</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>：FTS query 的 EXPLAIN</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>：FTS GIN index 在 standby 自動 replicate</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<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 vendor overview</a></li>
<li><a href="/blog/backend/01-database/vendors/postgresql/extension-ecosystem/" data-link-title="PostgreSQL Extension Ecosystem：把 PG 變成 vector DB / time-series / sharded 的 plugin 生態" data-link-desc="PG 的 extension 機制不只是 plugin、是 *結構性產品線擴張* — pgvector 讓 PG 變 vector DB、TimescaleDB 變 time-series、Citus 變 sharded、PostGIS 變 GIS。本文走 PG extension lifecycle、6 個 production-critical extension（pg_stat_statements / pg_partman / pg_repack / pgvector / TimescaleDB / PostGIS）、5 production 踩雷（extension version 跟 PG version 對齊 / managed PG 限制 / upgrade order / shared_preload_libraries 衝突 / extension 跟 logical replication 互動）、cloud vendor 對 extension 的限制">PG Extension Ecosystem</a>（pg_trgm / pgroonga）</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/jsonb-deep-dive/" data-link-title="PostgreSQL JSONB Deep Dive：Binary Storage &#43; GIN Index 為什麼是結構性優勢" data-link-desc="PG JSONB（9.4&#43;）是 *binary 儲存的 JSON*、可直接 GIN index、是 PG 在 JSON workload 的結構性優勢、跟 MongoDB / MySQL 8.0 JSON_TABLE 比仍領先。本文走 JSON vs JSONB 差異、GIN index 機制（jsonb_ops vs jsonb_path_ops）、operator &#43; path query、partial JSONB indexing、5 production 踩雷（大 JSONB 跟 TOAST / nested update / index 選錯 op class / jsonb_path_query 跟 jsonb_path_exists 行為差 / partial index 條件搞錯）、何時用 JSONB vs 拆 column">PG JSONB Deep Dive</a>（共用 GIN）</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 對比">PG Query Optimization</a>（FTS query plan）</li>
<li>官方：<a href="https://www.postgresql.org/docs/current/textsearch.html">PG Full-Text Search</a> / <a href="https://www.postgresql.org/docs/current/pgtrgm.html">pg_trgm</a></li>
</ul>
]]></content:encoded></item><item><title>PostgreSQL Replication Slot Management：Physical / Logical / Failover Slot 治理</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/replication-slot-management/</link><pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/replication-slot-management/</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>replication slot management&lt;/em> — physical / logical / failover slot 三類治理。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="replication-slot-兩大類">Replication Slot 兩大類&lt;/h2>
&lt;p>PG 兩種 replication slot：&lt;/p>
&lt;h3 id="physical-replication-slot">Physical Replication Slot&lt;/h3>
&lt;p>對應 &lt;em>streaming replication&lt;/em>（physical WAL byte-level）：&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">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">pg_create_physical_replication_slot&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;standby1_slot&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>用於：&lt;/p>
&lt;ul>
&lt;li>Streaming replication standby（&lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/replication-topology/" data-link-title="PostgreSQL Replication Topology：async / sync / quorum 三模式跟 LSN &amp;#43; replication slot 的三軸組合" data-link-desc="PostgreSQL streaming replication 不是「sync 或 async」、是 *durability / latency / consistency* 三軸組合 &amp;#43; LSN-based 進度追蹤 &amp;#43; replication slot 治理。本文走 3 軸取捨模型、async / sync / quorum-based sync 行為對比、LSN &amp;#43; replication slot 機制、配置 step-by-step、5 production 踩雷（standby lag 暴衝 / sync standby 退回 async / orphan replication slot / cascading replication 雪崩 / failover 後 timeline 分歧）、跟 Patroni HA &amp;#43; logical replication 整合">Replication Topology&lt;/a>）&lt;/li>
&lt;li>pg_basebackup 用 slot 防 WAL 清理&lt;/li>
&lt;li>高 lag standby 防 WAL premature deletion&lt;/li>
&lt;/ul>
&lt;h3 id="logical-replication-slot">Logical Replication Slot&lt;/h3>
&lt;p>對應 &lt;em>logical replication / logical decoding&lt;/em>：&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">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">pg_create_logical_replication_slot&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;my_slot&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;pgoutput&amp;#39;&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="c1">-- 或用 wal2json plugin
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">pg_create_logical_replication_slot&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;debezium_slot&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;wal2json&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>用於：&lt;/p>
&lt;ul>
&lt;li>PG-to-PG logical replication（publication / subscription）&lt;/li>
&lt;li>CDC（Debezium / Maxwell / pg_logical_emitter）&lt;/li>
&lt;li>Multi-master replication（BDR / pgEdge / Spock）&lt;/li>
&lt;/ul>
&lt;p>logical slot 跟 physical slot 共存、各自獨立 retention。&lt;/p>
&lt;h2 id="slot-lifecycle">Slot Lifecycle&lt;/h2>





&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">建立 → active（有 consumer）→ inactive（consumer 失聯）→ drop
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> ↓
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> WAL 持續累積（直到推進 LSN 或 drop）&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>狀態查詢&lt;/strong>：&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>replication slot management</em> — physical / logical / failover slot 三類治理。</p></blockquote>
<hr>
<h2 id="replication-slot-兩大類">Replication Slot 兩大類</h2>
<p>PG 兩種 replication slot：</p>
<h3 id="physical-replication-slot">Physical Replication Slot</h3>
<p>對應 <em>streaming replication</em>（physical WAL byte-level）：</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">SELECT</span><span class="w"> </span><span class="n">pg_create_physical_replication_slot</span><span class="p">(</span><span class="s1">&#39;standby1_slot&#39;</span><span class="p">);</span></span></span></code></pre></div><p>用於：</p>
<ul>
<li>Streaming replication standby（<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>）</li>
<li>pg_basebackup 用 slot 防 WAL 清理</li>
<li>高 lag standby 防 WAL premature deletion</li>
</ul>
<h3 id="logical-replication-slot">Logical Replication Slot</h3>
<p>對應 <em>logical replication / logical decoding</em>：</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">SELECT</span><span class="w"> </span><span class="n">pg_create_logical_replication_slot</span><span class="p">(</span><span class="s1">&#39;my_slot&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;pgoutput&#39;</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="c1">-- 或用 wal2json plugin
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">pg_create_logical_replication_slot</span><span class="p">(</span><span class="s1">&#39;debezium_slot&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;wal2json&#39;</span><span class="p">);</span></span></span></code></pre></div><p>用於：</p>
<ul>
<li>PG-to-PG logical replication（publication / subscription）</li>
<li>CDC（Debezium / Maxwell / pg_logical_emitter）</li>
<li>Multi-master replication（BDR / pgEdge / Spock）</li>
</ul>
<p>logical slot 跟 physical slot 共存、各自獨立 retention。</p>
<h2 id="slot-lifecycle">Slot Lifecycle</h2>





<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">建立 → active（有 consumer）→ inactive（consumer 失聯）→ drop
</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">                              WAL 持續累積（直到推進 LSN 或 drop）</span></span></code></pre></div><p><strong>狀態查詢</strong>：</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">SELECT</span><span class="w"> </span><span class="n">slot_name</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">slot_type</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">active</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="n">restart_lsn</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">confirmed_flush_lsn</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">pg_size_pretty</span><span class="p">(</span><span class="n">pg_wal_lsn_diff</span><span class="p">(</span><span class="n">pg_current_wal_lsn</span><span class="p">(),</span><span class="w"> </span><span class="n">restart_lsn</span><span class="p">))</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">retained_wal</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">FROM</span><span class="w"> </span><span class="n">pg_replication_slots</span><span class="p">;</span></span></span></code></pre></div><p>關鍵欄位：</p>
<ul>
<li><code>slot_type</code>：<code>physical</code> / <code>logical</code></li>
<li><code>active</code>：true / false（consumer 是否連著）</li>
<li><code>restart_lsn</code>：slot 起點 LSN、primary 必須保留這以後的 WAL</li>
<li><code>confirmed_flush_lsn</code>：logical slot 已 confirm flush 的 LSN</li>
<li><code>retained_wal</code>：當前因 slot 累積的 WAL</li>
</ul>
<h2 id="failover-slot-synchronization-pg-17">Failover Slot Synchronization (PG 17+)</h2>
<p>PG 17 之前的 <em>痛點</em>：logical replication slot 是 <em>primary 上的 state</em>、failover 後 <em>新 primary 沒這個 slot</em>、CDC consumer 失聯、需要重建（大工程）。</p>
<p>PG 17 加 <em>failover slot synchronization</em>：</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">-- PG 17+：標 slot 為 failover-tracked
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1">-- signature: pg_create_logical_replication_slot(slot_name, plugin, temporary, two_phase, failover)
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">pg_create_logical_replication_slot</span><span class="p">(</span><span class="s1">&#39;my_slot&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;pgoutput&#39;</span><span class="p">,</span><span class="w"> </span><span class="k">false</span><span class="p">,</span><span class="w"> </span><span class="k">false</span><span class="p">,</span><span class="w"> </span><span class="k">true</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="c1">--                                                                          ↑
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1">--                                                                     failover=true（第 5 個參數）
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1">-- 注意：第 4 個參數是 two_phase（這裡 false）、第 5 個才是 failover
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w"></span><span class="c1">-- Standby 上 enable sync_replication_slots
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span><span class="k">ALTER</span><span class="w"> </span><span class="k">SYSTEM</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="n">sync_replication_slots</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">on</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">SELECT</span><span class="w"> </span><span class="n">pg_reload_conf</span><span class="p">();</span></span></span></code></pre></div><p><code>sync_replication_slots = on</code> 後、physical replication 同步 slot state 到 standby。Failover promote standby 後、logical slot 仍可用、CDC consumer 重連即可。</p>
<p>PG 17 之前用 <a href="https://www.pgedge.com/">pgEdge</a> / <em>pglogical</em> 等 extension 提供類似功能、現在 PG core 內建。</p>
<h2 id="orphan-slot-治理">Orphan Slot 治理</h2>
<p><code>active = false</code> 的 slot 持續累積 WAL、disk 爆是 PG production 經典事故。</p>
<h3 id="監控-orphan-slot">監控 orphan slot</h3>





<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">-- 找 inactive 太久的 slot
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">slot_name</span><span class="p">,</span><span class="w"> </span><span class="n">active</span><span class="p">,</span><span class="w"> </span><span class="n">restart_lsn</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">pg_size_pretty</span><span class="p">(</span><span class="n">pg_wal_lsn_diff</span><span class="p">(</span><span class="n">pg_current_wal_lsn</span><span class="p">(),</span><span class="w"> </span><span class="n">restart_lsn</span><span class="p">))</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">retained_wal</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">FROM</span><span class="w"> </span><span class="n">pg_replication_slots</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">WHERE</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="n">active</span><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w">  </span><span class="k">AND</span><span class="w"> </span><span class="n">pg_wal_lsn_diff</span><span class="p">(</span><span class="n">pg_current_wal_lsn</span><span class="p">(),</span><span class="w"> </span><span class="n">restart_lsn</span><span class="p">)</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="mi">1024</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="mi">1024</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="mi">1024</span><span class="p">;</span><span class="w">  </span><span class="c1">-- &gt; 1 GB</span></span></span></code></pre></div><h3 id="自動-invalidate-slotpg-13">自動 invalidate slot（PG 13+）</h3>





<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">-- postgresql.conf
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">ALTER</span><span class="w"> </span><span class="k">SYSTEM</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="n">max_slot_wal_keep_size</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;50GB&#39;</span><span class="p">;</span><span class="w">  </span><span class="c1">-- slot 累積 &gt; 50GB 自動 invalidate</span></span></span></code></pre></div><p>當 slot 累積 WAL 超過 <code>max_slot_wal_keep_size</code>、PG 自動 invalidate slot（<code>active=false</code> 且不再保留 WAL）。Consumer 重連會 fail、必須重建（base backup + new slot）。</p>
<p>這是 <em>trade-off</em>：</p>
<ul>
<li>設 limit → 保護 disk、但 consumer 失聯 → 大重建工作</li>
<li>不設 limit → consumer 失聯 OK、但 disk 爆</li>
</ul>
<p>實務多數設 <code>max_slot_wal_keep_size</code> 給 <em>disk capacity 50%</em>、避免徹底 disk full。</p>
<h3 id="手動-drop-orphan-slot">手動 drop orphan slot</h3>





<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">-- 確認 slot 真的不需要
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">pg_replication_slots</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">slot_name</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;old_standby_slot&#39;</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></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="c1">-- Drop
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">pg_drop_replication_slot</span><span class="p">(</span><span class="s1">&#39;old_standby_slot&#39;</span><span class="p">);</span></span></span></code></pre></div><p>DR runbook 必須包含 <em>standby 退役流程</em>：先 standby fence、再 primary drop slot。</p>
<h2 id="5-個-production-踩雷">5 個 Production 踩雷</h2>
<h3 id="1-orphan-slot-disk-爆">1. Orphan slot disk 爆</h3>
<p>最經典 PG 事故：standby decomission 沒 drop slot、primary 持續保留 WAL、<code>pg_wal/</code> 累積到 disk full、primary 也掛。</p>
<p>修法：</p>
<ul>
<li>監控 <code>pg_replication_slots</code> + <code>pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn))</code> retained_wal</li>
<li>設 <code>max_slot_wal_keep_size</code>（PG 13+）— hard limit</li>
<li>Standby 退役 runbook 強制 <em>先 fence、再 drop slot</em></li>
<li>Cron job 自動 alert orphan slot</li>
</ul>
<h3 id="2-logical-slot-lag--cdc-consumer-跟不上">2. Logical slot lag — CDC consumer 跟不上</h3>
<p>Logical decoding 比 physical replication 慢（per-transaction logical event 重組）。CDC consumer（Debezium）跟不上 → slot lag 累積。</p>
<p>修法：</p>
<ul>
<li>監控 <code>pg_replication_slots.confirmed_flush_lsn</code> 跟 primary <code>pg_current_wal_lsn()</code> 對比</li>
<li>CDC consumer 性能調整（throughput / batch size）</li>
<li>Throttle source writes（如果不能升 consumer）</li>
<li>對 hot table 拆 publication / subscription、避免單 slot 處理所有變更</li>
</ul>
<p>詳見 <a href="/blog/backend/01-database/vendors/postgresql/logical-replication-debezium/" data-link-title="PostgreSQL Logical Replication &#43; Debezium CDC：replication slot × failure × recovery 對照" data-link-desc="PostgreSQL logical replication slot 跟 Debezium CDC 的失效模式對照表：slot lag 撐爆 primary disk / schema change 斷流 / 初始 COPY 鎖表 / zombie slot 不釋放 / replay storm 後 offset reset；publication / subscription / pgoutput 配置、跟 Kafka outbox pattern 整合">Logical Replication + Debezium</a>。</p>
<h3 id="3-failover-後-logical-slot-丟pg-16-之前">3. Failover 後 logical slot 丟（PG 16 之前）</h3>
<p>PG 16 之前、failover promote standby、新 primary 沒有原 logical slot。CDC consumer 試連、ERROR: <code>replication slot &quot;xxx&quot; does not exist</code>。</p>
<p>修法（PG 17+）：</p>
<ul>
<li>用 <em>failover slot synchronization</em>（如上）</li>
<li><code>pg_create_logical_replication_slot(...,  failover := true)</code></li>
<li>Standby <code>sync_replication_slots = on</code></li>
</ul>
<p>修法（PG 16-）：</p>
<ul>
<li>用 <a href="https://www.2ndquadrant.com/en/resources/pglogical/">pglogical</a> 或 <a href="https://www.pgedge.com/">pgEdge</a> extension</li>
<li>Failover runbook 包含 <em>新 primary 重建 logical slot</em>（CDC consumer 重 snapshot）</li>
<li>Pre-create slot on standby + manual sync（早期 workaround）</li>
</ul>
<h3 id="4-wal_keep_size-跟-slot-衝突">4. <code>wal_keep_size</code> 跟 slot 衝突</h3>
<p><code>wal_keep_size</code>（PG 13+）/ <code>wal_keep_segments</code>（&lt; 13）跟 slot 都會保留 WAL：</p>
<ul>
<li><code>wal_keep_size</code>：固定 minimum WAL 保留量</li>
<li>Slot：動態保留直到 consumer 推進</li>
</ul>
<p>兩者一起 set 時：實際保留 WAL = <code>max(wal_keep_size, slot 需要的量)</code>。</p>
<p>修法：</p>
<ul>
<li><code>wal_keep_size</code> 設小（如 1-2 GB）作 <em>minimum backup</em></li>
<li>主要靠 slot 動態保留 — 給 active consumer</li>
<li>監控 <code>pg_wal/</code> 大小 + 拆解 retention source（<code>wal_keep_size</code> vs slot 各佔多少）</li>
</ul>
<h3 id="5-slot-數量上限">5. Slot 數量上限</h3>
<p><code>max_replication_slots</code> 預設 10、不夠時新 slot 建不出來、報錯。</p>
<p>修法：</p>
<ul>
<li>Production 大 cluster 設 <code>max_replication_slots = 50</code> 或更多</li>
<li>對 <em>standby + logical replication + CDC consumer</em> 同時跑、計算需要的 slot 數</li>
<li>監控 <code>SELECT count(*) FROM pg_replication_slots</code> 接近 limit 時告警</li>
</ul>
<h2 id="slot-naming-convention">Slot Naming Convention</h2>
<p>Production 大 cluster 多 slot、命名 convention 重要：</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">&lt;consumer-type&gt;_&lt;consumer-name&gt;_&lt;purpose&gt;
</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">- physical_standby1_replication
</span></span><span class="line"><span class="ln">4</span><span class="cl">- physical_standby2_replication
</span></span><span class="line"><span class="ln">5</span><span class="cl">- logical_debezium_orders_cdc
</span></span><span class="line"><span class="ln">6</span><span class="cl">- logical_pgedge_node2_subscription
</span></span><span class="line"><span class="ln">7</span><span class="cl">- physical_pgbasebackup_temp（base backup 用、completed 後 drop）</span></span></code></pre></div><p>清楚命名讓 <em>看 slot 名</em> 就知道用途、誰負責、能不能 drop。</p>
<h2 id="跟其他模組整合">跟其他模組整合</h2>
<ul>
<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>：physical slot 給 streaming replication 用</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/logical-replication-debezium/" data-link-title="PostgreSQL Logical Replication &#43; Debezium CDC：replication slot × failure × recovery 對照" data-link-desc="PostgreSQL logical replication slot 跟 Debezium CDC 的失效模式對照表：slot lag 撐爆 primary disk / schema change 斷流 / 初始 COPY 鎖表 / zombie slot 不釋放 / replay storm 後 offset reset；publication / subscription / pgoutput 配置、跟 Kafka outbox pattern 整合">Logical Replication + Debezium</a>：logical slot 給 CDC</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/bdr-multi-master/" data-link-title="PostgreSQL BDR / Multi-Master：active-active 寫入的 3 種路徑跟 conflict 治理" data-link-desc="PG 預設是 single-primary、active-active 多寫入入口需要 *BDR (EDB)* / *pgEdge* / *Bucardo* 等 extension。本文走 3 種 multi-master 方案對比、conflict detection &#43; resolution model、async vs sync 取捨、配置 step-by-step（pgEdge 為主）、5 production 踩雷（last-write-wins data loss / sequence collision / DDL replication / conflict log 治理 / failover 後 timeline 分歧）、跟 MySQL Group Replication sibling 對比">BDR / Multi-Master</a>：multi-master 大量用 logical slot</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/pitr-wal-archiving/" data-link-title="PostgreSQL PITR &#43; WAL archiving：從 base backup 到 point-in-time recovery 的完整鏈" data-link-desc="Base backup &#43; WAL archive 構成 PITR 的雙軌資料、archive_command &#43; restore_command 配置、用 pgBackRest / WAL-G 替代手寫腳本、5 個 production 踩雷（archive 靜默失敗 / archive lag / 錯誤 target time / base backup 過期未清 / timeline 分歧 recovery 模糊）、跟 Patroni &#43; monitoring 整合">PITR + WAL Archiving</a>：WAL archive 跟 slot 是兩種 WAL retention 機制、可並行</li>
</ul>
<h2 id="監控-metric">監控 metric</h2>
<p>Production 持續監控：</p>
<ul>
<li><code>pg_replication_slots.active</code> — 失聯 slot</li>
<li><code>pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)</code> — slot 累積 WAL</li>
<li><code>pg_replication_slots.confirmed_flush_lsn</code> vs <code>pg_current_wal_lsn()</code> — logical slot lag</li>
<li><code>pg_ls_waldir()</code> 看 <code>pg_wal/</code> 目錄大小</li>
<li><code>count(*) FROM pg_replication_slots</code> 對 <code>max_replication_slots</code> 比例</li>
</ul>
<p>把這些丟進 Datadog / Prometheus + alert。</p>
<h2 id="相關連結">相關連結</h2>
<ul>
<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 vendor overview</a></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 整合">PG Replication Topology</a>（physical slot 用途）</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/logical-replication-debezium/" data-link-title="PostgreSQL Logical Replication &#43; Debezium CDC：replication slot × failure × recovery 對照" data-link-desc="PostgreSQL logical replication slot 跟 Debezium CDC 的失效模式對照表：slot lag 撐爆 primary disk / schema change 斷流 / 初始 COPY 鎖表 / zombie slot 不釋放 / replay storm 後 offset reset；publication / subscription / pgoutput 配置、跟 Kafka outbox pattern 整合">PG Logical Replication + Debezium</a>（logical slot 用途）</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/bdr-multi-master/" data-link-title="PostgreSQL BDR / Multi-Master：active-active 寫入的 3 種路徑跟 conflict 治理" data-link-desc="PG 預設是 single-primary、active-active 多寫入入口需要 *BDR (EDB)* / *pgEdge* / *Bucardo* 等 extension。本文走 3 種 multi-master 方案對比、conflict detection &#43; resolution model、async vs sync 取捨、配置 step-by-step（pgEdge 為主）、5 production 踩雷（last-write-wins data loss / sequence collision / DDL replication / conflict log 治理 / failover 後 timeline 分歧）、跟 MySQL Group Replication sibling 對比">PG BDR / Multi-Master</a>（multi-master 大量 slot）</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/pitr-wal-archiving/" data-link-title="PostgreSQL PITR &#43; WAL archiving：從 base backup 到 point-in-time recovery 的完整鏈" data-link-desc="Base backup &#43; WAL archive 構成 PITR 的雙軌資料、archive_command &#43; restore_command 配置、用 pgBackRest / WAL-G 替代手寫腳本、5 個 production 踩雷（archive 靜默失敗 / archive lag / 錯誤 target time / base backup 過期未清 / timeline 分歧 recovery 模糊）、跟 Patroni &#43; monitoring 整合">PG PITR + WAL Archiving</a>（WAL retention 兩種機制）</li>
<li>官方：<a href="https://www.postgresql.org/docs/current/warm-standby.html#STREAMING-REPLICATION-SLOTS">PG Replication Slots</a> / <a href="https://www.postgresql.org/docs/current/logicaldecoding.html">Logical Replication Slot</a></li>
</ul>
]]></content:encoded></item><item><title>TimescaleDB Deep Dive：Hypertable / Continuous Aggregate / Compression 把 PG 變 Time-Series DB</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/timescaledb-deep-dive/</link><pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/timescaledb-deep-dive/</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>TimescaleDB extension&lt;/em> — 用 PG 解 time-series workload 的路徑、跟 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/extension-ecosystem/" data-link-title="PostgreSQL Extension Ecosystem：把 PG 變成 vector DB / time-series / sharded 的 plugin 生態" data-link-desc="PG 的 extension 機制不只是 plugin、是 *結構性產品線擴張* — pgvector 讓 PG 變 vector DB、TimescaleDB 變 time-series、Citus 變 sharded、PostGIS 變 GIS。本文走 PG extension lifecycle、6 個 production-critical extension（pg_stat_statements / pg_partman / pg_repack / pgvector / TimescaleDB / PostGIS）、5 production 踩雷（extension version 跟 PG version 對齊 / managed PG 限制 / upgrade order / shared_preload_libraries 衝突 / extension 跟 logical replication 互動）、cloud vendor 對 extension 的限制">extension-ecosystem&lt;/a> 是 &lt;em>單一 extension 細節 vs ecosystem 全景&lt;/em> 的關係。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="timescaledb-是-pg-的-time-series-specialization">TimescaleDB 是 PG 的 &lt;em>Time-Series Specialization&lt;/em>&lt;/h2>
&lt;p>TimescaleDB 不是獨立 DB、是 PG extension：&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="n">EXTENSION&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">timescaledb&lt;/span>&lt;span class="p">;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>加完後、PG 多三個 time-series 專屬機制：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Hypertable&lt;/strong>：對 time column 自動 partition、應用層看是一張表&lt;/li>
&lt;li>&lt;strong>Continuous aggregate&lt;/strong>：incremental refresh 的 materialized view&lt;/li>
&lt;li>&lt;strong>Compression&lt;/strong>：對舊 chunk 壓縮（columnar-like format）&lt;/li>
&lt;/ol>
&lt;p>跟專業 time-series DB（InfluxDB / Prometheus / VictoriaMetrics）對比、TimescaleDB 的賣點不是「最快」而是「PG ecosystem 一致」：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>維度&lt;/th>
 &lt;th>TimescaleDB&lt;/th>
 &lt;th>InfluxDB&lt;/th>
 &lt;th>Prometheus&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Query 語言&lt;/td>
 &lt;td>標準 SQL&lt;/td>
 &lt;td>InfluxQL / Flux&lt;/td>
 &lt;td>PromQL&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>寫入效能&lt;/td>
 &lt;td>中（10-100K rows/s）&lt;/td>
 &lt;td>高（500K+ rows/s）&lt;/td>
 &lt;td>中（pull-based scrape）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>壓縮&lt;/td>
 &lt;td>90%+（columnar compression）&lt;/td>
 &lt;td>高&lt;/td>
 &lt;td>高&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Join&lt;/td>
 &lt;td>完整 SQL join&lt;/td>
 &lt;td>弱&lt;/td>
 &lt;td>不支援&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>跟既有 PG schema&lt;/td>
 &lt;td>同一個 DB、可 join&lt;/td>
 &lt;td>獨立&lt;/td>
 &lt;td>獨立&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>生態&lt;/td>
 &lt;td>完整 PG ecosystem&lt;/td>
 &lt;td>自家 ecosystem&lt;/td>
 &lt;td>自家 ecosystem&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Open source&lt;/td>
 &lt;td>Apache 2.0（部分功能 TSL license）&lt;/td>
 &lt;td>MIT&lt;/td>
 &lt;td>Apache 2.0&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>何時選 TimescaleDB&lt;/strong>：&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>TimescaleDB extension</em> — 用 PG 解 time-series workload 的路徑、跟 <a href="/blog/backend/01-database/vendors/postgresql/extension-ecosystem/" data-link-title="PostgreSQL Extension Ecosystem：把 PG 變成 vector DB / time-series / sharded 的 plugin 生態" data-link-desc="PG 的 extension 機制不只是 plugin、是 *結構性產品線擴張* — pgvector 讓 PG 變 vector DB、TimescaleDB 變 time-series、Citus 變 sharded、PostGIS 變 GIS。本文走 PG extension lifecycle、6 個 production-critical extension（pg_stat_statements / pg_partman / pg_repack / pgvector / TimescaleDB / PostGIS）、5 production 踩雷（extension version 跟 PG version 對齊 / managed PG 限制 / upgrade order / shared_preload_libraries 衝突 / extension 跟 logical replication 互動）、cloud vendor 對 extension 的限制">extension-ecosystem</a> 是 <em>單一 extension 細節 vs ecosystem 全景</em> 的關係。</p></blockquote>
<hr>
<h2 id="timescaledb-是-pg-的-time-series-specialization">TimescaleDB 是 PG 的 <em>Time-Series Specialization</em></h2>
<p>TimescaleDB 不是獨立 DB、是 PG extension：</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="n">EXTENSION</span><span class="w"> </span><span class="n">timescaledb</span><span class="p">;</span></span></span></code></pre></div><p>加完後、PG 多三個 time-series 專屬機制：</p>
<ol>
<li><strong>Hypertable</strong>：對 time column 自動 partition、應用層看是一張表</li>
<li><strong>Continuous aggregate</strong>：incremental refresh 的 materialized view</li>
<li><strong>Compression</strong>：對舊 chunk 壓縮（columnar-like format）</li>
</ol>
<p>跟專業 time-series DB（InfluxDB / Prometheus / VictoriaMetrics）對比、TimescaleDB 的賣點不是「最快」而是「PG ecosystem 一致」：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>TimescaleDB</th>
          <th>InfluxDB</th>
          <th>Prometheus</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Query 語言</td>
          <td>標準 SQL</td>
          <td>InfluxQL / Flux</td>
          <td>PromQL</td>
      </tr>
      <tr>
          <td>寫入效能</td>
          <td>中（10-100K rows/s）</td>
          <td>高（500K+ rows/s）</td>
          <td>中（pull-based scrape）</td>
      </tr>
      <tr>
          <td>壓縮</td>
          <td>90%+（columnar compression）</td>
          <td>高</td>
          <td>高</td>
      </tr>
      <tr>
          <td>Join</td>
          <td>完整 SQL join</td>
          <td>弱</td>
          <td>不支援</td>
      </tr>
      <tr>
          <td>跟既有 PG schema</td>
          <td>同一個 DB、可 join</td>
          <td>獨立</td>
          <td>獨立</td>
      </tr>
      <tr>
          <td>生態</td>
          <td>完整 PG ecosystem</td>
          <td>自家 ecosystem</td>
          <td>自家 ecosystem</td>
      </tr>
      <tr>
          <td>Open source</td>
          <td>Apache 2.0（部分功能 TSL license）</td>
          <td>MIT</td>
          <td>Apache 2.0</td>
      </tr>
  </tbody>
</table>
<p><strong>何時選 TimescaleDB</strong>：</p>
<ul>
<li>Application 已用 PG、不想多管一套 time-series DB</li>
<li>需要 join time-series 跟 application 表（user / device metadata）</li>
<li>不需 InfluxDB 級寫入速度（&lt; 100K rows/s）</li>
<li>Team SQL 熟、PromQL / Flux 學習成本不想付</li>
</ul>
<p><strong>何時選 InfluxDB / Prometheus（不選 TimescaleDB）</strong>：</p>
<ul>
<li>High-cardinality metric（10M+ unique series）— TSDB-purpose-built engine 在 cardinality 跟 retention 上比 hypertable 高效</li>
<li>Pull-based scrape model（Prometheus）跟 alerting / Grafana 生態深整合</li>
<li>PromQL operator（<code>rate()</code> / <code>histogram_quantile()</code>）對 metric query 比 SQL 直覺</li>
<li>TSL license 不能接受（TimescaleDB 部分功能在 Timescale License、不是純 Apache 2.0）</li>
<li>Operational team 已熟 InfluxDB / Prometheus、不想多學 PG 維運</li>
</ul>
<h2 id="hypertable自動-time-based-partitioning">Hypertable：自動 Time-based Partitioning</h2>
<p>普通 PG 表變 hypertable：</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">sensor_data</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">time</span><span class="w">        </span><span class="n">TIMESTAMPTZ</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">3</span><span class="cl"><span class="w">    </span><span class="n">sensor_id</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="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="n">temperature</span><span class="w"> </span><span class="n">DOUBLE</span><span class="w"> </span><span class="k">PRECISION</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">humidity</span><span class="w">    </span><span class="n">DOUBLE</span><span class="w"> </span><span class="k">PRECISION</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></span><span class="line"><span class="ln">8</span><span class="cl"><span class="w"></span><span class="c1">-- 變 hypertable、按 time 自動 partition
</span></span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">create_hypertable</span><span class="p">(</span><span class="s1">&#39;sensor_data&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;time&#39;</span><span class="p">);</span></span></span></code></pre></div><p>Hypertable 機制：</p>
<ul>
<li>後台自動拆 <em>chunk</em>（child partition）by time interval（預設 7 天）</li>
<li>Application 看到的是 <code>sensor_data</code> 一張表、實際資料分散在 <code>_timescaledb_internal._hyper_*_chunk</code> 表</li>
<li>Query 自動 chunk pruning（只掃命中時間範圍的 chunk）</li>
</ul>
<p><strong>Chunk interval 選擇</strong>很關鍵：</p>
<table>
  <thead>
      <tr>
          <th>Chunk interval</th>
          <th>適用</th>
          <th>問題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1 小時</td>
          <td>高頻 metrics（每秒 100+ row）</td>
          <td>Chunk 太多、catalog 膨脹</td>
      </tr>
      <tr>
          <td>1 天</td>
          <td>中高頻（每秒 10-100 row）</td>
          <td>OK</td>
      </tr>
      <tr>
          <td>7 天（預設）</td>
          <td>中頻（每分鐘 row）</td>
          <td>OK</td>
      </tr>
      <tr>
          <td>30 天</td>
          <td>低頻（每小時 row）</td>
          <td>OK</td>
      </tr>
  </tbody>
</table>
<p>通用原則：<em>每個 chunk 25% RAM</em>、超過退化 disk IO。Production 監控 <code>chunk_size</code> 跟 <code>shared_buffers</code> ratio 自動調。</p>
<p><strong>Multi-dimensional hypertable</strong>（time + space partition）：</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">-- 按 time + device_id 雙維 partition
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">create_hypertable</span><span class="p">(</span><span class="s1">&#39;sensor_data&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;time&#39;</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">partitioning_column</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="s1">&#39;sensor_id&#39;</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="n">number_partitions</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="mi">16</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="p">);</span></span></span></code></pre></div><p>適用 sensor 數 1000+ 的 IoT workload、單 chunk 太大時用 space partition 拆。</p>
<h2 id="continuous-aggregatecaggincremental-materialized-view">Continuous Aggregate（CAGG）：Incremental Materialized View</h2>
<p>普通 PG materialized view 是 <em>全量重算</em>、TimescaleDB CAGG 是 <em>incremental refresh</em>：</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">-- 1 小時粒度聚合
</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="n">MATERIALIZED</span><span class="w"> </span><span class="k">VIEW</span><span class="w"> </span><span class="n">sensor_hourly</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w"></span><span class="k">WITH</span><span class="w"> </span><span class="p">(</span><span class="n">timescaledb</span><span class="p">.</span><span class="n">continuous</span><span class="p">)</span><span class="w"> </span><span class="k">AS</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">SELECT</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">time_bucket</span><span class="p">(</span><span class="s1">&#39;1 hour&#39;</span><span class="p">,</span><span class="w"> </span><span class="n">time</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">hour</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">sensor_id</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">avg</span><span class="p">(</span><span class="n">temperature</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">avg_temp</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="k">max</span><span class="p">(</span><span class="n">temperature</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">max_temp</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">min</span><span class="p">(</span><span class="n">temperature</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">min_temp</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">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">sample_count</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="n">sensor_data</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</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="n">hour</span><span class="p">,</span><span class="w"> </span><span class="n">sensor_id</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></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w"></span><span class="c1">-- 加 refresh policy（每 30 分鐘 refresh 過去 1 天）
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">add_continuous_aggregate_policy</span><span class="p">(</span><span class="s1">&#39;sensor_hourly&#39;</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">start_offset</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="nb">INTERVAL</span><span class="w"> </span><span class="s1">&#39;1 day&#39;</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">end_offset</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="nb">INTERVAL</span><span class="w"> </span><span class="s1">&#39;30 minutes&#39;</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">schedule_interval</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="nb">INTERVAL</span><span class="w"> </span><span class="s1">&#39;30 minutes&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="w"></span><span class="p">);</span></span></span></code></pre></div><p>CAGG 機制：</p>
<ul>
<li>記錄哪些 time bucket 已 materialize、哪些 stale</li>
<li>Refresh 時只重算 stale bucket、不全量</li>
<li>Query CAGG 自動 fallback 到原 hypertable 補最新資料（real-time aggregation）</li>
</ul>
<p><strong>CAGG vs 普通 MV 對比</strong>：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>TimescaleDB CAGG</th>
          <th>普通 PG MV</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Refresh 模式</td>
          <td>Incremental</td>
          <td>全量重算</td>
      </tr>
      <tr>
          <td>Refresh 時間</td>
          <td>秒級</td>
          <td>表大時數十分鐘</td>
      </tr>
      <tr>
          <td>Real-time fallback</td>
          <td>自動補最新</td>
          <td>不支援、需手動 union</td>
      </tr>
      <tr>
          <td>Storage</td>
          <td>多一份 aggregated</td>
          <td>多一份 aggregated</td>
      </tr>
      <tr>
          <td>Policy</td>
          <td>內建排程</td>
          <td>需 pg_cron / 外部排程</td>
      </tr>
  </tbody>
</table>
<p><strong>CAGG hierarchy</strong>（多層聚合）：</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">-- 從 1 hour CAGG 再聚合到 1 day
</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="n">MATERIALIZED</span><span class="w"> </span><span class="k">VIEW</span><span class="w"> </span><span class="n">sensor_daily</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">WITH</span><span class="w"> </span><span class="p">(</span><span class="n">timescaledb</span><span class="p">.</span><span class="n">continuous</span><span class="p">)</span><span class="w"> </span><span class="k">AS</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">SELECT</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">time_bucket</span><span class="p">(</span><span class="s1">&#39;1 day&#39;</span><span class="p">,</span><span class="w"> </span><span class="n">hour</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="k">day</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">sensor_id</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">avg</span><span class="p">(</span><span class="n">avg_temp</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">daily_avg</span><span class="w">
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="n">sensor_hourly</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">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="k">day</span><span class="p">,</span><span class="w"> </span><span class="n">sensor_id</span><span class="p">;</span></span></span></code></pre></div><p>Application query 不同時間範圍時自動命中對應粒度、不必每次掃原始資料。</p>
<h2 id="compression把舊-chunk-壓-90">Compression：把舊 Chunk 壓 90%+</h2>
<p>舊 chunk 可以開啟 compression：</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">-- 開啟 compression（必須先設定 segment by）
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">sensor_data</span><span class="w"> </span><span class="k">SET</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">timescaledb</span><span class="p">.</span><span class="n">compress</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="n">timescaledb</span><span class="p">.</span><span class="n">compress_segmentby</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;sensor_id&#39;</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">timescaledb</span><span class="p">.</span><span class="n">compress_orderby</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;time DESC&#39;</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></span><span class="line"><span class="ln">8</span><span class="cl"><span class="w"></span><span class="c1">-- 自動壓縮 policy：7 天前 chunk 壓
</span></span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">add_compression_policy</span><span class="p">(</span><span class="s1">&#39;sensor_data&#39;</span><span class="p">,</span><span class="w"> </span><span class="nb">INTERVAL</span><span class="w"> </span><span class="s1">&#39;7 days&#39;</span><span class="p">);</span></span></span></code></pre></div><p>Compression 機制：</p>
<ul>
<li>把 chunk 內 row 按 <code>segmentby</code> 分組</li>
<li>每組內按 <code>orderby</code> 排序後、把每 column 變成 <em>columnar array</em></li>
<li>對 array 用 type-specific 壓縮（Gorilla for float / delta-of-delta for timestamp / dictionary for string）</li>
</ul>
<p>實際壓縮率：</p>
<table>
  <thead>
      <tr>
          <th>Workload</th>
          <th>壓縮率</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>IoT sensor（重複值多）</td>
          <td>95-98%</td>
      </tr>
      <tr>
          <td>Application metrics</td>
          <td>90-95%</td>
      </tr>
      <tr>
          <td>Trade tick（隨機浮點）</td>
          <td>70-85%</td>
      </tr>
      <tr>
          <td>Log line（高 cardinality string）</td>
          <td>50-70%</td>
      </tr>
  </tbody>
</table>
<p><strong>Compression 限制</strong>（重要）：</p>
<ul>
<li>壓縮後 chunk <strong>不能 UPDATE / DELETE 單 row</strong>（要先 decompress）</li>
<li>壓縮後 chunk <strong>不能加 column</strong>（要 decompress 所有 chunk）</li>
<li>壓縮後 chunk 只能 <em>append new row</em>、不能改舊 row</li>
<li>DDL 變更（加 column / 改 index）需 decompress</li>
</ul>
<p>實務：compression 是 <em>write-once cold data</em> 的工具、active OLTP chunk 不開。</p>
<h2 id="retention-policy自動刪舊資料">Retention Policy：自動刪舊資料</h2>





<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">-- 1 年前 chunk 自動刪
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">add_retention_policy</span><span class="p">(</span><span class="s1">&#39;sensor_data&#39;</span><span class="p">,</span><span class="w"> </span><span class="nb">INTERVAL</span><span class="w"> </span><span class="s1">&#39;1 year&#39;</span><span class="p">);</span></span></span></code></pre></div><p>Retention drop 整個 chunk（不是 DELETE row）、O(1) 操作、不產生 bloat。</p>
<p>CAGG 有獨立 retention：</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">-- 原始資料只留 30 天、aggregated 留 5 年
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">add_retention_policy</span><span class="p">(</span><span class="s1">&#39;sensor_data&#39;</span><span class="p">,</span><span class="w"> </span><span class="nb">INTERVAL</span><span class="w"> </span><span class="s1">&#39;30 days&#39;</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="k">SELECT</span><span class="w"> </span><span class="n">add_retention_policy</span><span class="p">(</span><span class="s1">&#39;sensor_hourly&#39;</span><span class="p">,</span><span class="w"> </span><span class="nb">INTERVAL</span><span class="w"> </span><span class="s1">&#39;5 years&#39;</span><span class="p">);</span></span></span></code></pre></div><p>這是 TimescaleDB 跟普通 PG partitioning 最大的價值差 — 普通 PG 要自己寫 cron drop partition、TimescaleDB policy 內建。</p>
<h2 id="5-個-production-踩雷">5 個 Production 踩雷</h2>
<h3 id="case-1chunk-size-不對catalog-膨脹">Case 1：Chunk size 不對、catalog 膨脹</h3>
<p><strong>情境</strong>：sensor 每秒寫 10 row、chunk_interval 設 1 小時、一年產 8760 chunk、<code>pg_class</code> 撐到 200 萬 row、planner 變慢。</p>
<p>修法：</p>
<ul>
<li>Chunk 數量上限 ~10000、超過 catalog overhead 出現</li>
<li>重設 chunk_interval：<code>SELECT set_chunk_time_interval('sensor_data', INTERVAL '1 day');</code></li>
<li>已存在 chunk 不會自動 merge、要靠 retention drop 自然消化</li>
</ul>
<h3 id="case-2cagg-refresh-落後-real-time">Case 2：CAGG refresh 落後 real-time</h3>
<p><strong>情境</strong>：CAGG refresh policy 每 1 小時跑、application 期待「即時 dashboard」、看到的數字落後 1 小時。</p>
<p>修法：</p>
<ul>
<li>縮短 <code>schedule_interval</code>（5 分鐘）</li>
<li>用 <code>real-time aggregation</code>（預設 ON、CAGG 自動 union 原始資料）</li>
<li>確認 <code>materialized_only = false</code>（real-time aggregation 開啟）</li>
</ul>





<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">ALTER</span><span class="w"> </span><span class="n">MATERIALIZED</span><span class="w"> </span><span class="k">VIEW</span><span class="w"> </span><span class="n">sensor_hourly</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="p">(</span><span class="n">timescaledb</span><span class="p">.</span><span class="n">materialized_only</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">false</span><span class="p">);</span></span></span></code></pre></div><h3 id="case-3compression-後想-update">Case 3：Compression 後想 UPDATE</h3>
<p><strong>情境</strong>：發現某個歷史 row 數值錯、想 UPDATE、報錯 <em>cannot update/delete from compressed chunk</em>。</p>
<p>修法：</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">-- 找到該 chunk 並 decompress
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">decompress_chunk</span><span class="p">(</span><span class="k">c</span><span class="p">)</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">show_chunks</span><span class="p">(</span><span class="s1">&#39;sensor_data&#39;</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">older_than</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="nb">INTERVAL</span><span class="w"> </span><span class="s1">&#39;7 days&#39;</span><span class="p">)</span><span class="w"> </span><span class="k">c</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="k">c</span><span class="p">::</span><span class="nb">text</span><span class="w"> </span><span class="k">LIKE</span><span class="w"> </span><span class="s1">&#39;%_5_chunk&#39;</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></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="c1">-- UPDATE 完再 compress 回去
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span><span class="k">UPDATE</span><span class="w"> </span><span class="n">sensor_data</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="n">temperature</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">22</span><span class="p">.</span><span class="mi">5</span><span class="w"> </span><span class="k">WHERE</span><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">SELECT</span><span class="w"> </span><span class="n">compress_chunk</span><span class="p">(...);</span></span></span></code></pre></div><p>或設計階段就避免 — compression 用在 <em>immutable data</em>、有可能改的留未壓。</p>
<h3 id="case-4hypertable-不能加-fk-到-non-hypertable">Case 4：Hypertable 不能加 FK 到 non-hypertable</h3>
<p><strong>情境</strong>：想對 <code>sensor_data</code> 加 FK 到 <code>sensors</code> 表、報錯 <em>foreign key constraints with hypertables are not supported</em>。</p>
<p>修法：</p>
<ul>
<li>Application 層維護 referential integrity</li>
<li>或反過來：<code>sensors</code> 可以 FK 到 hypertable（特定方向支援）</li>
<li>TimescaleDB 2.11+ 部分支援 FK from hypertable、但限制多</li>
</ul>
<h3 id="case-5timescaledb-跟-pg-主版本對齊">Case 5：TimescaleDB 跟 PG 主版本對齊</h3>
<p><strong>情境</strong>：PG 升級 14 → 16、TimescaleDB extension 沒對應升級、PG 啟動 fail。</p>
<p>TimescaleDB 跟 PG 版本對齊矩陣：</p>
<table>
  <thead>
      <tr>
          <th>TimescaleDB</th>
          <th>支援 PG version</th>
          <th>備註</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>2.11+</td>
          <td>13, 14, 15</td>
          <td></td>
      </tr>
      <tr>
          <td>2.13+</td>
          <td>13, 14, 15, 16</td>
          <td>加 PG 16 支援</td>
      </tr>
      <tr>
          <td>2.15.x</td>
          <td>13, 14, 15, 16</td>
          <td>最後支援 PG 13 的 minor</td>
      </tr>
      <tr>
          <td>2.16+</td>
          <td>14, 15, 16</td>
          <td>PG 13 drop</td>
      </tr>
      <tr>
          <td>2.17+</td>
          <td>14, 15, 16, 17</td>
          <td>PG 17 加入（需 17.2+ binary 對齊）</td>
      </tr>
      <tr>
          <td>2.18+</td>
          <td>14, 15, 16, 17</td>
          <td>PG 17 完整支援</td>
      </tr>
      <tr>
          <td>2.23+</td>
          <td>14, 15, 16, 17, 18</td>
          <td>PG 18 加入</td>
      </tr>
  </tbody>
</table>
<p>修法：</p>
<ul>
<li>升 PG 前先升 TimescaleDB 到支援目標 PG 版本的 extension</li>
<li>Production 升級順序：TimescaleDB minor upgrade → PG major upgrade → TimescaleDB final upgrade</li>
<li>Cloud managed（Timescale Cloud）自動處理</li>
</ul>
<h2 id="跟-pg-原生-partitioning-對比">跟 PG 原生 Partitioning 對比</h2>
<p>PG 10+ 有 declarative partitioning、不一定要 TimescaleDB：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>TimescaleDB hypertable</th>
          <th>PG declarative partitioning</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>自動建 chunk</td>
          <td>是</td>
          <td>否（需手動或 pg_partman）</td>
      </tr>
      <tr>
          <td>Chunk pruning</td>
          <td>自動</td>
          <td>自動（需 partition key）</td>
      </tr>
      <tr>
          <td>Retention 內建</td>
          <td>是</td>
          <td>否（pg_partman 或自寫 cron）</td>
      </tr>
      <tr>
          <td>Compression</td>
          <td>內建 columnar</td>
          <td>否</td>
      </tr>
      <tr>
          <td>Continuous aggregate</td>
          <td>內建</td>
          <td>否（自寫 incremental refresh）</td>
      </tr>
      <tr>
          <td>跨 chunk index</td>
          <td>統一 management</td>
          <td>Per-partition index</td>
      </tr>
      <tr>
          <td>Cardinality limit</td>
          <td>10000+ chunk OK</td>
          <td>1000+ partition 就慢</td>
      </tr>
  </tbody>
</table>
<p>何時用原生 partitioning（不用 TimescaleDB）：</p>
<ul>
<li>不需要 compression / CAGG</li>
<li>Partition 數 &lt; 1000</li>
<li>已用 pg_partman 不想換</li>
<li>公司禁用 TSL license（TimescaleDB 部分功能受限）</li>
</ul>
<p>何時用 TimescaleDB：</p>
<ul>
<li>高頻 time-series（compression 必要）</li>
<li>需要 CAGG（手寫 incremental MV 成本高）</li>
<li>Partition 數 &gt; 1000</li>
<li>IoT / metrics / observability workload</li>
</ul>
<p>詳細 partitioning 機制看 <a href="/blog/backend/01-database/vendors/postgresql/declarative-partitioning/" data-link-title="PostgreSQL declarative partitioning：partition 不是切表、是讓 planner pruning" data-link-desc="Declarative partitioning 的真實價值是 query planner pruning &#43; maintenance scope 縮小、不是「把大表切小」；RANGE / LIST / HASH 取捨、partition key 選法、5 個 production 踩雷（key 選錯不 prune / unique 不 enforce 跨 partition / ATTACH 鎖太久 / partition 數爆 / DETACH 不 reclaim 空間）、跟 autovacuum &#43; index 設計整合">declarative-partitioning</a>。</p>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/postgresql/extension-ecosystem/" data-link-title="PostgreSQL Extension Ecosystem：把 PG 變成 vector DB / time-series / sharded 的 plugin 生態" data-link-desc="PG 的 extension 機制不只是 plugin、是 *結構性產品線擴張* — pgvector 讓 PG 變 vector DB、TimescaleDB 變 time-series、Citus 變 sharded、PostGIS 變 GIS。本文走 PG extension lifecycle、6 個 production-critical extension（pg_stat_statements / pg_partman / pg_repack / pgvector / TimescaleDB / PostGIS）、5 production 踩雷（extension version 跟 PG version 對齊 / managed PG 限制 / upgrade order / shared_preload_libraries 衝突 / extension 跟 logical replication 互動）、cloud vendor 對 extension 的限制">extension-ecosystem</a>：PG extension 全景</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/declarative-partitioning/" data-link-title="PostgreSQL declarative partitioning：partition 不是切表、是讓 planner pruning" data-link-desc="Declarative partitioning 的真實價值是 query planner pruning &#43; maintenance scope 縮小、不是「把大表切小」；RANGE / LIST / HASH 取捨、partition key 選法、5 個 production 踩雷（key 選錯不 prune / unique 不 enforce 跨 partition / ATTACH 鎖太久 / partition 數爆 / DETACH 不 reclaim 空間）、跟 autovacuum &#43; index 設計整合">declarative-partitioning</a>：原生 partitioning</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/jsonb-deep-dive/" data-link-title="PostgreSQL JSONB Deep Dive：Binary Storage &#43; GIN Index 為什麼是結構性優勢" data-link-desc="PG JSONB（9.4&#43;）是 *binary 儲存的 JSON*、可直接 GIN index、是 PG 在 JSON workload 的結構性優勢、跟 MongoDB / MySQL 8.0 JSON_TABLE 比仍領先。本文走 JSON vs JSONB 差異、GIN index 機制（jsonb_ops vs jsonb_path_ops）、operator &#43; path query、partial JSONB indexing、5 production 踩雷（大 JSONB 跟 TOAST / nested update / index 選錯 op class / jsonb_path_query 跟 jsonb_path_exists 行為差 / partial index 條件搞錯）、何時用 JSONB vs 拆 column">jsonb-deep-dive</a>：IoT payload 用 JSONB 儲存</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>：hypertable autovacuum 行為</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/major-version-upgrade/" data-link-title="PostgreSQL major version upgrade (14 → 17)：為什麼這篇不套 5 type migration" data-link-desc="PostgreSQL major version upgrade 是 *5 type 漏類* 的實證 — source/target 同 vendor、5 維度都 Low 但 *upgrade-specific audit* 是核心；本文結構接近 deep article methodology 的 6-section &#43; 額外 upgrade audit 段；涵蓋 pg_upgrade / logical replication / blue-green 三方法、extension 相容性、5 production 踩雷">major-version-upgrade</a>：TimescaleDB + PG 升級順序</li>
</ul>
<h2 id="下一步">下一步</h2>
<ul>
<li>看 <a href="/blog/backend/01-database/vendors/postgresql/extension-ecosystem/" data-link-title="PostgreSQL Extension Ecosystem：把 PG 變成 vector DB / time-series / sharded 的 plugin 生態" data-link-desc="PG 的 extension 機制不只是 plugin、是 *結構性產品線擴張* — pgvector 讓 PG 變 vector DB、TimescaleDB 變 time-series、Citus 變 sharded、PostGIS 變 GIS。本文走 PG extension lifecycle、6 個 production-critical extension（pg_stat_statements / pg_partman / pg_repack / pgvector / TimescaleDB / PostGIS）、5 production 踩雷（extension version 跟 PG version 對齊 / managed PG 限制 / upgrade order / shared_preload_libraries 衝突 / extension 跟 logical replication 互動）、cloud vendor 對 extension 的限制">extension-ecosystem</a> 了解其他 PG 擴展選項</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><item><title>Aurora Storage Architecture：quorum-based 分散式 log 與韌性即性能設計</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/aurora/storage-architecture/</link><pubDate>Wed, 27 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/aurora/storage-architecture/</guid><description>&lt;p>Aurora 把 storage 從「block device + WAL on local disk」重寫成跨 AZ 分散式 log service、compute node 只負責 process query 跟 generate redo log records。這個設計直接決定 read replica、failover、backup 跟跨 AZ replication 的物理上限 — 不理解 storage layer 設計、就無法解釋為什麼 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/netflix-aurora-consolidation/" data-link-title="9.C23 Netflix：把關聯式 DB 統一到 Aurora、效能 &amp;#43;75%、成本 -28%" data-link-desc="Netflix 把多套關聯式 DB 統一到 Aurora、效能提升 75%、成本下降 28%、串流數十億小時">9.C23 Netflix consolidation&lt;/a> 拿到 +75% 效能、為什麼 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/draftkings-aurora-financial-ledger/" data-link-title="9.C4 DraftKings：Aurora 撐 100 萬 ops/min 的體育博彩金融帳本" data-link-desc="DraftKings 用 Aurora MySQL 跑體育博彩金融帳本、Super Bowl 流量 &amp;#43;50% 不影響延遲">9.C4 DraftKings&lt;/a> replication lag 從 30 秒降到 10-30ms、為什麼 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/standard-chartered-aurora-banking/" data-link-title="9.C14 Standard Chartered：受監管銀行的 Aurora 4000 TPS 容量提升" data-link-desc="Standard Chartered 銀行遷移到 Aurora 後吞吐量提升 10 倍至 4000 TPS、跨 7 個受監管市場">9.C14 Standard Chartered&lt;/a> 能同時把韌性跟性能當成單一目標。&lt;/p>
&lt;p>本文不是 Aurora overview（請看 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/aurora/" data-link-title="AWS Aurora" data-link-desc="AWS managed PostgreSQL / MySQL、storage / compute 分離、&amp;#43;75% 效能改善的 production 證據">Aurora vendor 頁&lt;/a>）— 而是 storage-level 設計的實作層教學。覆蓋 quorum-based replication 的工程含義、「韌性即性能」frame 為什麼成立、OLTP workload 在 storage 設計下的讀寫雙峰錯位、跟容量規劃的判讀槓桿。&lt;/p>
&lt;h2 id="問題情境">問題情境&lt;/h2>
&lt;p>典型觸發場景：團隊從 RDS PostgreSQL / 自管 PostgreSQL 遷到 Aurora、看到「跨 AZ replication lag 從秒級降到毫秒級」、但讀文件「quorum」「4-of-6」「分散式 storage」訊息密集、不知道哪些設計決策要相信、哪些是 marketing 詞。&lt;/p>
&lt;p>讀者常見的具體疑問：&lt;/p>
&lt;ul>
&lt;li>「為什麼 Aurora 寫入比 RDS 還低、不是該因為跨 AZ network round-trip 而變慢？」&lt;/li>
&lt;li>「Storage layer 跟 compute layer 分離具體怎麼影響 backup、failover 跟 read replica？」&lt;/li>
&lt;li>「6 個 storage node 失去 2 個還能寫嗎？失去 3 個呢？」&lt;/li>
&lt;li>「Aurora 文件講『韌性』跟『性能』都用 storage 設計解釋、是同一件事還是兩件事？」&lt;/li>
&lt;/ul>
&lt;p>進一步問題：傳統工程文化把可靠性跟性能視為對立 — HA 投資（跨 AZ replication、failover 演練）通常被當成性能成本、不被視為性能來源。Aurora 設計反這個直覺、但讀者需要看到具體機制才能信。Standard Chartered case 揭露這個 frame 在受監管銀行業務（要求兩者同時達標）的價值；DraftKings 揭露具體數字（讀 &amp;lt; 1ms、寫 6ms）。&lt;/p>
&lt;h2 id="核心機制quorum-based-分散式-log">核心機制：quorum-based 分散式 log&lt;/h2>
&lt;p>Aurora storage 的 first-class concept 是 &lt;em>quorum 寫入 + 6-way 跨 AZ replication&lt;/em>。傳統 PostgreSQL primary 把 storage 跟 CPU / RAM 綁定、storage 擴容要換 instance、replication 在 compute 層做（streaming replication、logical replication）。Aurora 把 storage 拉到分散式 log service、6 個 storage node 各自獨立、application 看到的仍是 single primary SQL。&lt;/p></description><content:encoded><![CDATA[<p>Aurora 把 storage 從「block device + WAL on local disk」重寫成跨 AZ 分散式 log service、compute node 只負責 process query 跟 generate redo log records。這個設計直接決定 read replica、failover、backup 跟跨 AZ replication 的物理上限 — 不理解 storage layer 設計、就無法解釋為什麼 <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 consolidation</a> 拿到 +75% 效能、為什麼 <a href="/blog/backend/09-performance-capacity/cases/draftkings-aurora-financial-ledger/" data-link-title="9.C4 DraftKings：Aurora 撐 100 萬 ops/min 的體育博彩金融帳本" data-link-desc="DraftKings 用 Aurora MySQL 跑體育博彩金融帳本、Super Bowl 流量 &#43;50% 不影響延遲">9.C4 DraftKings</a> replication lag 從 30 秒降到 10-30ms、為什麼 <a href="/blog/backend/09-performance-capacity/cases/standard-chartered-aurora-banking/" data-link-title="9.C14 Standard Chartered：受監管銀行的 Aurora 4000 TPS 容量提升" data-link-desc="Standard Chartered 銀行遷移到 Aurora 後吞吐量提升 10 倍至 4000 TPS、跨 7 個受監管市場">9.C14 Standard Chartered</a> 能同時把韌性跟性能當成單一目標。</p>
<p>本文不是 Aurora overview（請看 <a href="/blog/backend/01-database/vendors/aurora/" data-link-title="AWS Aurora" data-link-desc="AWS managed PostgreSQL / MySQL、storage / compute 分離、&#43;75% 效能改善的 production 證據">Aurora vendor 頁</a>）— 而是 storage-level 設計的實作層教學。覆蓋 quorum-based replication 的工程含義、「韌性即性能」frame 為什麼成立、OLTP workload 在 storage 設計下的讀寫雙峰錯位、跟容量規劃的判讀槓桿。</p>
<h2 id="問題情境">問題情境</h2>
<p>典型觸發場景：團隊從 RDS PostgreSQL / 自管 PostgreSQL 遷到 Aurora、看到「跨 AZ replication lag 從秒級降到毫秒級」、但讀文件「quorum」「4-of-6」「分散式 storage」訊息密集、不知道哪些設計決策要相信、哪些是 marketing 詞。</p>
<p>讀者常見的具體疑問：</p>
<ul>
<li>「為什麼 Aurora 寫入比 RDS 還低、不是該因為跨 AZ network round-trip 而變慢？」</li>
<li>「Storage layer 跟 compute layer 分離具體怎麼影響 backup、failover 跟 read replica？」</li>
<li>「6 個 storage node 失去 2 個還能寫嗎？失去 3 個呢？」</li>
<li>「Aurora 文件講『韌性』跟『性能』都用 storage 設計解釋、是同一件事還是兩件事？」</li>
</ul>
<p>進一步問題：傳統工程文化把可靠性跟性能視為對立 — HA 投資（跨 AZ replication、failover 演練）通常被當成性能成本、不被視為性能來源。Aurora 設計反這個直覺、但讀者需要看到具體機制才能信。Standard Chartered case 揭露這個 frame 在受監管銀行業務（要求兩者同時達標）的價值；DraftKings 揭露具體數字（讀 &lt; 1ms、寫 6ms）。</p>
<h2 id="核心機制quorum-based-分散式-log">核心機制：quorum-based 分散式 log</h2>
<p>Aurora storage 的 first-class concept 是 <em>quorum 寫入 + 6-way 跨 AZ replication</em>。傳統 PostgreSQL primary 把 storage 跟 CPU / RAM 綁定、storage 擴容要換 instance、replication 在 compute 層做（streaming replication、logical replication）。Aurora 把 storage 拉到分散式 log service、6 個 storage node 各自獨立、application 看到的仍是 single primary SQL。</p>
<p><strong>Storage layout</strong>：每個 storage segment 跨 3 AZ × 2 node、共 6 個 storage node。一個 cluster 的 storage 被切成多個 10GB segment、每個 segment 6-way 複製。</p>
<p><strong>Quorum 設定</strong>：</p>
<ul>
<li>Write quorum：4-of-6（4 個 storage node 確認寫入才算 commit）— 容忍 1 AZ 失效 + 1 node 失效仍能寫</li>
<li>Read quorum：3-of-6（讀 3 個 node 取最新版本）— 比 write 小、降低 read latency</li>
<li>算術不對稱：寫嚴讀鬆是設計選擇、不是 marketing — durability 由寫端保證、讀端可以放寬</li>
</ul>
<p><strong>Write path 跟傳統 PostgreSQL 的差異</strong>：</p>
<ul>
<li>PostgreSQL primary：寫 WAL 到 local disk + dirty page flush + 透過 streaming replication 推到 replica</li>
<li>Aurora compute node：只送 <em>redo log records</em> 到 storage、不送整個 page；storage node 自己 apply redo log 重建 page、自己 checkpoint、自己 backup</li>
<li>工程含義：compute node 寫量小、CPU 不被 dirty page flush 佔用、寫入路徑變短</li>
</ul>
<p><strong>「韌性即性能」frame</strong>（<a href="/blog/backend/09-performance-capacity/cases/standard-chartered-aurora-banking/" data-link-title="9.C14 Standard Chartered：受監管銀行的 Aurora 4000 TPS 容量提升" data-link-desc="Standard Chartered 銀行遷移到 Aurora 後吞吐量提升 10 倍至 4000 TPS、跨 7 個受監管市場">9.C14 Standard Chartered</a> 揭露）：</p>
<p>Aurora 把 HA 從 application-level（Patroni promotion + WAL catch-up）下推到 storage-level。設計含義是：storage 投資（6-way 跨 AZ replication）自動成為 read replica 的容量基底 — read replica 不需要 catch-up WAL、直接從共享 storage 讀、HA 預算同步轉成讀分流預算。</p>
<p>對 Standard Chartered 受監管銀行業務這代表：合規要求的 RPO / RTO 不能放棄、但業務也要求每秒 4000 TPS、兩者必須同時達成。傳統路徑要分別投資 HA（複雜的 streaming replication topology）跟性能（read replica catch-up tuning）、且兩個投資互相干擾。Aurora 讓 <em>同一份 storage 投資</em> 同時提供兩件事 — case「判讀」段第 2 點原話：「Aurora 的多 AZ storage + replica 同時提供性能（讀分流）跟韌性（故障切換）、達成 <em>韌性即性能</em> 的目標」。</p>
<p>對應 knowledge card：<a href="/blog/backend/knowledge-cards/quorum/" data-link-title="Quorum" data-link-desc="分散式系統以多數節點同意作為提交或讀取有效性的門檻">quorum</a>、<a href="/blog/backend/knowledge-cards/replication-lag/" data-link-title="Replication Lag" data-link-desc="說明資料副本落後正式來源多久，以及它如何影響讀取正確性">replication-lag</a>。</p>
<p><strong>跟通用 quorum 概念差在哪</strong>：Aurora quorum 是 <em>storage-level</em>（不是 application-level Cassandra 風格）、application 看到 single primary SQL、不用感知 quorum；vs Cassandra application 要選 consistency level（ONE / QUORUM / ALL）。</p>
<h2 id="oltp-workload-shape讀寫雙峰錯位">OLTP workload shape：讀寫雙峰錯位</h2>
<p>Aurora 設計的工程含義在 application 層落地時、要看 workload 形狀。<a href="/blog/backend/09-performance-capacity/cases/draftkings-aurora-financial-ledger/" data-link-title="9.C4 DraftKings：Aurora 撐 100 萬 ops/min 的體育博彩金融帳本" data-link-desc="DraftKings 用 Aurora MySQL 跑體育博彩金融帳本、Super Bowl 流量 &#43;50% 不影響延遲">9.C4 DraftKings</a> 揭露一個 OLTP 容量規劃的典型 pattern。</p>
<p><strong>DraftKings 揭露的雙峰錯位</strong>（case「觀察」段最後一行原文）：「write workloads spike up significantly around payout events, but opening the app during the game also activates a lot of balance queries」— 比賽進行時是讀爆量（balance query）、payout event 時是寫爆量（ledger write）、兩個峰不在同一時刻。</p>
<p><strong>工程含義</strong>：</p>
<ul>
<li>讀寫資源規劃要分開、不能用「峰值總 TPS」單一數字規劃容量</li>
<li>讀峰拉 read replica 容量、寫峰靠 primary instance class 跟 commit batching、兩條路徑獨立預配</li>
<li>預估 headroom 也要分開：讀的 headroom 可以靠 auto-scale replica 接、寫的 headroom 要靠 primary 提前升 instance class（不能 auto-scale）</li>
</ul>
<p><strong>Application-level boundary</strong>：雙峰錯位是 <em>application 層</em> 拆讀寫 datasource 的決策訊號、storage layer 本身不解。Aurora 共享 storage 提供 lag 上限可預測（10-30ms）— 這是 read replica 變成「production-grade 可用」的前提、但讀寫分流要 application 端拆 read / write data source 才能落地。Storage 設計給的是「可預測的 lag 上限」、不是「自動讀寫分離」。</p>
<p><strong>跨 case 對照</strong>：</p>
<p><a href="/blog/backend/09-performance-capacity/cases/fanduel-dual-peak-betting-streaming/" data-link-title="9.C28 FanDuel：體育直播 &#43; 投注的雙重峰值" data-link-desc="FanDuel 3.5M MAU、Super Bowl 期間擴容 5-10 倍、用 AWS Local Zones &#43; Wavelength &#43; Outposts 處理 20&#43; 州的雙重峰值">9.C28 FanDuel</a> 揭露另一種雙峰 — 直播 + 投注 <em>兩種服務</em> 同時峰、不是同服務讀寫錯位。這兩種雙峰類型要分清楚：</p>
<ul>
<li>同服務讀寫錯位（DraftKings）：解法是 read / write data source 拆分、共享 Aurora cluster</li>
<li>跨服務雙峰（FanDuel）：解法是不同服務各自獨立擴容、betting 走 Aurora、streaming 走 CDN</li>
</ul>
<p>雙峰類型不同、容量規劃策略不同。</p>
<h2 id="step-by-step-配置--觀測">Step-by-step 配置 / 觀測</h2>
<p>Aurora storage 是 cluster-level、不暴露 segment-level config。讀者能影響的維度是 instance class、storage type、backup retention 跟 monitoring。</p>
<p><strong>Cluster 建立</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">aws rds create-db-cluster <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --db-cluster-identifier my-cluster <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --engine aurora-postgresql <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --engine-version 15.5 <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  --master-username admin <span class="se">\
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="se"></span>  --master-user-password <span class="s2">&#34;</span><span class="k">$(</span>aws secretsmanager get-secret-value --secret-id db-password --query SecretString --output text<span class="k">)</span><span class="s2">&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="se"></span>  --storage-type aurora-iopt1 <span class="se">\
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="se"></span>  --backup-retention-period <span class="m">7</span></span></span></code></pre></div><p>關鍵欄位：</p>
<ul>
<li><code>--storage-type aurora-iopt1</code>：Aurora I/O-Optimized、月費高 30% 但無 I/O 收費；write-heavy + scan-heavy workload 才划算</li>
<li><code>--storage-type aurora</code>（預設）：Standard storage、按 I/O 計費；read-light workload 划算</li>
<li><code>--backup-retention-period 7</code>：1-35 天、影響 PITR 範圍</li>
</ul>
<p><strong>觀測 storage 狀態</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">aws rds describe-db-clusters <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --db-cluster-identifier my-cluster <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --query <span class="s1">&#39;DBClusters[0].{StorageType:StorageType,AllocatedStorage:AllocatedStorage,Status:Status}&#39;</span></span></span></code></pre></div><p><strong>CloudWatch metric</strong>（cluster-level）：</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">VolumeBytesUsed           # 當前 storage 用量、接近 128 TB 上限要警告
</span></span><span class="line"><span class="ln">2</span><span class="cl">VolumeReadIOPs            # storage 層讀 IOPS、判斷 I/O-Optimized ROI
</span></span><span class="line"><span class="ln">3</span><span class="cl">VolumeWriteIOPs           # storage 層寫 IOPS、跟 compute 層 WriteIOPS 對照
</span></span><span class="line"><span class="ln">4</span><span class="cl">AuroraVolumeBytesLeftTotal # 剩餘可用 storage</span></span></code></pre></div><p><strong>Performance Insights wait event</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">db.IO.aurora_redo_log_flush   # quorum write 等待訊號、p99 &gt; 10ms 要看
</span></span><span class="line"><span class="ln">2</span><span class="cl">db.IO.aurora_storage_xx       # storage layer I/O 細節</span></span></code></pre></div><p><strong>驗證點</strong>：</p>
<ul>
<li>寫入 latency p99：PostgreSQL primary 1-3ms vs Aurora 3-6ms、跨 AZ network round-trip 是物理下界</li>
<li>Read latency p99：Aurora &lt; 1ms（從共享 storage 讀、不跨 AZ）</li>
<li>Storage autoscale event：128 TB 上限前自動 grow per 10GB</li>
</ul>
<p><strong>Rollback boundary</strong>：Aurora storage 是 cluster-level、無法回滾 storage 設計；唯一 rollback 是切回 RDS / 自管（走 migration playbook、不是配置層 rollback）。</p>
<h2 id="故障模式--邊界-case">故障模式 / 邊界 case</h2>
<h3 id="case-1誤以為-aurora-寫入一定比-postgresql-primary-快">Case 1：誤以為 Aurora 寫入一定比 PostgreSQL primary 快</h3>
<p>徵兆：團隊期待 Aurora 寫入比自管 PostgreSQL 快、實測 p99 寫入 latency 沒明顯改善、甚至小 row + 單筆 commit 場景 Aurora 反而慢。</p>
<p>原因：跨 AZ network round-trip 是 3-5ms 物理下界、4-of-6 quorum 至少要等 4 個 storage node ack、單筆小寫場景 local SSD primary 仍有 latency 優勢。Aurora 的寫入優勢在 <em>壓力下</em> 才顯現 — write throughput 高峰時 PostgreSQL primary 受限於 dirty page flush + WAL fsync + replica catch-up、Aurora 的 storage layer 各自獨立處理 redo log apply。</p>
<blockquote>
<p><strong>數字口徑</strong>：「跨 AZ round-trip 3-5ms」屬通用工程估算（光速下界 + AWS 區內 AZ 物理距離）、case 未直接量化、實際值依 region / AZ pair / instance 類型而異、要看 AWS 官方 latency table 或自家 benchmark 校正。下方 DraftKings 6ms 寫入是 case 揭露的 production reference、可作為對照基線。</p></blockquote>
<p>修：</p>
<ul>
<li>benchmark 要跑壓力測試、不能只測單筆 latency</li>
<li>寫入 latency 不是 Aurora 的核心賣點、是 <em>可預測的 read replica lag + 韌性</em> 才是</li>
<li>DraftKings 6ms 寫入是 production reference：跨 AZ quorum 的物理下界、不是 Aurora 慢</li>
</ul>
<h3 id="case-2az-level-outage-期間寫入-latency-spike">Case 2：AZ-level outage 期間寫入 latency spike</h3>
<p>徵兆：1 個 AZ 失效後、寫入 p99 從 6ms spike 到 30-50ms、application timeout 增加。</p>
<p>原因：失去 1 AZ 後 quorum 仍成立（4-of-6 → 用剩 4 個 node 寫）、但 storage node fault 期間需要等 timeout 才確認；單一 storage node 額外 fault 會把寫推到 timeout。Aurora 在 AZ outage 期間 <em>能寫</em>、但不是 <em>性能不變</em>。</p>
<p>修：</p>
<ul>
<li>監測 <code>AuroraVolumeBytesLeftTotal</code> 跟 storage IOPS 分布、AZ outage 期間自動切到剩餘 AZ</li>
<li>application 端做 retry + circuit breaker、不要假設寫入永遠 6ms</li>
<li>確認 cluster 至少跨 3 AZ deploy、單 AZ outage 才有 quorum 餘地</li>
</ul>
<h3 id="case-3io-optimized-費用誤判">Case 3：I/O-Optimized 費用誤判</h3>
<p>徵兆：team 看 Aurora I/O-Optimized「無 I/O 收費」直接切過去、月帳變高 25%、沒看到 ROI。</p>
<p>原因：Standard storage 按 I/O 收費、I/O-Optimized 月費比 Standard 高 30%。只有 <em>write-heavy + scan-heavy</em> workload（I/O 月費接近 instance 費用）才划算；read-light + write-light workload 反而吃虧。</p>
<p>修：</p>
<ul>
<li>先量測 baseline I/O：<code>VolumeReadIOPs + VolumeWriteIOPs × $0.20 per million I/O</code> vs Standard 月費</li>
<li>I/O 費用 &gt; instance 費用 30% 才切 I/O-Optimized</li>
<li>DraftKings 用 I/O-Optimized 是因為金融帳本 write-heavy + balance query scan-heavy、ROI 明顯</li>
</ul>
<h3 id="case-4storage-autoscale-假設">Case 4：Storage autoscale 假設</h3>
<p>徵兆：TRUNCATE / DROP 大表釋放 50% storage、但下月帳單沒回落。</p>
<p>原因：Aurora storage 自動 grow、但 <em>不自動 shrink</em>。已分配的 storage 持續計費、TRUNCATE / DROP 只釋放 logical space、physical storage 仍占用。要 shrink 必須走 logical migration（dump / restore 到新 cluster）。</p>
<p>修：</p>
<ul>
<li>大量 DROP 操作前先評估是否值得做 logical migration</li>
<li>用 partition + DETACH 而非 DROP TABLE、partition 可以單獨 archive</li>
<li>接受 storage 用量是 <em>peak watermark</em> 而非 <em>current usage</em></li>
</ul>
<h3 id="case-5replication-lag-誤解">Case 5：Replication lag 誤解</h3>
<p>徵兆：read replica lag 10-30ms 看起來夠快、application 假設 read-after-write consistency、用戶下注後立刻查 balance 偶發看到舊資料。</p>
<p>原因：10-30ms 是 <em>typical</em>、heavy write + slow query 期間可能秒級。Aurora 共享 storage 設計讓 lag <em>可預測</em>（不會像 PostgreSQL streaming replication unbounded）、但 <em>可預測</em> 不等於 <em>zero</em>。Read-after-write 場景仍需要 application 端處理。</p>
<p>修：</p>
<ul>
<li>用戶寫操作後 N 秒內走 primary（N 由 lag p99 決定、典型 100ms）</li>
<li>Aurora 提供 session pinning：寫完同 session 短期內走 primary</li>
<li>不能假設「Aurora replication lag 小到可以忽略」、要看 application 容忍度</li>
</ul>
<h2 id="容量與觀測">容量與觀測</h2>
<p><strong>核心 metric</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">VolumeBytesUsed           # storage 用量、128 TB 上限預警
</span></span><span class="line"><span class="ln">2</span><span class="cl">AuroraReplicaLag          # replica lag、判斷讀寫分流可行性
</span></span><span class="line"><span class="ln">3</span><span class="cl">db.IO.aurora_redo_log_flush # quorum write 等待、storage 瓶頸訊號</span></span></code></pre></div><p><strong>Production reference number</strong>（<a href="/blog/backend/09-performance-capacity/cases/draftkings-aurora-financial-ledger/" data-link-title="9.C4 DraftKings：Aurora 撐 100 萬 ops/min 的體育博彩金融帳本" data-link-desc="DraftKings 用 Aurora MySQL 跑體育博彩金融帳本、Super Bowl 流量 &#43;50% 不影響延遲">9.C4 DraftKings</a> 揭露、case「觀察」段表格）：</p>
<table>
  <thead>
      <tr>
          <th>指標</th>
          <th>DraftKings 在 Aurora MySQL 的數字</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>讀延遲</td>
          <td>&lt; 1 ms</td>
      </tr>
      <tr>
          <td>寫延遲</td>
          <td>6 ms</td>
      </tr>
      <tr>
          <td>Replication lag</td>
          <td>從 30 秒降到 10-30 ms</td>
      </tr>
  </tbody>
</table>
<p>這個 production reference 取代用「typical 3-5ms」籠統說法。讀寫 6x 差距是 OLTP 容量規劃槓桿 baseline — 寫延遲是 quorum 4-of-6 + 跨 AZ network round-trip 的物理下界、不是 storage 設計能再壓低。引用時要明示是 DraftKings production reference、不是 Aurora marketing。</p>
<p><strong>容量上限</strong>：</p>
<ul>
<li>128 TB / cluster（超過要拆 cluster、見 <a href="../read-replica-scaling/">Aurora read replica scaling</a> fleet 治理 SSoT）</li>
<li>15 read replica / region（<a href="../read-replica-scaling/">Aurora read replica scaling</a> 展開）</li>
<li>Storage 自動 grow per 10GB</li>
</ul>
<p><strong>跨 region replication</strong>：<a href="../global-database-multi-region/">Aurora Global Database</a> 用 <code>AuroraGlobalDBReplicationLag</code> 監測、&lt; 1 秒 typical。</p>
<p><strong>回路徑</strong>：<a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">4.20 Observability Evidence Package</a> 抽 CloudWatch evidence、<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> 判斷 storage-bound vs compute-bound。</p>
<h2 id="netflix-75-效能改善的根因">Netflix +75% 效能改善的根因</h2>
<p><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 consolidation</a> 案例揭露 storage 設計的具體效能含義。Netflix 把多套 RDBMS（PostgreSQL / MySQL / Oracle）統一到 Aurora、拿到 <em>up to 75%</em> 效能改善、-28% 成本。</p>
<p><strong>+75% 的根因</strong>：</p>
<ul>
<li>傳統 PostgreSQL primary 寫 WAL + dirty page flush + 透過 streaming replication 推到 replica</li>
<li>Compute 大量 CPU 用在 dirty page flush + replication encoding、不是用在 query processing</li>
<li>Aurora compute 只送 redo log records、storage 自己 apply page、自己 checkpoint</li>
<li>→ 同樣 instance class 下、Aurora compute 能處理更多 query</li>
</ul>
<p>這不是 marketing 的「分散式儲存讓效能提升」籠統說法、而是具體的 <em>compute 不再 flush dirty page</em>。</p>
<p><strong>scope warning（必明示、case 自帶警示原話）</strong>：</p>
<p>「effective 75% improvement 是跨多 workload 的最大改善幅度、不是『每個 workload 都 +75%』。實際每個 workload 改善幅度從 10% 到 75% 不等」（case「需要警惕」段第 1 點）。</p>
<p>引用 Netflix 時不能把 75% 套到單一 workload — 容量規劃要看自家 workload 形狀（write-heavy / read-heavy / scan-heavy）、預估改善幅度範圍而非單一數字。</p>
<h2 id="fleet-治理cross-link不展開">Fleet 治理（cross-link、不展開）</h2>
<p>Production scale 不是「單一巨型 Aurora cluster」而是 <em>fleet of clusters</em> — 5 case 揭露同一 frame：</p>
<ul>
<li>DraftKings 200 個獨立 cluster（按業務切分）</li>
<li>Netflix 多 cluster（微服務私有 store）</li>
<li>Standard Chartered 7 個 cluster（受監管市場 boundary）</li>
</ul>
<p>跨 case 合成的 fleet 拓樸 3 條 driver（business sharding / microservice ownership / 合規市場 boundary）跟「何時拆 cluster vs 加 replica」的判讀順序、SSoT 在 <a href="../read-replica-scaling/">Aurora read replica scaling</a> 邊界段。Storage 設計本身不解 fleet 邊界決策 — Aurora 解 single-cluster scaling（quorum / 共享 storage / 共享 backup）、但「拆幾個 cluster」是業務拓樸決策。</p>
<h2 id="邊界與整合--下一步">邊界與整合 / 下一步</h2>
<p><strong>Sibling deep articles</strong>：</p>
<ul>
<li><a href="../cross-az-failover-rto/">Aurora cross-AZ failover RTO</a> — storage 設計如何加速 failover（replica 不需要 catch-up）</li>
<li><a href="../read-replica-scaling/">Aurora read replica scaling</a> — 共享 storage 為什麼能養 15 replica + fleet 治理 SSoT</li>
<li><a href="../global-database-multi-region/">Aurora Global Database</a> — 跨 region storage replication 設計</li>
</ul>
<p><strong>Migration playbook</strong>：</p>
<ul>
<li><a href="../migrate-from-self-managed-pg-mysql/">PostgreSQL / MySQL → Aurora</a> — storage 設計差是 operational redesign 的核心 driver</li>
</ul>
<p><strong>1.x 章節互引</strong>：</p>
<ul>
<li><a href="/blog/backend/01-database/transaction-boundary/" data-link-title="1.3 Transaction 與一致性邊界" data-link-desc="交易邊界、isolation level、retry 策略、distributed transaction（2PC、Saga）與跨 region 強一致取捨">1.3 Transaction Boundary</a> — quorum 寫入 vs single-primary transaction 邊界</li>
<li><a href="/blog/backend/01-database/global-distributed-oltp/" data-link-title="1.11 全球分散式 OLTP" data-link-desc="Spanner / Aurora DSQL / Cosmos DB multi-region write / CockroachDB / TiDB 的全球一致性取捨">1.11 全球分散式 OLTP</a> — Aurora storage 是 single-region scaling、不是 distributed SQL</li>
</ul>
<p><strong>何時不用本文</strong>：single-region OLTP 用 RDS 仍足夠、storage architecture 細節不影響容量規劃時可跳過、看 <a href="/blog/backend/01-database/vendors/aurora/" data-link-title="AWS Aurora" data-link-desc="AWS managed PostgreSQL / MySQL、storage / compute 分離、&#43;75% 效能改善的 production 證據">Aurora vendor overview</a> 即可。</p>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/aurora/" data-link-title="AWS Aurora" data-link-desc="AWS managed PostgreSQL / MySQL、storage / compute 分離、&#43;75% 效能改善的 production 證據">Aurora vendor overview</a> — 服務定位、適用 / 不適用場景</li>
<li><a href="/blog/backend/knowledge-cards/quorum/" data-link-title="Quorum" data-link-desc="分散式系統以多數節點同意作為提交或讀取有效性的門檻">Quorum 卡片</a> — 概念基底</li>
<li><a href="/blog/backend/knowledge-cards/replication-lag/" data-link-title="Replication Lag" data-link-desc="說明資料副本落後正式來源多久，以及它如何影響讀取正確性">Replication Lag 卡片</a> — 對照通用 replication lag 模型</li>
<li><a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章方法論</a> — 本文遵循的 6 規格面寫作模板</li>
<li>官方：<a href="https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/Aurora.Overview.StorageReliability.html">Aurora storage architecture</a></li>
</ul>
]]></content:encoded></item><item><title>CockroachDB HLC + Raft Consensus：軟體時鐘 + per-range 共識的 latency 與容量結構</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/cockroachdb/hlc-raft-consensus/</link><pubDate>Wed, 27 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/cockroachdb/hlc-raft-consensus/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/cockroachdb/" data-link-title="CockroachDB" data-link-desc="分散式 SQL、PostgreSQL 相容、跨區強一致、Spanner 的開源 / 跨雲替代">CockroachDB vendor overview&lt;/a> 的 implementation-layer deep article。Overview 已界定 CockroachDB 在 distributed SQL 譜系的定位、本文聚焦 &lt;em>HLC + Raft + range + leaseholder 四層機制&lt;/em> — 解釋為什麼 distributed SQL 的 latency / 容量曲線跟 PostgreSQL single-primary 完全不同、以及怎麼從 production 訊號倒推它對團隊的成本結構。寫作參照 &lt;a href="https://tarrragon.github.io/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">vendor deep article methodology&lt;/a>。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="為什麼這篇先講-hlc--raft">為什麼這篇先講 HLC + Raft&lt;/h2>
&lt;p>團隊評估 CockroachDB 替代 PostgreSQL streaming replication 時、會同時看到兩個訊號：「跨 region 強一致」很吸引人、「每次寫都經過 Raft majority」又讓人害怕。前者是賣點、後者是成本結構 — 不先把 HLC / Raft / range / leaseholder 拆清楚、後面講 survival goal、locality、transaction retry 都會卡在「為什麼這個機制存在」這層。&lt;/p>
&lt;p>讀者最常問的三題：&lt;/p>
&lt;ul>
&lt;li>Spanner 用 TrueTime 原子鐘做線性化、CockroachDB 沒硬體時鐘怎麼保證 ordering？&lt;/li>
&lt;li>Raft 每次寫要等 majority ack、不是比 PostgreSQL 慢得多？&lt;/li>
&lt;li>HLC clock skew 超出容忍區間時會發生什麼？節點隨機 panic 嗎？&lt;/li>
&lt;/ul>
&lt;p>三題都不只是 spec 問題、而是 &lt;em>production 容量規劃跟 incident 訊號的根本前置&lt;/em>。&lt;/p>
&lt;p>問題情境最常見的 trigger：&lt;a href="https://tarrragon.github.io/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&lt;/a> 在 2020-04-17 高峰 Aurora Postgres 撞到 1.636 M QPS、multi-hour outage。&lt;strong>這個數字是 Aurora 在那個時間點撞牆的痛點、case 自己警示「不是 CockroachDB 撐到 1.636 M QPS 的 throughput claim」&lt;/strong>。case 沒揭露遷移後單一 CockroachDB cluster 的峰值、只說「跑更多 cluster、alert volume 反而下降」。要把 CockroachDB 當寫入容量解法評估、就得先理解 Raft per range 怎麼把寫入從 single-primary 分散到多 node。&lt;/p>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/netflix-cockroachdb-multi-region-fleet/" data-link-title="9.C40 Netflix：380&amp;#43; CockroachDB cluster 的 multi-active 拓樸艦隊" data-link-desc="Netflix 把 Cassandra 不夠用的 transactional workload 移到 CockroachDB、380&amp;#43; cluster / 60&amp;#43; 跨 region、含 Open Connect、studio cloud drive、gaming control plane">9.C40 Netflix&lt;/a> 則提供另一條訊號：380+ cluster / 60+ multi-region、最大單區 cluster 60 nodes / 26.5 TB。這個規模證明 Raft 維運在 production 可承擔、但也揭露容量規劃顆粒不是「全公司一條容量曲線」、是「每 cluster 各自規劃」— artery of small DBs。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/cockroachdb/" data-link-title="CockroachDB" data-link-desc="分散式 SQL、PostgreSQL 相容、跨區強一致、Spanner 的開源 / 跨雲替代">CockroachDB vendor overview</a> 的 implementation-layer deep article。Overview 已界定 CockroachDB 在 distributed SQL 譜系的定位、本文聚焦 <em>HLC + Raft + range + leaseholder 四層機制</em> — 解釋為什麼 distributed SQL 的 latency / 容量曲線跟 PostgreSQL single-primary 完全不同、以及怎麼從 production 訊號倒推它對團隊的成本結構。寫作參照 <a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">vendor deep article methodology</a>。</p></blockquote>
<hr>
<h2 id="為什麼這篇先講-hlc--raft">為什麼這篇先講 HLC + Raft</h2>
<p>團隊評估 CockroachDB 替代 PostgreSQL streaming replication 時、會同時看到兩個訊號：「跨 region 強一致」很吸引人、「每次寫都經過 Raft majority」又讓人害怕。前者是賣點、後者是成本結構 — 不先把 HLC / Raft / range / leaseholder 拆清楚、後面講 survival goal、locality、transaction retry 都會卡在「為什麼這個機制存在」這層。</p>
<p>讀者最常問的三題：</p>
<ul>
<li>Spanner 用 TrueTime 原子鐘做線性化、CockroachDB 沒硬體時鐘怎麼保證 ordering？</li>
<li>Raft 每次寫要等 majority ack、不是比 PostgreSQL 慢得多？</li>
<li>HLC clock skew 超出容忍區間時會發生什麼？節點隨機 panic 嗎？</li>
</ul>
<p>三題都不只是 spec 問題、而是 <em>production 容量規劃跟 incident 訊號的根本前置</em>。</p>
<p>問題情境最常見的 trigger：<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</a> 在 2020-04-17 高峰 Aurora Postgres 撞到 1.636 M QPS、multi-hour outage。<strong>這個數字是 Aurora 在那個時間點撞牆的痛點、case 自己警示「不是 CockroachDB 撐到 1.636 M QPS 的 throughput claim」</strong>。case 沒揭露遷移後單一 CockroachDB cluster 的峰值、只說「跑更多 cluster、alert volume 反而下降」。要把 CockroachDB 當寫入容量解法評估、就得先理解 Raft per range 怎麼把寫入從 single-primary 分散到多 node。</p>
<p><a href="/blog/backend/09-performance-capacity/cases/netflix-cockroachdb-multi-region-fleet/" data-link-title="9.C40 Netflix：380&#43; CockroachDB cluster 的 multi-active 拓樸艦隊" data-link-desc="Netflix 把 Cassandra 不夠用的 transactional workload 移到 CockroachDB、380&#43; cluster / 60&#43; 跨 region、含 Open Connect、studio cloud drive、gaming control plane">9.C40 Netflix</a> 則提供另一條訊號：380+ cluster / 60+ multi-region、最大單區 cluster 60 nodes / 26.5 TB。這個規模證明 Raft 維運在 production 可承擔、但也揭露容量規劃顆粒不是「全公司一條容量曲線」、是「每 cluster 各自規劃」— artery of small DBs。</p>
<h2 id="核心機制hlc--raft--range--leaseholder-四層">核心機制：HLC + Raft + range + leaseholder 四層</h2>
<p>CockroachDB 的線性化保證來自四層機制疊加、缺一層都解釋不通實際 latency / failure 行為。</p>
<h3 id="hlc軟體時鐘把-wall-clock--logical-counter-混在一起">HLC：軟體時鐘把 wall clock + logical counter 混在一起</h3>
<p><a href="/blog/backend/knowledge-cards/hybrid-logical-clock/" data-link-title="Hybrid Logical Clock" data-link-desc="用 physical wall clock &#43; monotonic logical counter 給每個事件 timestamp、靠軟體 max-offset 保證跨節點時鐘差不超過上限、超過 panic 保護一致性">Hybrid Logical Clock</a> 結合 <em>physical time</em>（NTP 同步的牆鐘）跟 <em>logical counter</em>（單調遞增的事件序號）、給每個事件一個 <code>(physical, logical)</code> timestamp。對比 Spanner TrueTime 直接靠 GPS + atomic clock 給「時鐘 uncertainty bound」、CockroachDB HLC 不依賴硬體、用軟體保證「節點之間時鐘最多差 <code>max-offset</code>（default 500ms）、超過就 panic」。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">Node A 收到 write at wall=12:00:00.123, last_seen=12:00:00.100
</span></span><span class="line"><span class="ln">2</span><span class="cl">  → HLC = (12:00:00.123, 0)
</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">Node A 收到 RPC from B at wall=12:00:00.140, B.HLC=(12:00:00.200, 5)
</span></span><span class="line"><span class="ln">5</span><span class="cl">  → A 跳到 B 的 physical (12:00:00.200)、logical = 6
</span></span><span class="line"><span class="ln">6</span><span class="cl">  → HLC = (12:00:00.200, 6)</span></span></code></pre></div><p>HLC 的契約 <em>只要節點間時鐘差不超過 max-offset、所有 transaction 仍是 linearizable</em>。production 必跑 NTP / chronyd — 一旦本機時鐘飄超過 500ms、節點自動 panic 保護 cluster 一致性、不會發出錯誤 commit。</p>
<p>跟 Spanner TrueTime 對比：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>CockroachDB HLC</th>
          <th>Spanner TrueTime</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>硬體依賴</td>
          <td>無（純軟體 + NTP）</td>
          <td>GPS + atomic clock（每資料中心配）</td>
      </tr>
      <tr>
          <td>Uncertainty</td>
          <td>由 max-offset 上界、固定 500ms</td>
          <td>動態 uncertainty interval（通常 &lt; 7ms）</td>
      </tr>
      <tr>
          <td>Commit 等待</td>
          <td>不需要 wait out uncertainty</td>
          <td>需要 wait out（commit-wait）</td>
      </tr>
      <tr>
          <td>部署彈性</td>
          <td>任何雲 / on-prem 都可跑</td>
          <td>只在有 TrueTime infra 的 GCP region</td>
      </tr>
  </tbody>
</table>
<p>兩條路徑解同一個 <em>event ordering</em> 問題、用不同 trade-off。CockroachDB 把硬體成本換成軟體 max-offset 容忍度、結果是「可以跨雲跨 on-prem 跑、但 NTP 維運是必要條件」。</p>
<h3 id="raft每個-range-一個獨立的-majority-consensus-group">Raft：每個 range 一個獨立的 majority consensus group</h3>
<p>Raft 把寫入流程切成 <em>propose → replicate to majority → commit</em> 三段。每個 range 維護自己的 Raft group、預設 3 replica、寫入要至少 2 個 replica ack 才能 commit。</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">Client → Leaseholder (Raft leader)
</span></span><span class="line"><span class="ln">2</span><span class="cl">   1. Propose log entry (write intent)
</span></span><span class="line"><span class="ln">3</span><span class="cl">   2. Replicate to 2 follower replicas
</span></span><span class="line"><span class="ln">4</span><span class="cl">   3. Wait for majority ack (本身 + 1 個 follower)
</span></span><span class="line"><span class="ln">5</span><span class="cl">   4. Commit、apply to state machine
</span></span><span class="line"><span class="ln">6</span><span class="cl">   5. Reply to client</span></span></code></pre></div><p>關鍵差異跟 PostgreSQL streaming replication 比：</p>
<ul>
<li>PostgreSQL primary：1 個節點 ack 就 commit（async replication）、replica 可能落後</li>
<li>PostgreSQL sync replication：1 個 standby ack 才 commit、但仍是「primary 是 single point of write」</li>
<li>CockroachDB Raft：majority（2 of 3）ack 才 commit、任何 replica 都可以是 leaseholder、寫入分散到所有節點</li>
</ul>
<p>寫入 latency 因此 <em>結構性</em> 高於 PostgreSQL — 多了一次 cross-node round trip。但寫入 <em>吞吐</em> 可以線性擴展、因為不同 range 的 Raft group 跑在不同節點上。</p>
<h3 id="range把-key-space-切成-512-mb-的可分裂單位">Range：把 key space 切成 ~512 MB 的可分裂單位</h3>
<p>CockroachDB 用 <a href="/blog/backend/knowledge-cards/range-sharding/" data-link-title="Range Sharding" data-link-desc="分散式 SQL 把 key space 切成可自動 split / merge 的 range、每個 range 自己的 consensus group、application 透明">Range Sharding</a> 把整個 key space 切成 range、每個 range 預設上限 ~512 MB、超過自動 split。每個 range 是一個獨立的 Raft group、有自己的 3 replica 分佈。</p>
<p>對比其他 distributed DB 的等價概念：</p>
<ul>
<li>DynamoDB partition：固定 hash 分區、自動 split 但 hot partition 容易撞 ceiling</li>
<li>Spanner split：類似 range、但配置 / placement 語法不同</li>
<li>Vitess keyspace：application 端決定 shard key、不透明 split</li>
</ul>
<p>CockroachDB range 是 <em>系統內建透明</em> 的 — application 只看到 SQL table、不需要 shard key 設計。但 hot range 仍會發生（後面 failure mode 段展開）。</p>
<h3 id="leaseholder每個-range-的-read--write-entry-point">Leaseholder：每個 range 的 read / write entry point</h3>
<p>每個 range 在任一時間點有一個 <a href="/blog/backend/knowledge-cards/leaseholder/" data-link-title="Leaseholder" data-link-desc="分散式 SQL 每個 range 在任一時間點的 read / write entry point、通常等於 Raft leader、承擔該 range 的 coordination">Leaseholder</a>（通常等於 Raft leader）、承擔該 range 的所有 read / write coordination。leaseholder 也是 <em>follower read</em> 的 timestamp 邊界 holder。</p>
<p>leaseholder 概念對 production 訊號的影響：</p>
<ul>
<li>寫入 latency 主要來自 leaseholder → follower replicas 的 Raft round trip</li>
<li>leaseholder 集中在某節點 → 該節點 CPU 飽和（hot range 的根因之一）</li>
<li>leaseholder 換手（lease transfer）短期 p99 spike — rebalance 期間 / 節點 graceful drain 都會觸發</li>
</ul>
<h2 id="操作流程配置--驗證--rollback-邊界">操作流程：配置 + 驗證 + rollback 邊界</h2>
<h3 id="cluster-起手配置">Cluster 起手配置</h3>
<p>最小可運行配置是 3 節點（Raft quorum 下界）、production 通常 9 節點以上（3 region × 3 replica）。每個節點啟動時必須帶 locality tag、讓 Raft placement 知道副本怎麼分佈：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">cockroach start --insecure <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --locality<span class="o">=</span><span class="nv">region</span><span class="o">=</span>us-east1,zone<span class="o">=</span>us-east1-a <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --max-offset<span class="o">=</span>500ms <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --join<span class="o">=</span>node1:26257,node2:26257,node3:26257</span></span></code></pre></div><p><code>--max-offset</code> 是 HLC 容忍上界、超過會 panic — 不要為了「避免 panic」加大這個值、會犧牲 linearizability 保證。</p>
<p>NTP / chronyd 是 <em>必要前置</em>、不是 nice-to-have。production 應該在每個節點配置：</p>
<ul>
<li>NTP server 至少 3 個獨立 source（避免單一 server drift）</li>
<li>監控 <code>chronyc tracking</code> 的 offset、超過 100ms 就應該 alert（遠在 500ms panic 邊界之前）</li>
</ul>
<h3 id="驗證點">驗證點</h3>





<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">-- 看每節點當前 clock offset 跟 cluster 其他節點
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">node_id</span><span class="p">,</span><span class="w"> </span><span class="n">address</span><span class="p">,</span><span class="w"> </span><span class="n">offset_min_nanos</span><span class="p">,</span><span class="w"> </span><span class="n">offset_max_nanos</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="n">crdb_internal</span><span class="p">.</span><span class="n">gossip_nodes</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></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w"></span><span class="c1">-- 看 Raft 健康（每個 range 的 leaseholder 跟 replica 分佈）
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">range_id</span><span class="p">,</span><span class="w"> </span><span class="n">lease_holder</span><span class="p">,</span><span class="w"> </span><span class="n">replicas</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">FROM</span><span class="w"> </span><span class="n">crdb_internal</span><span class="p">.</span><span class="n">ranges</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="k">table_name</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;orders&#39;</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">LIMIT</span><span class="w"> </span><span class="mi">5</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></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w"></span><span class="c1">-- 看 cluster max-offset 設定
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"></span><span class="k">SHOW</span><span class="w"> </span><span class="k">CLUSTER</span><span class="w"> </span><span class="n">SETTING</span><span class="w"> </span><span class="n">server</span><span class="p">.</span><span class="n">clock</span><span class="p">.</span><span class="n">persist_upper_bound_interval</span><span class="p">;</span></span></span></code></pre></div><h3 id="rollback-邊界">Rollback 邊界</h3>
<p>HLC + Raft 對 rollback 的態度跟 PostgreSQL 不同：</p>
<ul>
<li>HLC 時鐘前進不可回滾 — 不能「改一下 max-offset 後重啟試試看」</li>
<li>Raft commit 不可回滾 — 一旦 majority ack、log entry 持久化</li>
<li>想還原業務狀態 <em>只能新交易補償</em>、不能 reverse Raft log</li>
</ul>
<p>實務上的影響：incident 時不要嘗試「強制回到舊版本」、應該走 transaction-level rollback / compensation。對應 <a href="/blog/backend/knowledge-cards/transaction-boundary/" data-link-title="Transaction Boundary" data-link-desc="說明哪些資料變更應在同一個交易中一起成功或一起回復">transaction boundary 卡</a> 跟業務層補償設計。</p>
<h2 id="失敗模式clock-skew--majority-lost--hot-range--retry-storm">失敗模式：clock skew / majority lost / hot range / retry storm</h2>
<h3 id="clock-skew-panic">Clock skew panic</h3>
<p>最常見：NTP 服務掛、節點時鐘漂移超過 max-offset、節點自動 panic。production incident 訊號：</p>
<ul>
<li><code>chronyc tracking</code> 顯示 offset 持續成長</li>
<li>CockroachDB log 出現 <code>clock synchronization error</code></li>
<li>Prometheus metric <code>clock_offset_meannanos</code> 接近 max-offset</li>
</ul>
<p>修法：先恢復 NTP service、節點重啟前再次驗證時鐘已同步、不要動 <code>--max-offset</code>。對比 PostgreSQL primary 不關心 time skew、distributed SQL 把時鐘變成 first-class operational concern。</p>
<h3 id="raft-majority-lost">Raft majority lost</h3>
<p>3 節點 cluster 失去 2 個、剩 1 個無法 commit、cluster 全 read-only（甚至連 read 都可能受影響、因為 leaseholder 拿不到 valid lease）。對比 PostgreSQL primary 失效後 streaming replica 仍可 read、CockroachDB 的 fault tolerance 是 <em>quorum-based</em>、不是 <em>primary-replica</em>。</p>
<p>production 規劃要點：跨 AZ / region 分佈時、必須保證任何 <em>單一 failure domain</em> 失敗後仍有 majority 存活。3 節點配 1 AZ → AZ 失敗 = cluster down。最小 production 配置是 3 AZ × 1 node 或 3 region × 3 node。</p>
<h3 id="hot-rangeleaseholder-節點-cpu-飽和">Hot range：leaseholder 節點 CPU 飽和</h3>
<p>某個 range 寫流量集中（例：訂單 table primary key 是時間序 / 自增 ID）、leaseholder 節點變成熱點。徵兆：</p>
<ul>
<li>CockroachDB Console「Leaseholder count per node」分佈不均</li>
<li>某節點 CPU 飽和、其他節點閒置</li>
<li><code>crdb_internal.ranges</code> 顯示該 range 的 QPS 遠高於其他 range</li>
</ul>
<p>修法：</p>
<ul>
<li>手動 <code>ALTER TABLE ... SPLIT AT VALUES (...)</code> 強制 split hot range</li>
<li>改 primary key 設計、避免時間序 / 自增 ID（用 UUID / hash-prefixed key）</li>
<li>partition by region、把 hot range 切到不同 region 的 leaseholder</li>
</ul>
<h3 id="transaction-retry-storm">Transaction retry storm</h3>
<p>serializable contention 嚴重時 application 端 retry loop、CPU 雪崩。這個議題的 application contract 重塑屬獨立議題、見 <a href="../transaction-retry-pattern/">transaction retry pattern</a>。</p>
<h3 id="range-split--rebalance-期間-p99-spike">Range split / rebalance 期間 p99 spike</h3>
<p>自動 split 大 range、leaseholder 換手期間有 ~100ms 的 lease transfer 視窗、p99 短期 spike。production 訊號：CockroachDB Console「Rebalance queue size」非零 + p99 latency 同期波動。一般是良性 — rebalance 完就回穩。但連續波動代表 range 在「split → 寫熱 → 再 split」循環、要從 schema 層解。</p>
<h2 id="容量與觀測per-cluster-顆粒--來源分層">容量與觀測：per-cluster 顆粒 + 來源分層</h2>
<h3 id="必看-metric">必看 metric</h3>
<ul>
<li><code>Raft log queue size</code>：Raft replication 延遲訊號、持續高代表 follower 跟不上</li>
<li><code>Range count per node</code>：range 分佈是否均勻、不均代表 placement 有偏</li>
<li><code>Leaseholder count per node</code>：leaseholder 分佈是否均勻、不均直接導致 CPU 熱點</li>
<li><code>HLC offset distribution</code>：時鐘同步健康</li>
<li><code>Transaction retry rate</code>：contention 訊號（細節在 <a href="../transaction-retry-pattern/">transaction retry pattern</a>）</li>
</ul>
<h3 id="per-cluster-容量規劃顆粒9c40-netflix-揭露f47">Per-cluster 容量規劃顆粒（9.C40 Netflix 揭露、F4.7）</h3>
<p>Netflix 的 380+ cluster 模型揭露一個反直覺結論：production scale 不是「全公司一條容量曲線」、而是 <em>artery of small DBs</em>。每個 cluster 對應一個 application boundary、cluster sizing 從幾個 node 到 60 nodes 不等、最大單區 60 nodes / 26.5 TB（case 觀察段表格揭露）。</p>
<p>容量規劃顆粒對齊 application boundary 的好處：</p>
<ul>
<li>每個 cluster 各自規劃 capacity、不必預測「全公司加總 QPS」</li>
<li>blast radius 限縮在單一 app — 某 cluster 撞 hot range / Raft majority lost、其他 cluster 不受影響</li>
<li>upgrade / backup 可分批跑、不必整廠 maintenance window</li>
</ul>
<p>但也帶來 ops 成本：380+ cluster 需要 <em>專屬 Database Platform Team</em>（含 backup、upgrade、incident response、capacity review）— Netflix case 直接揭露這個前置條件。沒這量級團隊就走 Cockroach Cloud managed、不要 self-host。</p>
<p>per-app cluster vs shared cluster 的決策軸主寫於 <a href="../aurora-dsql-spanner-decision-tree/">aurora-dsql-spanner-decision-tree</a>、本篇 cross-link 不展開。</p>
<h3 id="寫入-latency-預算屬通用工程估算case-未揭露具體數字">寫入 latency 預算（屬通用工程估算、case 未揭露具體數字）</h3>
<p>以下數字屬通用工程估算 / 物理光速下界推導、<strong>DoorDash / Netflix / Hard Rock 三個 direct case 都沒揭露單一 cluster p99 latency</strong>。引用時必須明示來源層次：</p>
<ul>
<li>single-region 3-replica write p99 3-5ms（通用估算、跨 AZ Raft round trip）</li>
<li>multi-region 跨洲 write p99 100-150ms（光速下界 — 跨洲 round trip 物理 ~70-80ms × 2）</li>
<li>單一 range 寫 throughput ~1000 QPS（通用估算、實際依 row size / contention 而定）</li>
<li>整 cluster scale-out 加 range、寫入吞吐近線性擴展（理論、實際依 hot range 分佈）</li>
</ul>
<p>這些是「合理的工程估算量級」、不是 case 揭露的 p99 數字。讀者用這些做容量規劃時、應該 <em>自己 benchmark</em> 而不是直接套。</p>
<h3 id="doordash-1636-m-qps-引用紀律f41case-自帶警示">DoorDash 1.636 M QPS 引用紀律（F4.1、case 自帶警示）</h3>
<p>DoorDash case 揭露的 1.636 M QPS 是 <em>Aurora Postgres single-primary 在 2020-04-17 高峰撞牆的痛點</em>（multi-hour outage）、<strong>不是 CockroachDB throughput claim</strong>。case 明確警告不要把這個數字當「CockroachDB 撐 1.636 M QPS 的證據」。case 沒揭露遷移後單一 CockroachDB cluster 的峰值、只說「跑更多 cluster、alert volume 反而下降」。</p>
<p>引用這個數字時的口徑：</p>
<ul>
<li>寫成「Aurora 撞牆訊號」、不寫成「CockroachDB 容量證明」</li>
<li>single-primary 撞牆的轉折點是 <em>primary CPU + WAL flush rate</em>（DoorDash 策略段 1）、不是 IOPS</li>
<li>「換引擎」前先評估「兩階段紓壓」— DoorDash 路徑是先把 hot table 拆到獨立 Aurora cluster（紓壓）、再規劃 Aurora → CockroachDB 換引擎（<a href="/blog/backend/01-database/database-migration-playbook/" data-link-title="1.6 資料庫轉換實作：雙寫、回填、切流與回滾" data-link-desc="同 DB 內 schema 演進與資料變更的可分段驗證流程、跟 1.12 cross-DB migration 分工">1.6 database migration playbook</a>）</li>
</ul>
<h3 id="回路徑">回路徑</h3>
<ul>
<li><a href="/blog/backend/09-performance-capacity/" data-link-title="模組九：效能工程與容量規劃" data-link-desc="把『目前配置能撐多少、要加多少機器』變成可量化、可驗證、可改進的工程流程">9.5 瓶頸定位流程</a> 判斷 Raft-bound vs storage-bound</li>
<li><a href="/blog/backend/09-performance-capacity/" data-link-title="模組九：效能工程與容量規劃" data-link-desc="把『目前配置能撐多少、要加多少機器』變成可量化、可驗證、可改進的工程流程">9.6 容量規劃模型</a> replication factor × latency budget</li>
<li><a href="/blog/backend/knowledge-cards/latency-budget/" data-link-title="Latency Budget" data-link-desc="把 user-perceived latency 拆到每個 stage 的配額、反推架構選擇">latency budget 卡</a> cross-region quorum 預算</li>
</ul>
<h2 id="邊界與整合">邊界與整合</h2>
<h3 id="sibling-deep-articles">Sibling deep articles</h3>
<ul>
<li><a href="../survival-goals/">CockroachDB survival goals</a>：Raft replica 怎麼分佈到 zone / region、決定 RTO / RPO</li>
<li><a href="../transaction-retry-pattern/">CockroachDB transaction retry pattern</a>：serializable default 對 application 契約的重塑</li>
<li><a href="../locality-aware-schema/">CockroachDB locality-aware schema</a>：range placement 控制 + locality 配置</li>
</ul>
<h3 id="跟-aurora-對照">跟 Aurora 對照</h3>
<p>Aurora 是 <em>storage-level quorum</em>（4 of 6 storage replica）、compute 仍是 single primary。CockroachDB 是 <em>range-level Raft</em>（每個 range 獨立 majority）、compute 跟 storage 在每節點。兩者解的是不同 layer 的 consensus、結果是 Aurora 寫入仍受 primary 限制、CockroachDB 寫入隨節點線性擴。</p>
<h3 id="aurora-dsql--spanner-對比">Aurora DSQL / Spanner 對比</h3>
<p>完整三家 distributed SQL 對比、撞牆訊號分型、PostgreSQL 相容性 audit、團隊規模 vs vendor sizing barrier 等議題在 <a href="../aurora-dsql-spanner-decision-tree/">aurora-dsql-spanner-decision-tree</a>。</p>
<h3 id="1x-章節互引">1.x 章節互引</h3>
<ul>
<li><a href="/blog/backend/01-database/global-distributed-oltp/" data-link-title="1.11 全球分散式 OLTP" data-link-desc="Spanner / Aurora DSQL / Cosmos DB multi-region write / CockroachDB / TiDB 的全球一致性取捨">1.11 全球分散式 OLTP</a> 上游選型</li>
<li><a href="/blog/backend/01-database/transaction-boundary/" data-link-title="1.3 Transaction 與一致性邊界" data-link-desc="交易邊界、isolation level、retry 策略、distributed transaction（2PC、Saga）與跨 region 強一致取捨">1.3 Transaction Boundary</a> distributed transaction 邊界</li>
</ul>
<h3 id="何時不用本文">何時不用本文</h3>
<ul>
<li>single-region OLTP + 寫入未撞 PostgreSQL primary 天花板 → PostgreSQL 已足夠</li>
<li>對 cross-region quorum 100-150ms latency 預算無法接受 → 走 async replication 路線</li>
<li>沒 NTP 維運能力 → distributed SQL 把時鐘變 ops concern、沒準備好不要硬上</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/cockroachdb/" data-link-title="CockroachDB" data-link-desc="分散式 SQL、PostgreSQL 相容、跨區強一致、Spanner 的開源 / 跨雲替代">CockroachDB vendor overview</a></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</a>（Aurora 1.636 M QPS 撞牆訊號）</li>
<li><a href="/blog/backend/09-performance-capacity/cases/netflix-cockroachdb-multi-region-fleet/" data-link-title="9.C40 Netflix：380&#43; CockroachDB cluster 的 multi-active 拓樸艦隊" data-link-desc="Netflix 把 Cassandra 不夠用的 transactional workload 移到 CockroachDB、380&#43; cluster / 60&#43; 跨 region、含 Open Connect、studio cloud drive、gaming control plane">9.C40 Netflix</a>（380+ cluster artery of small DBs）</li>
<li><a href="/blog/backend/09-performance-capacity/cases/spanner-planetary-scale-database-gcp/" data-link-title="9.C10 Cloud Spanner：每秒 10 億請求的全球一致性資料庫" data-link-desc="Google Cloud Spanner 內部峰值 10 億 req/sec、跨地區強一致 — 全球分散式 OLTP 容量參考">9.C10 Spanner planetary scale</a>（TrueTime 對照）</li>
<li><a href="/blog/backend/knowledge-cards/distributed-sql/" data-link-title="Distributed SQL" data-link-desc="把 SQL 與交易語意延伸到多節點與多區域的資料庫形態">distributed SQL 卡</a></li>
<li><a href="/blog/backend/knowledge-cards/quorum/" data-link-title="Quorum" data-link-desc="分散式系統以多數節點同意作為提交或讀取有效性的門檻">quorum 卡</a></li>
<li>官方：<a href="https://www.cockroachlabs.com/docs/stable/architecture/overview.html">CockroachDB Architecture</a> / <a href="https://cse.buffalo.edu/tech-reports/2014-04.pdf">Hybrid Logical Clocks paper</a> / <a href="https://raft.github.io/raft.pdf">Raft paper</a></li>
</ul>
]]></content:encoded></item><item><title>Cosmos DB MongoDB API vs SQL API：遷移路徑、dogfood signal、multi-model、跨雲 hedging</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/cosmosdb/mongodb-api-vs-sql-api/</link><pubDate>Wed, 27 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/cosmosdb/mongodb-api-vs-sql-api/</guid><description>&lt;p>Cosmos DB 提供 &lt;em>5 個 API&lt;/em>（SQL / MongoDB / Cassandra / Gremlin / Table）、底層是同一個分散式 document store。團隊從 MongoDB 來、第一個問題通常是「MongoDB API 跟 native SQL API 我選哪個」 — 但這個問題框架太窄。讀者真正在比的是 &lt;em>vendor selection&lt;/em>、不是兩個 API 的 syntax 差。本文把選型推到四層問題：(a) 你的遷移路徑屬於哪一型、(b) dogfood signal 怎麼讀、(c) multi-model 差異化是否真用上、(d) 跨雲 hedging 還是單雲 lock-in。先把四層 framing 講清楚、再進兩個 API 的機制差異、最後給 MongoDB → Cosmos DB MongoDB API 的 migration playbook。&lt;/p>
&lt;p>本文不是 Cosmos DB overview（請看 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/cosmosdb/" data-link-title="Azure Cosmos DB" data-link-desc="全球分散式 multi-model DB、5 個 consistency levels、Microsoft 自家 dogfood 證據">Cosmos DB vendor 頁&lt;/a>）— 而是 &lt;em>選型決策 + 遷移實作&lt;/em> 的深度展開。Case anchor 是 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/microsoft-365-cosmos-db-analytics/" data-link-title="9.C30 Microsoft 365：從 MongoDB 遷移到 Cosmos DB 的分析平台" data-link-desc="Microsoft 365 把使用分析平台從 MongoDB 遷移到 Cosmos DB、planet-scale 全球分散式分析">9.C30 Microsoft 365&lt;/a> — Microsoft 自家 dogfood、MongoDB → Cosmos DB MongoDB API 的 planet-scale 分析平台、提供四層 framing 的證據錨點。&lt;/p>
&lt;h2 id="問題情境選型問題不是兩個-api-哪個快">問題情境：選型問題不是「兩個 API 哪個快」&lt;/h2>
&lt;p>典型觸發場景：團隊已用 MongoDB（自管 或 Atlas）、評估遷到 Azure；Cosmos DB 提供 MongoDB API（wire protocol 相容）跟 native SQL API 兩條路；文件講「MongoDB API 是 wire compat、SQL API 是 native」、但這個敘述沒回答真實決策問題。&lt;/p>
&lt;p>讀者實際在問：&lt;/p>
&lt;ul>
&lt;li>「MongoDB API 我們的 aggregation pipeline 跑得起來嗎」&lt;/li>
&lt;li>「&lt;code>$lookup&lt;/code> 在 Cosmos DB MongoDB API 支援嗎」&lt;/li>
&lt;li>「change stream 跟 Change Feed 是同一回事嗎」&lt;/li>
&lt;li>「為什麼有人說 MongoDB API 只是過渡、最終要遷 SQL API」&lt;/li>
&lt;li>「Microsoft 自己選了 MongoDB API、是不是代表 MongoDB API 才是對的選擇」&lt;/li>
&lt;/ul>
&lt;p>這些問題背後的 &lt;em>真實壓力&lt;/em> 是 vendor selection：團隊已選 Azure、要決定「留 Atlas 還是進 Cosmos DB、進了 Cosmos DB 用哪個 API」、選錯的成本是 &lt;em>年級的工程遷移&lt;/em> — 不是 &lt;em>config 改不改&lt;/em> 等級。Microsoft 365 案例（&lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/microsoft-365-cosmos-db-analytics/" data-link-title="9.C30 Microsoft 365：從 MongoDB 遷移到 Cosmos DB 的分析平台" data-link-desc="Microsoft 365 把使用分析平台從 MongoDB 遷移到 Cosmos DB、planet-scale 全球分散式分析">9.C30&lt;/a>）從 MongoDB 遷到 Cosmos DB MongoDB API 是 dogfood、但 case 自承「沒有提具體 throughput、latency、cost 數字」— 引用時不能拿這個案例的「成功」當 benchmark、只能取它的 framing。&lt;/p></description><content:encoded><![CDATA[<p>Cosmos DB 提供 <em>5 個 API</em>（SQL / MongoDB / Cassandra / Gremlin / Table）、底層是同一個分散式 document store。團隊從 MongoDB 來、第一個問題通常是「MongoDB API 跟 native SQL API 我選哪個」 — 但這個問題框架太窄。讀者真正在比的是 <em>vendor selection</em>、不是兩個 API 的 syntax 差。本文把選型推到四層問題：(a) 你的遷移路徑屬於哪一型、(b) dogfood signal 怎麼讀、(c) multi-model 差異化是否真用上、(d) 跨雲 hedging 還是單雲 lock-in。先把四層 framing 講清楚、再進兩個 API 的機制差異、最後給 MongoDB → Cosmos DB MongoDB API 的 migration playbook。</p>
<p>本文不是 Cosmos DB overview（請看 <a href="/blog/backend/01-database/vendors/cosmosdb/" data-link-title="Azure Cosmos DB" data-link-desc="全球分散式 multi-model DB、5 個 consistency levels、Microsoft 自家 dogfood 證據">Cosmos DB vendor 頁</a>）— 而是 <em>選型決策 + 遷移實作</em> 的深度展開。Case anchor 是 <a href="/blog/backend/09-performance-capacity/cases/microsoft-365-cosmos-db-analytics/" data-link-title="9.C30 Microsoft 365：從 MongoDB 遷移到 Cosmos DB 的分析平台" data-link-desc="Microsoft 365 把使用分析平台從 MongoDB 遷移到 Cosmos DB、planet-scale 全球分散式分析">9.C30 Microsoft 365</a> — Microsoft 自家 dogfood、MongoDB → Cosmos DB MongoDB API 的 planet-scale 分析平台、提供四層 framing 的證據錨點。</p>
<h2 id="問題情境選型問題不是兩個-api-哪個快">問題情境：選型問題不是「兩個 API 哪個快」</h2>
<p>典型觸發場景：團隊已用 MongoDB（自管 或 Atlas）、評估遷到 Azure；Cosmos DB 提供 MongoDB API（wire protocol 相容）跟 native SQL API 兩條路；文件講「MongoDB API 是 wire compat、SQL API 是 native」、但這個敘述沒回答真實決策問題。</p>
<p>讀者實際在問：</p>
<ul>
<li>「MongoDB API 我們的 aggregation pipeline 跑得起來嗎」</li>
<li>「<code>$lookup</code> 在 Cosmos DB MongoDB API 支援嗎」</li>
<li>「change stream 跟 Change Feed 是同一回事嗎」</li>
<li>「為什麼有人說 MongoDB API 只是過渡、最終要遷 SQL API」</li>
<li>「Microsoft 自己選了 MongoDB API、是不是代表 MongoDB API 才是對的選擇」</li>
</ul>
<p>這些問題背後的 <em>真實壓力</em> 是 vendor selection：團隊已選 Azure、要決定「留 Atlas 還是進 Cosmos DB、進了 Cosmos DB 用哪個 API」、選錯的成本是 <em>年級的工程遷移</em> — 不是 <em>config 改不改</em> 等級。Microsoft 365 案例（<a href="/blog/backend/09-performance-capacity/cases/microsoft-365-cosmos-db-analytics/" data-link-title="9.C30 Microsoft 365：從 MongoDB 遷移到 Cosmos DB 的分析平台" data-link-desc="Microsoft 365 把使用分析平台從 MongoDB 遷移到 Cosmos DB、planet-scale 全球分散式分析">9.C30</a>）從 MongoDB 遷到 Cosmos DB MongoDB API 是 dogfood、但 case 自承「沒有提具體 throughput、latency、cost 數字」— 引用時不能拿這個案例的「成功」當 benchmark、只能取它的 framing。</p>
<h2 id="四層-framingvendor-selection-的真實決策軸">四層 framing：vendor selection 的真實決策軸</h2>
<h3 id="framing-1document-model-三型遷移路徑對照本章合成-frame">Framing 1：document model 三型遷移路徑對照（本章合成 frame）</h3>
<p>「MongoDB → Cosmos DB」是 <em>一種</em> 遷移、不是 <em>全部</em> 遷移。document model 的遷移路徑在 case 庫至少呈現三型、風險跟 ROI 完全不同：</p>
<table>
  <thead>
      <tr>
          <th>遷移型</th>
          <th>案例</th>
          <th>工程複雜度</th>
          <th>ROI</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>保留 + 補周邊</td>
          <td><a href="/blog/backend/09-performance-capacity/cases/coinbase-mongodb-document-platform/" data-link-title="9.C36 Coinbase：MongoDB 撐 Ruby 單體 &#43; 1.5M reads/sec identity 服務" data-link-desc="Coinbase 以 MongoDB 為主資料層、自建 mongobetween connection proxy、users 服務在加密貨幣 surge 時撐 1.5M reads/sec">9.C36 Coinbase</a>（mongobetween + freshness token + ML predictive scaling）</td>
          <td>低、漸進、保留 MongoDB 自管</td>
          <td>中、解 connection storm 等瓶頸</td>
      </tr>
      <tr>
          <td>同 DB 換託管</td>
          <td><a href="/blog/backend/09-performance-capacity/cases/forbes-mongodb-atlas-multi-cloud-migration/" data-link-title="9.C37 Forbes：自管 MongoDB → Atlas on GCP、build 時間 25 → 9 分鐘" data-link-desc="Forbes 把自管 MongoDB 遷到 Atlas on Google Cloud、6 個月完成、build 25 → 9 分鐘、120M 不重複訪客單月承接">9.C37 Forbes</a>（自管 → Atlas、6 個月）</td>
          <td>中、schema 跟 access pattern 保留</td>
          <td>高、釋放 ops 人力</td>
      </tr>
      <tr>
          <td>同 model 換 vendor</td>
          <td><a href="/blog/backend/09-performance-capacity/cases/microsoft-365-cosmos-db-analytics/" data-link-title="9.C30 Microsoft 365：從 MongoDB 遷移到 Cosmos DB 的分析平台" data-link-desc="Microsoft 365 把使用分析平台從 MongoDB 遷移到 Cosmos DB、planet-scale 全球分散式分析">9.C30 Microsoft 365</a>（MongoDB → Cosmos DB MongoDB API）</td>
          <td>高、底層架構換、driver 保留</td>
          <td>高、planet-scale 擴展性</td>
      </tr>
  </tbody>
</table>
<p><strong>三型 frame 是本章合成、case 原文沒有此分類</strong>。引用時要明示：Forbes 6 個月遷移成功 <em>不代表</em> Microsoft 365 也是 6 個月、底層架構換的工程複雜度遠高於託管換。讀者開頭要先問「我屬於哪一型」、再進兩個 API 比較 — 「保留 + 補周邊」根本不需要進 Cosmos DB selection、「同 DB 換託管」的主要 trade-off 是 Atlas vs Cosmos DB 跨雲問題（Framing 4）、「同 model 換 vendor」才是本文聚焦的決策。</p>
<p>把三型混淆的後果是：拿 Forbes 6 個月時程當 baseline 估 Microsoft 365 型遷移、實際工程複雜度高 3-5 倍、project plan 從第一天就 over-commit。</p>
<h3 id="framing-2dogfood-是高權重-selection-signal但案例數字常不公開">Framing 2：dogfood 是高權重 selection signal、但案例數字常不公開</h3>
<p>Microsoft 365 案例揭露的核心 signal 是「Microsoft 自家旗艦產品 dogfood Cosmos DB」— 跟 Amazon Prime Day 用 DynamoDB、Google 自家用 Spanner 一樣、雲商旗艦 DB 都用在自家旗艦產品上、這個 signal 在 vendor selection 的權重高、因為「雲商自己賭身家」。讀者該把這當 <em>選型訊號</em>、不是當 <em>production benchmark</em>。</p>
<p>但 9.C30 case 自承的警示必須明示：</p>
<ul>
<li>「沒有提具體 throughput、latency、cost 數字。Microsoft 內部數字通常不公開、跟 AWS / GCP 案例的數字密度差很多」</li>
<li>「『MongoDB 不夠用』是行銷話術。實際是 <em>MongoDB 在某些 workload pattern 下不夠用</em>、不是普遍結論」</li>
</ul>
<p>兩條警示直接影響寫作紀律：</p>
<ul>
<li>不能拿「Microsoft 365 遷成功」當「我們也會成功」的證據 — 規模 / workload pattern / 團隊能力都不同</li>
<li>不能拿「Microsoft 從 MongoDB 遷出」當「MongoDB 不行」的結論 — Microsoft 自己也有大量 MongoDB / Cosmos DB / SQL Server 並用、不是全部遷出</li>
</ul>
<p>dogfood signal 的 <em>正確用法</em> 是當 frame 借鑑（multi-model 差異化、planet-scale 抽象單位、API compatibility 層）、不是當數字 benchmark。</p>
<h3 id="framing-3multi-model-是-cosmos-db-的差異化價值不總是真用上">Framing 3：multi-model 是 Cosmos DB 的差異化價值、不總是真用上</h3>
<p>Cosmos DB 的差異化價值不是「比 Atlas 更會跑 MongoDB」、是 <em>單一服務支援 5 個 API</em>（SQL / MongoDB / Cassandra / Gremlin / Table）。跨雲對照揭露這個差異化的稀有度：</p>
<ul>
<li>AWS：DynamoDB（KV）+ DocumentDB（MongoDB-compatible）+ Neptune（graph）+ Keyspaces（Cassandra）— 各 use case 一個產品</li>
<li>GCP：Firestore（document）+ Bigtable（KV）+ Spanner（SQL）— 各 use case 一個產品</li>
<li>Azure Cosmos DB：5 個 API 在 <em>同一個服務</em> 內、partition + RU + region 治理共用</li>
</ul>
<p>對 selection 的意義：若團隊預期同一系統會用 document + KV + graph 混合、Cosmos DB 的 multi-model 是 <em>運維單一服務</em> 的 unique value、不是只看「MongoDB 替代品」就能 ROI 評估。但 anti-pattern 也明確：<em>若團隊只用 MongoDB API、不會用其他 4 個 API</em>、multi-model 差異化價值對該團隊 <em>不成立</em>、不該變成 selection 理由。</p>
<p>判讀時要把 multi-model 當「條件性價值」、不是「普遍優勢」 — 條件是「現在或可預見未來會用到第二個 API」。9.C30 Microsoft 365 case 策略段直接揭露「Multi-model 是 Cosmos DB 的差異化價值」、但這個價值對「只用 MongoDB API」的團隊不成立、不能套到所有讀者。</p>
<h3 id="framing-4跨雲-hedging-vs-單雲-lock-in-的-trade-off">Framing 4：跨雲 hedging vs 單雲 lock-in 的 trade-off</h3>
<p>選 Cosmos DB（單雲、Azure-only）跟選 MongoDB Atlas（跨雲、AWS / GCP / Azure 都能跑）的核心 trade-off 不是「哪個技術更強」、是 <em>未來不確定性的對沖價值</em> — 對應 <a href="/blog/backend/knowledge-cards/vendor-lock-in/" data-link-title="Vendor Lock-In" data-link-desc="說明採用供應商產品後，其 API 與格式滲入程式碼造成的退出成本">vendor lock-in</a> 的退出成本評估：</p>
<ul>
<li>Atlas：跨雲部署能力、未來換雲商不用換 DB、9.C37 Forbes 用 GCP 但保留跨雲彈性</li>
<li>Cosmos DB / DynamoDB / Spanner：三大雲商各自的單雲 DB、選一個就綁該雲商生態</li>
</ul>
<p>對 <em>未來雲商策略尚未底定</em> 的團隊、Atlas 的 hedging 價值 <em>高</em>、即使當下單雲就夠用 — 因為 5 年後換雲商的工程成本可能遠高於每月多付的 hosting 費用。對 <em>已綁 Azure 生態</em> 的團隊（Microsoft 365 dogfood、企業 AAD / Office / Power Platform 整合）、Cosmos DB 的 Azure-only 是 <em>整合延伸</em>、不是 <em>lock-in 損失</em> — 雲商已綁、再加一個 lock-in 不增邊際成本。</p>
<p>引用時必須明示這是 <em>未來不確定性 vs 當下整合</em> 的 hedging trade-off、不是「跨雲一定比較好」。讀者該問自己：「我們未來 5 年雲商策略是已定還是未定」、答案會直接決定 Atlas vs Cosmos DB 的選擇方向。</p>
<h2 id="兩個-api-的機制差異">兩個 API 的機制差異</h2>
<p>四層 framing 講完、再進 API 機制 — 不是為了「哪個快」、是為了讓 selection 後的實作不踩坑。</p>
<p>兩個 API 的關係：底層是同一個 Cosmos DB 分散式 document store、API layer 翻譯不同 wire protocol。MongoDB API 把 MongoDB 操作翻譯成 Cosmos DB internal、實際跑 Cosmos DB 自身 engine、不執行 MongoDB engine；SQL API 直接操作 Cosmos DB native query language。</p>
<p><strong>MongoDB API</strong>：</p>
<ul>
<li>相容 MongoDB wire protocol（時間敏感 claim、查 <a href="https://learn.microsoft.com/azure/cosmos-db/mongodb/feature-support-60">最新支援版本</a>、目前對齊 6.0 / 7.0 但仍落後 native MongoDB）</li>
<li>Driver 不變：直接用 mongo-go-driver / pymongo / mongoose</li>
<li>翻譯層有 overhead、相同 query 的 <a href="/blog/backend/knowledge-cards/request-unit/" data-link-title="Request Unit" data-link-desc="Cosmos DB 的容量抽象單位、1 RU = 1KB document strong-consistent read 的 CPU &#43; memory &#43; IOPS 綜合 cost、寫 ~5 RU、複雜 query 數百 RU">Request Unit</a> 通常比 SQL API 多 10-20%（屬通用工程估算、Microsoft 公開文件未列具體比例、case 也未直接量化、實際 overhead 依 query shape / driver 版本 / region 而異、應該以自家 workload benchmark 校正）</li>
</ul>
<p><strong>SQL API</strong>：</p>
<ul>
<li>Cosmos DB native query language（SQL-like、不是標準 SQL、不支援 JOIN）</li>
<li>直接操作 JSON document、ARRAY / nested field native 支援</li>
<li>完整 Cosmos DB feature 支援（Change Feed、stored procedure、trigger）</li>
</ul>
<p><strong>關鍵差異點</strong>：</p>
<ul>
<li><code>$lookup</code>（join）：MongoDB API 支援度有限、跨 partition 性能差；SQL API 沒 JOIN（document model 哲學）</li>
<li>Aggregation pipeline：部分 stage 不支援或行為不同（時間敏感、查 <a href="https://learn.microsoft.com/azure/cosmos-db/mongodb/feature-support-60#aggregation-pipeline">支援列表</a>）</li>
<li>Index：MongoDB API hint / explain 行為跟 native MongoDB 不同</li>
<li>Change stream：MongoDB API 提供 change stream wire compat、但底層是 Cosmos DB Change Feed（語義 / ordering / retention 有差）</li>
<li>Transaction：兩邊都限同 partition、跨 partition transaction 都要改 workflow</li>
</ul>
<p>API kind 是 <em>account 層設定</em>、<em>建 account 時選擇、無法事後切換</em>。MongoDB API → SQL API 的「升級」是 export → recreate account → import + 重寫 application 的全量遷移、不是 in-place 切換。</p>
<h2 id="migration-playbookmongodb--cosmos-db-mongodb-api">Migration playbook：MongoDB → Cosmos DB MongoDB API</h2>
<p>「同 model 換 vendor」型遷移（Framing 1 第三型）的 6 規格面 audit：</p>
<h3 id="規格面-1driver">規格面 1：Driver</h3>
<ul>
<li>主要 driver：Azure 生態整合、需要更好的 global distribution、Atlas 跨雲成本不必要（單雲團隊）</li>
<li>對應 Framing 4 的「已綁 Azure 生態」條件</li>
</ul>
<h3 id="規格面-2no-go-condition">規格面 2：No-go condition</h3>
<ul>
<li>跨雲需求（Framing 4、Atlas 仍是首選、Forbes 案例證據）</li>
<li>需要 native MongoDB latest feature（MongoDB API server version 落後 native MongoDB）</li>
<li>未來雲商策略未定（hedging 價值喪失）</li>
<li>純 MongoDB 投資、無 Azure 生態其他服務整合（Framing 3 multi-model 不成立）</li>
</ul>
<h3 id="規格面-3diff-audit6-維度">規格面 3：Diff audit（6 維度）</h3>
<ul>
<li><strong>Schema</strong>：document shape 不變（wire compat）；但 <code>_id</code> 行為跟 Cosmos DB partition key 綁定方式要審</li>
<li><strong>Operational</strong>：自管 MongoDB → managed Cosmos DB、replica set / sharding 變成 partition + region、備份 / monitoring 全換</li>
<li><strong>Paradigm</strong>：不變（仍 document model）</li>
<li><strong>Components</strong>：MongoDB driver 保留、aggregation pipeline 部分需重寫</li>
<li><strong>Application change</strong>：connection string、authentication mechanism（SCRAM → Azure key / AAD）、read preference 對應 consistency level</li>
<li><strong>Topology</strong>：replica set → multi-region replication、shard key → partition key</li>
</ul>
<p>遷移類型判定：<strong>Type B drop-in（partial）</strong>、wire compat 但有相容性 gap、必須 dual-write per query pattern 驗證、不是一次切換。</p>
<h3 id="規格面-4phase-plan">規格面 4：Phase plan</h3>
<ul>
<li>Phase 0：相容性 audit、列 unsupported aggregation stage、production query corpus 對齊</li>
<li>Phase 1：partition key 設計（從 shard key 翻譯）、見 <a href="../partition-key-design/">partition-key-design</a></li>
<li>Phase 2：bulk export-import（mongodump → Cosmos DB Data Migration Tool）</li>
<li>Phase 3：CDC sync（MongoDB oplog → Azure Data Factory / 自寫 connector）</li>
<li>Phase 4：shadow read 驗證 query 一致性、量 RU consumption baseline</li>
<li>Phase 5：read cutover（讀切 Cosmos、寫仍 MongoDB）</li>
<li>Phase 6：write cutover</li>
<li>Phase 7：cleanup、退役 MongoDB cluster、保留 dump 90 天</li>
</ul>
<h3 id="規格面-5evidence">規格面 5：Evidence</h3>
<ul>
<li>query 一致性 diff log、aggregation result checksum、RU consumption baseline、replication lag</li>
<li>對應 <a href="/blog/backend/01-database/schema-migration-rollout-evidence/" data-link-title="1.7 Schema Migration Rollout 證據（Schema Migration Rollout Evidence）實作示範" data-link-desc="以訂單付款狀態欄位演進示範 schema migration 如何產出 evidence、release gate 與 incident decision log。">schema-migration-rollout-evidence</a> 的 dual-write 驗證</li>
</ul>
<h3 id="規格面-6cutover--cleanup">規格面 6：Cutover + cleanup</h3>
<ul>
<li>read-only window &lt; 10 min、aggregation result 對齊驗證</li>
<li>Rollback 條件：query error rate &gt; 1% 或 RU consumption 異常偏高（翻譯層 cost 高於估算）</li>
</ul>
<h2 id="失敗模式">失敗模式</h2>
<h3 id="failure-1假設-wire-compat--100-行為相同">Failure 1：假設 wire compat = 100% 行為相同</h3>
<p>「100% wire compat」是 vendor 行銷話術、實際是「在某些 query pattern 下相容」— aggregation pipeline 跑出不同結果、上 production 才發現。9.C30 case 揭露的「『MongoDB 不夠用』是行銷話術。實際是 <em>MongoDB 在某些 workload pattern 下不夠用</em>」同模型反向適用 — <em>相容性</em> 也是「在某些 query pattern 下相容」、不是普遍相容。</p>
<p>修法：production query corpus dual-write 跑一遍、case-by-case 驗證每個 query pattern、不能假設 wire compat = 行為 100% 一致。Phase 4 shadow read 不是「跑一些 test」、是 <em>把所有 production query 跑一遍、對 checksum</em>。</p>
<h3 id="failure-2_id-當-partition-key">Failure 2：<code>_id</code> 當 partition key</h3>
<p>MongoDB 的 <code>_id</code> 預設 ObjectId、跟 Cosmos DB partition key 邏輯不同；直接拿 <code>_id</code> 當 partition key 容易在 high-cardinality 但低均勻度的 access pattern 下 hot partition（VIP 用戶、機器人帳號）。要審 application 的真實 query pattern、選會均勻散佈的欄位、見 <a href="../partition-key-design/">partition-key-design</a>。</p>
<h3 id="failure-3change-stream-resume-token-跨-api-不可用">Failure 3：Change stream resume token 跨 API 不可用</h3>
<p>MongoDB API 提供 change stream wire compat、但 resume token 格式跟 native MongoDB 不同、跨環境 resume 會失敗。CDC pipeline 在遷移期間需要分兩段：MongoDB 端用原生 resume token、Cosmos DB 端用 Change Feed continuation token、不能 <em>把 token 從 MongoDB 帶到 Cosmos DB 繼續</em>。</p>
<h3 id="failure-4評估時只測-happy-path">Failure 4：評估時只測 happy path</h3>
<p>unsupported aggregation stage 在 dev 環境的 sample data 看不出、production 才爆。常見漏的 stage：<code>$graphLookup</code> / <code>$facet</code> / <code>$bucket</code> / 部分 <code>$lookup</code> pattern / window function。Phase 0 audit 要把 production aggregation pipeline 拉出來、對照 <a href="https://learn.microsoft.com/azure/cosmos-db/mongodb/feature-support-60">Cosmos DB MongoDB API feature support</a> 清單。</p>
<h3 id="failure-5把-dogfood-案例數字當-benchmark">Failure 5：把 dogfood 案例數字當 benchmark</h3>
<p>9.C30 Microsoft 365 case 自承沒提具體 throughput / latency / cost 數字、不能拿 dogfood 案例的「成功」推論「我們團隊遷過去也會成功」— 規模 / workload pattern / 團隊能力都不同。寫 sizing 計畫時要回到 <a href="../ru-cost-model-sizing/">ru-cost-model-sizing</a> 用自己的 query corpus 量、不是抄 dogfood case。</p>
<h3 id="failure-6選-mongodb-api-後想升級-native-mongodb-feature">Failure 6：選 MongoDB API 後想升級 native MongoDB feature</h3>
<p>MongoDB API server version 升級節奏跟 native MongoDB 不同步、新 feature 等待時間長。選 MongoDB API 等於放棄「拿到 native MongoDB 最新 feature」、若團隊 long-term commit Cosmos DB、SQL API 反而是更穩的選擇（feature 自己決定、不等翻譯層）。這條 trade-off 在 selection 階段就要決定、不能 phase 6 才發現。</p>
<h2 id="容量與觀測">容量與觀測</h2>
<ul>
<li>必看 metric：MongoDB API 特有 <code>MongoRequests</code> / <code>MongoRequestCharge</code>、diagnostic log 看 aggregation stage 是否被翻譯成 cross-partition query</li>
<li>容量規劃：MongoDB API 翻譯層有 overhead、相同 query SQL API 通常便宜 10-20% — 但這個差距通常不足以驅動 API 切換（切換成本太高、見 Failure 6）</li>
<li>RU baseline：Phase 4 shadow read 階段量每個 query pattern 的 <code>x-ms-request-charge</code>、進 <a href="../ru-cost-model-sizing/">ru-cost-model-sizing</a> 的 capacity forecast</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>：API kind 選擇進 cost forecast、不是 sizing 後才補</li>
</ul>
<h2 id="cosmos-db-unique-selection-value-整合四層-framing-收束">Cosmos DB unique selection value 整合（四層 framing 收束）</h2>
<p>讀者讀完本篇要能回答：「我該選 Cosmos DB MongoDB API、Cosmos DB SQL API、還是留 Atlas」 — 答案的四層判讀（對應 Framing 1-4）：</p>
<ul>
<li><strong>遷移路徑（Framing 1）</strong>：你是要保留 + 補周邊、換託管、還是換 vendor？三型風險不同、Forbes 時程不代表 Microsoft 365 時程</li>
<li><strong>dogfood signal（Framing 2）</strong>：你能用 frame 借鑑 Microsoft 365、但避免拿 dogfood 數字當 benchmark</li>
<li><strong>multi-model 是否真用上（Framing 3）</strong>：你的系統未來會不會用 graph / Cassandra / Table API？只用一個 API 時 multi-model unique value 不成立</li>
<li><strong>跨雲 hedging vs Azure 整合（Framing 4）</strong>：你的雲商策略是已定還是未定？已綁 Azure 時 lock-in 是整合延伸、未定時 lock-in 是 hedging 損失</li>
</ul>
<p>四層回答完、selection 才能落地、不是「Azure 上要不要用 Cosmos DB」單一問題。</p>
<h2 id="anti-recommendation">Anti-recommendation</h2>
<ul>
<li>純 MongoDB 投資、未來不會綁 Azure、應留在 Atlas — 跨雲彈性的長期價值高於每月 hosting 差價</li>
<li>MongoDB API 是「Azure 上的 MongoDB 替代品」、<em>不是</em> MongoDB 升級版 — 想要 native MongoDB latest feature 應留在 Atlas / 自管 MongoDB</li>
<li>跨雲 hedging 是 selection 主 driver 時、Cosmos DB（單雲）+ DynamoDB（單雲）+ Spanner（單雲）都不該進候選名單</li>
<li>只用 document model、不用其他 4 個 API 時、multi-model 不該變成 selection 理由 — 此時 Atlas managed 服務的 MongoDB 原生行為通常更穩</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/cosmosdb/" data-link-title="Azure Cosmos DB" data-link-desc="全球分散式 multi-model DB、5 個 consistency levels、Microsoft 自家 dogfood 證據">Cosmos DB vendor overview</a> — 本文是該頁尾「MongoDB API vs native SQL API trade-off」backlog 的深度展開</li>
<li><a href="/blog/backend/09-performance-capacity/cases/microsoft-365-cosmos-db-analytics/" data-link-title="9.C30 Microsoft 365：從 MongoDB 遷移到 Cosmos DB 的分析平台" data-link-desc="Microsoft 365 把使用分析平台從 MongoDB 遷移到 Cosmos DB、planet-scale 全球分散式分析">9.C30 Microsoft 365 dogfood case</a> — 本文主案例、四層 framing 的證據錨點</li>
<li><a href="/blog/backend/09-performance-capacity/cases/coinbase-mongodb-document-platform/" data-link-title="9.C36 Coinbase：MongoDB 撐 Ruby 單體 &#43; 1.5M reads/sec identity 服務" data-link-desc="Coinbase 以 MongoDB 為主資料層、自建 mongobetween connection proxy、users 服務在加密貨幣 surge 時撐 1.5M reads/sec">9.C36 Coinbase</a> — 三型遷移路徑「保留 + 補周邊」對照</li>
<li><a href="/blog/backend/09-performance-capacity/cases/forbes-mongodb-atlas-multi-cloud-migration/" data-link-title="9.C37 Forbes：自管 MongoDB → Atlas on GCP、build 時間 25 → 9 分鐘" data-link-desc="Forbes 把自管 MongoDB 遷到 Atlas on Google Cloud、6 個月完成、build 25 → 9 分鐘、120M 不重複訪客單月承接">9.C37 Forbes</a> — 三型遷移路徑「同 DB 換託管」對照</li>
<li><a href="../partition-key-design/">partition-key-design</a> — Phase 1 partition key 從 shard key 翻譯</li>
<li><a href="../ru-cost-model-sizing/">ru-cost-model-sizing</a> — Phase 4 RU consumption baseline</li>
<li><a href="../consistency-levels-engineering/">consistency-levels-engineering</a> — read preference 對應 consistency level</li>
<li><a href="/blog/backend/01-database/vendors/mongodb/" data-link-title="MongoDB" data-link-desc="Document database 代表、Atlas managed、跨雲可用、許多大規模平台從 MongoDB 起家">MongoDB vendor</a> — Atlas 對照</li>
<li><a href="/blog/backend/01-database/large-scale-db-migration/" data-link-title="1.12 大規模 DB 遷移實戰" data-link-desc="跨 DB 遷移的 dual-write、[shadow read](/backend/knowledge-cards/shadow-read/)、cutover、rollback 流程 — 從實戰案例提煉的工程做法">1.12 大規模 DB 遷移實戰</a> — 跨 vendor 遷移共通模型</li>
<li><a href="/blog/backend/knowledge-cards/database/" data-link-title="Database" data-link-desc="說明 database 在後端系統中如何承擔正式狀態、查詢與一致性責任">Database 卡片</a> / <a href="/blog/backend/knowledge-cards/change-data-capture/" data-link-title="Change Data Capture" data-link-desc="說明資料變更如何被捕捉並傳送到其他系統">Change Data Capture 卡片</a> — 概念基底</li>
<li>官方：<a href="https://learn.microsoft.com/azure/cosmos-db/mongodb/feature-support-60">Cosmos DB MongoDB API feature support</a></li>
</ul>
]]></content:encoded></item><item><title>DynamoDB Single-Table Design：從適用度前置判讀到 access pattern 反推 PK/SK</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/dynamodb/single-table-design-pattern/</link><pubDate>Wed, 27 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/dynamodb/single-table-design-pattern/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/dynamodb/" data-link-title="DynamoDB" data-link-desc="AWS managed key-value、partition-based scaling、9000 萬 RPS sustained 實戰證據">DynamoDB&lt;/a> overview 的 implementation-layer deep article。寫作參照 &lt;a href="https://tarrragon.github.io/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">vendor deep article methodology&lt;/a>。&lt;/p>&lt;/blockquote>
&lt;p>team 用 RDBMS 設計思維建多個 DynamoDB table（&lt;code>user&lt;/code> / &lt;code>order&lt;/code> / &lt;code>order_item&lt;/code>）跑了一季、第二季開始撞「每個 query 要打 2-3 個 table、application 端拼接邏輯爆炸、latency 跟 cost 線性上升」。最直覺的補救是再加 GSI、結果 GSI 數量超過 5 個還是抓不到 access pattern。這時 team 通常開始問「DynamoDB 怎麼 join」— 那是 &lt;em>誤問&lt;/em>。DynamoDB 不做 join，要嘛把相關 entity 放同 PK 用 SK 前綴區分（single-table design），要嘛這個 workload 根本不該用 DynamoDB。本文先回答後者（DynamoDB 適用度前置判讀），再展開前者（single-table 設計流程）。&lt;/p>
&lt;h2 id="dynamodb-適用度前置判讀4-軸">DynamoDB 適用度前置判讀（4 軸）&lt;/h2>
&lt;p>進到 single-table 設計細節之前要先判讀 workload 是否在 DynamoDB 適用區。下面 4 個維度同時成立、single-table 才有意義；任一條不成立、改回 SQL / 多 vendor 組合可能更便宜。9 個 production case（Zoom / Disney+ / Capcom / PayPay / Tixcraft / Lemino / Amazon Ads / Genesys / Zomato）跨 case 重複揭露這 4 軸是適用度的真實邊界。&lt;/p>
&lt;h3 id="軸-1partition-key-是否天然均勻">軸 1：Partition key 是否天然均勻&lt;/h3>
&lt;p>DynamoDB 容量 = 每 partition 上限 × partition 數量、最熱 partition saturation 就是 workload 的天花板。&lt;code>meeting_id&lt;/code>（Zoom）/ &lt;code>player_id&lt;/code>（Capcom）/ &lt;code>message_id&lt;/code>（PayPay）/ &lt;code>user_id&lt;/code>（Disney+）這類 ID 天然散布、不會集中在少數 partition；反之 &lt;code>event_id&lt;/code>（Tixcraft 售票）/ &lt;code>date&lt;/code>（時間序）/ &lt;code>status&lt;/code>（少數枚舉值）這類 PK 天然不均勻、要 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/composite-partition-key/" data-link-title="Composite Partition Key" data-link-desc="多欄位合成 partition key 把單一 logical hot key 拆成多個物理 shard、寫入分散讀取 fan-out">Composite Partition Key&lt;/a> 修補才能 single-table。修補成本見 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/dynamodb/partition-key-antipatterns/" data-link-title="DynamoDB Partition Key 反模式與 Write Sharding：composite key 修復跟 mode × partition 交叉判讀" data-link-desc="DynamoDB partition 上限 1000 WCU 是 hot partition 的根因；composite key（event_id &amp;#43; shard suffix）跟 calculated shard（hash % N）兩種修法、mode × partition 在 provisioned / on-demand 不同表現，以及 9.C15 Tixcraft 6750x 擴展的工程細節">partition-key-antipatterns&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/dynamodb/" data-link-title="DynamoDB" data-link-desc="AWS managed key-value、partition-based scaling、9000 萬 RPS sustained 實戰證據">DynamoDB</a> overview 的 implementation-layer deep article。寫作參照 <a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">vendor deep article methodology</a>。</p></blockquote>
<p>team 用 RDBMS 設計思維建多個 DynamoDB table（<code>user</code> / <code>order</code> / <code>order_item</code>）跑了一季、第二季開始撞「每個 query 要打 2-3 個 table、application 端拼接邏輯爆炸、latency 跟 cost 線性上升」。最直覺的補救是再加 GSI、結果 GSI 數量超過 5 個還是抓不到 access pattern。這時 team 通常開始問「DynamoDB 怎麼 join」— 那是 <em>誤問</em>。DynamoDB 不做 join，要嘛把相關 entity 放同 PK 用 SK 前綴區分（single-table design），要嘛這個 workload 根本不該用 DynamoDB。本文先回答後者（DynamoDB 適用度前置判讀），再展開前者（single-table 設計流程）。</p>
<h2 id="dynamodb-適用度前置判讀4-軸">DynamoDB 適用度前置判讀（4 軸）</h2>
<p>進到 single-table 設計細節之前要先判讀 workload 是否在 DynamoDB 適用區。下面 4 個維度同時成立、single-table 才有意義；任一條不成立、改回 SQL / 多 vendor 組合可能更便宜。9 個 production case（Zoom / Disney+ / Capcom / PayPay / Tixcraft / Lemino / Amazon Ads / Genesys / Zomato）跨 case 重複揭露這 4 軸是適用度的真實邊界。</p>
<h3 id="軸-1partition-key-是否天然均勻">軸 1：Partition key 是否天然均勻</h3>
<p>DynamoDB 容量 = 每 partition 上限 × partition 數量、最熱 partition saturation 就是 workload 的天花板。<code>meeting_id</code>（Zoom）/ <code>player_id</code>（Capcom）/ <code>message_id</code>（PayPay）/ <code>user_id</code>（Disney+）這類 ID 天然散布、不會集中在少數 partition；反之 <code>event_id</code>（Tixcraft 售票）/ <code>date</code>（時間序）/ <code>status</code>（少數枚舉值）這類 PK 天然不均勻、要 <a href="/blog/backend/knowledge-cards/composite-partition-key/" data-link-title="Composite Partition Key" data-link-desc="多欄位合成 partition key 把單一 logical hot key 拆成多個物理 shard、寫入分散讀取 fan-out">Composite Partition Key</a> 修補才能 single-table。修補成本見 <a href="/blog/backend/01-database/vendors/dynamodb/partition-key-antipatterns/" data-link-title="DynamoDB Partition Key 反模式與 Write Sharding：composite key 修復跟 mode × partition 交叉判讀" data-link-desc="DynamoDB partition 上限 1000 WCU 是 hot partition 的根因；composite key（event_id &#43; shard suffix）跟 calculated shard（hash % N）兩種修法、mode × partition 在 provisioned / on-demand 不同表現，以及 9.C15 Tixcraft 6750x 擴展的工程細節">partition-key-antipatterns</a>。</p>
<p><code>9.C18 Zoom</code>、<code>9.C19 Capcom</code>、<code>9.C26 PayPay</code>、<code>9.C27 Disney+</code> 4 個 case 都揭露 partition key 天然均勻是 DynamoDB 「能撐」的前提之一。</p>
<h3 id="軸-2workload-是-control-plane-還是-data-plane">軸 2：Workload 是 control plane 還是 data plane</h3>
<p>DynamoDB 適合存 metadata / state，實際大流量（影音串流 / 大型 BLOB / 全文搜尋）走 CDN / WebRTC / object store。<code>9.C18 Zoom</code> 把媒體串流放 P2P + edge servers、DynamoDB 只承擔會議 metadata；<code>9.C27 Disney+</code> 把 content 放 S3 + CDN、DynamoDB 只承擔 watchlist + 播放進度；<code>9.C19 Capcom</code> 把即時遊戲邏輯放 EKS、DynamoDB 處理持久狀態。讀者該問的不是「DynamoDB 能撐多大流量」、是「我的系統哪一層該放 DynamoDB」。</p>
<p>如果 workload 是 data plane（單筆 payload 上 MB、要做全文搜尋、要存 BLOB），用 DynamoDB 是反模式 — single item 上限 400KB 直接擋掉 BLOB 場景。</p>
<h3 id="軸-3consistency-需求是否可接受-eventual">軸 3：Consistency 需求是否可接受 eventual</h3>
<p>DynamoDB 預設 eventually consistent read、strong read 也只在同 region quorum 內成立。最終一致性可接受的 workload 才適合；strong consistency 必要（跨 entity 原子寫入 / 跨 region 強一致 / 全局單調遞增 ID）必須走 SQL / NewSQL。本軸屬通用工程判讀、case 沒有揭露具體 staleness 閾值；判讀工具是 <a href="/blog/backend/01-database/vendors/dynamodb/consistency-model-optimization/" data-link-title="DynamoDB Strongly Consistent → Eventually Consistent：same protocol, different contract" data-link-desc="DynamoDB consistency model 從 strongly consistent read 改 eventually consistent read 是 50% cost 優化但風險集中在 application contract — 同 vendor / 同 protocol / 同 table / 不同 read consistency；驗證 [#128](/report/data-topology-as-audit-dimension/) self-aware limitation 提出的 consistency axis 候選；涵蓋 read pattern audit / 5 個 production 踩雷">consistency-model-optimization</a> 的 per-call site review。</p>
<h3 id="軸-4access-pattern-是否穩定">軸 4：Access pattern 是否穩定</h3>
<p>access pattern 數量穩定且窮舉可列（典型 10-30 個）single-table 才能精準設計 PK/SK 跟 GSI；查詢仍在探索期、pattern 頻繁變動，SQL 多 table 較容易演化、改 query 不用改 schema。本軸也屬通用工程判讀、case 沒明示 access pattern 數量閾值，但 9 個 case 寫進 production 的 access pattern 多半是 <em>業務契約已凍結</em> 的場景（會議 metadata、watchlist、玩家戰績、訊息推送）。</p>
<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 vendor</a> 或考慮多 vendor 組合。4 軸都成立、再進 single-table 設計。</p>
<h2 id="核心概念access-pattern-先於-schema">核心概念：access pattern 先於 schema</h2>
<p>Single-table design 的 first-class concept 是 <em>access pattern 先於 schema</em>：先列 15-30 個 query 才開始設 key、不是先設 schema 再想怎麼 query。</p>
<p>DynamoDB 的 key 結構：</p>
<ul>
<li><strong>PK（partition key）</strong>：決定資料散布到哪個 partition；同 PK 的 item 物理共置（item collection）</li>
<li><strong>SK（sort key）</strong>：決定同 partition 內排序與範圍查詢；composite SK 用 <code>#</code> 分隔層級（如 <code>ORDER#2026-05-27#001</code>）</li>
<li><strong>同 PK 不同 SK 前綴</strong>：把相關 entity 物理共置、用一次 <code>Query</code> 拿回多個 entity；對應 RDB 的 JOIN</li>
</ul>
<p>實際範例（Disney+ 9.C27 揭露的 access pattern）：</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">PK             SK                          Entity
</span></span><span class="line"><span class="ln">2</span><span class="cl">USER#u123      PROFILE                     用戶資料
</span></span><span class="line"><span class="ln">3</span><span class="cl">USER#u123      WATCHLIST#m456              觀看清單項目
</span></span><span class="line"><span class="ln">4</span><span class="cl">USER#u123      PROGRESS#device-iPad#m456   播放進度
</span></span><span class="line"><span class="ln">5</span><span class="cl">USER#u123      PROGRESS#device-TV#m456     播放進度（跨裝置）</span></span></code></pre></div><p>一次 <code>Query PK=USER#u123</code> 拿回該 user 的所有資料、不需要 join。SK 前綴 <code>PROFILE</code> / <code>WATCHLIST#</code> / <code>PROGRESS#</code> 區分 entity type、range query 還能限定「只取 watchlist」（<code>begins_with(SK, &quot;WATCHLIST#&quot;)</code>）。</p>
<p>對應 knowledge card：<a href="/blog/backend/knowledge-cards/hot-partition/" data-link-title="Hot Partition" data-link-desc="說明分散式 KV / OLTP 中、單一 partition 流量遠超其他的容量問題">hot partition</a>、<a href="/blog/backend/knowledge-cards/workload-model/" data-link-title="Workload Model" data-link-desc="描述 production traffic 形狀的可重播模型 — 容量規劃跟壓測的共同輸入">workload model</a>。</p>
<h2 id="設計流程">設計流程</h2>
<p>從 access pattern 反推 PK/SK 跟 GSI 的 5 步流程。</p>
<h4 id="step-1access-pattern-表窮舉">Step 1：access pattern 表窮舉</h4>
<p>每個 user story 寫成一條 query：</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">| #  | User story                          | Query                                 | Latency | Consistency |
</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">| 1  | 顯示用戶 profile                    | GetItem PK=USER#{id} SK=PROFILE       | p99 5ms | eventual    |
</span></span><span class="line"><span class="ln">4</span><span class="cl">| 2  | 取用戶所有觀看清單                  | Query PK=USER#{id} begins_with(SK, &#34;WATCHLIST#&#34;) | p99 10ms | eventual |
</span></span><span class="line"><span class="ln">5</span><span class="cl">| 3  | 跨裝置同步播放進度（最新）          | GetItem PK=USER#{id} SK=PROGRESS#{movie}#latest | p99 15ms | strong |</span></span></code></pre></div><p>15-30 條 query 全列出，這是 single-table 的契約。漏列等於設計時看不到、上線後撞。</p>
<h4 id="step-2entity-relationship--pksk-映射">Step 2：entity-relationship → PK/SK 映射</h4>
<p>常見模式：</p>
<ul>
<li>主 entity 用 <code>{ENTITY}#{id}</code> 當 PK（USER / ORDER / PRODUCT）</li>
<li>子 entity 用同 PK + 不同 SK 前綴（<code>PROFILE</code> / <code>ORDER#{timestamp}</code> / <code>ITEM#{id}</code>）</li>
<li>1-N 關係（user 有多個 watchlist）用同 PK + 不同 SK</li>
<li>N-N 關係（user 跟 friend）用兩條 item（A→B 與 B→A）或單獨 relationship entity</li>
</ul>
<h4 id="step-3gsi-補反向查詢">Step 3：GSI 補反向查詢</h4>
<p>主 PK 覆蓋不到的 access pattern 用 GSI 補：</p>
<ul>
<li>「依 status 查所有 order」→ GSI PK = <code>status</code>、SK = <code>created_at</code></li>
<li>「依 product 查所有買家」→ GSI PK = <code>product_id</code>、SK = <code>user_id</code></li>
</ul>
<p>GSI 數量上限 20、實務 &lt; 5；過多時表示主 PK 設計沒覆蓋夠多 access pattern、應重新設計。詳見 <a href="/blog/backend/01-database/vendors/dynamodb/gsi-lsi-design/" data-link-title="DynamoDB GSI 與 LSI 設計：access pattern 補位、projection、consistency 跟 DAX 補位" data-link-desc="GSI / LSI 是 single-table 沒覆蓋的 access pattern 補位、不是萬靈丹；本文涵蓋 projection 三型選擇、sparse index、GSI 自己會 hot partition、DAX 讀峰值補位的觸發條件（含 Capcom 是 derive vs Lemino 是 case fact 的分層）">gsi-lsi-design</a>。</p>
<h4 id="step-4cloudformation--terraform-ddl">Step 4：CloudFormation / Terraform DDL</h4>





<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">Resources</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">SingleTable</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="nt">Type</span><span class="p">:</span><span class="w"> </span><span class="l">AWS::DynamoDB::Table</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">Properties</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="nt">BillingMode</span><span class="p">:</span><span class="w"> </span><span class="l">PAY_PER_REQUEST</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">      </span><span class="nt">AttributeDefinitions</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="nt">AttributeName</span><span class="p">:</span><span class="w"> </span><span class="l">PK</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">          </span><span class="nt">AttributeType</span><span class="p">:</span><span class="w"> </span><span class="l">S</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">        </span>- <span class="nt">AttributeName</span><span class="p">:</span><span class="w"> </span><span class="l">SK</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">          </span><span class="nt">AttributeType</span><span class="p">:</span><span class="w"> </span><span class="l">S</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">        </span>- <span class="nt">AttributeName</span><span class="p">:</span><span class="w"> </span><span class="l">GSI1PK</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">          </span><span class="nt">AttributeType</span><span class="p">:</span><span class="w"> </span><span class="l">S</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">        </span>- <span class="nt">AttributeName</span><span class="p">:</span><span class="w"> </span><span class="l">GSI1SK</span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w">          </span><span class="nt">AttributeType</span><span class="p">:</span><span class="w"> </span><span class="l">S</span><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w">      </span><span class="nt">KeySchema</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="nt">AttributeName</span><span class="p">:</span><span class="w"> </span><span class="l">PK</span><span class="w">
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="w">          </span><span class="nt">KeyType</span><span class="p">:</span><span class="w"> </span><span class="l">HASH</span><span class="w">
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="w">        </span>- <span class="nt">AttributeName</span><span class="p">:</span><span class="w"> </span><span class="l">SK</span><span class="w">
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="w">          </span><span class="nt">KeyType</span><span class="p">:</span><span class="w"> </span><span class="l">RANGE</span><span class="w">
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="w">      </span><span class="nt">GlobalSecondaryIndexes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="w">        </span>- <span class="nt">IndexName</span><span class="p">:</span><span class="w"> </span><span class="l">GSI1</span><span class="w">
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="w">          </span><span class="nt">KeySchema</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="nt">AttributeName</span><span class="p">:</span><span class="w"> </span><span class="l">GSI1PK</span><span class="w">
</span></span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="w">              </span><span class="nt">KeyType</span><span class="p">:</span><span class="w"> </span><span class="l">HASH</span><span class="w">
</span></span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="w">            </span>- <span class="nt">AttributeName</span><span class="p">:</span><span class="w"> </span><span class="l">GSI1SK</span><span class="w">
</span></span></span><span class="line"><span class="ln">26</span><span class="cl"><span class="w">              </span><span class="nt">KeyType</span><span class="p">:</span><span class="w"> </span><span class="l">RANGE</span><span class="w">
</span></span></span><span class="line"><span class="ln">27</span><span class="cl"><span class="w">          </span><span class="nt">Projection</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">28</span><span class="cl"><span class="w">            </span><span class="nt">ProjectionType</span><span class="p">:</span><span class="w"> </span><span class="l">INCLUDE</span><span class="w">
</span></span></span><span class="line"><span class="ln">29</span><span class="cl"><span class="w">            </span><span class="nt">NonKeyAttributes</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">status, created_at]</span></span></span></code></pre></div><h4 id="step-5驗證點">Step 5：驗證點</h4>
<ul>
<li>每個 access pattern 對應一個 <code>Query</code> / <code>GetItem</code>、沒有 <code>Scan</code>、沒有 application-side join</li>
<li>Contributor Insights 看 top-N PK 訪問是否均勻</li>
<li>CloudWatch <code>ConsumedReadCapacityUnits</code> / <code>ConsumedWriteCapacityUnits</code> 按 partition 分布觀察</li>
</ul>
<p><strong>Rollback boundary</strong>：access pattern 改動可加 GSI 補；entity 拆 table 比合 table 容易，先合再拆。</p>
<h2 id="失敗模式">失敗模式</h2>
<p>5 個 production 常見踩雷：</p>
<h4 id="case-1late-binding-access-pattern">Case 1：late-binding access pattern</h4>
<p>production 上線半年後 PM 要新 query「按地區列訂單」、PK 沒包 region、只能 <code>Scan</code> 或加 GSI。根因是 access pattern 沒在設計階段窮舉，這是 single-table design 的核心責任。修法：access pattern 表列完整、不可省略；新需求進來先回 access pattern 表 review、再決定加 GSI 還是重設計 PK。</p>
<h4 id="case-2sk-排序衝突">Case 2：SK 排序衝突</h4>
<p>同 PK 下兩種 entity（<code>ORDER#{timestamp}</code> 與 <code>PAYMENT#{timestamp}</code>）混用同 SK 空間、range query 拿 <code>BETWEEN '2026-01-01' AND '2026-12-31'</code> 時 entity 邊界錯亂。修法：SK 前綴必須能 <em>用 <code>begins_with</code> 完全區隔</em> entity（<code>ORDER#2026-...</code> vs <code>PAYMENT#2026-...</code>）。</p>
<h4 id="case-3item-collection-超過-10gb">Case 3：item collection 超過 10GB</h4>
<p>單 PK 下所有 item 加起來超過 10GB 上限、DynamoDB 拒絕新寫入。常見於「user 為 PK + user 有大量歷史 event」場景。修法：歷史 event 改用 <code>USER#{id}#YYYYMM</code> 當 PK 把時間 bucket 切開、或把歷史 event 寫進另一張 archive table（cold path）。</p>
<h4 id="case-4gsi-反向變主表">Case 4：GSI 反向變主表</h4>
<p>開始 GSI 只補 1-2 個 query，半年後 GSI 流量超過主表、cost 翻倍。根因是主 PK 沒設計好、GSI 變成 <em>實質的主存取路徑</em>。修法：重新設計 PK、把 GSI 流量主要的 access pattern 升為主表 query；GSI 從多到少要 application 端配合 cutover。</p>
<h4 id="case-5dynamodb-當-rdbms-用">Case 5：DynamoDB 當 RDBMS 用</h4>
<p>把 normalize 過的 schema 直接搬、每個 business query 要 2-3 個 <code>GetItem</code>、latency 從 5ms 變 30ms。修法：normalize 適合 SQL、不適合 KV；single-table 是把 normalize 拍平、用 denormalize 換 read latency。</p>
<p><strong>Anti-recommendation</strong>：access pattern &lt; 5 個、entity 間關聯弱、查詢仍在探索期 → 用 SQL 或 multi-table 先寫、access pattern 穩定再 single-table。</p>
<h2 id="容量與觀測">容量與觀測</h2>
<p>CloudWatch metric：</p>
<ul>
<li><code>ConsumedReadCapacityUnits</code> / <code>ConsumedWriteCapacityUnits</code>：按 partition 分布看是否均勻</li>
<li><code>ThrottledRequests</code>：早期 hot partition 訊號（provisioned 模式立即可見）</li>
<li><code>SuccessfulRequestLatency</code> p99：on-demand 模式下 hot partition 表現為 latency spike（見 <a href="/blog/backend/01-database/vendors/dynamodb/partition-key-antipatterns/" data-link-title="DynamoDB Partition Key 反模式與 Write Sharding：composite key 修復跟 mode × partition 交叉判讀" data-link-desc="DynamoDB partition 上限 1000 WCU 是 hot partition 的根因；composite key（event_id &#43; shard suffix）跟 calculated shard（hash % N）兩種修法、mode × partition 在 provisioned / on-demand 不同表現，以及 9.C15 Tixcraft 6750x 擴展的工程細節">partition-key-antipatterns</a> 的 mode × partition 交叉判讀）</li>
</ul>
<p>Contributor Insights：top-N partition key 訪問頻率，揭露 single-table 設計後是否仍均勻；每月 cost ~$0.02 per million event、值得開。</p>
<p>GSI 觀測：每個 GSI 獨立 RCU/WCU、projection type（<code>KEYS_ONLY</code> / <code>INCLUDE</code> / <code>ALL</code>）決定 storage cost。</p>
<p>TTL 是 storage cost 防爆的標配（特別在 message-class workload）— PayPay <code>9.C26</code> 揭露 3 億 / 天 × 30 天 = 90 億筆記錄、不清理會撐死 storage 預算；設 TTL attribute 讓 DynamoDB 自動刪過期 item、消耗 0 WCU。</p>
<p>接回 <a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">4.20 Observability Evidence Package</a> 跟 <a href="/blog/backend/09-performance-capacity/bottleneck-localization/" data-link-title="9.5 瓶頸定位流程" data-link-desc="從 app 到 DB / cache / broker / 第三方 quota 的逐層瓶頸定位">9.5 Bottleneck localization</a>。</p>
<h2 id="邊界與整合">邊界與整合</h2>
<h3 id="frame-3dynamodb-在-fleet-治理-frame-的退化">Frame 3：DynamoDB 在 fleet 治理 frame 的退化</h3>
<p>跨 vendor 共通 frame：production scale 走 <em>fleet of clusters</em>（Aurora 200 cluster / CockroachDB 380+ cluster / MongoDB Atlas 20 DB 都是這個 frame）。DynamoDB 在這 frame 退化得最徹底 — <em>不走 fleet of clusters</em>、是用 partition 內部自動切。</p>
<p>對照其他 vendor：</p>
<table>
  <thead>
      <tr>
          <th>Vendor</th>
          <th>Scale-out 拓樸</th>
          <th>容量決策層</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>DynamoDB</td>
          <td>單 table、partition 自動 split / merge</td>
          <td>mode 選擇 + PK 均勻 + GSI 補位</td>
      </tr>
      <tr>
          <td>Aurora</td>
          <td>Fleet of clusters（business / microservice / 合規）</td>
          <td>Cluster boundary + replica 數量</td>
      </tr>
      <tr>
          <td>CockroachDB</td>
          <td>Fleet of clusters or 邏輯一個 cluster + locality</td>
          <td>Per-app vs shared cluster 決策</td>
      </tr>
      <tr>
          <td>MongoDB</td>
          <td>Sharded cluster + 多 cluster（blast radius）</td>
          <td>Shard key + cluster ownership boundary</td>
      </tr>
  </tbody>
</table>
<p><strong>DynamoDB 退化點</strong>：partition 是 <em>vendor 內部物理層</em>、不暴露給應用 — application 看到的永遠是「一張 table」、不需要規劃 cluster boundary。代價是 <em>partition key 設計責任全壓在 schema 上</em>（<a href="/blog/backend/01-database/vendors/dynamodb/partition-key-antipatterns/" data-link-title="DynamoDB Partition Key 反模式與 Write Sharding：composite key 修復跟 mode × partition 交叉判讀" data-link-desc="DynamoDB partition 上限 1000 WCU 是 hot partition 的根因；composite key（event_id &#43; shard suffix）跟 calculated shard（hash % N）兩種修法、mode × partition 在 provisioned / on-demand 不同表現，以及 9.C15 Tixcraft 6750x 擴展的工程細節">partition-key-antipatterns</a>）、不能用「拆 cluster 解 blast radius」當逃避路徑。</p>
<p><strong>例外情境</strong>：DynamoDB 在 <em>合規場景</em> 仍可能走「多 table per market」拓樸（見 Frame 5 <a href="/blog/backend/01-database/vendors/dynamodb/global-tables-conflict/" data-link-title="DynamoDB Global Tables：multi-region active-active、LWW conflict 與 cross-device sync 正向用例" data-link-desc="Global Tables 不只是 conflict 痛點、也是 cross-device sync / global read / DR failover 的正向工程方案；本文展開 B2B SaaS vs B2C 業務 driver、LWW conflict resolution、reconciliation pipeline，含 Genesys 99.999% 跨 15 region 跟 Disney&#43; 跨裝置同步的對照">global-tables-conflict</a> region-pinned 段）— 但動機是合規 boundary 而非 capacity scale、跟 Aurora fleet driver 結構不同。</p>
<h3 id="dynamodb-在系統中的角色control-plane--metadata--state">DynamoDB 在系統中的角色：control plane / metadata / state</h3>
<p>DynamoDB 不是 universal store、不是 SQL 替代品。3 個 case 重複揭露同一定位：</p>
<ul>
<li><strong>9.C18 Zoom</strong>：媒體串流走 P2P + edge servers、DynamoDB 只承擔會議 / 用戶 metadata。control plane 跟 data plane 分離是 30x DAU surge 能撐的工程前提（不是 DynamoDB 自己魔法）。</li>
<li><strong>9.C27 Disney+</strong>：content 走 S3 + CDN、DynamoDB 只承擔 metadata / watchlist / cross-device 進度。</li>
<li><strong>9.C19 Capcom</strong>：EKS 跑 game server / 處理即時遊戲邏輯、DynamoDB 處理持久狀態。</li>
</ul>
<h3 id="durable-queue--write-buffer-作為正向非-oltp-access-pattern">Durable queue / write-buffer 作為正向非 OLTP access pattern</h3>
<p><code>9.C15 Tixcraft</code> 揭露 DynamoDB 的另一種正向用法 — <em>寫入緩衝層</em>、不是 OLTP：</p>
<ul>
<li>拓元用 DynamoDB 接「訂單」寫入、不是即時生效、是讓 traditional server（金流 / 票庫）用自己能承受的速度消費</li>
<li>架構上 DynamoDB 扮演 durable queue、不是傳統 OLTP DB；這層解耦讓「前端可擴 130 倍、後端不用同步擴」</li>
<li>對比 RDBMS：RDB 寫入要即時可讀、即時索引、即時 transaction commit；DynamoDB 寫入可以「先 durable、之後處理」</li>
<li>寫進你的設計時要明示：這是 <em>非預設</em> access pattern、是 flash-sale / 高峰寫入解耦的工程選擇、不是 DynamoDB 預設定位</li>
</ul>
<p>這個 access pattern 跟 single-table 設計兼容 — PK 仍是 <code>event_id#shard</code>、SK 是 <code>ORDER#{user_id}#{timestamp}</code>、寫入時直接寫，後端傳統 server 慢消費；只是讀路徑是 <em>後端服務 batch 取</em> 而非 user-facing query。</p>
<h3 id="rdb-connection-limit-機制對照">RDB connection limit 機制對照</h3>
<p><code>9.C29 Lemino</code> 揭露為什麼 DynamoDB 在 surge 下不會踩 RDB 的隱性天花板：</p>
<ul>
<li>「connection limits became bottlenecks when experiencing a rapid increase in access」— PostgreSQL/MySQL 每連線吃記憶體 / process、pool 上限 1K-5K、connection 是 RDB 在 surge 下 <em>第一個爆點</em>（不是 CPU / disk）</li>
<li>DynamoDB 的 HTTP API（無 long-lived connection state）天然解這個問題；client 不需要維護 connection pool、AWS SDK 用 connection-less HTTP request</li>
</ul>
<p>選 DynamoDB 不只是 schema 選擇、是 connection model 選擇。single-table 設計 <em>外部</em> 的容量優勢、寫進邊界判讀條件。</p>
<h3 id="sibling-與-cross-link">Sibling 與 cross-link</h3>
<ul>
<li><a href="/blog/backend/01-database/vendors/dynamodb/partition-key-antipatterns/" data-link-title="DynamoDB Partition Key 反模式與 Write Sharding：composite key 修復跟 mode × partition 交叉判讀" data-link-desc="DynamoDB partition 上限 1000 WCU 是 hot partition 的根因；composite key（event_id &#43; shard suffix）跟 calculated shard（hash % N）兩種修法、mode × partition 在 provisioned / on-demand 不同表現，以及 9.C15 Tixcraft 6750x 擴展的工程細節">partition-key-antipatterns</a> — 軸 1 不天然均勻時的 composite key 補救</li>
<li><a href="/blog/backend/01-database/vendors/dynamodb/gsi-lsi-design/" data-link-title="DynamoDB GSI 與 LSI 設計：access pattern 補位、projection、consistency 跟 DAX 補位" data-link-desc="GSI / LSI 是 single-table 沒覆蓋的 access pattern 補位、不是萬靈丹；本文涵蓋 projection 三型選擇、sparse index、GSI 自己會 hot partition、DAX 讀峰值補位的觸發條件（含 Capcom 是 derive vs Lemino 是 case fact 的分層）">gsi-lsi-design</a> — 主 PK 覆蓋不到的 access pattern 補位</li>
<li><a href="/blog/backend/01-database/vendors/dynamodb/on-demand-vs-provisioned/" data-link-title="DynamoDB On-Demand vs Provisioned：6 軸決策、auto-scaling 邊界與 cost crossover" data-link-desc="capacity mode 選擇不是單軸 peak/avg ratio；本文展開 6 軸決策（peak/avg / 讀寫比 trend / surge 暫時 vs 永久 baseline / predictable-peak vs flash-sale / DBA 工時釋放 / vendor vs 自管 cost crossover），含 Zomato 50% 成本下降、Zoom 30x permanent surge、Amazon Ads sustained workload 等 case 分軸 anchor">on-demand-vs-provisioned</a> — access pattern 影響 capacity mode 選擇</li>
<li><a href="/blog/backend/01-database/vendors/dynamodb/consistency-model-optimization/" data-link-title="DynamoDB Strongly Consistent → Eventually Consistent：same protocol, different contract" data-link-desc="DynamoDB consistency model 從 strongly consistent read 改 eventually consistent read 是 50% cost 優化但風險集中在 application contract — 同 vendor / 同 protocol / 同 table / 不同 read consistency；驗證 [#128](/report/data-topology-as-audit-dimension/) self-aware limitation 提出的 consistency axis 候選；涵蓋 read pattern audit / 5 個 production 踩雷">consistency-model-optimization</a> — 軸 3 的 per-call site review</li>
<li><a href="/blog/backend/01-database/vendors/dynamodb/global-tables-conflict/" data-link-title="DynamoDB Global Tables：multi-region active-active、LWW conflict 與 cross-device sync 正向用例" data-link-desc="Global Tables 不只是 conflict 痛點、也是 cross-device sync / global read / DR failover 的正向工程方案；本文展開 B2B SaaS vs B2C 業務 driver、LWW conflict resolution、reconciliation pipeline，含 Genesys 99.999% 跨 15 region 跟 Disney&#43; 跨裝置同步的對照">global-tables-conflict</a> — 跨 region 多寫入時 single-table 仍適用、但 conflict resolution 加一層</li>
<li>反向路由：access pattern 探索期 / strong consistency 必要 / data plane workload → 回 <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 vendor</a></li>
</ul>
]]></content:encoded></item><item><title>MongoDB Schema Design Pattern：contract layer 在哪 vs embedded / reference</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/mongodb/schema-design-pattern/</link><pubDate>Wed, 27 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/mongodb/schema-design-pattern/</guid><description>&lt;p>MongoDB schema design 的初學討論常停在「embedded vs reference 二選一」。真實 production 議題遠不止此：document model 給的 schema flexibility 在第一年是紅利、跑半年後同 collection 開始混三代 schema、application code 三層 if-else 處理欄位缺失與型別漂移。這時候讀者要解的不是「embed 還是 reference」、是 &lt;strong>schema contract 該由誰守、守在哪一層&lt;/strong>。本文把這個議題拆成三條 contract layer 路徑（DB-layer validator / app-layer abstraction / 混合）、配合 embedded / reference / polymorphic 機制與 time-series collection 邊界一起討論。&lt;/p>
&lt;p>本文不重複 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/mongodb/" data-link-title="MongoDB" data-link-desc="Document database 代表、Atlas managed、跨雲可用、許多大規模平台從 MongoDB 起家">MongoDB vendor overview&lt;/a> 已寫過的 document model 適用條件 — 而是 production 部署 + schema governance + 失敗修復 的實作層教學。&lt;/p>
&lt;h2 id="問題情境document-自由的後座力">問題情境：document 自由的後座力&lt;/h2>
&lt;p>MongoDB 適用度的前置判讀有三件事要確認：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>document shape 是否主導資料&lt;/strong>：sensor signal / CMS article / order aggregate 這類「形狀本來就多型 + 隨產品演進」適合 document model；access pattern 固定 + 欄位定型的反而該回 KV 系統或 SQL&lt;/li>
&lt;li>&lt;strong>contract layer 該放哪&lt;/strong>：DB-layer validator 適合 schema 穩定 / 跨服務共用 collection 的場景；app-layer abstraction 適合 schema 演進快 / 微服務獨立 owner；混合適合大型 production&lt;/li>
&lt;li>&lt;strong>跨雲 hedging 是否需要&lt;/strong>：若團隊未來雲商策略不確定、Atlas 跨雲是 selection 訊號；只在單雲跑就不必為 hedging 多付代價&lt;/li>
&lt;/ul>
&lt;p>確認 MongoDB 該用之後，讀者真正在 production 撞到的徵兆：&lt;/p>
&lt;ul>
&lt;li>Document model 早期 schema-less 紅利、跑半年後 collection 同時混三代 schema、application 寫 if-else 處理欄位缺失與型別漂移&lt;/li>
&lt;li>子文件越塞越深、單 document 突破 1-2MB、partial update 仍要把整顆 document load + write、IO 跟 working set 雙重壓力&lt;/li>
&lt;li>反向過度 normalize：訂單跟訂單 item 拆兩個 collection、單一查詢得 N+1 &lt;code>$lookup&lt;/code>、aggregation cost 飆&lt;/li>
&lt;li>IoT / sensor / event log workload 寫進 regular collection、寫入吞吐撞牆但沒考慮 time-series collection&lt;/li>
&lt;li>&lt;code>$lookup&lt;/code> 出現在 hot path、document size warning（16MB 上限預警）、partial update 卻產生大量 disk write、schema validation 報錯比例突然爬升&lt;/li>
&lt;/ul>
&lt;p>Case anchor：&lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/toyota-connected-mongodb-telematics-iot/" data-link-title="9.C38 Toyota Connected：MongoDB Atlas 撐 900 萬車輛 telematics、月 180 億 transaction" data-link-desc="Toyota Connected 用 MongoDB Atlas 撐 Safety Connect 900 萬車、月 180 億 transaction、緊急訊號 3 秒內到 agent">9.C38 Toyota Connected&lt;/a> 揭露車載 sensor schema 隨車型 / 年份 / 規範演進、polymorphic document 與 schema governance 並存；&lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/forbes-mongodb-atlas-multi-cloud-migration/" data-link-title="9.C37 Forbes：自管 MongoDB → Atlas on GCP、build 時間 25 → 9 分鐘" data-link-desc="Forbes 把自管 MongoDB 遷到 Atlas on Google Cloud、6 個月完成、build 25 → 9 分鐘、120M 不重複訪客單月承接">9.C37 Forbes&lt;/a> 揭露 CMS 50+ 微服務透過自建中介 abstraction layer 隔離 schema 變動；&lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/microsoft-365-cosmos-db-analytics/" data-link-title="9.C30 Microsoft 365：從 MongoDB 遷移到 Cosmos DB 的分析平台" data-link-desc="Microsoft 365 把使用分析平台從 MongoDB 遷移到 Cosmos DB、planet-scale 全球分散式分析">9.C30 Microsoft 365&lt;/a> 揭露 document model 保留 + 跨 vendor 形狀治理。早期 startup MongoDB 三代 schema 並存的具體 incident 細節需未來 case 補完、本文先以「常見 failure pattern」處理。&lt;/p></description><content:encoded><![CDATA[<p>MongoDB schema design 的初學討論常停在「embedded vs reference 二選一」。真實 production 議題遠不止此：document model 給的 schema flexibility 在第一年是紅利、跑半年後同 collection 開始混三代 schema、application code 三層 if-else 處理欄位缺失與型別漂移。這時候讀者要解的不是「embed 還是 reference」、是 <strong>schema contract 該由誰守、守在哪一層</strong>。本文把這個議題拆成三條 contract layer 路徑（DB-layer validator / app-layer abstraction / 混合）、配合 embedded / reference / polymorphic 機制與 time-series collection 邊界一起討論。</p>
<p>本文不重複 <a href="/blog/backend/01-database/vendors/mongodb/" data-link-title="MongoDB" data-link-desc="Document database 代表、Atlas managed、跨雲可用、許多大規模平台從 MongoDB 起家">MongoDB vendor overview</a> 已寫過的 document model 適用條件 — 而是 production 部署 + schema governance + 失敗修復 的實作層教學。</p>
<h2 id="問題情境document-自由的後座力">問題情境：document 自由的後座力</h2>
<p>MongoDB 適用度的前置判讀有三件事要確認：</p>
<ul>
<li><strong>document shape 是否主導資料</strong>：sensor signal / CMS article / order aggregate 這類「形狀本來就多型 + 隨產品演進」適合 document model；access pattern 固定 + 欄位定型的反而該回 KV 系統或 SQL</li>
<li><strong>contract layer 該放哪</strong>：DB-layer validator 適合 schema 穩定 / 跨服務共用 collection 的場景；app-layer abstraction 適合 schema 演進快 / 微服務獨立 owner；混合適合大型 production</li>
<li><strong>跨雲 hedging 是否需要</strong>：若團隊未來雲商策略不確定、Atlas 跨雲是 selection 訊號；只在單雲跑就不必為 hedging 多付代價</li>
</ul>
<p>確認 MongoDB 該用之後，讀者真正在 production 撞到的徵兆：</p>
<ul>
<li>Document model 早期 schema-less 紅利、跑半年後 collection 同時混三代 schema、application 寫 if-else 處理欄位缺失與型別漂移</li>
<li>子文件越塞越深、單 document 突破 1-2MB、partial update 仍要把整顆 document load + write、IO 跟 working set 雙重壓力</li>
<li>反向過度 normalize：訂單跟訂單 item 拆兩個 collection、單一查詢得 N+1 <code>$lookup</code>、aggregation cost 飆</li>
<li>IoT / sensor / event log workload 寫進 regular collection、寫入吞吐撞牆但沒考慮 time-series collection</li>
<li><code>$lookup</code> 出現在 hot path、document size warning（16MB 上限預警）、partial update 卻產生大量 disk write、schema validation 報錯比例突然爬升</li>
</ul>
<p>Case anchor：<a href="/blog/backend/09-performance-capacity/cases/toyota-connected-mongodb-telematics-iot/" data-link-title="9.C38 Toyota Connected：MongoDB Atlas 撐 900 萬車輛 telematics、月 180 億 transaction" data-link-desc="Toyota Connected 用 MongoDB Atlas 撐 Safety Connect 900 萬車、月 180 億 transaction、緊急訊號 3 秒內到 agent">9.C38 Toyota Connected</a> 揭露車載 sensor schema 隨車型 / 年份 / 規範演進、polymorphic document 與 schema governance 並存；<a href="/blog/backend/09-performance-capacity/cases/forbes-mongodb-atlas-multi-cloud-migration/" data-link-title="9.C37 Forbes：自管 MongoDB → Atlas on GCP、build 時間 25 → 9 分鐘" data-link-desc="Forbes 把自管 MongoDB 遷到 Atlas on Google Cloud、6 個月完成、build 25 → 9 分鐘、120M 不重複訪客單月承接">9.C37 Forbes</a> 揭露 CMS 50+ 微服務透過自建中介 abstraction layer 隔離 schema 變動；<a href="/blog/backend/09-performance-capacity/cases/microsoft-365-cosmos-db-analytics/" data-link-title="9.C30 Microsoft 365：從 MongoDB 遷移到 Cosmos DB 的分析平台" data-link-desc="Microsoft 365 把使用分析平台從 MongoDB 遷移到 Cosmos DB、planet-scale 全球分散式分析">9.C30 Microsoft 365</a> 揭露 document model 保留 + 跨 vendor 形狀治理。早期 startup MongoDB 三代 schema 並存的具體 incident 細節需未來 case 補完、本文先以「常見 failure pattern」處理。</p>
<h2 id="核心機制aggregate-rootembeddedreferencepolymorphic">核心機制：aggregate root、embedded、reference、polymorphic</h2>
<p>MongoDB schema design 的第一層是 <em>aggregate root 決定 atomicity 邊界</em>。MongoDB 把寫入 atomicity 限制在「單 document 內」、跨 document 要 multi-document transaction（5.0+ 在 replica set / sharded cluster 都支援、但跨 shard 有性能成本）。aggregate root 是 DDD 概念落地到 MongoDB 的具體實作 — 把「一起讀、一起寫、一致性邊界一致」的資料塞同一個 document。</p>
<ul>
<li><strong>Embedded（subdocument / array）</strong>：寫入 atomic、讀取一次到位；代價是 update sub-element 仍要 rewrite 整顆 document，sub-element 寫頻很高時不適合</li>
<li><strong>Reference（手動 <code>_id</code> foreign key + <code>$lookup</code>）</strong>：document 大小可控，但 join 在 application 或 aggregation 階段做；JOIN-heavy workload 跑這條路徑會 N+1</li>
<li><strong>Polymorphic pattern</strong>：同 collection 用 <code>type</code> discriminator 存多型實體；MongoDB 沒 inheritance、靠 schema validator 與 partial index 維持邊界</li>
<li><strong>16MB document hard limit</strong>：是 MongoDB 機制邊界；working set 在 RAM 的隱性軟限制（單 doc 大小直接影響 page cache 效率）更早就會出問題</li>
</ul>
<h3 id="contract-layer-三條路徑">Contract layer 三條路徑</h3>
<p>跨 case 合成 frame（本章合成、Toyota + Forbes 共同揭露）：document model 的 schema flexibility 在 production 必須以 schema governance 對沖、否則「schema 自由」變「production data inconsistency」（Toyota case 明示）。讀者要選的不是「要不要做 schema governance」、是「contract 守在哪一層」。三條路徑：</p>
<table>
  <thead>
      <tr>
          <th>路徑</th>
          <th>實作機制</th>
          <th>適用條件</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>DB-layer contract</td>
          <td>MongoDB <code>$jsonSchema</code> validator + <code>validationLevel</code> + <code>validationAction</code></td>
          <td>Schema 穩定、多服務共用 collection、要 DB 擋髒資料</td>
      </tr>
      <tr>
          <td>App-layer contract</td>
          <td>自建 API abstraction + middleware schema 驗證</td>
          <td>Schema 演進快、微服務獨立 owner、跨雲彈性需求</td>
      </tr>
      <tr>
          <td>混合</td>
          <td>DB 層擋型別 / 必填、app 層擋業務語意 / 版本</td>
          <td>大型 production、多 owner、跨團隊</td>
      </tr>
  </tbody>
</table>
<p><strong>DB-layer 路徑</strong>：<code>$jsonSchema</code> validator 在 production 是「契約 enforcement」工具、不是 dev-time linter。設 <code>validationAction: &quot;error&quot;</code> 寫入直接擋；設 <code>&quot;warn&quot;</code> 只記 log。<code>validationLevel: &quot;moderate&quot;</code> 對既有 doc 放行、對新寫入嚴格；<code>&quot;strict&quot;</code> 對所有寫入都嚴格。適合 schema 穩定到「跨服務共用 collection」的程度。</p>
<p><strong>App-layer 路徑</strong>：9.C37 Forbes 揭露的模式 — 50+ 微服務透過自建中介 abstraction layer 看到穩定的 contract API、DB schema 變動限制在 owner microservice 內。Forbes 跨雲彈性能用起來、核心原因是 abstraction layer 把 schema 治理收斂到單點、跨雲遷移時 abstraction layer 不變、微服務不知道底層 DB 換 cluster 換雲。</p>
<p><strong>混合路徑</strong>：Atlas Application Services、enterprise schema registry 屬此類。DB 層 validator 守底線（欄位型別、必填欄位）、app 層 abstraction 守業務（版本欄位 / 相容處理 / cross-document 一致性）。代價是兩層都要維護、版本同步成本高、適合 production 規模真的撐住這個複雜度的團隊。</p>
<p>讀者選哪條路徑要看：team 規模 / collection 跨服務程度 / schema 演進速度。</p>
<h3 id="time-series-collection60">Time-series collection（6.0+）</h3>
<p>Time-series collection 是 MongoDB 為 IoT / sensor / event log / metrics 設計的 vendor-specific 機制 — 比 regular collection 寫入吞吐高 3-5x、storage 壓縮率更好。資料形狀必須是 <code>{ timestamp, metadata, measurement }</code> 三段式、timestamp 主導。</p>
<p>適用情境：sensor signal 高頻寫入、metrics 系統的 time series、application event log。<strong>不適用情境</strong>：schema 不以 timestamp 為主、需要跨 document update、需要 polymorphic discriminator。</p>
<p>9.C38 Toyota Connected 自承「20 個 Atlas database 沒明確說有沒有用 time series collection — 對 IoT 案例這是重要區分、但 case study 沒揭露」。寫進 production 時必須明示：IoT / sensor 場景該考慮 time-series collection、Toyota case 未揭露實際使用情況、不可寫成「Toyota 使用 time-series collection」。</p>
<p>對應 knowledge card：<a href="/blog/backend/knowledge-cards/document-store/" data-link-title="Document Store" data-link-desc="說明以 JSON 文件與彈性 schema 提供資料存取的模式，以及它仍需的治理邊界">document-store</a>、<a href="/blog/backend/knowledge-cards/transaction-boundary/" data-link-title="Transaction Boundary" data-link-desc="說明哪些資料變更應在同一個交易中一起成功或一起回復">transaction-boundary</a>（aggregate boundary = transaction boundary）、<a href="/blog/backend/knowledge-cards/data-inconsistency/" data-link-title="Data Inconsistency" data-link-desc="說明多份資料暫時不同步時如何判斷產品後果與修復責任">data-inconsistency</a>。</p>
<h2 id="操作流程">操作流程</h2>
<p><strong>Step 1：access pattern 盤點</strong>。列出 top 10 query / write、標 read together / write together 集合 — 這份清單決定 embedded vs reference vs polymorphic 的候選。</p>
<p><strong>Step 2：contract layer 決策</strong>。</p>
<table>
  <thead>
      <tr>
          <th>條件</th>
          <th>路徑</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Collection 跨多服務 + schema 穩定</td>
          <td>DB-layer validator</td>
      </tr>
      <tr>
          <td>Schema 演進快 + 微服務獨立 owner</td>
          <td>App-layer abstraction</td>
      </tr>
      <tr>
          <td>大型 production + 多 owner + 跨團隊</td>
          <td>混合（兩者並用）</td>
      </tr>
      <tr>
          <td>IoT / sensor / event log + timestamp 主導</td>
          <td>Time-series collection（取代 regular collection）</td>
      </tr>
  </tbody>
</table>
<p><strong>Step 3：embed 判準</strong> — 1:few、life-cycle 同步、&lt; 1MB 預期上限；<strong>reference 判準</strong> — 1:many 寫頻不對稱、跨 aggregate 引用。</p>
<p><strong>Step 4：DB-layer 路徑 validator 配置</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nx">db</span><span class="p">.</span><span class="nx">runCommand</span><span class="p">({</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="nx">collMod</span><span class="o">:</span> <span class="s2">&#34;orders&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="nx">validator</span><span class="o">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">$jsonSchema</span><span class="o">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">      <span class="nx">bsonType</span><span class="o">:</span> <span class="s2">&#34;object&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">      <span class="nx">required</span><span class="o">:</span> <span class="p">[</span><span class="s2">&#34;_id&#34;</span><span class="p">,</span> <span class="s2">&#34;tenantId&#34;</span><span class="p">,</span> <span class="s2">&#34;createdAt&#34;</span><span class="p">,</span> <span class="s2">&#34;items&#34;</span><span class="p">],</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">      <span class="nx">properties</span><span class="o">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">tenantId</span><span class="o">:</span> <span class="p">{</span> <span class="nx">bsonType</span><span class="o">:</span> <span class="s2">&#34;string&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nx">createdAt</span><span class="o">:</span> <span class="p">{</span> <span class="nx">bsonType</span><span class="o">:</span> <span class="s2">&#34;date&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">items</span><span class="o">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">          <span class="nx">bsonType</span><span class="o">:</span> <span class="s2">&#34;array&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">          <span class="nx">minItems</span><span class="o">:</span> <span class="mi">1</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">          <span class="nx">items</span><span class="o">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">            <span class="nx">bsonType</span><span class="o">:</span> <span class="s2">&#34;object&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">            <span class="nx">required</span><span class="o">:</span> <span class="p">[</span><span class="s2">&#34;sku&#34;</span><span class="p">,</span> <span class="s2">&#34;qty&#34;</span><span class="p">],</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">            <span class="nx">properties</span><span class="o">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">              <span class="nx">sku</span><span class="o">:</span> <span class="p">{</span> <span class="nx">bsonType</span><span class="o">:</span> <span class="s2">&#34;string&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">              <span class="nx">qty</span><span class="o">:</span> <span class="p">{</span> <span class="nx">bsonType</span><span class="o">:</span> <span class="s2">&#34;int&#34;</span><span class="p">,</span> <span class="nx">minimum</span><span class="o">:</span> <span class="mi">1</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">          <span class="p">}</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">      <span class="p">}</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">  <span class="p">},</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">  <span class="nx">validationLevel</span><span class="o">:</span> <span class="s2">&#34;moderate&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">  <span class="nx">validationAction</span><span class="o">:</span> <span class="s2">&#34;warn&#34;</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl"><span class="p">})</span></span></span></code></pre></div><p>灰度策略：先 <code>validationLevel: &quot;moderate&quot;</code> + <code>validationAction: &quot;warn&quot;</code> 觀察兩週、確認 application 不寫違規 doc、再切 <code>&quot;strict&quot;</code> + <code>&quot;error&quot;</code> 封死。</p>
<p><strong>Step 5：App-layer 路徑 abstraction 介面</strong>。9.C37 Forbes 揭露的模式 — middleware 攔截 microservice 寫入、驗 schema、套版本欄位、把 owner microservice 的 schema 變動隔離在 abstraction 內。</p>
<p><strong>Step 6：Polymorphic + partial index</strong> — <code>partialFilterExpression</code> 避免冷分支吃 index 成本：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">db</span><span class="p">.</span><span class="nx">events</span><span class="p">.</span><span class="nx">createIndex</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="p">{</span> <span class="nx">type</span><span class="o">:</span> <span class="mi">1</span><span class="p">,</span> <span class="nx">timestamp</span><span class="o">:</span> <span class="o">-</span><span class="mi">1</span> <span class="p">},</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="p">{</span> <span class="nx">partialFilterExpression</span><span class="o">:</span> <span class="p">{</span> <span class="nx">type</span><span class="o">:</span> <span class="p">{</span> <span class="nx">$in</span><span class="o">:</span> <span class="p">[</span><span class="s2">&#34;click&#34;</span><span class="p">,</span> <span class="s2">&#34;purchase&#34;</span><span class="p">]</span> <span class="p">}</span> <span class="p">}</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">)</span></span></span></code></pre></div><p><strong>Step 7：量測 doc 形狀</strong>。用 <code>bsondump</code> + <code>$bsonSize</code> + <code>collStats</code> 量測：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">db</span><span class="p">.</span><span class="nx">coll</span><span class="p">.</span><span class="nx">aggregate</span><span class="p">([</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="p">{</span> <span class="nx">$group</span><span class="o">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">      <span class="nx">_id</span><span class="o">:</span> <span class="kc">null</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">      <span class="nx">avg</span><span class="o">:</span> <span class="p">{</span> <span class="nx">$avg</span><span class="o">:</span> <span class="p">{</span> <span class="nx">$bsonSize</span><span class="o">:</span> <span class="s2">&#34;$$ROOT&#34;</span> <span class="p">}</span> <span class="p">},</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">      <span class="nx">max</span><span class="o">:</span> <span class="p">{</span> <span class="nx">$max</span><span class="o">:</span> <span class="p">{</span> <span class="nx">$bsonSize</span><span class="o">:</span> <span class="s2">&#34;$$ROOT&#34;</span> <span class="p">}</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="p">}}</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">])</span></span></span></code></pre></div><p>驗證點：avgObjSize 在預期範圍、validator failure rate &lt; SLO、abstraction layer schema mismatch rate 可追溯。</p>
<p><strong>Rollback boundary</strong>：validator 從 <code>strict</code> 退回 <code>moderate</code> 是 single-command、application code 不必改；abstraction layer 換版需 application code 灰度；已 embed 進去的 schema 變更要靠 backfill migration script、無法 in-place 還原。</p>
<h2 id="失敗模式">失敗模式</h2>
<p><strong>Unbounded array growth</strong>：把「使用者所有訊息」embed 進 user document、document 撞 16MB → 寫入直接 reject。修法是改 reference、訊息獨立 collection、用 <code>userId</code> 索引。</p>
<p><strong>Hot subdocument update</strong>：所有寫都打同一個 nested field、wiredTiger document-level lock 退化成熱點，concurrency 看似多核卻被序列化。修法是把熱寫欄位拆 reference document、或改 sharded collection 把寫散開（見 <a href="../shard-key-selection/">shard key selection</a>）。</p>
<p><strong><code>$lookup</code> 在 hot path</strong>：reference 沒設好變 join、p99 latency 隨 collection 大小線性退化。修法是 schema design 階段 denormalize、把 read-together 資料 embed 回 aggregate root；或 <code>$merge</code> 寫 materialized view（見 <a href="../aggregation-pipeline-optimization/">aggregation pipeline optimization</a>）。</p>
<p><strong>Schema 三代並存（缺 contract layer）</strong>：缺 validator 跟 abstraction layer、舊版欄位殘留、application code 三層 fallback、新 dev onboarding 看不懂哪個欄位是現役。9.C38 Toyota 揭露：document model 的彈性「成本是 production 必須做 schema governance」、否則「schema 自由」變「production data inconsistency」。</p>
<p><strong>Abstraction layer 變成 lock-in</strong>：app-layer contract 寫得太重、跨 vendor 遷移時 abstraction 本身要重寫。該層應該薄、只做 schema 隔離、不做業務邏輯。</p>
<p><strong>Polymorphic 全表掃描</strong>：discriminator 沒進 index、<code>type: &quot;rare&quot;</code> 查詢全表 scan。修法用 partial index 把熱類型蓋住、冷類型走全表也只是冷路徑。</p>
<p><strong>Time-series collection 用錯場景</strong>：把非 timestamp 主導資料塞進 time-series collection、失去 flexibility 又拿不到吞吐紅利。Time-series collection 是專屬優化、不是普適 collection 升級。</p>
<p>Anti-recommendation：</p>
<ul>
<li>access pattern 還沒穩定的早期 MVP 不需要鎖死 schema validator；先用 app-layer abstraction、production 穩定後再決定 DB 層該不該封死</li>
<li>JOIN-heavy / 強 normalize workload 一開始就該回 PostgreSQL JSONB 或 SQL、不是塞進 MongoDB 再 <code>$lookup</code></li>
<li>跨案合成 frame：「不是所有資料都該進 MongoDB」、document-shaped + 形狀變化頻繁的進、access pattern 固定的 KV 走 KV（9.C36 Coinbase 揭露 MongoDB + DynamoDB 按 workload 分流）</li>
</ul>
<h2 id="容量與觀測">容量與觀測</h2>
<p>關鍵 metric：</p>
<ul>
<li><strong>Document 形狀</strong>：<code>collStats.avgObjSize</code>、<code>collStats.size</code> vs <code>storageSize</code>（壓縮比）</li>
<li><strong>Contract 健康</strong>：document validation failure rate、abstraction layer schema mismatch rate</li>
<li><strong>Working set 壓力</strong>：<code>wiredTiger.cache.bytes currently in the cache</code> 對比 working set 估算</li>
<li><strong>Aggregation 副作用</strong>：profiler slow op、<code>$lookup</code> / <code>$unwind</code> 在 hot path 出現位置</li>
</ul>
<p>Mongo command：</p>
<ul>
<li><code>db.coll.stats()</code> 看 document 平均 / 最大 size、storage / index size</li>
<li><code>db.runCommand({collMod: ..., validator: ...})</code> 改 validator</li>
<li><code>db.setProfilingLevel(1, {slowms: 100})</code> 抓 slow op</li>
</ul>
<p>回到 <a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">4.20 observability evidence</a>：把 doc size 分布、validator failure rate、abstraction layer schema mismatch、<code>$lookup</code> 出現位置列為 evidence 三件套。</p>
<p>回到 <a href="/blog/backend/09-performance-capacity/bottleneck-localization/" data-link-title="9.5 瓶頸定位流程" data-link-desc="從 app 到 DB / cache / broker / 第三方 quota 的逐層瓶頸定位">9.5 bottleneck localization</a>：working set 撐爆 RAM 時的 page fault 信號、跟 doc size 異常增長強相關。</p>
<h2 id="邊界與整合">邊界與整合</h2>
<p>Sibling deep articles：</p>
<ul>
<li><a href="../shard-key-selection/">shard key selection</a> — document 形狀決定 shard key 候選空間</li>
<li><a href="../aggregation-pipeline-optimization/">aggregation pipeline optimization</a> — <code>$lookup</code> 與 schema reference 互相牽動</li>
<li><a href="../connection-management-and-cache-layer/">connection management and cache layer</a> — abstraction layer 跟 cache 層協作</li>
</ul>
<p>Migration playbook：</p>
<ul>
<li>document 形狀走樣到無法治理時的 <a href="/blog/backend/01-database/large-scale-db-migration/" data-link-title="1.12 大規模 DB 遷移實戰" data-link-desc="跨 DB 遷移的 dual-write、[shadow read](/backend/knowledge-cards/shadow-read/)、cutover、rollback 流程 — 從實戰案例提煉的工程做法">→ MongoDB → PostgreSQL 拆 normalize</a> 路徑</li>
<li>保留 document model 換 vendor 三型對照 — 保留主 DB 補周邊（Coinbase）/ 同 DB 換託管（Forbes Atlas）/ 同 model 換 vendor（<a href="/blog/backend/01-database/vendors/cosmosdb/" data-link-title="Azure Cosmos DB" data-link-desc="全球分散式 multi-model DB、5 個 consistency levels、Microsoft 自家 dogfood 證據">Microsoft 365 Cosmos DB MongoDB API</a>）</li>
</ul>
<p>跟 1.x 互引：<a href="/blog/backend/01-database/schema-design/" data-link-title="1.2 Schema Design 與資料建模" data-link-desc="整理 table、index、key、partition、denormalization 與命名規則">1.2 schema design</a> 處理通用 schema 演進原則、本文是 MongoDB-specific 落地；<a href="/blog/backend/01-database/transaction-boundary/" data-link-title="1.3 Transaction 與一致性邊界" data-link-desc="交易邊界、isolation level、retry 策略、distributed transaction（2PC、Saga）與跨 region 強一致取捨">1.3 transaction boundary</a> 對齊 aggregate = atomic 邊界。</p>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/mongodb/" data-link-title="MongoDB" data-link-desc="Document database 代表、Atlas managed、跨雲可用、許多大規模平台從 MongoDB 起家">MongoDB vendor overview</a> — 本文是該頁尾「schema design pattern」backlog 的深度展開</li>
<li><a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章方法論</a></li>
<li><a href="/blog/backend/09-performance-capacity/cases/toyota-connected-mongodb-telematics-iot/" data-link-title="9.C38 Toyota Connected：MongoDB Atlas 撐 900 萬車輛 telematics、月 180 億 transaction" data-link-desc="Toyota Connected 用 MongoDB Atlas 撐 Safety Connect 900 萬車、月 180 億 transaction、緊急訊號 3 秒內到 agent">9.C38 Toyota Connected</a> — polymorphic + governance</li>
<li><a href="/blog/backend/09-performance-capacity/cases/forbes-mongodb-atlas-multi-cloud-migration/" data-link-title="9.C37 Forbes：自管 MongoDB → Atlas on GCP、build 時間 25 → 9 分鐘" data-link-desc="Forbes 把自管 MongoDB 遷到 Atlas on Google Cloud、6 個月完成、build 25 → 9 分鐘、120M 不重複訪客單月承接">9.C37 Forbes</a> — abstraction layer 模式</li>
<li>官方：<a href="https://www.mongodb.com/docs/manual/core/data-modeling-introduction/">MongoDB Data Modeling</a>、<a href="https://www.mongodb.com/docs/manual/core/schema-validation/">Schema Validation</a>、<a href="https://www.mongodb.com/docs/manual/core/timeseries-collections/">Time Series Collections</a></li>
</ul>
]]></content:encoded></item><item><title>Spanner TrueTime API 深度：GPS + 原子鐘、commit wait、為什麼 line-rate scaling 才是設計目的</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/spanner/truetime-api-depth/</link><pubDate>Wed, 27 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/spanner/truetime-api-depth/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/spanner/" data-link-title="Google Cloud Spanner" data-link-desc="全球分散式 strong-consistency OLTP、TrueTime API、線性擴展到 10 億 req/sec">Cloud Spanner&lt;/a> overview 的 implementation-layer deep article。Overview 已說明 Spanner 在全球 OLTP 譜系的定位、本文聚焦 &lt;em>TrueTime API&lt;/em> — Spanner 用來消滅 single coordinator bottleneck、換到 line-rate scaling 的核心機制。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="商業邏輯先行truetime-是手段line-rate-scaling-才是目的">商業邏輯先行：TrueTime 是手段、line-rate scaling 才是目的&lt;/h2>
&lt;p>TrueTime 的設計目的是消滅 single coordinator bottleneck、讓 OLTP 拿到 line-rate scaling — external consistency 只是這條路徑上拿到的副產品。讀者若把 TrueTime 當成「一個保證 external consistency 的精巧時間 trick」、會誤把工具當目標、後續所有 commit wait / Paxos / GPS 細節都解錯方向。&lt;/p>
&lt;p>傳統 OLTP（PostgreSQL、MySQL、Cloud SQL）跨節點交易要靠一個 coordinator 決定全局順序、coordinator 本身就是 bottleneck。&lt;code>1x node = 1x throughput&lt;/code> 的線性擴展在 single-primary 模型撞牆、想 scale 只能往應用層 sharding 走、付管理 shard key / 跨 shard query / resharding 的代價。Spanner 換掉這條路徑：TrueTime 把 wall-clock 變成跨 datacenter 可比較的 &lt;em>interval&lt;/em>、Paxos 把 coordinator 變成「拓樸感知的多 leader」（每個 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/range-sharding/" data-link-title="Range Sharding" data-link-desc="分散式 SQL 把 key space 切成可自動 split / merge 的 range、每個 range 自己的 consensus group、application 透明">Range Sharding&lt;/a> split 自己的 Paxos group 各自前進）、commit timestamp 用 TrueTime 對齊到 real-time 順序、不再需要一個全局 coordinator 串行所有 transaction。&lt;/p>
&lt;p>9.C10 Cloud Spanner planetary scale case 揭露的線性擴展證據：「2 nodes → 45K reads/sec、4 nodes → 90K reads/sec」是 Spanner 設計目標的直接證據、不只是 marketing 數字。這條揭露 Spanner external consistency 不是「加強版 serializable isolation」、是「coordinator 換拓樸」的 paradigm shift。寫到這裡讀者該意識到一件事：選 Spanner 不是選一個更貴更強的 SQL、是選一條 &lt;em>把 coordinator 拆掉&lt;/em> 的 scaling 路徑。&lt;/p>
&lt;p>&lt;strong>Dogfood 邊界（本文反覆強調）&lt;/strong>：9.C10 是 Google internal dogfood case、不是 customer-facing capacity 參考。「10 億 req/sec」是 Google 全使用者加總、不是單一 instance 配額；「2 nodes → 45K reads / 4 nodes → 90K reads」是 Google internal benchmark 揭露的線性擴展 &lt;em>模式&lt;/em>、不是客戶 SLA 承諾。本文後續所有 9.C10 數字引用都會明示這條邊界、避免讀者誤把 dogfood 當配額。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/spanner/" data-link-title="Google Cloud Spanner" data-link-desc="全球分散式 strong-consistency OLTP、TrueTime API、線性擴展到 10 億 req/sec">Cloud Spanner</a> overview 的 implementation-layer deep article。Overview 已說明 Spanner 在全球 OLTP 譜系的定位、本文聚焦 <em>TrueTime API</em> — Spanner 用來消滅 single coordinator bottleneck、換到 line-rate scaling 的核心機制。</p></blockquote>
<hr>
<h2 id="商業邏輯先行truetime-是手段line-rate-scaling-才是目的">商業邏輯先行：TrueTime 是手段、line-rate scaling 才是目的</h2>
<p>TrueTime 的設計目的是消滅 single coordinator bottleneck、讓 OLTP 拿到 line-rate scaling — external consistency 只是這條路徑上拿到的副產品。讀者若把 TrueTime 當成「一個保證 external consistency 的精巧時間 trick」、會誤把工具當目標、後續所有 commit wait / Paxos / GPS 細節都解錯方向。</p>
<p>傳統 OLTP（PostgreSQL、MySQL、Cloud SQL）跨節點交易要靠一個 coordinator 決定全局順序、coordinator 本身就是 bottleneck。<code>1x node = 1x throughput</code> 的線性擴展在 single-primary 模型撞牆、想 scale 只能往應用層 sharding 走、付管理 shard key / 跨 shard query / resharding 的代價。Spanner 換掉這條路徑：TrueTime 把 wall-clock 變成跨 datacenter 可比較的 <em>interval</em>、Paxos 把 coordinator 變成「拓樸感知的多 leader」（每個 <a href="/blog/backend/knowledge-cards/range-sharding/" data-link-title="Range Sharding" data-link-desc="分散式 SQL 把 key space 切成可自動 split / merge 的 range、每個 range 自己的 consensus group、application 透明">Range Sharding</a> split 自己的 Paxos group 各自前進）、commit timestamp 用 TrueTime 對齊到 real-time 順序、不再需要一個全局 coordinator 串行所有 transaction。</p>
<p>9.C10 Cloud Spanner planetary scale case 揭露的線性擴展證據：「2 nodes → 45K reads/sec、4 nodes → 90K reads/sec」是 Spanner 設計目標的直接證據、不只是 marketing 數字。這條揭露 Spanner external consistency 不是「加強版 serializable isolation」、是「coordinator 換拓樸」的 paradigm shift。寫到這裡讀者該意識到一件事：選 Spanner 不是選一個更貴更強的 SQL、是選一條 <em>把 coordinator 拆掉</em> 的 scaling 路徑。</p>
<p><strong>Dogfood 邊界（本文反覆強調）</strong>：9.C10 是 Google internal dogfood case、不是 customer-facing capacity 參考。「10 億 req/sec」是 Google 全使用者加總、不是單一 instance 配額；「2 nodes → 45K reads / 4 nodes → 90K reads」是 Google internal benchmark 揭露的線性擴展 <em>模式</em>、不是客戶 SLA 承諾。本文後續所有 9.C10 數字引用都會明示這條邊界、避免讀者誤把 dogfood 當配額。</p>
<p><strong>Fact vs derive 分層警告</strong>：本段「coordinator bottleneck → TrueTime + Paxos」frame 是跨 Spanner 2012 OSDI 論文 + 公開文件（2024-2026）+ 9.C10 case 合成的工程 frame、不是 9.C10 case 直接展開實作層細節。9.C10 案例直接揭露的 fact 是線性擴展數字跟 dogfood 邊界；本文 derive 的 frame 是「為什麼傳統 OLTP coordinator 是 bottleneck」。引用時這條分層在每段引用具體數字時都會重申。</p>
<h2 id="問題情境跨-region-oltp-的順序漏洞">問題情境：跨 region OLTP 的順序漏洞</h2>
<p>跨 region OLTP 想保證「全球用戶看到的交易順序跟 wall clock 一致」、但 NTP 同步誤差動輒 10-100ms、足夠讓 region A 已 commit 的計費事件被 region B 看到一個更新的 timestamp 卻是舊狀態。讀者徵兆通常從這幾個地方浮現：分散式系統團隊在 Cloud SQL / Aurora 多 region 上做 read replica、發現「跨 region read 順序顛倒」、audit log timestamp 不可靠、reconcile 對帳對不上、業務以為自己用了 transaction 就有「強一致」、實際只有 single-node 的 serializable isolation。</p>
<p>真實壓力場景：Google Ads 計費需要把每筆扣款事件放進可驗證的 <em>外部</em> 順序、不只是 transaction 內部 serializable。讀者若把這套需求帶回自家系統、會發現一條共同訊號 — 「兩個 transaction 都 commit 成功、用戶體感卻違反順序」這種事故、不是 isolation level 的問題、是 <em>external consistency</em> 的問題。</p>
<p>Case anchor：<a href="/blog/backend/09-performance-capacity/cases/spanner-planetary-scale-database-gcp/" data-link-title="9.C10 Cloud Spanner：每秒 10 億請求的全球一致性資料庫" data-link-desc="Google Cloud Spanner 內部峰值 10 億 req/sec、跨地區強一致 — 全球分散式 OLTP 容量參考">9.C10 Cloud Spanner planetary scale</a> — Google Ads / Play 訂閱 / Search 計費跟 TrueTime 綁定。<strong>dogfood 邊界明示</strong>：9.C10 是 Google 內部 dogfood case、不是 customer-facing capacity 參考；引用其揭露的線性 scaling 模式時要分清「設計目標證據」vs「客戶可獲得配額」。</p>
<h2 id="核心機制truetime-的-api-跟硬體基礎">核心機制：TrueTime 的 API 跟硬體基礎</h2>
<p>TrueTime 對外只有兩個 primitive — <code>TT.now()</code> 回傳一個 <em>interval</em> <code>[earliest, latest]</code>、不是單一時刻；<code>TT.after(t)</code> / <code>TT.before(t)</code> 判斷一個事件是否確定在 t 之後 / 之前。整個 external consistency 演算法都建立在「時間是一個 interval、不是一個點」這個 API 設計上。</p>
<h3 id="硬體基礎gps--原子鐘冗餘">硬體基礎：GPS + 原子鐘冗餘</h3>
<p>每個 datacenter 部署 GPS 接收器 + 原子鐘（armageddon master、用來防 GPS 全網干擾）、time master 之間互相比對排除離群值、TrueTime daemon 從多個 master 拉時間並算 worst-case bound。GPS 給 absolute time reference、原子鐘給 short-term stability（GPS 短暫失聯時仍能用 drift bound 撐過去）。雙來源是為了把 ε 的失敗模式限制在「絕大多數時間 ε ≤ 7ms、極端事件下 ε spike 但不會無限制漂移」。</p>
<h3 id="不確定性-εepsilon">不確定性 ε（epsilon）</h3>
<p>跨 datacenter 同步 + clock drift 估計、ε 目標維持在 1-7ms 區間。</p>
<p><strong>Fact source 分層警告</strong>：1-7ms 是 Google 2012 OSDI 論文 + Spanner 公開文件（2024-2026）引用的範圍、9.C10 dogfood case 未直接揭露 production ε 分布。引用時這組數字明標「來自 Spanner vendor docs / 2012 論文、不是 9.C10 case 直接揭露」、避免讀者把兩種來源混為一談。</p>
<h3 id="commit-wait-機制external-consistency-的核心">Commit wait 機制：external consistency 的核心</h3>
<p>read-write transaction 要拿 commit timestamp s 時、Spanner 設 <code>s = TT.now().latest</code>、然後 <em>等待</em> 直到 <code>TT.after(s)</code> 才回 ACK。這段「等」就是 <a href="/blog/backend/knowledge-cards/commit-wait/" data-link-title="Commit Wait" data-link-desc="Spanner external consistency 的核心機制 — read-write transaction 拿 commit timestamp s 後等到 TT.after(s) 才 ACK、wait ≈ 2ε、付 latency tax 換 commit 順序 = real-time 順序">Commit Wait</a> — Spanner 特有的物理延遲、由 TrueTime ε 主導、跟 <a href="/blog/backend/knowledge-cards/cross-region-quorum/" data-link-title="Cross-Region Quorum" data-link-desc="multi-region distributed SQL 強制 voting replica 跨 region、commit 等多 region quorum ack、跨洲 RTT 物理硬限">Cross-Region Quorum</a> 的網路 RTT 是兩個獨立的延遲來源、不能混算。</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">T1 開始 commit            T1 確定可回 ACK
</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">       v                          v
</span></span><span class="line"><span class="ln">4</span><span class="cl">TT.now().earliest .... s = TT.now().latest .... TT.after(s)
</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">                            |---------- commit wait ≈ ε ----------|
</span></span><span class="line"><span class="ln">7</span><span class="cl">       |---------- total commit wait ≈ 2ε（從拿 s 那刻開始） ---------|</span></span></code></pre></div><p>commit wait ≈ 2ε 的數學保證了「下一個 transaction 拿到的 timestamp 一定 &gt; s」、external consistency 的全序性質就由這個 wait 撐住。<strong>Fact source 分層</strong>：commit wait ≈ 2ε 的推導來自 Spanner 2012 OSDI 論文 + 官方文件、不是 9.C10 case 直接展開實作層數學。引用這條數學要附「來源 vendor docs / paper」、避免讀者誤以為這是 case 揭露。</p>
<h3 id="跟通用-linearizability-卡片的差異">跟通用 linearizability 卡片的差異</h3>
<p><a href="/blog/backend/knowledge-cards/linearizability/" data-link-title="Linearizability" data-link-desc="每次操作看起來都在單一全域順序中即時生效的一致性語意">Linearizability</a> 只要求「存在某個全序」、external consistency 進一步要求「全序跟 real-time 順序一致」。TrueTime 是把後者變可實作的關鍵 — 它把跨 datacenter 的「real-time 順序」變成可機械判定的 <code>TT.after(s)</code>、不需要全局 coordinator 來決定誰先誰後。對應的概念卡：<a href="/blog/backend/knowledge-cards/external-consistency/" data-link-title="External Consistency" data-link-desc="交易可見順序與外部真實時間順序一致的強一致性語意">external-consistency</a>、<a href="/blog/backend/knowledge-cards/linearizability/" data-link-title="Linearizability" data-link-desc="每次操作看起來都在單一全域順序中即時生效的一致性語意">linearizability</a>、<a href="/blog/backend/knowledge-cards/quorum/" data-link-title="Quorum" data-link-desc="分散式系統以多數節點同意作為提交或讀取有效性的門檻">quorum</a>。</p>
<h2 id="操作流程怎麼觀測-ε-跟調用-truetime">操作流程：怎麼觀測 ε 跟調用 TrueTime</h2>
<p>TrueTime 本身不對外暴露給 application 操作、ε / commit wait 由 Spanner 內部執行。團隊能做的是 <em>觀測</em> ε 跟 <em>選擇</em> 不同強度的 read consistency。</p>
<h3 id="觀測-ε">觀測 ε</h3>
<p>Cloud Monitoring metric <code>spanner.googleapis.com/instance/clock_skew_ms</code> 是 ε 的對外指標、判讀正常 &lt; 7ms、異常 spike &gt; 50ms 代表 time master 失聯或 GPS 干擾。把這條 metric 跟 <code>commit_latencies</code> p99 配成 evidence pair：ε spike 時 commit latency heatmap 應該整層平移、若 commit latency 動但 ε 沒動、不是 TrueTime 的問題、是 quorum / network 的問題。</p>
<h3 id="跨-region-instance-配置時的-truetime-影響">跨 region instance 配置時的 TrueTime 影響</h3>
<p>voting region 越分散、ε 上限越高、commit wait 越長 → write latency 直接受 ε 影響。multi-region instance config 在做 region layout 決策時要把「voting region 散布範圍」當 latency budget 的固定支出、不是配完才補觀測。</p>
<h3 id="read-only-transaction-的-staleness-選項">read-only transaction 的 staleness 選項</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">strong              → 等 TrueTime 確認可讀最新、付完整 commit wait + quorum cost
</span></span><span class="line"><span class="ln">2</span><span class="cl">exact_staleness(t)  → 讀 t 秒前快照、避開 commit wait、適合 reporting / analytics
</span></span><span class="line"><span class="ln">3</span><span class="cl">bounded_staleness(t)→ 容忍 t 秒、可讀最近的本地 replica 副本、不跨 region quorum</span></span></code></pre></div><p>stale / bounded staleness 走的是 Spanner 版的 <a href="/blog/backend/knowledge-cards/follower-read/" data-link-title="Follower Read" data-link-desc="分散式 SQL 從 non-voting replica 讀 closed timestamp 之前的資料、不參與 Raft commit、低 latency 但 read-after-write 場景仍可能 stale">Follower Read</a> — 本地 replica serve 不參與 commit 的 read、避開跨 region quorum 把 read latency 降到 single-region 等級。</p>
<p>三者 trade-off 在 SDK 層顯式設定、不是 isolation level：</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">// Spanner Go SDK 範例（time-sensitive、查最新文件確認 API）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">client</span><span class="p">.</span><span class="nf">Single</span><span class="p">().</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nf">WithTimestampBound</span><span class="p">(</span><span class="nx">spanner</span><span class="p">.</span><span class="nf">MaxStaleness</span><span class="p">(</span><span class="mi">10</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Second</span><span class="p">)).</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nf">Query</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">statement</span><span class="p">)</span></span></span></code></pre></div><h3 id="驗證點跟-rollback-boundary">驗證點跟 rollback boundary</h3>
<p>跑 cross-region write + cross-region read benchmark、量 p50 / p99 write latency、確認 ≈ 2ε + quorum RTT 的數量級。TrueTime 配置不由用戶調、commit wait 由 Spanner 自動執行；應用層 rollback boundary 在「改用 stale read / bounded staleness」而不是「關掉 TrueTime」 — TrueTime 是 Spanner 內部不可關的機制、不是 feature flag。</p>
<h2 id="失敗模式ε-暴衝跟誤用-strong-read">失敗模式：ε 暴衝跟誤用 strong read</h2>
<h3 id="ε-暴衝time-master-失聯">ε 暴衝（time master 失聯）</h3>
<p>GPS 干擾、datacenter time master 雙故障、ε 從 4ms 跳到 200ms → 所有 write 的 commit wait 暴增、p99 write latency 從 50ms 變 500ms。徵兆是 Cloud Monitoring <code>commit_latencies</code> heatmap 整層平移、<code>clock_skew_ms</code> 同步上升。根因不在 application、在 datacenter 物理層、修法是等 GCP 內部 time master 恢復、應用層只能臨時降到 bounded staleness 救 read path。</p>
<h3 id="把-strong-read-用在不需要的路徑">把 strong read 用在不需要的路徑</h3>
<p>報表、analytics、user profile fetch 全用 strong read、每次 read 都付 TrueTime 對齊代價、p99 read 跟 write 同步退化。徵兆是 <code>commit_latencies</code> 沒動、但 <code>api/request_latencies</code> for <code>ExecuteSql</code> 整體上升。修法是把 read path 分類、reporting / analytics 改 bounded staleness、保留 strong read 給「讀後決策再寫」的 critical path。</p>
<h3 id="在-client-側做自己的-timestamp">在 client 側做「自己的 timestamp」</h3>
<p>application 用 <code>time.Now()</code> 當業務 key、跨 region 寫入時 client clock skew 直接破壞順序 — Spanner 內部 external consistency 對、業務層卻錯。徵兆是對帳系統發現 timestamp 順序顛倒、但 Spanner audit log 都 OK。修法是業務層 timestamp 全改用 Spanner <code>PENDING_COMMIT_TIMESTAMP</code> sentinel、commit 時由 Spanner 填、不靠 client clock。</p>
<h3 id="把-spanner-當-single-region-sql-用卻配-multi-region-instance">把 Spanner 當 single-region SQL 用、卻配 multi-region instance</h3>
<p>每筆 write 都付跨洲 quorum + commit wait、cost 跟 latency 都浪費。徵兆是 instance config 是 multi-region 但實際 read 99% 來自單一 region、write 也是。修法是降到 regional instance、把跨 region 需求改用 read-only replica 或 export 到 BigQuery。</p>
<h3 id="ε-沒監控">ε 沒監控</h3>
<p>團隊直到事故才看 clock_skew metric、被動處理而非主動告警。建議 <code>clock_skew_ms &gt; 20ms</code> warn、<code>&gt; 50ms</code> page、跟 commit_latencies p99 偏離 baseline 2x 一起當 saturation discovery 訊號（回 <a href="/blog/backend/09-performance-capacity/saturation-discovery/" data-link-title="9.4 Saturation Discovery" data-link-desc="找出 throughput plateau 與 latency knee 的方法">9.4 Saturation Discovery</a>）。</p>
<h2 id="容量與觀測truetime-ε-是-latency-budget-的固定支出">容量與觀測：TrueTime ε 是 latency budget 的固定支出</h2>
<p>必看 metric：</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">commit_latencies (p50 / p95 / p99)        → commit wait + quorum RTT 的總和
</span></span><span class="line"><span class="ln">2</span><span class="cl">api/request_count by method               → strong read vs stale read 的分布
</span></span><span class="line"><span class="ln">3</span><span class="cl">instance/cpu/utilization_by_priority      → high / low priority 分流
</span></span><span class="line"><span class="ln">4</span><span class="cl">clock_skew_ms                             → TrueTime ε 的對外指標</span></span></code></pre></div><p>用 <a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">4.20 Observability Evidence Package</a> 框架把 TrueTime ε 跟 commit latency 配成 evidence pair。Capacity 規劃路由回 <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>、把「ε × write rate」當 latency budget 的固定支出 — 寫越多筆、commit wait 累積成本越高、不是 free。</p>
<p>Alert 建議：</p>
<table>
  <thead>
      <tr>
          <th>Metric</th>
          <th>Warn</th>
          <th>Page</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>clock_skew_ms</code></td>
          <td>&gt; 20ms</td>
          <td>&gt; 50ms</td>
      </tr>
      <tr>
          <td><code>commit_latencies</code> p99</td>
          <td>baseline 1.5x</td>
          <td>baseline 2x</td>
      </tr>
      <tr>
          <td><code>low_priority_utilization</code></td>
          <td>&gt; 80%</td>
          <td>&gt; 90%</td>
      </tr>
  </tbody>
</table>
<h3 id="line-rate-scaling-驗證呼應商業邏輯先行段">Line-rate scaling 驗證（呼應商業邏輯先行段）</h3>
<p>擴 node 數時量「read throughput / node」是否維持線性 — 9.C10 揭露的 2 → 4 nodes = 45K → 90K reads/sec 是 Google internal dogfood 的線性模式、不是客戶 SLA 承諾。團隊在自己 instance 上要驗證的不是「能不能達到 90K reads」、是「擴 node 後 throughput / node 有沒有保持線性」。若曲線 sub-linear、檢查是否 hot split / hot range / Paxos group 不均、TrueTime 機制本身不解這層。</p>
<h2 id="邊界與整合何時不用-truetime或不用-spanner">邊界與整合：何時不用 TrueTime（或不用 Spanner）</h2>
<h3 id="何時改用-stale-read">何時改用 stale read</h3>
<p>reporting / analytics / dashboard 場景改用 bounded staleness 換 cost、不付 commit wait 的 latency tax。判準：若這個 read path 用 5 秒前的資料不會影響業務決策、改 stale read；若會、保留 strong read。</p>
<h3 id="何時不該升-spanner">何時不該升 Spanner</h3>
<p>單 region workload 不該為了 external consistency 升 Spanner、Cloud SQL + serializable isolation 已經夠。9.C10 dogfood 揭露的線性 scaling 是「跨 region + 大規模」場景的設計目標、單 region 用戶拿不到對應的 cost / latency benefit。詳見遷移判讀：<a href="../migrate-from-cloud-sql-pg/">Cloud SQL → Spanner Migration Playbook</a> 的 no-go condition 段。</p>
<h3 id="sibling-deep-articles-路由">Sibling deep articles 路由</h3>
<ul>
<li><a href="../consistency-models-comparison/">consistency-models-comparison</a>：為什麼 external consistency ≠ serializability ≠ linearizability、line-rate scaling 對照表、cross-region quorum 100-200ms 物理硬限</li>
<li><a href="../schema-migration-interleaved-tables/">schema-migration-interleaved-tables</a>：schema change 也用 TrueTime 保證 version 邊界、parent-child storage layout</li>
<li><a href="../migrate-from-cloud-sql-pg/">migrate-from-cloud-sql-pg</a>：cutover 階段需要把 application 對 timestamp 的假設審一遍（特別是 client 端 <code>time.Now()</code> 那條失敗模式）</li>
</ul>
<h3 id="跟-1x-章節的互引">跟 1.x 章節的互引</h3>
<ul>
<li><a href="/blog/backend/01-database/global-distributed-oltp/" data-link-title="1.11 全球分散式 OLTP" data-link-desc="Spanner / Aurora DSQL / Cosmos DB multi-region write / CockroachDB / TiDB 的全球一致性取捨">1.11 全球分散式 OLTP</a>：Spanner 是 PC 系統的代表、Cosmos DB AP 系統當對照</li>
<li><a href="/blog/backend/knowledge-cards/transaction-boundary/" data-link-title="Transaction Boundary" data-link-desc="說明哪些資料變更應在同一個交易中一起成功或一起回復">transaction boundary</a>：external consistency 是 transaction boundary 的全球延伸</li>
</ul>
<h3 id="anti-recommendation">Anti-recommendation</h3>
<p>讀者讀完本文應該能判斷：TrueTime 不是「保證強一致」的功能、是「換 scaling 路徑」的核心；若團隊只想要「強一致」、不需要「跨節點線性擴展」、PostgreSQL serializable + 應用層補上 client-side ordering 就夠、不必為 TrueTime 付 GCP lock-in 的 cost。</p>
]]></content:encoded></item><item><title>pgvector Deep Dive：HNSW / IVFFlat 取捨跟跟專業 Vector DB 對比</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/pgvector-deep-dive/</link><pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/pgvector-deep-dive/</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>pgvector extension&lt;/em> — 用 PG 解 vector search workload 的路徑、是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/extension-ecosystem/" data-link-title="PostgreSQL Extension Ecosystem：把 PG 變成 vector DB / time-series / sharded 的 plugin 生態" data-link-desc="PG 的 extension 機制不只是 plugin、是 *結構性產品線擴張* — pgvector 讓 PG 變 vector DB、TimescaleDB 變 time-series、Citus 變 sharded、PostGIS 變 GIS。本文走 PG extension lifecycle、6 個 production-critical extension（pg_stat_statements / pg_partman / pg_repack / pgvector / TimescaleDB / PostGIS）、5 production 踩雷（extension version 跟 PG version 對齊 / managed PG 限制 / upgrade order / shared_preload_libraries 衝突 / extension 跟 logical replication 互動）、cloud vendor 對 extension 的限制">extension-ecosystem&lt;/a> 內最受關注的 extension。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="pgvector-是-pg-變-vector-db-的最短路徑">pgvector 是 PG 變 Vector DB 的最短路徑&lt;/h2>
&lt;p>pgvector 加兩件事：&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="n">EXTENSION&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">vector&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>&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="c1">-- 加 vector column（dimension 必須事先決定）
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&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">documents&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"> 5&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">SERIAL&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="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">content&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"> 7&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">embedding&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">vector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">1536&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- OpenAI ada-002 維度
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="c1">&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>&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="c1">-- 三種 distance operator
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">documents&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ORDER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">embedding&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;lt;-&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;[0.1, 0.2, ...]&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">LIMIT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">10&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- L2
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">documents&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ORDER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">embedding&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;lt;#&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;[0.1, 0.2, ...]&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">LIMIT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">10&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- inner product
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">documents&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ORDER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">embedding&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;lt;=&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;[0.1, 0.2, ...]&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">LIMIT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">10&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- cosine&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Operator 對應：&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>pgvector extension</em> — 用 PG 解 vector search workload 的路徑、是 <a href="/blog/backend/01-database/vendors/postgresql/extension-ecosystem/" data-link-title="PostgreSQL Extension Ecosystem：把 PG 變成 vector DB / time-series / sharded 的 plugin 生態" data-link-desc="PG 的 extension 機制不只是 plugin、是 *結構性產品線擴張* — pgvector 讓 PG 變 vector DB、TimescaleDB 變 time-series、Citus 變 sharded、PostGIS 變 GIS。本文走 PG extension lifecycle、6 個 production-critical extension（pg_stat_statements / pg_partman / pg_repack / pgvector / TimescaleDB / PostGIS）、5 production 踩雷（extension version 跟 PG version 對齊 / managed PG 限制 / upgrade order / shared_preload_libraries 衝突 / extension 跟 logical replication 互動）、cloud vendor 對 extension 的限制">extension-ecosystem</a> 內最受關注的 extension。</p></blockquote>
<hr>
<h2 id="pgvector-是-pg-變-vector-db-的最短路徑">pgvector 是 PG 變 Vector DB 的最短路徑</h2>
<p>pgvector 加兩件事：</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="n">EXTENSION</span><span class="w"> </span><span class="n">vector</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></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w"></span><span class="c1">-- 加 vector column（dimension 必須事先決定）
</span></span></span><span class="line"><span class="ln"> 4</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">documents</span><span class="w"> </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">id</span><span class="w"> </span><span class="nb">SERIAL</span><span class="w"> </span><span class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</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">content</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"> 7</span><span class="cl"><span class="w">    </span><span class="n">embedding</span><span class="w"> </span><span class="n">vector</span><span class="p">(</span><span class="mi">1536</span><span class="p">)</span><span class="w">  </span><span class="c1">-- OpenAI ada-002 維度
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"></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></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w"></span><span class="c1">-- 三種 distance operator
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">documents</span><span class="w"> </span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">embedding</span><span class="w"> </span><span class="o">&lt;-&gt;</span><span class="w"> </span><span class="s1">&#39;[0.1, 0.2, ...]&#39;</span><span class="w"> </span><span class="k">LIMIT</span><span class="w"> </span><span class="mi">10</span><span class="p">;</span><span class="w">  </span><span class="c1">-- L2
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">documents</span><span class="w"> </span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">embedding</span><span class="w"> </span><span class="o">&lt;#&gt;</span><span class="w"> </span><span class="s1">&#39;[0.1, 0.2, ...]&#39;</span><span class="w"> </span><span class="k">LIMIT</span><span class="w"> </span><span class="mi">10</span><span class="p">;</span><span class="w">  </span><span class="c1">-- inner product
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">documents</span><span class="w"> </span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">embedding</span><span class="w"> </span><span class="o">&lt;=&gt;</span><span class="w"> </span><span class="s1">&#39;[0.1, 0.2, ...]&#39;</span><span class="w"> </span><span class="k">LIMIT</span><span class="w"> </span><span class="mi">10</span><span class="p">;</span><span class="w">  </span><span class="c1">-- cosine</span></span></span></code></pre></div><p>Operator 對應：</p>
<table>
  <thead>
      <tr>
          <th>Operator</th>
          <th>意義</th>
          <th>適用</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>&lt;-&gt;</code></td>
          <td>L2 distance</td>
          <td>通用、空間距離</td>
      </tr>
      <tr>
          <td><code>&lt;#&gt;</code></td>
          <td>Negative inner product</td>
          <td>normalized vector、cosine 等價</td>
      </tr>
      <tr>
          <td><code>&lt;=&gt;</code></td>
          <td>Cosine distance</td>
          <td>embedding 比較最常用</td>
      </tr>
  </tbody>
</table>
<p>對 OpenAI / Cohere / sentence-transformers embedding、通常用 <code>&lt;=&gt;</code>（cosine）— embedding model 訓練時是 cosine objective。</p>
<h2 id="ann-index-是-vector-search-的核心">ANN Index 是 Vector Search 的核心</h2>
<p>不加 index 的 <code>ORDER BY embedding &lt;=&gt; ?</code> 是 <em>full scan</em>：</p>
<ul>
<li>100K row、1536 dim、每 query ~2-5s（不可用）</li>
<li>1M row 直接超時</li>
</ul>
<p>pgvector 提供兩種 <em>Approximate Nearest Neighbor</em>（ANN）index：</p>
<table>
  <thead>
      <tr>
          <th>Index</th>
          <th>Build 時間</th>
          <th>Query 時間</th>
          <th>Recall@10</th>
          <th>Memory cost</th>
          <th>Update 行為</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>IVFFlat</td>
          <td>快（分鐘級）</td>
          <td>中（10-100ms）</td>
          <td>90-95%</td>
          <td>中（lists 數量）</td>
          <td>Insert OK、需重建保持 recall</td>
      </tr>
      <tr>
          <td>HNSW</td>
          <td>慢（小時級）</td>
          <td>快（1-10ms）</td>
          <td>95-99%</td>
          <td>高（2-4x 資料）</td>
          <td>Insert OK、graph 漸進維護</td>
      </tr>
  </tbody>
</table>
<p><strong>選 IVFFlat 的場景</strong>：</p>
<ul>
<li>Embedding 量 &lt; 1M</li>
<li>Build 時間敏感（CI / batch 環境）</li>
<li>Memory 緊</li>
<li>接受重建 cost（每月 / 每季）</li>
</ul>
<p><strong>選 HNSW 的場景</strong>：</p>
<ul>
<li>Embedding 量 1M-100M</li>
<li>Query latency &lt; 50ms 要求</li>
<li>Memory 充足</li>
<li>Insert 量穩定（不會爆炸性增長）</li>
</ul>
<h2 id="ivfflat分-cluster-找鄰居">IVFFlat：分 Cluster 找鄰居</h2>
<p>IVFFlat 機制：</p>
<ol>
<li><strong>Build</strong>：跑 k-means 把所有 vector 分 <code>lists</code> 個 cluster</li>
<li><strong>Query</strong>：先找最近的 <code>probes</code> 個 cluster、再在這些 cluster 內找 nearest neighbor</li>
</ol>





<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">-- Build（lists 數量重要）
</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="k">ON</span><span class="w"> </span><span class="n">documents</span><span class="w"> </span><span class="k">USING</span><span class="w"> </span><span class="n">ivfflat</span><span class="w"> </span><span class="p">(</span><span class="n">embedding</span><span class="w"> </span><span class="n">vector_cosine_ops</span><span class="p">)</span><span class="w"> </span><span class="k">WITH</span><span class="w"> </span><span class="p">(</span><span class="n">lists</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">100</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></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="c1">-- Query 時調 probes 換 recall vs latency
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="k">SET</span><span class="w"> </span><span class="n">ivfflat</span><span class="p">.</span><span class="n">probes</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">10</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="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">documents</span><span class="w"> </span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">embedding</span><span class="w"> </span><span class="o">&lt;=&gt;</span><span class="w"> </span><span class="o">?</span><span class="w"> </span><span class="k">LIMIT</span><span class="w"> </span><span class="mi">10</span><span class="p">;</span></span></span></code></pre></div><p><strong>Lists 跟 probes sizing 規則</strong>（pgvector 官方建議）：</p>
<table>
  <thead>
      <tr>
          <th>Row count</th>
          <th>lists 建議</th>
          <th>probes 建議</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>&lt; 1M</td>
          <td><code>rows / 1000</code></td>
          <td><code>sqrt(lists)</code></td>
      </tr>
      <tr>
          <td>&gt; 1M</td>
          <td><code>sqrt(rows)</code></td>
          <td><code>sqrt(lists)</code></td>
      </tr>
  </tbody>
</table>
<p>實務：100K row → lists=100 / probes=10、1M row → lists=1000 / probes=32。</p>
<p><strong>IVFFlat 的 recall drift</strong>：cluster 是 build 時固定的、新 insert 的 vector 進入「最近 cluster」、但隨資料分布改變、cluster center 可能不再代表性、recall 隨時間下降。</p>
<p>修法：定期 <code>REINDEX INDEX CONCURRENTLY ...</code>（每月 / 每 100K 新 row）。</p>
<h2 id="hnswmulti-level-graph-找鄰居">HNSW：Multi-level Graph 找鄰居</h2>
<p>HNSW（Hierarchical Navigable Small World）機制：</p>
<ol>
<li>多層 graph、上層稀疏、下層密集</li>
<li>Query 從上層 entry point 開始、逐層找近鄰、最後在底層精細搜尋</li>
<li>Insert 漸進維護 graph、不必重建</li>
</ol>





<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">-- Build（兩個關鍵參數）
</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="k">ON</span><span class="w"> </span><span class="n">documents</span><span class="w"> </span><span class="k">USING</span><span class="w"> </span><span class="n">hnsw</span><span class="w"> </span><span class="p">(</span><span class="n">embedding</span><span class="w"> </span><span class="n">vector_cosine_ops</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="k">WITH</span><span class="w"> </span><span class="p">(</span><span class="n">m</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">16</span><span class="p">,</span><span class="w"> </span><span class="n">ef_construction</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">64</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></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="c1">-- Query 時調 ef_search
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span><span class="k">SET</span><span class="w"> </span><span class="n">hnsw</span><span class="p">.</span><span class="n">ef_search</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">100</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">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">documents</span><span class="w"> </span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">embedding</span><span class="w"> </span><span class="o">&lt;=&gt;</span><span class="w"> </span><span class="o">?</span><span class="w"> </span><span class="k">LIMIT</span><span class="w"> </span><span class="mi">10</span><span class="p">;</span></span></span></code></pre></div><p><strong>參數含義</strong>：</p>
<table>
  <thead>
      <tr>
          <th>參數</th>
          <th>含義</th>
          <th>預設</th>
          <th>Trade-off</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>m</code></td>
          <td>每 node 最多鄰居數</td>
          <td>16</td>
          <td>大 → recall 高、memory 多</td>
      </tr>
      <tr>
          <td><code>ef_construction</code></td>
          <td>Build 時 graph 質量參數</td>
          <td>64</td>
          <td>大 → build 慢、graph 質量好</td>
      </tr>
      <tr>
          <td><code>ef_search</code></td>
          <td>Query 時搜尋範圍</td>
          <td>40</td>
          <td>大 → recall 高、latency 高</td>
      </tr>
  </tbody>
</table>
<p><strong>Build cost 真實量級</strong>（1M vector × 1536 dim）：</p>
<table>
  <thead>
      <tr>
          <th>配置</th>
          <th>Build 時間</th>
          <th>Memory</th>
          <th>Recall@10</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>m=8, ef_construction=32</td>
          <td>30 min</td>
          <td>4GB</td>
          <td>92%</td>
      </tr>
      <tr>
          <td>m=16, ef_construction=64</td>
          <td>2 hour</td>
          <td>8GB</td>
          <td>96%</td>
      </tr>
      <tr>
          <td>m=32, ef_construction=200</td>
          <td>8 hour</td>
          <td>16GB</td>
          <td>98%</td>
      </tr>
  </tbody>
</table>
<p>Production 多數選中間 <code>m=16, ef_construction=64</code>、recall / cost 平衡。</p>
<h2 id="hybrid-searchvector--filter-一起">Hybrid Search：Vector + Filter 一起</h2>
<p>Vector search 加 SQL filter 是 pgvector 比專業 vector DB 強的場景：</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">-- Vector + metadata filter
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">documents</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="n">category</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;tech&#39;</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="n">created_at</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="s1">&#39;2025-01-01&#39;</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">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">embedding</span><span class="w"> </span><span class="o">&lt;=&gt;</span><span class="w"> </span><span class="s1">&#39;[0.1, 0.2, ...]&#39;</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">LIMIT</span><span class="w"> </span><span class="mi">10</span><span class="p">;</span></span></span></code></pre></div><p>但這裡有個 <em>pgvector 的踩雷</em>：filter 跟 ANN index 互動有兩種模式：</p>
<ol>
<li><strong>Pre-filter</strong>（planner 選）：先 filter 出符合條件的 row、再對 subset 跑 vector ordering → 不用 ANN index、可能慢</li>
<li><strong>Post-filter</strong>：用 ANN index 找 top-N、再 filter、可能 N 不夠補</li>
</ol>
<p>pgvector 0.8+（2024-10 release）加入 <em>iterative index scan</em>：HNSW / IVFFlat 一邊掃 graph 一邊 filter、效能比 pre-filter 好 5-10x。0.7+（2024-07）加 halfvec / binary quantization / parallel HNSW build。</p>
<p>實務：filter selectivity 高（&lt; 10%）時、考慮對 filter column 加 index 走 pre-filter；selectivity 低（&gt; 50%）走 iterative scan。</p>
<h2 id="quantization-跟-dimension-reduction">Quantization 跟 Dimension Reduction</h2>
<p>1536 dim float32 vector 一筆 6KB、1M row 6GB、加 HNSW index 後 ~20GB。Memory 緊時的省法：</p>
<h3 id="half-precisionpgvector-07">Half-precision（pgvector 0.7+）</h3>





<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">documents</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">embedding</span><span class="w"> </span><span class="n">halfvec</span><span class="p">(</span><span class="mi">1536</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="p">);</span></span></span></code></pre></div><p><code>halfvec</code> 是 float16、storage 減半、recall 損失通常 &lt; 1%。</p>
<h3 id="binary-quantization">Binary quantization</h3>





<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">-- 把每維壓成 1 bit
</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="k">ON</span><span class="w"> </span><span class="n">documents</span><span class="w"> </span><span class="k">USING</span><span class="w"> </span><span class="n">hnsw</span><span class="w"> </span><span class="p">(</span><span class="n">embedding</span><span class="w"> </span><span class="n">bit_hamming_ops</span><span class="p">);</span></span></span></code></pre></div><p>Recall 下降明顯（85-90%）、但 storage 1/32、適合「先粗篩再 rerank」hybrid pipeline。</p>
<h3 id="dimension-reduction">Dimension reduction</h3>
<p>訓練 PCA / Matryoshka model 把 1536 dim 降到 256-512 dim、recall 通常損失 &lt; 3%、storage 1/3-1/6。</p>
<h2 id="5-個-production-踩雷">5 個 Production 踩雷</h2>
<h3 id="case-1dimension-超-2000-限制">Case 1：Dimension 超 2000 限制</h3>
<p><strong>情境</strong>：要用 OpenAI text-embedding-3-large（3072 dim）、<code>CREATE TABLE ... embedding vector(3072)</code> 報錯。</p>
<p>pgvector <code>vector</code> type 上限 2000 dim（IVFFlat / HNSW index 限制）。</p>
<p>修法：</p>
<ul>
<li>改用 <code>halfvec</code>（pgvector 0.7+ 支援 4000 dim）</li>
<li>用 Matryoshka 截斷到 2000 dim 以下</li>
<li>換 embedding model（OpenAI text-embedding-3-small 1536 dim / 可截斷到 256-1024）</li>
</ul>
<h3 id="case-2hnsw-build-太慢">Case 2：HNSW build 太慢</h3>
<p><strong>情境</strong>：1M row build HNSW、跑 8 小時、blocking production。</p>
<p>修法：</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">-- 用 CONCURRENTLY 不 block
</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">CONCURRENTLY</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">documents</span><span class="w"> </span><span class="k">USING</span><span class="w"> </span><span class="n">hnsw</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></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="c1">-- 開 maintenance_work_mem
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="k">SET</span><span class="w"> </span><span class="n">maintenance_work_mem</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;8GB&#39;</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></span><span class="line"><span class="ln">7</span><span class="cl"><span class="w"></span><span class="c1">-- 開 parallel
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1"></span><span class="k">SET</span><span class="w"> </span><span class="n">max_parallel_maintenance_workers</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">7</span><span class="p">;</span></span></span></code></pre></div><p>仍慢的話、考慮：</p>
<ul>
<li>切分 batch insert + index（適合 read-heavy）</li>
<li>用 IVFFlat 短期上線、之後再切 HNSW</li>
<li>改用 cloud managed pgvector（提供更大 instance）</li>
</ul>
<h3 id="case-3ivfflat-不重建-recall-漂移">Case 3：IVFFlat 不重建 recall 漂移</h3>
<p><strong>情境</strong>：IVFFlat build 時資料 100K、現在 500K、新資料 recall 從 92% 降到 75%、user 抱怨「找不到相關文件」。</p>
<p>修法：</p>
<ul>
<li>Monitor recall：定期跑 ground-truth eval（brute-force 對比）</li>
<li>設定 reindex policy：每 100K 新 row 或每月 reindex</li>
<li>換 HNSW：insert 漸進維護、不需 reindex（trade-off：build 更慢）</li>
</ul>
<h3 id="case-4hybrid-search-filter-selectivity-沒設計">Case 4：Hybrid search filter selectivity 沒設計</h3>
<p><strong>情境</strong>：query <code>WHERE user_id = ? ORDER BY embedding &lt;=&gt; ?</code>、user_id 高選擇性（1/1M）、planner 選 vector index scan、掃到 top-K 全不符 user_id、補抓無止盡。</p>
<p>修法：</p>
<ul>
<li><code>EXPLAIN</code> 看 planner 選 pre-filter 還是 vector-first</li>
<li>對 <code>user_id</code> 加 B-tree index、強 planner pre-filter（hint 不容易、用 statistics）</li>
<li>pgvector 0.8+ 用 iterative scan、自動處理</li>
<li>設計 schema：高選擇性 filter（user_id）建議走 pre-filter；低選擇性（category）走 iterative</li>
</ul>
<h3 id="case-5memory-budget-沒抓">Case 5：Memory budget 沒抓</h3>
<p><strong>情境</strong>：1M vector × 1536 dim × HNSW（m=16）= ~12GB index、shared_buffers 8GB、index 不在 cache、每 query disk IO、latency 100ms+。</p>
<p>修法：</p>
<ul>
<li>算 vector + index memory：<code>row × dim × 4 bytes × (1 + index_overhead)</code></li>
<li><code>shared_buffers</code> 至少能放 hot index portion</li>
<li>不行就降 dim（halfvec）/ 升 instance / 拆 sharded</li>
</ul>
<h2 id="跟專業-vector-db-對比">跟專業 Vector DB 對比</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>pgvector</th>
          <th>Pinecone</th>
          <th>Weaviate</th>
          <th>Milvus</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Query 介面</td>
          <td>SQL</td>
          <td>REST/gRPC API</td>
          <td>GraphQL / REST</td>
          <td>gRPC</td>
      </tr>
      <tr>
          <td>Recall</td>
          <td>95-99%（HNSW）</td>
          <td>95-99%</td>
          <td>95-99%</td>
          <td>95-99%</td>
      </tr>
      <tr>
          <td>Throughput</td>
          <td>中（PG 限制）</td>
          <td>高</td>
          <td>高</td>
          <td>高</td>
      </tr>
      <tr>
          <td>Hybrid search</td>
          <td>強（完整 SQL）</td>
          <td>中（metadata filter）</td>
          <td>中</td>
          <td>中</td>
      </tr>
      <tr>
          <td>跟既有 PG 整合</td>
          <td>完美（同 DB join）</td>
          <td>需 sync</td>
          <td>需 sync</td>
          <td>需 sync</td>
      </tr>
      <tr>
          <td>Multi-tenant</td>
          <td>row-level（PG 一致）</td>
          <td>內建</td>
          <td>內建</td>
          <td>partition</td>
      </tr>
      <tr>
          <td>Open source</td>
          <td>是</td>
          <td>否</td>
          <td>是</td>
          <td>是</td>
      </tr>
      <tr>
          <td>Operational cost</td>
          <td>跟 PG 一樣（管 PG 即可）</td>
          <td>Managed-only</td>
          <td>需自管或 cloud</td>
          <td>需自管或 cloud</td>
      </tr>
      <tr>
          <td>Scale 上限</td>
          <td>10M-100M vector</td>
          <td>10B+</td>
          <td>1B+</td>
          <td>10B+</td>
      </tr>
  </tbody>
</table>
<p><strong>選 pgvector 的場景</strong>：</p>
<ul>
<li>Application 已用 PG、不想多管系統</li>
<li>Vector 量 &lt; 100M</li>
<li>需要 join vector + relational</li>
<li>Team SQL 熟、不想學 API SDK</li>
<li>Cost 敏感（managed Pinecone 1M vector 月 ~$70+）</li>
</ul>
<p><strong>選專業 vector DB 的場景</strong>：</p>
<ul>
<li>Vector 量 &gt; 5-20M（依 dim / QPS / recall 要求、pgvector 在這個級別 + 高 QPS 已開始痛、不必撐到 100M 才換）</li>
<li>純 vector workload（沒 relational integration）</li>
<li>需要 multi-tenant SaaS</li>
<li>Throughput 要求極高（&gt; 10K QPS）</li>
<li>不想自管 HNSW build / memory budget / recall drift（managed Pinecone 把這層 ops 轉嫁、cost 換 ops 時間）</li>
<li>需要 dim &gt; 2000（pgvector vector type 限制、halfvec 可到 4000、再大需 dimension reduction）</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/postgresql/extension-ecosystem/" data-link-title="PostgreSQL Extension Ecosystem：把 PG 變成 vector DB / time-series / sharded 的 plugin 生態" data-link-desc="PG 的 extension 機制不只是 plugin、是 *結構性產品線擴張* — pgvector 讓 PG 變 vector DB、TimescaleDB 變 time-series、Citus 變 sharded、PostGIS 變 GIS。本文走 PG extension lifecycle、6 個 production-critical extension（pg_stat_statements / pg_partman / pg_repack / pgvector / TimescaleDB / PostGIS）、5 production 踩雷（extension version 跟 PG version 對齊 / managed PG 限制 / upgrade order / shared_preload_libraries 衝突 / extension 跟 logical replication 互動）、cloud vendor 對 extension 的限制">extension-ecosystem</a>：其他 PG extension</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/jsonb-deep-dive/" data-link-title="PostgreSQL JSONB Deep Dive：Binary Storage &#43; GIN Index 為什麼是結構性優勢" data-link-desc="PG JSONB（9.4&#43;）是 *binary 儲存的 JSON*、可直接 GIN index、是 PG 在 JSON workload 的結構性優勢、跟 MongoDB / MySQL 8.0 JSON_TABLE 比仍領先。本文走 JSON vs JSONB 差異、GIN index 機制（jsonb_ops vs jsonb_path_ops）、operator &#43; path query、partial JSONB indexing、5 production 踩雷（大 JSONB 跟 TOAST / nested update / index 選錯 op class / jsonb_path_query 跟 jsonb_path_exists 行為差 / partial index 條件搞錯）、何時用 JSONB vs 拆 column">jsonb-deep-dive</a>：embedding 通常配 metadata JSONB</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/index-selection/" data-link-title="PostgreSQL Index Selection：B-tree / GIN / GiST / BRIN / Hash 對應 workload 的決策樹" data-link-desc="PG 有 6 種 index method（B-tree / Hash / GIN / GiST / SP-GiST / BRIN）跟 partial / expression / covering 三種變體、不是「都用 B-tree 就好」。每種 index 有自己的 query pattern、儲存代價、write amplification 跟 maintenance 成本。本文走 6 種 index 的適用 workload 對照、決策樹、partial / expression / covering / multi-column 變體、5 production 踩雷（過度 index / partial 條件不對 / B-tree 對 JSON 無效 / BRIN 對非 correlated 資料無效 / multi-column 順序錯）、跟 query-optimization 的 EXPLAIN 互補">index-selection</a>：B-tree / GIN / HNSW 整體比較</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>：vector query 的 EXPLAIN</li>
</ul>
<h2 id="下一步">下一步</h2>
<ul>
<li>看 <a href="/blog/backend/01-database/vendors/postgresql/extension-ecosystem/" data-link-title="PostgreSQL Extension Ecosystem：把 PG 變成 vector DB / time-series / sharded 的 plugin 生態" data-link-desc="PG 的 extension 機制不只是 plugin、是 *結構性產品線擴張* — pgvector 讓 PG 變 vector DB、TimescaleDB 變 time-series、Citus 變 sharded、PostGIS 變 GIS。本文走 PG extension lifecycle、6 個 production-critical extension（pg_stat_statements / pg_partman / pg_repack / pgvector / TimescaleDB / PostGIS）、5 production 踩雷（extension version 跟 PG version 對齊 / managed PG 限制 / upgrade order / shared_preload_libraries 衝突 / extension 跟 logical replication 互動）、cloud vendor 對 extension 的限制">extension-ecosystem</a> 探索其他 PG 擴展可能</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><item><title>Aurora Serverless v2 適用判斷：ACU 自動擴縮、混合 cluster 與何時不該用</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/aurora/serverless-v2-scaling/</link><pubDate>Tue, 02 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/aurora/serverless-v2-scaling/</guid><description>&lt;p>Aurora Serverless v2 把 instance 的容量從「開機時固定的 instance class」改成「按負載秒級伸縮的 ACU」。它解的問題很具體：固定 provisioned cluster 在離峰時段付滿整台機器的錢、卻只用一小部分；尖峰來時又被 instance class 上限卡住。但 serverless v2 不是「比較便宜的 Aurora」——穩定高負載下它反而比同等 provisioned 貴。要不要用，取決於 workload 的負載形狀是否間歇、是否難預測。&lt;/p>
&lt;p>本文不是 Aurora overview（請看 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/aurora/" data-link-title="AWS Aurora" data-link-desc="AWS managed PostgreSQL / MySQL、storage / compute 分離、&amp;#43;75% 效能改善的 production 證據">Aurora vendor 頁&lt;/a>）— 而是 Serverless v2 的容量機制、設定與適用邊界的實作層教學。&lt;/p>
&lt;h2 id="核心機制acu-與秒級擴縮">核心機制：ACU 與秒級擴縮&lt;/h2>
&lt;p>Serverless v2 的容量單位是 ACU（Aurora Capacity Unit），一個 ACU 對應一組固定比例的記憶體與運算資源。cluster 不再綁定一個 instance class，而是設一個 ACU 區間（min / max），Aurora 依即時負載在區間內伸縮：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>屬性&lt;/th>
 &lt;th>Provisioned&lt;/th>
 &lt;th>Serverless v2&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>容量設定&lt;/td>
 &lt;td>固定 instance class（如 db.r6g.xlarge）&lt;/td>
 &lt;td>min / max ACU 區間&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>計費&lt;/td>
 &lt;td>按 instance 開機時數&lt;/td>
 &lt;td>按實際消耗的 ACU-秒&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>擴縮&lt;/td>
 &lt;td>手動改 instance class（有中斷）&lt;/td>
 &lt;td>秒級自動伸縮、無中斷&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>離峰成本&lt;/td>
 &lt;td>付滿整台&lt;/td>
 &lt;td>縮到 min ACU、只付低水位&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>適用負載&lt;/td>
 &lt;td>穩定、可預測&lt;/td>
 &lt;td>間歇、突發、難預測&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>擴縮行為&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>負載上升時 ACU 平滑增加、不需要切換 instance、無連線中斷&lt;/li>
&lt;li>負載下降時縮回低水位、但受 min ACU 下限約束&lt;/li>
&lt;li>min ACU 決定離峰的最低成本與「保留多少暖容量」；max ACU 決定尖峰的上限與成本天花板&lt;/li>
&lt;/ul>
&lt;blockquote>
&lt;p>&lt;strong>Scope warning&lt;/strong>：「ACU 對應的記憶體比例」「serverless v2 是否能縮到 0」「最小 ACU 粒度」這些屬 AWS vendor 規格、會隨版本演進（auto-pause 等能力陸續調整）、實作時 cross-verify 官方 doc 當前值。本文不含 production case 揭露的 ACU 配置數字。&lt;/p>&lt;/blockquote>
&lt;p>對應 knowledge card：&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/peak-forecast/" data-link-title="Peak Forecast" data-link-desc="說明預期峰值流量的預測方法 — 容量規劃的第一個輸入">peak forecast&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/cost-per-request/" data-link-title="Cost Per Request" data-link-desc="把雲端成本拆到單一 API 請求的 unit economics 模型">cost per request&lt;/a>。&lt;/p>
&lt;h2 id="min--max-acu-的設定權衡">min / max ACU 的設定權衡&lt;/h2>
&lt;p>min 與 max ACU 不是隨便設，兩端各自承擔不同風險。&lt;/p>
&lt;p>&lt;strong>min ACU 太低&lt;/strong>：離峰省錢，但流量回升時從很低的水位往上爬、爬升期間可能容量不足、且 buffer cache 在低 ACU 時被壓縮、回升後 cache 重新暖機、query latency 短暫升高。對延遲敏感、又有規律日週期的 workload，min ACU 不要壓到極限。&lt;/p>
&lt;p>&lt;strong>max ACU 太低&lt;/strong>：尖峰被天花板卡住、等同 provisioned 的 instance class 上限問題又回來。max ACU 要按「預期尖峰 + 餘量」設，並把它當成成本天花板來監控——max 設太高雖然不會平時就花錢，但失控 query（如缺索引的全表掃描）可能把 ACU 一路推到 max、帳單尖峰。&lt;/p>
&lt;p>&lt;strong>暖容量考量&lt;/strong>：min ACU 同時決定「保留多少隨時可用的暖容量」。完全不可預測、且要求第一個請求就低延遲的場景，min ACU 要留足暖機水位，不能為了省錢設到最低。&lt;/p>
&lt;h2 id="混合-clusterserverless--provisioned-並存">混合 cluster：serverless + provisioned 並存&lt;/h2>
&lt;p>Serverless v2 不是「整個 cluster 要嘛全 serverless、要嘛全 provisioned」。同一個 Aurora cluster 可以混用：writer 用 provisioned 保穩定、read replica 用 serverless v2 吸收讀取尖峰；或反過來。這讓 workload 的不同部分各取所需：&lt;/p></description><content:encoded><![CDATA[<p>Aurora Serverless v2 把 instance 的容量從「開機時固定的 instance class」改成「按負載秒級伸縮的 ACU」。它解的問題很具體：固定 provisioned cluster 在離峰時段付滿整台機器的錢、卻只用一小部分；尖峰來時又被 instance class 上限卡住。但 serverless v2 不是「比較便宜的 Aurora」——穩定高負載下它反而比同等 provisioned 貴。要不要用，取決於 workload 的負載形狀是否間歇、是否難預測。</p>
<p>本文不是 Aurora overview（請看 <a href="/blog/backend/01-database/vendors/aurora/" data-link-title="AWS Aurora" data-link-desc="AWS managed PostgreSQL / MySQL、storage / compute 分離、&#43;75% 效能改善的 production 證據">Aurora vendor 頁</a>）— 而是 Serverless v2 的容量機制、設定與適用邊界的實作層教學。</p>
<h2 id="核心機制acu-與秒級擴縮">核心機制：ACU 與秒級擴縮</h2>
<p>Serverless v2 的容量單位是 ACU（Aurora Capacity Unit），一個 ACU 對應一組固定比例的記憶體與運算資源。cluster 不再綁定一個 instance class，而是設一個 ACU 區間（min / max），Aurora 依即時負載在區間內伸縮：</p>
<table>
  <thead>
      <tr>
          <th>屬性</th>
          <th>Provisioned</th>
          <th>Serverless v2</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>容量設定</td>
          <td>固定 instance class（如 db.r6g.xlarge）</td>
          <td>min / max ACU 區間</td>
      </tr>
      <tr>
          <td>計費</td>
          <td>按 instance 開機時數</td>
          <td>按實際消耗的 ACU-秒</td>
      </tr>
      <tr>
          <td>擴縮</td>
          <td>手動改 instance class（有中斷）</td>
          <td>秒級自動伸縮、無中斷</td>
      </tr>
      <tr>
          <td>離峰成本</td>
          <td>付滿整台</td>
          <td>縮到 min ACU、只付低水位</td>
      </tr>
      <tr>
          <td>適用負載</td>
          <td>穩定、可預測</td>
          <td>間歇、突發、難預測</td>
      </tr>
  </tbody>
</table>
<p><strong>擴縮行為</strong>：</p>
<ul>
<li>負載上升時 ACU 平滑增加、不需要切換 instance、無連線中斷</li>
<li>負載下降時縮回低水位、但受 min ACU 下限約束</li>
<li>min ACU 決定離峰的最低成本與「保留多少暖容量」；max ACU 決定尖峰的上限與成本天花板</li>
</ul>
<blockquote>
<p><strong>Scope warning</strong>：「ACU 對應的記憶體比例」「serverless v2 是否能縮到 0」「最小 ACU 粒度」這些屬 AWS vendor 規格、會隨版本演進（auto-pause 等能力陸續調整）、實作時 cross-verify 官方 doc 當前值。本文不含 production case 揭露的 ACU 配置數字。</p></blockquote>
<p>對應 knowledge card：<a href="/blog/backend/knowledge-cards/peak-forecast/" data-link-title="Peak Forecast" data-link-desc="說明預期峰值流量的預測方法 — 容量規劃的第一個輸入">peak forecast</a>、<a href="/blog/backend/knowledge-cards/cost-per-request/" data-link-title="Cost Per Request" data-link-desc="把雲端成本拆到單一 API 請求的 unit economics 模型">cost per request</a>。</p>
<h2 id="min--max-acu-的設定權衡">min / max ACU 的設定權衡</h2>
<p>min 與 max ACU 不是隨便設，兩端各自承擔不同風險。</p>
<p><strong>min ACU 太低</strong>：離峰省錢，但流量回升時從很低的水位往上爬、爬升期間可能容量不足、且 buffer cache 在低 ACU 時被壓縮、回升後 cache 重新暖機、query latency 短暫升高。對延遲敏感、又有規律日週期的 workload，min ACU 不要壓到極限。</p>
<p><strong>max ACU 太低</strong>：尖峰被天花板卡住、等同 provisioned 的 instance class 上限問題又回來。max ACU 要按「預期尖峰 + 餘量」設，並把它當成成本天花板來監控——max 設太高雖然不會平時就花錢，但失控 query（如缺索引的全表掃描）可能把 ACU 一路推到 max、帳單尖峰。</p>
<p><strong>暖容量考量</strong>：min ACU 同時決定「保留多少隨時可用的暖容量」。完全不可預測、且要求第一個請求就低延遲的場景，min ACU 要留足暖機水位，不能為了省錢設到最低。</p>
<h2 id="混合-clusterserverless--provisioned-並存">混合 cluster：serverless + provisioned 並存</h2>
<p>Serverless v2 不是「整個 cluster 要嘛全 serverless、要嘛全 provisioned」。同一個 Aurora cluster 可以混用：writer 用 provisioned 保穩定、read replica 用 serverless v2 吸收讀取尖峰；或反過來。這讓 workload 的不同部分各取所需：</p>
<ul>
<li>穩定的寫入路徑用 provisioned instance、成本可預測</li>
<li>間歇的讀取分析、報表副本用 serverless v2、平時縮到低水位</li>
<li>failover 目標可指定 provisioned 或 serverless，依可用性需求</li>
</ul>
<p>混合配置的判讀是把 cluster 內每個角色當獨立的負載形狀評估，而非整個 cluster 一刀切。</p>
<h2 id="操作流程">操作流程</h2>
<p>從負載形狀評估到上線的 6 步流程。</p>
<h4 id="step-1判斷負載形狀">Step 1：判斷負載形狀</h4>
<p>用 CloudWatch 過去 30 天的 CPU / connection / IOPS，看負載是穩定平緩、規律日週期、還是不規則突發：</p>
<ul>
<li>穩定高負載（平均使用率高、波動小）→ provisioned 通常更划算</li>
<li>間歇 / 突發 / 開發測試 / 多租戶各自小 DB → serverless v2 適合</li>
<li>規律日週期（白天高晚上低）→ serverless v2 或 provisioned + scheduled 都可，算成本 crossover</li>
</ul>
<h4 id="step-2估-min--max-acu">Step 2：估 min / max ACU</h4>
<p>min 依離峰最低負載 + 暖容量需求；max 依尖峰負載 + 餘量。第一次設保守一點、上線後依實際 ACU 曲線收斂。</p>
<h4 id="step-3建立或轉換">Step 3：建立或轉換</h4>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 新 cluster 指定 serverless v2 capacity range</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">aws rds create-db-cluster <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --db-cluster-identifier my-cluster <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --engine aurora-postgresql <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  --serverless-v2-scaling-configuration <span class="nv">MinCapacity</span><span class="o">=</span>2,MaxCapacity<span class="o">=</span><span class="m">32</span></span></span></code></pre></div><p>既有 provisioned cluster 可加 serverless v2 reader、逐步驗證再調整 writer。</p>
<h4 id="step-4觀察-acu-曲線">Step 4：觀察 ACU 曲線</h4>
<p>上線後盯 <code>ServerlessDatabaseCapacity</code>（即時 ACU）與 <code>ACUUtilization</code>，確認伸縮符合負載、min/max 設定合理。</p>
<h4 id="step-5成本對照">Step 5：成本對照</h4>
<p>把實際 ACU-秒換算的帳單，跟「同等 provisioned instance 全時段開機」對照。若 serverless 帳單接近或超過 provisioned，代表負載其實夠穩定、該回 provisioned。</p>
<h4 id="step-6驗證點">Step 6：驗證點</h4>





<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"># 驗證離峰真的縮到 min ACU（看 ServerlessDatabaseCapacity 低谷）
</span></span><span class="line"><span class="ln">2</span><span class="cl"># 驗證尖峰沒撞 max ACU 天花板（看是否長時間貼著 max）
</span></span><span class="line"><span class="ln">3</span><span class="cl"># 驗證回升期 latency 可接受（min ACU 暖容量是否足夠）</span></span></code></pre></div><p><strong>Rollback boundary</strong>：serverless v2 與 provisioned 可互轉、reader 先轉驗證再動 writer；轉換本身有短暫中斷，要排 maintenance window。</p>
<h2 id="失敗模式">失敗模式</h2>
<p>production 常見的 5 個踩雷：</p>
<h4 id="case-1穩定高負載用-serverless-反而更貴">Case 1：穩定高負載用 serverless 反而更貴</h4>
<p>把一個 7x24 高使用率的 cluster 改 serverless「以為省錢」，實際 ACU 幾乎全時段貼近高水位、按 ACU-秒計費比固定 instance 貴。修法：穩定高負載用 provisioned；serverless 的省錢前提是「有顯著的離峰可以縮」。</p>
<h4 id="case-2min-acu-設太低回升期-latency-尖刺">Case 2：min ACU 設太低、回升期 latency 尖刺</h4>
<p>離峰縮到極低、早上流量回來時 cache 冷、ACU 從低水位爬、前幾分鐘 query 變慢。修法：規律日週期的 workload，min ACU 留足暖容量；或用 provisioned + scheduled scaling 處理可預測的日週期。</p>
<h4 id="case-3max-acu-沒當成本天花板監控">Case 3：max ACU 沒當成本天花板監控</h4>
<p>缺索引的 query 觸發全表掃描、ACU 一路衝到 max、帳單尖峰才發現。修法：max ACU 設合理上限 + CloudWatch alarm 盯 ACU 長時間貼 max（那是 query 或容量問題的訊號，不是正常擴縮）。</p>
<h4 id="case-4把-serverless-當不用做容量規劃">Case 4：把 serverless 當「不用做容量規劃」</h4>
<p>以為 serverless 自動伸縮就不必估容量、min/max 隨便設。修法：serverless 改變的是「不用手動切 instance」，不是「不用理解負載形狀」；min/max 仍要基於負載曲線設定。</p>
<h4 id="case-5對延遲極敏感的-oltp-全-serverless">Case 5：對延遲極敏感的 OLTP 全 serverless</h4>
<p>核心交易路徑要求穩定低延遲、卻用會伸縮的 serverless writer、伸縮邊界期間 latency 抖動。修法：穩定低延遲的核心寫入用 provisioned writer，serverless 留給可容忍伸縮抖動的讀取 / 分析副本（混合 cluster）。</p>
<p><strong>Anti-recommendation</strong>：負載穩定、使用率長期偏高、或對延遲抖動零容忍的核心 OLTP → 用 provisioned；serverless v2 的價值在「間歇、突發、難預測、或有大量離峰」的負載，沒有離峰可縮就沒有省錢空間。</p>
<h2 id="容量與觀測">容量與觀測</h2>
<p>CloudWatch metric：</p>
<ul>
<li><code>ServerlessDatabaseCapacity</code>：即時 ACU、看伸縮曲線</li>
<li><code>ACUUtilization</code>：ACU 使用率、判斷 min/max 設定是否合理</li>
<li><code>CPUUtilization</code> / <code>DatabaseConnections</code>：底層負載、對照 ACU 是否跟得上</li>
</ul>
<p><strong>判讀</strong>：</p>
<ul>
<li>ACU 長時間貼近 max → max 設太低或有失控 query，要查</li>
<li>ACU 長時間貼近 min 且使用率低 → 負載其實很輕，min 可能可再降、或這個 cluster 適合更小配置</li>
<li>ACU 幾乎不波動且水位高 → 負載穩定，serverless 沒發揮價值，評估改 provisioned</li>
</ul>
<blockquote>
<p><strong>Scope warning</strong>：本文未引用 production case 的 ACU 數字；上述 metric 與判讀屬 vendor 規格 + 通用容量工程。</p></blockquote>
<p>接回 <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/01-database/vendors/aurora/" data-link-title="AWS Aurora" data-link-desc="AWS managed PostgreSQL / MySQL、storage / compute 分離、&#43;75% 效能改善的 production 證據">Aurora 容量規劃要點</a>。</p>
<h2 id="邊界與整合">邊界與整合</h2>
<h3 id="serverless-v2-vs-provisioned--scheduled-scaling">Serverless v2 vs provisioned + scheduled scaling</h3>
<p>兩者都能處理「負載隨時間變」，但適用場景不同：</p>
<ul>
<li><strong>scheduled scaling（provisioned）</strong>：負載 <em>可預測</em>（已知的日週期、已知大活動）→ 預先排程改容量，成本最可控</li>
<li><strong>serverless v2</strong>：負載 <em>不可預測</em>（突發、不規則）→ 自動伸縮吸收，不需預測</li>
</ul>
<p>可預測的尖峰用 scheduled、不可預測的用 serverless，這跟 <a href="/blog/backend/01-database/vendors/dynamodb/on-demand-vs-provisioned/" data-link-title="DynamoDB On-Demand vs Provisioned：6 軸決策、auto-scaling 邊界與 cost crossover" data-link-desc="capacity mode 選擇不是單軸 peak/avg ratio；本文展開 6 軸決策（peak/avg / 讀寫比 trend / surge 暫時 vs 永久 baseline / predictable-peak vs flash-sale / DBA 工時釋放 / vendor vs 自管 cost crossover），含 Zomato 50% 成本下降、Zoom 30x permanent surge、Amazon Ads sustained workload 等 case 分軸 anchor">DynamoDB capacity mode</a> 的 predictable-peak vs flash-sale 判讀同源。</p>
<h3 id="sibling-與-cross-link">Sibling 與 cross-link</h3>
<ul>
<li><a href="/blog/backend/01-database/vendors/aurora/storage-architecture/" data-link-title="Aurora Storage Architecture：quorum-based 分散式 log 與韌性即性能設計" data-link-desc="Aurora storage / compute 分離、6-way 跨 AZ replication、4-of-6 write / 3-of-6 read quorum、韌性投資自動 amortize 成 read 性能、DraftKings 6ms 寫 / &lt;1ms 讀 production reference">storage-architecture</a> — serverless 只改 compute 層容量、storage 層 quorum 設計不變</li>
<li><a href="/blog/backend/01-database/vendors/aurora/read-replica-scaling/" data-link-title="Aurora Read Replica Scaling：15 replica 上限、lag profile、headroom 預留與 fleet 治理" data-link-desc="Aurora 15 replica 上限、共享 storage 為什麼能養大量 replica、事件型容量分級表、DraftKings headroom 預留判讀、FanDuel 雙 SLO 並行、fleet 治理 3 條 driver（business sharding / microservice / 合規）">read-replica-scaling</a> — serverless reader 吸收讀取尖峰、與 fleet 治理結合</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/aurora-io-optimized-cost/" data-link-title="Aurora PostgreSQL I/O-Optimized Cost" data-link-desc="Aurora PostgreSQL Standard 與 I/O-Optimized 的成本模型、I/O 壓力、workload 判斷、遷移與回退條件">Aurora I/O-Optimized cost</a> — serverless 算的是 compute（ACU）成本、I/O-Optimized 算的是 storage I/O 成本，兩個成本軸獨立、要分開評估</li>
<li><a href="/blog/backend/01-database/vendors/aurora/rds-proxy-connection-pooling/" data-link-title="Aurora RDS Proxy 與連線管理：connection multiplexing、pinning 陷阱與 failover 加速" data-link-desc="RDS Proxy 不是「連上去就自動省連線」；本文展開 connection multiplexing 機制、哪些 session 操作會觸發 pinning 讓 multiplexing 失效、failover 期間 proxy 如何保持 client 連線縮短中斷，以及 RDS Proxy 與自管 pgbouncer 的責任切分">rds-proxy-connection-pooling</a> — serverless + Lambda 場景的連線管理</li>
<li>替代路由：負載穩定且高 → provisioned；KV access pattern → <a href="/blog/backend/01-database/vendors/dynamodb/" data-link-title="DynamoDB" data-link-desc="AWS managed key-value、partition-based scaling、9000 萬 RPS sustained 實戰證據">DynamoDB</a></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%、串流數十億小時">Netflix 9.C23</a> 互引：polyglot 架構下不同 workload 用不同 Aurora 配置（穩定 OLTP provisioned、間歇副本 serverless）</li>
</ul>
]]></content:encoded></item><item><title>DynamoDB Partition Key 反模式與 Write Sharding：composite key 修復跟 mode × partition 交叉判讀</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/dynamodb/partition-key-antipatterns/</link><pubDate>Wed, 27 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/dynamodb/partition-key-antipatterns/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/dynamodb/" data-link-title="DynamoDB" data-link-desc="AWS managed key-value、partition-based scaling、9000 萬 RPS sustained 實戰證據">DynamoDB&lt;/a> overview 的 implementation-layer deep article。寫作參照 &lt;a href="https://tarrragon.github.io/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">vendor deep article methodology&lt;/a>。&lt;/p>&lt;/blockquote>
&lt;p>售票網站開賣前一小時把 DynamoDB capacity 從 200 WCU 拉到 5000、心想「容量加 25 倍應該夠」。開賣瞬間還是看到 &lt;code>ThrottledRequests&lt;/code> 拉警報、CloudWatch 顯示總 capacity 才用了 1500 WCU。打開 partition-level metric 才看到某一個 partition 已經達到 1000 WCU 上限、其他 partition 閒置 — &lt;code>event_id&lt;/code> 當 PK、單一熱門場次把所有寫入集中到同一個 partition。Capacity 加再多都救不了，因為單 partition 上限是 1000 WCU / 3000 RCU、跟 table 總容量無關。這就是 hot partition 的本質：partition key 設計問題、不是 capacity 不夠。&lt;/p>
&lt;p>本文展開 partition key 反模式的識別、composite key / write sharding 兩種修法、mode × partition 在 provisioned / on-demand 下的不同表現、以及 9.C15 拓元 6750x IOPS 擴展案例的工程細節。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>DynamoDB 適用度前置判讀&lt;/strong>：本篇假設 workload 已通過 DynamoDB 適用度 4 軸（PK 天然均勻 / control plane vs data plane / consistency 可接受 eventual / access pattern 穩定）— 詳見 &lt;a href="../single-table-design-pattern/#dynamodb-%e9%81%a9%e7%94%a8%e5%ba%a6%e5%89%8d%e7%bd%ae%e5%88%a4%e8%ae%804-%e8%bb%b8">single-table-design-pattern 開頭 4 軸前置判讀&lt;/a>、本篇不重複展開。Partition key 反模式是 &lt;em>已選 DynamoDB 後&lt;/em> 的 schema 修補議題；若 4 軸不成立、改回 SQL 比補 composite key 更合理。&lt;/p>&lt;/blockquote>
&lt;blockquote>
&lt;p>&lt;strong>跨 vendor 可逆性對照 SSoT&lt;/strong>：MongoDB / DynamoDB / Cosmos DB 三家 partition key 可逆性不在同一光譜（DynamoDB 走 backfill 到新 table、屬中度可逆）、跨 vendor 對照 SSoT 主寫位置在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/db3-vendor-selection/#%e4%b8%89-vendor-%e5%b0%8d%e6%af%94-10-%e8%bb%b8" data-link-title="DB3 Vendor Selection：document / KV / multi-model 三方選型 &amp;#43; workload shape 前置判讀" data-link-desc="MongoDB / DynamoDB / Cosmos DB 三家 NoSQL 選型 entry point：workload shape × access pattern × consistency 三軸前置判讀、migration path 三型、federated DB 視角、三 vendor 對比 10 軸">DB3 entry — 三 vendor 對比 10 軸&lt;/a> + 對應的&lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/db3-vendor-selection/#%e8%bb%b8%e7%9a%84%e5%bb%b6%e4%bc%b8%e5%ad%90%e6%ae%b5" data-link-title="DB3 Vendor Selection：document / KV / multi-model 三方選型 &amp;#43; workload shape 前置判讀" data-link-desc="MongoDB / DynamoDB / Cosmos DB 三家 NoSQL 選型 entry point：workload shape × access pattern × consistency 三軸前置判讀、migration path 三型、federated DB 視角、三 vendor 對比 10 軸">軸的延伸子段&lt;/a>。本篇聚焦 DynamoDB 內部如何識別 partition key 反模式 + composite key / write sharding 修法、不重複跨 vendor 比較。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/dynamodb/" data-link-title="DynamoDB" data-link-desc="AWS managed key-value、partition-based scaling、9000 萬 RPS sustained 實戰證據">DynamoDB</a> overview 的 implementation-layer deep article。寫作參照 <a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">vendor deep article methodology</a>。</p></blockquote>
<p>售票網站開賣前一小時把 DynamoDB capacity 從 200 WCU 拉到 5000、心想「容量加 25 倍應該夠」。開賣瞬間還是看到 <code>ThrottledRequests</code> 拉警報、CloudWatch 顯示總 capacity 才用了 1500 WCU。打開 partition-level metric 才看到某一個 partition 已經達到 1000 WCU 上限、其他 partition 閒置 — <code>event_id</code> 當 PK、單一熱門場次把所有寫入集中到同一個 partition。Capacity 加再多都救不了，因為單 partition 上限是 1000 WCU / 3000 RCU、跟 table 總容量無關。這就是 hot partition 的本質：partition key 設計問題、不是 capacity 不夠。</p>
<p>本文展開 partition key 反模式的識別、composite key / write sharding 兩種修法、mode × partition 在 provisioned / on-demand 下的不同表現、以及 9.C15 拓元 6750x IOPS 擴展案例的工程細節。</p>
<blockquote>
<p><strong>DynamoDB 適用度前置判讀</strong>：本篇假設 workload 已通過 DynamoDB 適用度 4 軸（PK 天然均勻 / control plane vs data plane / consistency 可接受 eventual / access pattern 穩定）— 詳見 <a href="../single-table-design-pattern/#dynamodb-%e9%81%a9%e7%94%a8%e5%ba%a6%e5%89%8d%e7%bd%ae%e5%88%a4%e8%ae%804-%e8%bb%b8">single-table-design-pattern 開頭 4 軸前置判讀</a>、本篇不重複展開。Partition key 反模式是 <em>已選 DynamoDB 後</em> 的 schema 修補議題；若 4 軸不成立、改回 SQL 比補 composite key 更合理。</p></blockquote>
<blockquote>
<p><strong>跨 vendor 可逆性對照 SSoT</strong>：MongoDB / DynamoDB / Cosmos DB 三家 partition key 可逆性不在同一光譜（DynamoDB 走 backfill 到新 table、屬中度可逆）、跨 vendor 對照 SSoT 主寫位置在 <a href="/blog/backend/01-database/vendors/db3-vendor-selection/#%e4%b8%89-vendor-%e5%b0%8d%e6%af%94-10-%e8%bb%b8" data-link-title="DB3 Vendor Selection：document / KV / multi-model 三方選型 &#43; workload shape 前置判讀" data-link-desc="MongoDB / DynamoDB / Cosmos DB 三家 NoSQL 選型 entry point：workload shape × access pattern × consistency 三軸前置判讀、migration path 三型、federated DB 視角、三 vendor 對比 10 軸">DB3 entry — 三 vendor 對比 10 軸</a> + 對應的<a href="/blog/backend/01-database/vendors/db3-vendor-selection/#%e8%bb%b8%e7%9a%84%e5%bb%b6%e4%bc%b8%e5%ad%90%e6%ae%b5" data-link-title="DB3 Vendor Selection：document / KV / multi-model 三方選型 &#43; workload shape 前置判讀" data-link-desc="MongoDB / DynamoDB / Cosmos DB 三家 NoSQL 選型 entry point：workload shape × access pattern × consistency 三軸前置判讀、migration path 三型、federated DB 視角、三 vendor 對比 10 軸">軸的延伸子段</a>。本篇聚焦 DynamoDB 內部如何識別 partition key 反模式 + composite key / write sharding 修法、不重複跨 vendor 比較。</p></blockquote>
<h2 id="核心機制partition-上限是工程硬天花板">核心機制：partition 上限是工程硬天花板</h2>
<p>DynamoDB 把 capacity 抽象成 RCU / WCU、但底下仍是物理 partition。理解 partition 的 4 條硬規則：</p>
<ul>
<li><strong>單 partition 上限</strong>：3000 RCU、1000 WCU、10GB storage；超過任一個觸發 partition split</li>
<li><strong>總容量公式</strong>：<code>partition 數量 × 每 partition 上限</code>、partition 數量由 vendor 自動管理</li>
<li><strong>Adaptive Capacity</strong>：跨 partition 重新分配閒置容量、但 <em>單 partition 仍硬上限</em>；不解 single-key 集中</li>
<li><strong>Splitting on heat</strong>：vendor 偵測 hot partition 後自動 split、有分鐘級延遲；突發流量來不及 split 就先 throttle</li>
</ul>
<p><code>9.C5 Amazon Ads</code> 揭露同一 frame：「容量 = 每 partition 上限 × partition 數量、最熱 partition saturation 是工程天花板」。Amazon Ads 90M reads/sec 不是把單 partition 推到極限、是 <em>partition key 設計讓流量散到極多 partition</em>、每個 partition 都在合理區間。</p>
<p>對應 knowledge card：<a href="/blog/backend/knowledge-cards/hot-partition/" data-link-title="Hot Partition" data-link-desc="說明分散式 KV / OLTP 中、單一 partition 流量遠超其他的容量問題">hot partition</a>、<a href="/blog/backend/knowledge-cards/database-sharding/" data-link-title="Database Sharding" data-link-desc="說明資料庫如何依 shard key 分散資料、路由請求與承擔跨 shard 查詢成本">database-sharding</a>。</p>
<h2 id="mode--partition-交叉判讀">Mode × Partition 交叉判讀</h2>
<p>Hot partition 在 capacity mode 不同下表現不同、但根因都是 schema。這是 single-table / partition-key / capacity-mode 三篇 deep article 的交叉軸 — mode 切換不解 partition 設計問題、partition 設計也不解 mode 選擇問題。</p>
<table>
  <thead>
      <tr>
          <th>表現面</th>
          <th>Provisioned 模式</th>
          <th>On-demand 模式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Throttle 可見性</td>
          <td><code>WriteThrottleEvents</code> 立即可見、CloudWatch 直接抓</td>
          <td>不顯示 throttle event、表現為 <code>SuccessfulRequestLatency</code> p99 突然跳高</td>
      </tr>
      <tr>
          <td>Application 表現</td>
          <td><code>ProvisionedThroughputExceededException</code> 立即拋</td>
          <td>timeout / retry 加劇、看起來像「DynamoDB 變慢」</td>
      </tr>
      <tr>
          <td>工程誤判風險</td>
          <td>低（exception 明顯）</td>
          <td>高（latency spike 容易被誤判成網路 / 應用層 / 下游服務問題）</td>
      </tr>
      <tr>
          <td>解法</td>
          <td>改 PK schema（composite key / write sharding）</td>
          <td>改 PK schema（同左、不是切 mode）</td>
      </tr>
  </tbody>
</table>
<p><code>9.C15 Tixcraft</code> 警惕段明示這個 frame：「DynamoDB 寫入排隊本身就是隱性限流」— provisioned 看得到、on-demand 看不到，但都是同一個 schema 問題。</p>
<p><strong>核心 frame</strong>：on-demand 不是 partition key 設計的逃避路徑。看到 on-demand 模式 latency spike 但 throttle 為零，<em>第一個懷疑就是 hot partition</em>、不是網路或應用層。</p>
<p>跟 <a href="/blog/backend/01-database/vendors/dynamodb/on-demand-vs-provisioned/" data-link-title="DynamoDB On-Demand vs Provisioned：6 軸決策、auto-scaling 邊界與 cost crossover" data-link-desc="capacity mode 選擇不是單軸 peak/avg ratio；本文展開 6 軸決策（peak/avg / 讀寫比 trend / surge 暫時 vs 永久 baseline / predictable-peak vs flash-sale / DBA 工時釋放 / vendor vs 自管 cost crossover），含 Zomato 50% 成本下降、Zoom 30x permanent surge、Amazon Ads sustained workload 等 case 分軸 anchor">on-demand-vs-provisioned</a> 共軸閱讀：本篇從 schema 視角切入、那篇從 mode 選擇視角切入、合起來才是完整判讀。</p>
<h2 id="修復流程">修復流程</h2>
<p>從 access pattern audit 到 composite key 設計的 5 步流程。</p>
<h4 id="step-1識別寫入集中的-logical-key">Step 1：識別寫入集中的 logical key</h4>
<p>審視 access pattern 表、抓出 <em>寫入集中</em> 的 key：</p>
<ul>
<li>單一 event / single user 寫入比例 &gt; 10%（如熱門場次售票、bot 帳號）</li>
<li>時間 bucket（<code>PK = date</code> / <code>PK = hour</code>）— 寫入永遠打當下 partition、舊 partition 閒置</li>
<li>少數枚舉值（<code>PK = status</code> / <code>PK = country</code> 但只有 5-10 個值）</li>
</ul>
<p><code>9.C15 Tixcraft</code> 揭露的具體場景：演唱會某一熱門場次的 <code>event_id</code> 為 PK、開賣瞬間 200K 用戶同時搶該場次、所有寫入集中到單一 partition。</p>
<h4 id="step-2選-shard-數">Step 2：選 shard 數</h4>
<p>把單一 logical key 切成 N 個物理 shard。N 的估算邏輯：</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">單 partition WCU 上限 = 1000
</span></span><span class="line"><span class="ln">2</span><span class="cl">留 20% buffer            = 800
</span></span><span class="line"><span class="ln">3</span><span class="cl">N = 單 logical key 預期峰值 WCU / 800（最小 shard 數）</span></span></code></pre></div><blockquote>
<p><strong>Scope warning</strong>：「shard 數 10-100」、「800 WCU 留 buffer」這些具體數字是通用工程估算、9.C15 case <em>沒有</em> 揭露 Tixcraft 用幾個 shard。case 揭露的是「composite key 分散」概念跟「IOPS 從 20 衝到 135K」的結果、不是具體 shard 數量。寫進你自己的設計時、shard 數依預期單 logical key 峰值估算、不要照搬本文數字。</p></blockquote>
<h4 id="step-3composite-key-設計random-shard">Step 3：composite key 設計（random shard）</h4>
<p><a href="/blog/backend/knowledge-cards/composite-partition-key/" data-link-title="Composite Partition Key" data-link-desc="多欄位合成 partition key 把單一 logical hot key 拆成多個物理 shard、寫入分散讀取 fan-out">Composite Partition Key</a> 把 logical key 加上 random suffix、把 hot logical 值分散到多個 partition：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="kn">import</span> <span class="nn">random</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="k">def</span> <span class="nf">write_order</span><span class="p">(</span><span class="n">event_id</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">user_id</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">order_data</span><span class="p">:</span> <span class="nb">dict</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="c1"># 寫入端：random suffix 分散到 N shard</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="n">shard</span> <span class="o">=</span> <span class="n">random</span><span class="o">.</span><span class="n">randint</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="n">N</span> <span class="o">-</span> <span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="n">pk</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">event_id</span><span class="si">}</span><span class="s2">#</span><span class="si">{</span><span class="n">shard</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="n">sk</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;USER#</span><span class="si">{</span><span class="n">user_id</span><span class="si">}</span><span class="s2">#</span><span class="si">{</span><span class="n">timestamp</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">    <span class="n">table</span><span class="o">.</span><span class="n">put_item</span><span class="p">(</span><span class="n">Item</span><span class="o">=</span><span class="p">{</span><span class="s2">&#34;PK&#34;</span><span class="p">:</span> <span class="n">pk</span><span class="p">,</span> <span class="s2">&#34;SK&#34;</span><span class="p">:</span> <span class="n">sk</span><span class="p">,</span> <span class="o">**</span><span class="n">order_data</span><span class="p">})</span></span></span></code></pre></div><p>讀取時 fan-out 到所有 shard：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">def</span> <span class="nf">query_event_orders</span><span class="p">(</span><span class="n">event_id</span><span class="p">:</span> <span class="nb">str</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="n">results</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="k">for</span> <span class="n">shard</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">N</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">        <span class="n">pk</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">event_id</span><span class="si">}</span><span class="s2">#</span><span class="si">{</span><span class="n">shard</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">        <span class="n">page</span> <span class="o">=</span> <span class="n">table</span><span class="o">.</span><span class="n">query</span><span class="p">(</span><span class="n">KeyConditionExpression</span><span class="o">=</span><span class="n">Key</span><span class="p">(</span><span class="s2">&#34;PK&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">eq</span><span class="p">(</span><span class="n">pk</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">        <span class="n">results</span><span class="o">.</span><span class="n">extend</span><span class="p">(</span><span class="n">page</span><span class="p">[</span><span class="s2">&#34;Items&#34;</span><span class="p">])</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="k">return</span> <span class="n">results</span></span></span></code></pre></div><h4 id="step-4calculated-shard讓同-user-仍可預測讀取">Step 4：calculated shard（讓同 user 仍可預測讀取）</h4>
<p>random shard 的代價是讀取要 fan-out N 次。當你需要「同 user 寫入分散、但讀取 <em>該 user</em> 自己的資料時不要 fan-out」、改用 calculated shard：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kn">import</span> <span class="nn">hashlib</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="k">def</span> <span class="nf">shard_for_user</span><span class="p">(</span><span class="n">user_id</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">n</span><span class="p">:</span> <span class="nb">int</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">int</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="n">h</span> <span class="o">=</span> <span class="n">hashlib</span><span class="o">.</span><span class="n">md5</span><span class="p">(</span><span class="n">user_id</span><span class="o">.</span><span class="n">encode</span><span class="p">())</span><span class="o">.</span><span class="n">hexdigest</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">return</span> <span class="nb">int</span><span class="p">(</span><span class="n">h</span><span class="p">,</span> <span class="mi">16</span><span class="p">)</span> <span class="o">%</span> <span class="n">n</span>
</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 class="k">def</span> <span class="nf">write_user_event</span><span class="p">(</span><span class="n">user_id</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">event_data</span><span class="p">:</span> <span class="nb">dict</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="n">shard</span> <span class="o">=</span> <span class="n">shard_for_user</span><span class="p">(</span><span class="n">user_id</span><span class="p">,</span> <span class="n">N</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="n">pk</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;USER#</span><span class="si">{</span><span class="n">user_id</span><span class="si">}</span><span class="s2">#</span><span class="si">{</span><span class="n">shard</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="c1"># 同一 user_id 永遠拿到同一 shard</span></span></span></code></pre></div><p>讀單一 user 只 query 一個 shard、讀全平台 user 才 fan-out N 個 shard。</p>
<p>選擇：</p>
<ul>
<li><strong>random shard</strong>：寫入完全均勻、但所有讀路徑都要 fan-out；適合 <em>flash-sale / 緩衝層</em>（讀路徑是後端慢消費、不在乎 fan-out latency）</li>
<li><strong>calculated shard</strong>：寫入按 hash 均勻、user-level 讀路徑單 shard；適合 <em>user-facing OLTP</em>（user 讀自己資料延遲敏感）</li>
</ul>
<h4 id="step-5驗證點">Step 5：驗證點</h4>
<ul>
<li>Contributor Insights 看 top-N PK 訪問是否平均分布</li>
<li>CloudWatch partition-level throttle = 0</li>
<li>Application 端 read fan-out latency 在預算內</li>
</ul>
<p><strong>Rollback boundary</strong>：composite key 寫入端可雙寫舊 + 新 key 一段時間（雙寫窗口）、application read 端 fallback 到舊 PK；不可逆動作只在「移除舊 key」階段。</p>
<h2 id="失敗模式">失敗模式</h2>
<p>production case 揭露的 5 個踩雷情境：</p>
<h4 id="case-1時間序-pk-集中">Case 1：時間序 PK 集中</h4>
<p><code>PK = date</code> 或 <code>PK = hour</code> — 寫入永遠打當下 partition、舊 partition 閒置。每日凌晨換 partition 時瞬間冷啟動、寫入 latency spike。修法：<code>date#shard</code> 把當下 partition 拆 N 個物理 shard、或改用 event-stream pattern（每個 event 獨立 ID 為 PK）。</p>
<h4 id="case-2bot-user-集中">Case 2：bot user 集中</h4>
<p>PK = <code>user_id</code>、某個 bot 帳號每秒寫 1000 次、單 user_id 達 1000 WCU 上限。修法：</p>
<ul>
<li>偵測高頻 user 後動態加 shard suffix（<code>user_id#shard0</code> … <code>user_id#shardN</code>）</li>
<li>或在 application 層 rate limit、不讓 bot 直接打 DynamoDB</li>
</ul>
<h4 id="case-3composite-key-但-read-端忘記-fan-out">Case 3：composite key 但 read 端忘記 fan-out</h4>
<p>寫入分散到 100 shard、讀取只 query 一個 shard、結果不完整。修法：讀取必須 N 次 query 並 application 端合併、或建反向 GSI（GSI PK = <code>event_id</code>、不加 shard suffix；但 GSI 自己也會 hot partition）。</p>
<h4 id="case-4shard-數選太多-read-fan-out-latency-爆">Case 4：shard 數選太多 read fan-out latency 爆</h4>
<p>N 過大時讀取 fan-out latency 從 5ms 變 200ms（具體數字隨網路延遲跟並行度變動、9.C15 case 未揭露 Tixcraft 用幾個 shard）。修法：shard 數依「單 logical key 預期峰值 / 800」估算、不是越多越好；read latency 跟寫入分散度是 trade-off。</p>
<h4 id="case-5on-demand-模式以為不會-hot-partition">Case 5：on-demand 模式以為不會 hot partition</h4>
<p>on-demand 仍受單 partition 1000 WCU 限制、只是 throttling 表現為 latency spike 而非 exception。team 看到「沒有 ThrottledRequests」就以為沒問題、實際 p99 已經從 5ms 跳到 50ms。修法：on-demand 不是 partition key 設計的逃避路徑、依然要做 composite key；觀測上看 <code>SuccessfulRequestLatency</code> p99 不只看 throttle。跟 <a href="/blog/backend/01-database/vendors/dynamodb/on-demand-vs-provisioned/" data-link-title="DynamoDB On-Demand vs Provisioned：6 軸決策、auto-scaling 邊界與 cost crossover" data-link-desc="capacity mode 選擇不是單軸 peak/avg ratio；本文展開 6 軸決策（peak/avg / 讀寫比 trend / surge 暫時 vs 永久 baseline / predictable-peak vs flash-sale / DBA 工時釋放 / vendor vs 自管 cost crossover），含 Zomato 50% 成本下降、Zoom 30x permanent surge、Amazon Ads sustained workload 等 case 分軸 anchor">on-demand-vs-provisioned</a> 共軸閱讀。</p>
<p><strong>Anti-recommendation</strong>：access pattern 寫入分散自然均勻（如 UUID 為 PK、無 logical hot key），不要預先 sharding；增加 read 端 fan-out 複雜度沒帶來收益。</p>
<h2 id="容量與觀測">容量與觀測</h2>
<p>CloudWatch metric：</p>
<ul>
<li><code>WriteThrottleEvents</code> / <code>ReadThrottleEvents</code>：按 table 跟 GSI 分；provisioned 模式直接訊號</li>
<li><code>SuccessfulRequestLatency</code> p99：on-demand 模式下 hot partition 的訊號（throttle 為零但 latency 跳高）</li>
<li>partition-level metric 透過 Contributor Insights 看，不是 CloudWatch 預設 panel</li>
</ul>
<p><strong>Contributor Insights 必開</strong>：top-N partition key by access frequency；每月 cost ~$0.02 per million event、值得開。沒開 Contributor Insights 你看不到 partition-level 分布、只能從總 capacity 跟 throttle 反推。</p>
<p>DynamoDB Streams：可用來抓 hot key debugging — 寫入事件落 Lambda 後統計 PK 頻率。</p>
<p><strong>Mode × partition 觀測差異</strong>（重申交叉判讀）：</p>
<ul>
<li>Provisioned 模式：看 <code>WriteThrottleEvents</code>、立即可見</li>
<li>On-demand 模式：看 <code>SuccessfulRequestLatency</code> p99、看 partition-level Contributor Insights、看 application 端 timeout / retry trend</li>
</ul>
<p>接回 <a href="/blog/backend/09-performance-capacity/saturation-discovery/" data-link-title="9.4 Saturation Discovery" data-link-desc="找出 throughput plateau 與 latency knee 的方法">9.4 Saturation Discovery</a> 的 partition 章節。</p>
<h2 id="邊界與整合">邊界與整合</h2>
<h3 id="9c15-tixcraft-6750x-擴展的工程拆解">9.C15 Tixcraft 6750x 擴展的工程拆解</h3>
<p><code>9.C15 Tixcraft</code> 揭露的數字：IOPS 從 20 衝到 135K（6750 倍）、6 servers 變 800 servers、總成本 $4200、throttle rate 0.26%。但「6750x 擴展」不是 DynamoDB 自己的魔法、是 <em>partition key 均勻分散 + 架構解耦</em> 的組合結果：</p>
<ul>
<li><strong>partition key 均勻</strong>：composite key（<code>event_id</code> 加分散 suffix）把單一熱門場次散到多個 partition、每個 partition 都在合理區間（case 揭露概念、未揭露具體 shard 數）</li>
<li><strong>架構解耦</strong>：DynamoDB 當 durable queue、後端傳統 server（金流 / 票庫）用自己節奏消費、不被前端 130x 流量拖垮（見 <a href="/blog/backend/01-database/vendors/dynamodb/single-table-design-pattern/" data-link-title="DynamoDB Single-Table Design：從適用度前置判讀到 access pattern 反推 PK/SK" data-link-desc="DynamoDB single-table 設計不是「資料表越少越好」，而是 access pattern 反推 PK/SK 跟 GSI；本文先做 DynamoDB 適用度 4 軸前置判讀（PK 天然均勻 / control plane vs data plane / consistency / access pattern 穩定），再展開設計流程、failure modes 與 durable queue 正向用例">single-table-design-pattern</a> 的 durable queue 段）</li>
<li><strong>付款層獨立</strong>：付款不是 DynamoDB、是另一層獨立服務、避免搶票流量影響付款</li>
</ul>
<p>讀者該學的不是「DynamoDB 能撐 6750x」、是「composite key + 架構解耦 + 服務分層」三件事一起做才能撐。</p>
<h3 id="sibling-與-cross-link">Sibling 與 cross-link</h3>
<ul>
<li><a href="/blog/backend/01-database/vendors/dynamodb/single-table-design-pattern/" data-link-title="DynamoDB Single-Table Design：從適用度前置判讀到 access pattern 反推 PK/SK" data-link-desc="DynamoDB single-table 設計不是「資料表越少越好」，而是 access pattern 反推 PK/SK 跟 GSI；本文先做 DynamoDB 適用度 4 軸前置判讀（PK 天然均勻 / control plane vs data plane / consistency / access pattern 穩定），再展開設計流程、failure modes 與 durable queue 正向用例">single-table-design-pattern</a> — PK 設計上游、本篇是 PK 不天然均勻時的補救</li>
<li><a href="/blog/backend/01-database/vendors/dynamodb/on-demand-vs-provisioned/" data-link-title="DynamoDB On-Demand vs Provisioned：6 軸決策、auto-scaling 邊界與 cost crossover" data-link-desc="capacity mode 選擇不是單軸 peak/avg ratio；本文展開 6 軸決策（peak/avg / 讀寫比 trend / surge 暫時 vs 永久 baseline / predictable-peak vs flash-sale / DBA 工時釋放 / vendor vs 自管 cost crossover），含 Zomato 50% 成本下降、Zoom 30x permanent surge、Amazon Ads sustained workload 等 case 分軸 anchor">on-demand-vs-provisioned</a> — capacity mode 對 hot partition 表現的影響、mode × partition 交叉判讀的另一視角</li>
<li><a href="/blog/backend/01-database/vendors/dynamodb/gsi-lsi-design/" data-link-title="DynamoDB GSI 與 LSI 設計：access pattern 補位、projection、consistency 跟 DAX 補位" data-link-desc="GSI / LSI 是 single-table 沒覆蓋的 access pattern 補位、不是萬靈丹；本文涵蓋 projection 三型選擇、sparse index、GSI 自己會 hot partition、DAX 讀峰值補位的觸發條件（含 Capcom 是 derive vs Lemino 是 case fact 的分層）">gsi-lsi-design</a> — GSI 自己也會 hot partition、GSI PK 設計獨立 review</li>
<li>Migration playbook：composite key migration 屬「topology re-layout」、寫入需雙軌；對應 <a href="/blog/posts/migration-playbook-%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84stage-0-variant-%E8%A6%8F%E5%8A%83%E6%8A%8A-collapse-%E7%8E%87%E5%BE%9E-60-%E9%99%8D%E5%88%B0-0/" data-link-title="Migration Playbook 方法論的演化紀錄：Stage 0 variant 規劃把 collapse 率從 60% 降到 0%" data-link-desc="跨 vendor migration playbook 需要獨立寫作方法論的依據，以及這套方法論從三輪 batch dogfood 中演化出來的驗證證據。">migration playbook methodology</a></li>
<li>跟 <a href="/blog/backend/09-performance-capacity/cases/tixcraft-ticketing-flash-sale-spike/" data-link-title="9.C15 拓元 Tixcraft：售票搶購的瞬間爆量架構" data-link-desc="拓元用 DynamoDB 當寫入緩衝 &#43; 傳統伺服器當慢速消費者、承受 100K&#43; 同時選位 &#43; 30 秒從 6 台擴到 800 台">Tixcraft 9.C15</a> 互引：售票模式的 6750x 擴展細節、composite key 是工程選擇而非 vendor 魔法</li>
<li>跟 <a href="/blog/backend/09-performance-capacity/cases/amazon-ads-dynamodb-extreme-kv/" data-link-title="9.C5 Amazon Ads：DynamoDB 9000 萬 reads/sec 的廣告事件量測" data-link-desc="Amazon Ads 在 DynamoDB 上跑 9000 萬 reads/sec &#43; 500 萬 writes/sec、99.999% 可用性的廣告事件量測">Amazon Ads 9.C5</a> 互引：容量 = 每 partition 上限 × partition 數量、最熱 partition saturation 是容量天花板</li>
<li>跟 <a href="/blog/backend/09-performance-capacity/cases/ntt-docomo-lemino-japanese-streaming/" data-link-title="9.C29 NTT DOCOMO Lemino：3 個月達 500 萬 MAU 的串流後端" data-link-desc="Lemino 用 DynamoDB &#43; AWS Media Services 撐 30 channels live &#43; 5M MAU、工程工時下降 90%">Lemino 9.C29</a> 互引：connection-free scale 的另一面是 partition 設計責任</li>
</ul>
]]></content:encoded></item><item><title>MongoDB Shard Key Selection：hashed vs ranged、單 cluster 切 shard vs 多 cluster 切 blast radius</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/mongodb/shard-key-selection/</link><pubDate>Wed, 27 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/mongodb/shard-key-selection/</guid><description>&lt;p>MongoDB shard key 是 sharded cluster 上線時最難回頭的決策。Shard key 一旦設定錯、5.0 之前完全不可逆、5.0+ 用 &lt;code>reshardCollection&lt;/code> 可改但仍是長時間運算 + 額外磁碟 + 寫入暫停窗口。但 shard key 不是 production 唯一的橫向擴展選項 — 還有「多 cluster」這條路徑（Toyota Connected 揭露），兩者解的問題完全不同。本文把 shard key 三特性（cardinality / frequency / monotonicity）跟「單 cluster vs 多 cluster」對照在一起、配合跨 vendor partition key 可逆性紀律一起討論。&lt;/p>
&lt;p>本文不重複 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/mongodb/" data-link-title="MongoDB" data-link-desc="Document database 代表、Atlas managed、跨雲可用、許多大規模平台從 MongoDB 起家">MongoDB vendor overview&lt;/a> 已寫過的 sharding 簡介 — 而是 production 設計 + 失敗修復的實作層教學。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>MongoDB 適用度前置判讀&lt;/strong>：進到 shard key 設計前先確認 workload 在 MongoDB 適用區（document shape 主導 / contract layer 該放哪 / 跨雲 hedging 是否需要）— 詳見 &lt;a href="../schema-design-pattern/#%e5%95%8f%e9%a1%8c%e6%83%85%e5%a2%83document-%e8%87%aa%e7%94%b1%e7%9a%84%e5%be%8c%e5%ba%a7%e5%8a%9b">schema-design-pattern 開頭 3 軸前置判讀&lt;/a>、本篇不重複展開。Sharded cluster 是 &lt;em>已選 MongoDB 後&lt;/em> 的容量決策、不是 vendor 選型決策。&lt;/p>&lt;/blockquote>
&lt;h2 id="問題情境橫向擴展不是只有-sharded-cluster-一條路">問題情境：橫向擴展不是只有 sharded cluster 一條路&lt;/h2>
&lt;p>典型觸發場景：single replica set 撐到上限、writes 已經把 primary 推到 CPU 90% / disk IO 飽和、working set 超出 RAM。讀者下意識會想到「分 shard」、但同時還有「分 cluster」這條路徑、兩者 trigger 完全不同：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>單 cluster 切 shard&lt;/strong>：解的是 &lt;em>單一資料域寫入飽和&lt;/em>、collection 大到單 replica set 撐不住&lt;/li>
&lt;li>&lt;strong>多 cluster 切 DB&lt;/strong>：解的是 &lt;em>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/blast-radius/" data-link-title="Blast Radius" data-link-desc="說明事故影響面如何估算與隔離">blast radius&lt;/a> / ownership / 合規邊界&lt;/em>、不一定是吞吐問題&lt;/li>
&lt;/ul>
&lt;p>混淆兩者的後果：吞吐沒撞牆但 blast radius 是議題、強行分 shard → aggregation / transaction / &lt;code>$lookup&lt;/code> 成本全部跳一級、業務 ownership 仍混在一起。或反過來：吞吐撞牆但選了分 cluster → 跨 cluster transaction 不存在、單一 collection 跨多 cluster 要在 application 層拼。&lt;/p>
&lt;p>讀者徵兆：&lt;/p>
&lt;ul>
&lt;li>&lt;code>mongos&lt;/code> 的 &lt;code>targeted query / scatter-gather query&lt;/code> 比例失衡&lt;/li>
&lt;li>單一 shard CPU 遠高其他 shard、balancer 移 chunk 跟不上寫入速度&lt;/li>
&lt;li>&lt;code>chunkMigrated&lt;/code> 異常頻繁、&lt;code>sh.status()&lt;/code> 顯示 chunk 分布偏斜&lt;/li>
&lt;li>微服務 ownership 跟 collection 邊界不對齊、某 microservice 故障打到其他服務&lt;/li>
&lt;/ul>
&lt;p>Case anchor：&lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/toyota-connected-mongodb-telematics-iot/" data-link-title="9.C38 Toyota Connected：MongoDB Atlas 撐 900 萬車輛 telematics、月 180 億 transaction" data-link-desc="Toyota Connected 用 MongoDB Atlas 撐 Safety Connect 900 萬車、月 180 億 transaction、緊急訊號 3 秒內到 agent">9.C38 Toyota Connected&lt;/a> 揭露「20 個 Atlas database 是業務邊界切分、不是吞吐切分」（單 cluster vs 多 cluster 對照）；hot shard 在 e-commerce flash sale / 遊戲開新區 / B2B 大客戶獨佔 chunk 的具體 incident 細節需未來 case 補完、本文以「常見 failure pattern」處理、不憑空編造 incident 數字。&lt;/p></description><content:encoded><![CDATA[<p>MongoDB shard key 是 sharded cluster 上線時最難回頭的決策。Shard key 一旦設定錯、5.0 之前完全不可逆、5.0+ 用 <code>reshardCollection</code> 可改但仍是長時間運算 + 額外磁碟 + 寫入暫停窗口。但 shard key 不是 production 唯一的橫向擴展選項 — 還有「多 cluster」這條路徑（Toyota Connected 揭露），兩者解的問題完全不同。本文把 shard key 三特性（cardinality / frequency / monotonicity）跟「單 cluster vs 多 cluster」對照在一起、配合跨 vendor partition key 可逆性紀律一起討論。</p>
<p>本文不重複 <a href="/blog/backend/01-database/vendors/mongodb/" data-link-title="MongoDB" data-link-desc="Document database 代表、Atlas managed、跨雲可用、許多大規模平台從 MongoDB 起家">MongoDB vendor overview</a> 已寫過的 sharding 簡介 — 而是 production 設計 + 失敗修復的實作層教學。</p>
<blockquote>
<p><strong>MongoDB 適用度前置判讀</strong>：進到 shard key 設計前先確認 workload 在 MongoDB 適用區（document shape 主導 / contract layer 該放哪 / 跨雲 hedging 是否需要）— 詳見 <a href="../schema-design-pattern/#%e5%95%8f%e9%a1%8c%e6%83%85%e5%a2%83document-%e8%87%aa%e7%94%b1%e7%9a%84%e5%be%8c%e5%ba%a7%e5%8a%9b">schema-design-pattern 開頭 3 軸前置判讀</a>、本篇不重複展開。Sharded cluster 是 <em>已選 MongoDB 後</em> 的容量決策、不是 vendor 選型決策。</p></blockquote>
<h2 id="問題情境橫向擴展不是只有-sharded-cluster-一條路">問題情境：橫向擴展不是只有 sharded cluster 一條路</h2>
<p>典型觸發場景：single replica set 撐到上限、writes 已經把 primary 推到 CPU 90% / disk IO 飽和、working set 超出 RAM。讀者下意識會想到「分 shard」、但同時還有「分 cluster」這條路徑、兩者 trigger 完全不同：</p>
<ul>
<li><strong>單 cluster 切 shard</strong>：解的是 <em>單一資料域寫入飽和</em>、collection 大到單 replica set 撐不住</li>
<li><strong>多 cluster 切 DB</strong>：解的是 <em><a href="/blog/backend/knowledge-cards/blast-radius/" data-link-title="Blast Radius" data-link-desc="說明事故影響面如何估算與隔離">blast radius</a> / ownership / 合規邊界</em>、不一定是吞吐問題</li>
</ul>
<p>混淆兩者的後果：吞吐沒撞牆但 blast radius 是議題、強行分 shard → aggregation / transaction / <code>$lookup</code> 成本全部跳一級、業務 ownership 仍混在一起。或反過來：吞吐撞牆但選了分 cluster → 跨 cluster transaction 不存在、單一 collection 跨多 cluster 要在 application 層拼。</p>
<p>讀者徵兆：</p>
<ul>
<li><code>mongos</code> 的 <code>targeted query / scatter-gather query</code> 比例失衡</li>
<li>單一 shard CPU 遠高其他 shard、balancer 移 chunk 跟不上寫入速度</li>
<li><code>chunkMigrated</code> 異常頻繁、<code>sh.status()</code> 顯示 chunk 分布偏斜</li>
<li>微服務 ownership 跟 collection 邊界不對齊、某 microservice 故障打到其他服務</li>
</ul>
<p>Case anchor：<a href="/blog/backend/09-performance-capacity/cases/toyota-connected-mongodb-telematics-iot/" data-link-title="9.C38 Toyota Connected：MongoDB Atlas 撐 900 萬車輛 telematics、月 180 億 transaction" data-link-desc="Toyota Connected 用 MongoDB Atlas 撐 Safety Connect 900 萬車、月 180 億 transaction、緊急訊號 3 秒內到 agent">9.C38 Toyota Connected</a> 揭露「20 個 Atlas database 是業務邊界切分、不是吞吐切分」（單 cluster vs 多 cluster 對照）；hot shard 在 e-commerce flash sale / 遊戲開新區 / B2B 大客戶獨佔 chunk 的具體 incident 細節需未來 case 補完、本文以「常見 failure pattern」處理、不憑空編造 incident 數字。</p>
<h2 id="核心機制shard-keychunkbalancer">核心機制：shard key、chunk、balancer</h2>
<p>Shard key 三特性決定 sharded cluster 行為：</p>
<ul>
<li><strong>Cardinality（基數）</strong>：shard key 的不同值數量。<code>status: &quot;active&quot; | &quot;inactive&quot;</code> 只有兩個值、cardinality = 2、不能分到多 chunk</li>
<li><strong>Frequency（頻率分布）</strong>：值的分布是否平均。<code>country</code> 在全球流量中通常一兩個國家佔 80%</li>
<li><strong>Monotonicity（單調性）</strong>：值是否單調遞增。<code>_id</code>（ObjectId）/ 時間戳 / 自增 ID 都是單調</li>
</ul>
<p>三特性決定 shard key 行為：</p>
<ul>
<li><strong>Hashed shard key</strong>：hash function 把 key 打散、寫入分布均勻、但 range query 變 scatter-gather（每個 shard 都問）</li>
<li><strong>Ranged shard key</strong>：相同 key 相近 → 同 chunk → range query 高效；但單調 key + ranged → 所有寫打最後 chunk</li>
<li><strong>Compound shard key</strong>（5.0+ 是常用做法、對應 <a href="/blog/backend/knowledge-cards/composite-partition-key/" data-link-title="Composite Partition Key" data-link-desc="多欄位合成 partition key 把單一 logical hot key 拆成多個物理 shard、寫入分散讀取 fan-out">Composite Partition Key</a> 的 MongoDB 實作）：例如 <code>{ tenantId: 1, _id: &quot;hashed&quot; }</code> — 先 tenant 隔離、再 hash 避免 tenant 內熱點</li>
<li><strong>Zone sharding</strong>：把特定 chunk 釘到特定 shard（地域 / 合規 / 硬體分層）</li>
</ul>
<p>Chunk 是 MongoDB 在 collection 上劃出的 64MB（預設）邏輯區塊。Balancer 在 shard 間搬 chunk 達成均衡。<strong>Chunk 不可 split 的條件</strong>是 shard key 在該範圍只有一個值（low cardinality / 大 tenant 獨佔範圍）— chunk split 不了、balancer 也搬不開。</p>
<p><code>reshardCollection</code>（4.4+）：透過 temporary collection + chunk 重切 + 雙寫 + cutover、耗時等比於資料量、需額外 ~1.2x 磁碟。是「設計錯了還有補救機會」但不是 free lunch。</p>
<p>對應 knowledge card：<a href="/blog/backend/knowledge-cards/database-sharding/" data-link-title="Database Sharding" data-link-desc="說明資料庫如何依 shard key 分散資料、路由請求與承擔跨 shard 查詢成本">database-sharding</a>、<a href="/blog/backend/knowledge-cards/hot-partition/" data-link-title="Hot Partition" data-link-desc="說明分散式 KV / OLTP 中、單一 partition 流量遠超其他的容量問題">hot-partition</a>、<a href="/blog/backend/knowledge-cards/partition/" data-link-title="Partition" data-link-desc="說明事件流如何切分成多個可並行處理的有序片段">partition</a>。</p>
<h3 id="單-cluster-切-shard-vs-多-cluster-切-blast-radius">單 cluster 切 shard vs 多 cluster 切 blast radius</h3>
<p>跨案合成 frame（本章合成、9.C38 Toyota 揭露事實但 case 原文沒提這個 frame）：橫向擴展不是只有「sharded cluster 一條路」、多 cluster 是另一條路。</p>
<p>9.C38 Toyota Connected 揭露事實：</p>
<ul>
<li>18B transactions / 月 ÷ 30 天 ÷ 86400 秒 ≈ 7K txn/sec（口徑：月度滾動平均、非瞬時尖峰）</li>
<li>單一 MongoDB cluster 完全撐得下這個吞吐</li>
<li>Toyota 切 20 個 Atlas database <strong>不是吞吐切分</strong>、是 <em>microservice ownership</em> + <em>blast radius</em> 切分</li>
<li>「每個 microservice 擁有自己的 DB、單一 DB 故障不影響其他服務」</li>
</ul>
<p>兩條路徑的判讀條件不同：</p>
<table>
  <thead>
      <tr>
          <th>路徑</th>
          <th>Trigger</th>
          <th>代價</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Sharded cluster（分 shard）</td>
          <td>單一 collection 寫入飽和、storage 撐爆單 replica set、access pattern 在同一個資料域內</td>
          <td>aggregation / transaction / <code>$lookup</code> 成本全部跳一級</td>
      </tr>
      <tr>
          <td>多 cluster（分 DB）</td>
          <td>微服務 ownership 邊界、blast radius 隔離、合規 boundary、不同 workload shape 共處風險</td>
          <td>跨 cluster transaction 不存在、跨 DB join 必須在 application 層做</td>
      </tr>
  </tbody>
</table>
<p>兩者可以同時用：每個 microservice 有獨立 cluster、cluster 內部該分 shard 還是分。寫設計文件時要避免讓讀者以為「sharded cluster 是唯一橫向擴展選項」。</p>
<h3 id="partition-key-可逆性跨-vendor-對照">Partition key 可逆性跨 vendor 對照</h3>
<blockquote>
<p><strong>跨 vendor 可逆性對照 SSoT</strong>：MongoDB / DynamoDB / Cosmos DB 三家可逆性不在同一光譜、跨 vendor 對照的 SSoT 主寫位置在 <a href="/blog/backend/01-database/vendors/db3-vendor-selection/#%e4%b8%89-vendor-%e5%b0%8d%e6%af%94-10-%e8%bb%b8" data-link-title="DB3 Vendor Selection：document / KV / multi-model 三方選型 &#43; workload shape 前置判讀" data-link-desc="MongoDB / DynamoDB / Cosmos DB 三家 NoSQL 選型 entry point：workload shape × access pattern × consistency 三軸前置判讀、migration path 三型、federated DB 視角、三 vendor 對比 10 軸">DB3 entry — 三 vendor 對比 10 軸</a> + 對應的<a href="/blog/backend/01-database/vendors/db3-vendor-selection/#%e8%bb%b8%e7%9a%84%e5%bb%b6%e4%bc%b8%e5%ad%90%e6%ae%b5" data-link-title="DB3 Vendor Selection：document / KV / multi-model 三方選型 &#43; workload shape 前置判讀" data-link-desc="MongoDB / DynamoDB / Cosmos DB 三家 NoSQL 選型 entry point：workload shape × access pattern × consistency 三軸前置判讀、migration path 三型、federated DB 視角、三 vendor 對比 10 軸">軸的延伸子段</a>。本段聚焦 MongoDB 5.0+ <code>reshardCollection</code> 對 shard key 設計的影響、不重複展開三 vendor 全光譜比較。</p></blockquote>
<p>不同 vendor 對 partition key 可逆性紀律完全不在同一光譜：</p>
<table>
  <thead>
      <tr>
          <th>Vendor</th>
          <th>機制</th>
          <th>可逆性</th>
          <th>成本</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>MongoDB</td>
          <td>Shard key（<code>shardCollection</code>）</td>
          <td>4.4+ <code>reshardCollection</code> 可改、5.0 前完全不可逆</td>
          <td>等比資料量、~1.2x 磁碟、雙寫 + cutover</td>
      </tr>
      <tr>
          <td>DynamoDB</td>
          <td>Partition key</td>
          <td>可改（用 backfill 到新 table）</td>
          <td>重設計 access pattern、流量切換成本</td>
      </tr>
      <tr>
          <td>Cosmos DB</td>
          <td>Partition key</td>
          <td>不可改（必須 export-recreate-import）</td>
          <td>全量重灌、雙寫驗證、最大遷移成本</td>
      </tr>
  </tbody>
</table>
<p>寫進設計文件時必須附 vendor + 版本、避免讓讀者把三家當「partition key 都不可改」、也避免把 MongoDB 5.0+ 的 <code>reshardCollection</code> 當「便宜遷移」。</p>
<h2 id="操作流程">操作流程</h2>
<p><strong>Step 1：横向擴展路徑決策</strong>。先問「我要解的是 <em>單一資料域寫入飽和</em> 還是 <em>blast radius / ownership</em>」、選分 shard 或分 cluster。若兩者都要、決定 cluster 邊界後再在 cluster 內分 shard。</p>
<p><strong>Step 2：access pattern audit</strong>。列出所有讀寫 query、標出哪些 query 必須走 single shard（targeted），哪些 query 不在意 scatter-gather。</p>
<p><strong>Step 3：候選 key 評估表</strong>。對每個候選打 cardinality / frequency / monotonicity 三項評分：</p>
<table>
  <thead>
      <tr>
          <th>候選 key</th>
          <th>Cardinality</th>
          <th>Frequency</th>
          <th>Monotonicity</th>
          <th>適合？</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>_id</code>（ObjectId）</td>
          <td>極高</td>
          <td>均勻</td>
          <td>單調</td>
          <td>否（單調寫熱）</td>
      </tr>
      <tr>
          <td><code>tenantId</code></td>
          <td>中</td>
          <td>偏斜</td>
          <td>否</td>
          <td>視 tenant 分布</td>
      </tr>
      <tr>
          <td><code>{ tenantId: 1, _id: &quot;hashed&quot; }</code></td>
          <td>高</td>
          <td>均勻</td>
          <td>否</td>
          <td>通常合適</td>
      </tr>
      <tr>
          <td><code>country</code></td>
          <td>極低（~200）</td>
          <td>嚴重偏斜</td>
          <td>否</td>
          <td>否</td>
      </tr>
  </tbody>
</table>
<p><strong>Step 4：dry-run 採樣</strong>。對既有資料採樣，跑 <code>db.coll.aggregate([{$sample:{size:100000}}, {$group:{_id:&quot;$candidateKey&quot;, c:{$sum:1}}}, {$sort:{c:-1}}])</code> 看分布、確認沒有單一 key value 吃掉 &gt; 20% 流量。</p>
<p><strong>Step 5：shardCollection</strong>。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">sh</span><span class="p">.</span><span class="nx">enableSharding</span><span class="p">(</span><span class="s2">&#34;shop&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">sh</span><span class="p">.</span><span class="nx">shardCollection</span><span class="p">(</span><span class="s2">&#34;shop.orders&#34;</span><span class="p">,</span> <span class="p">{</span> <span class="nx">tenantId</span><span class="o">:</span> <span class="mi">1</span><span class="p">,</span> <span class="nx">_id</span><span class="o">:</span> <span class="s2">&#34;hashed&#34;</span> <span class="p">})</span></span></span></code></pre></div><p>先在 staging 跑流量重放、確認 chunk 分布平均、targeted query 比例 &gt; 90%。</p>
<p><strong>Step 6：監控</strong>。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">sh</span><span class="p">.</span><span class="nx">status</span><span class="p">()</span>                              <span class="c1">// 看 cluster 狀態
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="nx">db</span><span class="p">.</span><span class="nx">orders</span><span class="p">.</span><span class="nx">getShardDistribution</span><span class="p">()</span>         <span class="c1">// 看 chunk 分布
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span><span class="nx">db</span><span class="p">.</span><span class="nx">adminCommand</span><span class="p">({</span> <span class="nx">balancerStatus</span><span class="o">:</span> <span class="mi">1</span> <span class="p">})</span>   <span class="c1">// 看 balancer 狀態
</span></span></span></code></pre></div><p><strong>Step 7：若已上錯 key</strong>。評估 <code>reshardCollection</code>（4.4+）vs application-level 雙寫遷移：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">db</span><span class="p">.</span><span class="nx">adminCommand</span><span class="p">({</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nx">reshardCollection</span><span class="o">:</span> <span class="s2">&#34;shop.orders&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nx">key</span><span class="o">:</span> <span class="p">{</span> <span class="nx">tenantId</span><span class="o">:</span> <span class="mi">1</span><span class="p">,</span> <span class="nx">region</span><span class="o">:</span> <span class="mi">1</span><span class="p">,</span> <span class="nx">_id</span><span class="o">:</span> <span class="s2">&#34;hashed&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">})</span></span></span></code></pre></div><p><code>reshardCollection</code> 進入 cutover 後不能回退、必須 dry-run 估完時間 + 磁碟 + IO 影響再上。</p>
<p>驗證點：targeted query 比例 &gt; 90%、單 shard QPS 變異係數 &lt; 20%、balancer migration 速率追上寫入速率。</p>
<p>Rollback boundary：<code>shardCollection</code> 是不可逆操作（5.0 前完全不可逆、5.0+ 透過 reshardCollection 可改但需重做）；<code>reshardCollection</code> 進入 cutover 後不能回退。</p>
<h2 id="失敗模式">失敗模式</h2>
<p><strong>單調 key 寫熱點</strong>：<code>_id</code>（ObjectId）/ 時間戳 / 自增 ID 當 ranged shard key → 所有寫進最後 chunk，scale-out 等於零。修法是 hashed key 或 compound key 把單調軸拌散。</p>
<p><strong>低 cardinality key</strong>：用 <code>country</code> 當 shard key、某個 country 佔 80% 流量、chunk 無法繼續 split、該 shard 永久熱。修法是加一個高 cardinality 軸（compound key）讓 chunk 可繼續分。</p>
<p><strong>Tenant skew</strong>：B2B 場景大客戶獨佔 chunk、且該 tenant 的 chunk 還會繼續長大、balancer 搬不走。修法 compound key <code>{ tenantId: 1, _id: &quot;hashed&quot; }</code> — tenant 隔離但 tenant 內 hash 散開。</p>
<p><strong>Scatter-gather 過多</strong>：選了 hashed <code>_id</code> 但業務查詢主要是 <code>tenantId</code> 範圍查、每筆 query 打所有 shard、p99 隨 shard 數線性退化。修法 compound key 把常用查詢軸放第一位、targeted query 才能對 single shard。</p>
<p><strong>Resharding 卡在 build 階段</strong>：磁碟不夠（需 1.2x source size）、IO 飽和影響線上 workload、預期 4 小時實際跑 14 小時。修法是先擴磁碟、staging 跑 dry-run 量實際耗時、production 在低峰期啟動。</p>
<p><strong>Zone sharding 規則打架</strong>：合規規則（資料必須留在某 region）跟負載平衡規則衝突、balancer 無法移動 chunk → 熱點固化。修法是 zone 規則 vs balancer 設計階段就劃清、不要事後加 zone。</p>
<p><strong>誤把多 cluster 當分 shard 解</strong>：blast radius 議題塞到 sharded cluster、單 cluster 故障仍打掉全部 microservice。該分 cluster 的就分 cluster、不是塞到 shard。9.C38 Toyota 揭露：7K txn/sec 仍切 20 DB 的 trigger 是 microservice ownership、不是吞吐。</p>
<p><strong>Cluster 擴容時間估計太樂觀</strong>：MongoDB cluster 擴容是天級議題、不是 console 點點就好。9.C36 Coinbase 揭露 cluster 擴容要 70 分鐘（口徑：Coinbase 特定環境 cluster tier / 資料量 / Atlas API 條件下、reactive scaling 起點到完成、非 MongoDB 普遍承諾）；預測性流量必須走 predictive / scheduled scaling、不能只靠 sharded cluster 動態橫向擴展接住 surge（見 <a href="../connection-management-and-cache-layer/">connection management and cache layer</a>）。</p>
<p>Anti-recommendation：</p>
<ul>
<li>寫入 &lt; 5K WPS、storage &lt; 1TB、single replica set 還能撐就不該分 shard；分了之後 aggregation、transaction、<code>$lookup</code>、index 成本全部跳一級</li>
<li><strong>shard vs 多 cluster 對照</strong>：吞吐沒撞牆但 blast radius / ownership 是議題、走多 cluster 不是強行分 shard（9.C38 Toyota 7K txn/sec 仍切 20 DB 的 trigger）</li>
<li>跨 case 合成 frame：「不是所有資料都該進同一個 MongoDB cluster」、按 microservice ownership / blast radius / 合規邊界切</li>
</ul>
<h2 id="容量與觀測">容量與觀測</h2>
<p>關鍵 metric：</p>
<ul>
<li><strong>Shard 分布健康</strong>：每 shard QPS / CPU / disk usage 變異係數（&lt; 20% 合理）</li>
<li><strong>Query 路由</strong>：targeted vs scatter-gather query 比例（targeted &gt; 90% 合理）</li>
<li><strong>Balancer 健康</strong>：chunk migration rate、balancer round duration</li>
<li><strong>Cluster 邊界</strong>：cluster-to-cluster ownership 邊界、跨 cluster query 比例</li>
</ul>
<p>Mongo command：</p>
<ul>
<li><code>sh.status()</code>：cluster 整體狀態</li>
<li><code>db.coll.getShardDistribution()</code>：collection 在各 shard 的分布</li>
<li><code>db.adminCommand({balancerStatus:1})</code>：balancer 狀態</li>
<li><code>db.serverStatus().sharding</code>：sharding metric</li>
</ul>
<p><code>mongos</code> profiler：每 query 帶 <code>executionStats.executionStages.shards[]</code>、看是否 single shard。</p>
<p>回到 <a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">4.20 observability evidence</a>：把 shard distribution、targeted ratio、resharding 進度列為 evidence 三件套。</p>
<p>回到 <a href="/blog/backend/09-performance-capacity/saturation-discovery/" data-link-title="9.4 Saturation Discovery" data-link-desc="找出 throughput plateau 與 latency knee 的方法">9.4 saturation discovery</a>：hot shard 是 partition-level saturation 的典型例子。</p>
<p>回到 <a href="/blog/backend/09-performance-capacity/bottleneck-localization/" data-link-title="9.5 瓶頸定位流程" data-link-desc="從 app 到 DB / cache / broker / 第三方 quota 的逐層瓶頸定位">9.5 bottleneck localization</a>：當整 cluster CPU 看似只用 25%、實際是 1/4 shard 在 100%。</p>
<h2 id="邊界與整合">邊界與整合</h2>
<p>Sibling deep articles：</p>
<ul>
<li><a href="../schema-design-pattern/">schema design pattern</a> — document 形狀決定 shard key 選擇空間</li>
<li><a href="../aggregation-pipeline-optimization/">aggregation pipeline optimization</a> — cross-shard aggregation 的 <code>$out</code> / <code>$merge</code> 限制</li>
<li><a href="../change-streams-kafka/">change streams + Kafka</a> — cluster-wide vs collection-level change stream 在 sharded cluster 的差異</li>
<li><a href="../connection-management-and-cache-layer/">connection management and cache layer</a> — cluster 擴容時間是天級議題、必須跟 predictive scaling / proxy 層配合</li>
</ul>
<p>Migration playbook：</p>
<ul>
<li>避免自管 sharding 走 <a href="/blog/backend/01-database/vendors/mongodb/migrate-to-atlas/" data-link-title="MongoDB → Atlas：Atlas 不是 MongoDB &#43; managed、是另一個 product" data-link-desc="Atlas 號稱「MongoDB managed」但 operational model 完全不同（auto-scaling / VPC peering / IAM-driven access / 內建 backup / billing 模型）；本文採用 Type C operational redesign hybrid 結構、4-phase operational migration &#43; drop-in cutover、5 個 production 踩雷（連線數限制 / IP whitelist / backup retention / IAM token 過期 / billing 暴漲）">→ Atlas</a> 用 managed shard tier</li>
<li>徹底重新分區走 <a href="/blog/backend/01-database/vendors/mongodb/shard-expansion-multi-dc/" data-link-title="MongoDB Shard Expansion &#43; Multi-DC：Type F「不需要 parallel run」的 multi-region 例外" data-link-desc="MongoDB sharded cluster 加 shard &#43; 跨 DC expansion 是 Type F「topology re-layout」第 3 個 dogfood — 同時改 sharding &#43; replication topology &#43; region distribution；驗證 [#128](/report/data-topology-as-audit-dimension/) self-aware limitation 第 3 點「Type F 不需要 parallel run」claim 的例外（multi-region rollout 必須 parallel run &#43; 切流量）；涵蓋 chunk migration / replica set add member / cross-DC routing">shard expansion + multi-DC</a></li>
</ul>
<p>跟 1.x 互引：<a href="/blog/backend/01-database/kv-document-capacity-planning/" data-link-title="1.10 KV / Document DB 容量規劃" data-link-desc="DynamoDB / Cosmos DB / Bigtable / MongoDB 等 KV / Document DB 的容量設計、partition key 取捨、capacity mode 選擇">1.10 KV / Document DB 容量規劃</a> 把 shard key 列為 capacity 決策；<a href="/blog/backend/01-database/large-scale-db-migration/" data-link-title="1.12 大規模 DB 遷移實戰" data-link-desc="跨 DB 遷移的 dual-write、[shadow read](/backend/knowledge-cards/shadow-read/)、cutover、rollback 流程 — 從實戰案例提煉的工程做法">1.12 大規模 DB 遷移實戰</a> 收 resharding 失敗 retrospective。</p>
<p>跨 vendor 對照：<a href="/blog/backend/01-database/vendors/dynamodb/" data-link-title="DynamoDB" data-link-desc="AWS managed key-value、partition-based scaling、9000 萬 RPS sustained 實戰證據">DynamoDB vendor page</a>（partition key + adaptive capacity + backfill 可改）、<a href="/blog/backend/01-database/vendors/cosmosdb/" data-link-title="Azure Cosmos DB" data-link-desc="全球分散式 multi-model DB、5 個 consistency levels、Microsoft 自家 dogfood 證據">Cosmos DB vendor page</a>（partition key 不可改）。</p>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/mongodb/" data-link-title="MongoDB" data-link-desc="Document database 代表、Atlas managed、跨雲可用、許多大規模平台從 MongoDB 起家">MongoDB vendor overview</a> — 本文是該頁尾「shard key 選型」backlog 的深度展開</li>
<li><a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章方法論</a></li>
<li><a href="/blog/backend/09-performance-capacity/cases/toyota-connected-mongodb-telematics-iot/" data-link-title="9.C38 Toyota Connected：MongoDB Atlas 撐 900 萬車輛 telematics、月 180 億 transaction" data-link-desc="Toyota Connected 用 MongoDB Atlas 撐 Safety Connect 900 萬車、月 180 億 transaction、緊急訊號 3 秒內到 agent">9.C38 Toyota Connected</a> — 20 個 Atlas DB 切 blast radius</li>
<li><a href="/blog/backend/09-performance-capacity/cases/coinbase-mongodb-document-platform/" data-link-title="9.C36 Coinbase：MongoDB 撐 Ruby 單體 &#43; 1.5M reads/sec identity 服務" data-link-desc="Coinbase 以 MongoDB 為主資料層、自建 mongobetween connection proxy、users 服務在加密貨幣 surge 時撐 1.5M reads/sec">9.C36 Coinbase</a> — cluster 擴容 70 分鐘特定環境數字</li>
<li>官方：<a href="https://www.mongodb.com/docs/manual/sharding/">MongoDB Sharding</a>、<a href="https://www.mongodb.com/docs/manual/core/sharding-shard-key/">Choosing a Shard Key</a>、<a href="https://www.mongodb.com/docs/manual/core/sharding-reshard-a-collection/">Resharding</a></li>
</ul>
]]></content:encoded></item><item><title>Spanner Consistency Models 對照：external consistency vs serializability vs linearizability</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/spanner/consistency-models-comparison/</link><pubDate>Wed, 27 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/spanner/consistency-models-comparison/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/spanner/" data-link-title="Google Cloud Spanner" data-link-desc="全球分散式 strong-consistency OLTP、TrueTime API、線性擴展到 10 億 req/sec">Cloud Spanner&lt;/a> overview 的 concept-layer deep article。Overview 已說明 Spanner 在強一致 SQL 譜系的定位、本文聚焦 &lt;em>consistency model&lt;/em> — 三個常被混用的概念（external consistency / serializability / linearizability）的精確差異、line-rate scaling 對照、跟 cross-region quorum 的物理硬限。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="問題情境五個詞混用的選型困境">問題情境：五個詞混用的選型困境&lt;/h2>
&lt;p>團隊在 Spanner / CockroachDB / Aurora DSQL 之間選型、看文件講 strict serializability、external consistency、linearizable、snapshot isolation、serializable — 五個詞混用、不確定買的是哪一種保證。讀者徵兆通常是「我們需要強一致」但說不出強到哪、把 serializable transaction 跟 linearizable read 當同一件事、debug 對帳時發現「兩個 transaction 都 commit 成功、順序卻違反 user 體感」。&lt;/p>
&lt;p>真實壓力場景：金融帳本 — A 在台北轉帳給 B、B 在東京立即收到通知然後查餘額、結果查到「轉帳前」的餘額。serializable 允許這種行為（兩 transaction 可以排成任意順序、不要求跟 wall clock 一致）、external consistency 不允許（必須等 commit 後的順序符合 real-time）。混用兩個詞會讓選型結論在系統實作後才被推翻、那時候改架構成本已經高了。&lt;/p>
&lt;p>Case anchor：&lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/spanner-planetary-scale-database-gcp/" data-link-title="9.C10 Cloud Spanner：每秒 10 億請求的全球一致性資料庫" data-link-desc="Google Cloud Spanner 內部峰值 10 億 req/sec、跨地區強一致 — 全球分散式 OLTP 容量參考">9.C10 Cloud Spanner planetary scale&lt;/a> — Google Ads 計費需要 external consistency；對照 PostgreSQL SSI、CockroachDB HLC、Aurora DSQL。&lt;strong>dogfood 邊界明示&lt;/strong>：9.C10 是 Google 內部 dogfood case、不是 customer-facing capacity 參考；本文引用其 line-rate scaling 數字時要附「Google internal dogfood 揭露的設計目標、不是客戶 SLA」邊界。&lt;/p>
&lt;h2 id="三個概念的精確定義">三個概念的精確定義&lt;/h2>
&lt;h3 id="serializability">Serializability&lt;/h3>
&lt;p>transaction 的執行結果等同於 &lt;em>某個&lt;/em> 序列順序執行；不要求順序跟 real-time 一致。PostgreSQL SERIALIZABLE isolation level（SSI 實作）給的就是這個保證。它解決的問題是 &lt;em>concurrent transaction 之間互相干擾的 anomaly&lt;/em>（dirty read / lost update / write skew / G2-item）、不解決「跨 transaction 的 wall-clock 順序」。&lt;/p>
&lt;p>範例：A 在 10:00:00 commit T1（餘額 +100）、B 在 10:00:01 commit T2（查餘額）。serializable 允許系統把 T2 排在 T1 之前、B 看到舊餘額 — 兩 transaction 都成功、isolation 沒被破壞、但用戶體感違反順序。&lt;/p>
&lt;h3 id="linearizability">Linearizability&lt;/h3>
&lt;p>單一 object 操作有全序、且全序跟 real-time wall-clock 一致。只談 single-object、不談跨 object transaction。DynamoDB strongly consistent read 是 single-item linearizability、Redis &lt;code>INCR&lt;/code> 是 single-key linearizability。對應 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/linearizability/" data-link-title="Linearizability" data-link-desc="每次操作看起來都在單一全域順序中即時生效的一致性語意">linearizability&lt;/a> 卡。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/spanner/" data-link-title="Google Cloud Spanner" data-link-desc="全球分散式 strong-consistency OLTP、TrueTime API、線性擴展到 10 億 req/sec">Cloud Spanner</a> overview 的 concept-layer deep article。Overview 已說明 Spanner 在強一致 SQL 譜系的定位、本文聚焦 <em>consistency model</em> — 三個常被混用的概念（external consistency / serializability / linearizability）的精確差異、line-rate scaling 對照、跟 cross-region quorum 的物理硬限。</p></blockquote>
<hr>
<h2 id="問題情境五個詞混用的選型困境">問題情境：五個詞混用的選型困境</h2>
<p>團隊在 Spanner / CockroachDB / Aurora DSQL 之間選型、看文件講 strict serializability、external consistency、linearizable、snapshot isolation、serializable — 五個詞混用、不確定買的是哪一種保證。讀者徵兆通常是「我們需要強一致」但說不出強到哪、把 serializable transaction 跟 linearizable read 當同一件事、debug 對帳時發現「兩個 transaction 都 commit 成功、順序卻違反 user 體感」。</p>
<p>真實壓力場景：金融帳本 — A 在台北轉帳給 B、B 在東京立即收到通知然後查餘額、結果查到「轉帳前」的餘額。serializable 允許這種行為（兩 transaction 可以排成任意順序、不要求跟 wall clock 一致）、external consistency 不允許（必須等 commit 後的順序符合 real-time）。混用兩個詞會讓選型結論在系統實作後才被推翻、那時候改架構成本已經高了。</p>
<p>Case anchor：<a href="/blog/backend/09-performance-capacity/cases/spanner-planetary-scale-database-gcp/" data-link-title="9.C10 Cloud Spanner：每秒 10 億請求的全球一致性資料庫" data-link-desc="Google Cloud Spanner 內部峰值 10 億 req/sec、跨地區強一致 — 全球分散式 OLTP 容量參考">9.C10 Cloud Spanner planetary scale</a> — Google Ads 計費需要 external consistency；對照 PostgreSQL SSI、CockroachDB HLC、Aurora DSQL。<strong>dogfood 邊界明示</strong>：9.C10 是 Google 內部 dogfood case、不是 customer-facing capacity 參考；本文引用其 line-rate scaling 數字時要附「Google internal dogfood 揭露的設計目標、不是客戶 SLA」邊界。</p>
<h2 id="三個概念的精確定義">三個概念的精確定義</h2>
<h3 id="serializability">Serializability</h3>
<p>transaction 的執行結果等同於 <em>某個</em> 序列順序執行；不要求順序跟 real-time 一致。PostgreSQL SERIALIZABLE isolation level（SSI 實作）給的就是這個保證。它解決的問題是 <em>concurrent transaction 之間互相干擾的 anomaly</em>（dirty read / lost update / write skew / G2-item）、不解決「跨 transaction 的 wall-clock 順序」。</p>
<p>範例：A 在 10:00:00 commit T1（餘額 +100）、B 在 10:00:01 commit T2（查餘額）。serializable 允許系統把 T2 排在 T1 之前、B 看到舊餘額 — 兩 transaction 都成功、isolation 沒被破壞、但用戶體感違反順序。</p>
<h3 id="linearizability">Linearizability</h3>
<p>單一 object 操作有全序、且全序跟 real-time wall-clock 一致。只談 single-object、不談跨 object transaction。DynamoDB strongly consistent read 是 single-item linearizability、Redis <code>INCR</code> 是 single-key linearizability。對應 <a href="/blog/backend/knowledge-cards/linearizability/" data-link-title="Linearizability" data-link-desc="每次操作看起來都在單一全域順序中即時生效的一致性語意">linearizability</a> 卡。</p>
<p>linearizability 跟 serializability 是 <em>正交</em> 的兩個概念 — linearizability 講「單一 object 的 real-time 順序」、serializability 講「transaction 的 anomaly-free 執行」。一個系統可以是 linearizable 但不 serializable（單 object 強保證、跨 object transaction 沒有）、也可以是 serializable 但不 linearizable（PostgreSQL SSI single-node 在 replica lag 後就不 linearizable）。</p>
<h3 id="external-consistency--strict-serializability">External consistency / Strict serializability</h3>
<p>transaction 層級的 serializability + 全序跟 real-time 一致 — 等同於把 linearizability 推廣到 multi-object transaction。Spanner 用 TrueTime + commit wait 實作、保證 commit timestamp 順序 = real-time 順序。對應 <a href="/blog/backend/knowledge-cards/external-consistency/" data-link-title="External Consistency" data-link-desc="交易可見順序與外部真實時間順序一致的強一致性語意">external-consistency</a> 卡。</p>
<p>回到金融帳本例：external consistency 不允許 T2 排在 T1 之前、因為 T2 的 transaction timestamp 必須大於 T1 的 commit timestamp、用戶查餘額必看到 +100 後的金額。</p>
<h2 id="line-rate-scaling-對照為什麼-pg-serializable-在-multi-node-拿不到-line-rate">Line-rate scaling 對照：為什麼 PG serializable 在 multi-node 拿不到 line-rate</h2>
<p>這段的核心責任是回答「為什麼 Spanner 不只是『更強的 serializable』、是『coordinator 換拓樸』的 paradigm shift」、扣 <a href="../truetime-api-depth/">truetime-api-depth</a> 的商業邏輯先行 frame。讀者選 consistency 等級時、實際在選「系統的 scaling 路徑」、不只是「應用層 anomaly 哪些被排除」。</p>
<h3 id="9c10-揭露的線性擴展數字">9.C10 揭露的線性擴展數字</h3>
<p>「2 nodes → 45K reads/sec、4 nodes → 90K reads/sec」這條線性 scaling 揭露 Spanner external consistency 不是「加強版 serializable」、是把跨節點 coordinator 從 single-point 換成「拓樸感知的多 leader（每個 split 自己的 Paxos group）」、所以擴 node 數可以線性拿 throughput。</p>
<p><strong>Dogfood 邊界明示</strong>：9.C10 數字是 Google internal dogfood、不是 customer-facing capacity 承諾。客戶能拿到的 line-rate 受 instance config、region layout、workload shape 影響、不會自動複製 Google 內部曲線。</p>
<h3 id="對照表四個系統的-scaling-路徑">對照表：四個系統的 scaling 路徑</h3>
<table>
  <thead>
      <tr>
          <th>系統</th>
          <th>Isolation / Consistency 等級</th>
          <th>Multi-node scaling 路徑</th>
          <th>為什麼撞天花板（或不撞）</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>PostgreSQL SSI</td>
          <td>Serializable</td>
          <td>single-primary + read replica</td>
          <td>寫只能 single primary、跨節點交易要 2PC + coordinator、replica 寫不了；scaling 路徑停在 single-primary 容量上限</td>
      </tr>
      <tr>
          <td>CockroachDB</td>
          <td>Serializable + per-key linearizable</td>
          <td>range-based + HLC</td>
          <td>range coordinator 仍存在、但 range 拆細了；retry contract 接住跨 range conflict、扣 serializable restart cost</td>
      </tr>
      <tr>
          <td>Spanner</td>
          <td>External consistency</td>
          <td>split-based + Paxos + TrueTime</td>
          <td>coordinator 變多 leader、TrueTime 對齊 commit 順序、線性擴展是設計目標（9.C10 揭露 dogfood 線性模式）</td>
      </tr>
      <tr>
          <td>Aurora DSQL</td>
          <td>Strong consistency（2024 推出）</td>
          <td>文件未完全公開、查最新 docs</td>
          <td>時間敏感 claim、本文不擴寫；讀者實作前查官方文件確認最新 scaling 模型</td>
      </tr>
  </tbody>
</table>
<p>每個欄位都要回到具體的 scaling 機制讀。PostgreSQL SSI 跟「single-primary」綁定 — 想 scale write 只能 sharding；CockroachDB 把 range 拆細、coordinator 分布到 range 層、但跨 range conflict 還是會 trigger retry；Spanner 用 Paxos group per split、commit timestamp 用 TrueTime 對齊、不需要全局 coordinator 來決定順序；Aurora DSQL 是新系統、機制細節隨版本演進。</p>
<h3 id="為什麼這個對照寫進-consistency-文章不是純機制文章">為什麼這個對照寫進 consistency 文章、不是純機制文章</h3>
<p>讀者選 consistency 等級時、實際在選「系統的 scaling 路徑」、不只是「應用層 anomaly 哪些被排除」。external consistency 的 cost 包含 commit wait latency、但 benefit 包含 line-rate scaling — 兩者要一起講、不能拆開。把對照表放這裡、讓 consistency 跟 scaling 在同一段被讀者一起判讀、避免「我們需要強一致」這種需求被翻譯成「升級到 Spanner」這種跳號決策。</p>
<h2 id="cross-region-quorum-100-200ms-物理硬限強一致--全球不是免費">Cross-region quorum 100-200ms 物理硬限：強一致 + 全球不是免費</h2>
<p><a href="/blog/backend/knowledge-cards/cross-region-quorum/" data-link-title="Cross-Region Quorum" data-link-desc="multi-region distributed SQL 強制 voting replica 跨 region、commit 等多 region quorum ack、跨洲 RTT 物理硬限">Cross-Region Quorum</a> + external consistency + multi-region 不是「免費全球」、是「用 latency 換 consistency」。讀者若沒看到具體數量級、會誤把 Spanner 當作「強一致 + 全球 + 低延遲」的奇蹟、實際 cross-region write 在物理光速硬限下必須付跨洲 round-trip cost。</p>
<h3 id="9c10-揭露的數量級">9.C10 揭露的數量級</h3>
<p>「external consistency 必須等多區 quorum、跨洲交易延遲可達 100-200ms」 — 這是 9.C10 case 直接揭露的工程數字、不是本章 derive。<strong>Dogfood 邊界明示</strong>：9.C10 case 揭露的是 Google internal dogfood 觀察到的數量級、不是 SLA 承諾；實際客戶的 cross-region write latency 隨 voting region 配置、network path 變化。</p>
<h3 id="latency-拆解模型cross-region-write">Latency 拆解模型（cross-region write）</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">total write latency ≈ 2ε（[Commit Wait](/backend/knowledge-cards/commit-wait/)、TrueTime ε 兩倍 ≈ 2-14ms）
</span></span><span class="line"><span class="ln">2</span><span class="cl">                    + quorum RTT across voting regions
</span></span><span class="line"><span class="ln">3</span><span class="cl">                       跨洲：50-100ms one-way、來回 100-200ms
</span></span><span class="line"><span class="ln">4</span><span class="cl">                       跨大陸內：10-30ms
</span></span><span class="line"><span class="ln">5</span><span class="cl">                       跨 zone（同 region）：&lt; 5ms
</span></span><span class="line"><span class="ln">6</span><span class="cl">                    + Spanner internal processing</span></span></code></pre></div><p>跨洲 quorum 在這個模型裡是 <em>dominant term</em>、不是 <a href="/blog/backend/knowledge-cards/commit-wait/" data-link-title="Commit Wait" data-link-desc="Spanner external consistency 的核心機制 — read-write transaction 拿 commit timestamp s 後等到 TT.after(s) 才 ACK、wait ≈ 2ε、付 latency tax 換 commit 順序 = real-time 順序">commit wait</a> — 判讀時要明示「commit wait 跟跨 region quorum 是兩個獨立的物理 cost、不能混用一個 latency 數字解釋兩者」。讀者常見的誤解是把 100-200ms 寫成「Spanner commit wait」、實際 commit wait 只是其中 2-14ms、剩下 100ms+ 是物理光速限定的 quorum RTT。</p>
<h3 id="scope-warning實際-latency-依-region-配置">Scope warning：實際 latency 依 region 配置</h3>
<p>100-200ms 是 9.C10 case 揭露的範圍、實際 latency 隨 voting region 配置變化：</p>
<table>
  <thead>
      <tr>
          <th>Instance config 類型</th>
          <th>Voting region 散布</th>
          <th>典型 write p99</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Regional（單 region 多 zone）</td>
          <td>同 region 內</td>
          <td>&lt; 10ms</td>
      </tr>
      <tr>
          <td>Dual-region（同大陸）</td>
          <td>跨大陸內</td>
          <td>20-50ms</td>
      </tr>
      <tr>
          <td>Multi-region（跨洲）</td>
          <td>跨大陸或跨洲</td>
          <td>100-200ms</td>
      </tr>
  </tbody>
</table>
<p>引用要附條件「跨洲多 region instance、實際數字依 region 配置」、不能寫成「Spanner cross-region write 一律 100-200ms」。讀者拿這條 latency anchor 做 capacity planning 時、必須先 audit 自家 instance 是哪種 config、不能套用 100-200ms 當基線。</p>
<h2 id="ssot-對齊strong--multi-region-互斥議題不在此處展開">SSoT 對齊：Strong + multi-region 互斥議題不在此處展開</h2>
<p>Strong consistency + multi-region 互斥議題（包含 Cosmos DB 5 levels 的 Strong + multi-region 限制）的 SSoT 是 <a href="/blog/backend/01-database/vendors/cosmosdb/multi-region-write-conflict/" data-link-title="Cosmos DB Multi-Region Write：active-active、LWW、custom merge、Strong &#43; multi-region 互斥的 AP 取捨" data-link-desc="Multi-region active-active write 的 conflict resolution（LWW / custom merge / conflict feed）、Strong 跟 multi-region write 為什麼互斥、廣告 SLA vs 實測可用性鏈路拆解 — 從 Minecraft Earth &#43; Toyota Connected 切入">Cosmos DB multi-region-write-conflict</a>。本篇 cross-link 不展開、避免重複展開同議題。</p>
<p>本篇展開的子議題：</p>
<ul>
<li>external consistency / serializability / linearizability 的精確定義差異</li>
<li>Spanner external consistency 的 TrueTime 實作機制（細節在 <a href="../truetime-api-depth/">truetime-api-depth</a>）</li>
<li>cross-region quorum 的物理 cost 數量級</li>
<li>line-rate scaling 對照表（為什麼 single-primary 系統拿不到線性）</li>
</ul>
<p>兩個 SSoT 處理同一個讀者問題（強一致 vs multi-region）的不同切面 — 本篇從 <em>系統 scaling 路徑</em> 切入、Cosmos DB 文章從 <em>consistency level 選擇</em> 切入。讀者讀完本篇後若還在問「為什麼 Cosmos DB strong consistency 不能配 multi-region write」、跳 Cosmos DB SSoT。</p>
<h2 id="操作流程怎麼驗證-consistency-等級">操作流程：怎麼驗證 consistency 等級</h2>
<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">跨 multi-object transaction 嗎？
</span></span><span class="line"><span class="ln">2</span><span class="cl">├─ 否 → DynamoDB linearizable read / Redis single-key 足夠
</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">   跨 region 寫入嗎？
</span></span><span class="line"><span class="ln">5</span><span class="cl">   ├─ 否 → CockroachDB / PostgreSQL serializable 足夠
</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">      real-time 順序是產品契約嗎？
</span></span><span class="line"><span class="ln">8</span><span class="cl">      ├─ 否 → CockroachDB multi-region 可接受
</span></span><span class="line"><span class="ln">9</span><span class="cl">      └─ 是 → Spanner / Aurora DSQL</span></span></code></pre></div><h3 id="驗證-consistency-等級的方法">驗證 consistency 等級的方法</h3>
<p>跑 Jepsen-style test、寫 read-write workload 跑 anomaly checker、量 dirty write / lost update / write skew / G2 anomaly。production 系統若不能跑完整 Jepsen、至少要在 staging 跑 <em>對應 anomaly 的具體 test case</em> — 例如金融帳本跑「轉帳後立即跨 region 查餘額、能不能看到舊值」這個具體 case、不是只看 isolation level 設定文字。</p>
<h3 id="sdk-層的選擇點">SDK 層的選擇點</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">Spanner          → 預設就是 external consistency、read 可降到 bounded staleness
</span></span><span class="line"><span class="ln">2</span><span class="cl">CockroachDB      → 預設 serializable、可選 AS OF SYSTEM TIME 換 stale read
</span></span><span class="line"><span class="ln">3</span><span class="cl">PostgreSQL       → 要顯式 SET TRANSACTION ISOLATION LEVEL SERIALIZABLE
</span></span><span class="line"><span class="ln">4</span><span class="cl">DynamoDB         → 預設 eventually consistent、ConsistentRead=true 換強一致</span></span></code></pre></div><p>每個 SDK 的 default 都不同、不能假設「沒設就是強的」。PostgreSQL default 是 READ COMMITTED、write skew 直接漏。</p>
<h3 id="rollback-boundary">Rollback boundary</h3>
<p>若一致性等級從強降到弱、要審計應用層所有讀取點（特別是「讀後決策再寫」的 critical path）。降級不是 config 一行的事、是 audit 一遍應用層假設的事。</p>
<h2 id="失敗模式把-transaction-當強一致的五種誤用">失敗模式：把 transaction 當「強一致」的五種誤用</h2>
<h3 id="把我們用-transaction當強一致">把「我們用 transaction」當「強一致」</h3>
<p>transaction 只保證原子性、不保證 isolation level；預設 isolation 可能是 READ COMMITTED、write skew 直接漏。修法是顯式設定 isolation level、跑對應 anomaly test 驗證、不靠「我們用 transaction」這種口頭契約。</p>
<h3 id="假設-single-node-serializable--distributed-serializable">假設 single-node serializable = distributed serializable</h3>
<p>PostgreSQL SSI 跨 read replica 立刻失效（replica lag）、團隊以為加 replica 還是 serializable。實際 replica 的 read 是 eventually consistent、可能看到舊 snapshot。修法是區分 primary read vs replica read、replica read path 標 <code>bounded staleness</code>、不混用 isolation level 字眼。</p>
<h3 id="跨系統-timestamp-假設">跨系統 timestamp 假設</h3>
<p>service A 用 Spanner、service B 用 Redis、用各自 timestamp 重組事件順序 — service B 的 clock 沒 TrueTime 保證、跨系統 external consistency 不成立。修法是跨系統事件順序要走 <em>單一系統的 timestamp</em> 或 <em>event sequence number</em>、不靠各系統自己的 wall-clock 拼出順序。</p>
<h3 id="把-linearizability-跟-strong-consistency-混用忽略-multi-object-場景">把 linearizability 跟 strong consistency 混用、忽略 multi-object 場景</h3>
<p>DynamoDB strongly consistent read 是 single-item linearizability、不等於跨 item transaction 強一致。團隊以為「我用了 strongly consistent read 就 OK」、實際跨 item 的順序保證沒有。修法是區分 single-object vs multi-object、跨 item 邏輯如果有順序需求、要用 DynamoDB transaction API（付 2x WCU 的 cost）或換到 Spanner。</p>
<h3 id="過度承諾-external-consistency">過度承諾 external consistency</h3>
<p>dashboard / analytics 強寫 strong read、付不必要的 latency tax。修法是把 read path 分類、analytics / reporting 改 bounded staleness、保留 strong read 給 critical path。回 <a href="../truetime-api-depth/">truetime-api-depth</a> 的「把 strong read 用在不需要的路徑」失敗模式。</p>
<h2 id="容量與觀測一致性等級的-latency-量化">容量與觀測：一致性等級的 latency 量化</h2>
<table>
  <thead>
      <tr>
          <th>一致性等級</th>
          <th>latency 影響</th>
          <th>適用場景</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>External consistency（strong）</td>
          <td>baseline = 2ε + quorum RTT</td>
          <td>critical path、金融帳本、計費</td>
      </tr>
      <tr>
          <td>Bounded staleness（5-10s）</td>
          <td>省 commit wait（10-50ms）、可讀本地 replica</td>
          <td>dashboard、reporting</td>
      </tr>
      <tr>
          <td>Eventual</td>
          <td>砍 quorum RTT、只讀本地 replica</td>
          <td>analytics、推薦</td>
      </tr>
  </tbody>
</table>
<p>跨 region 延遲量化（finding F3.15、來源 9.C10）：external consistency + multi-region instance config、跨洲 quorum 把 write latency 推到 100-200ms 數量級；單 region instance 的 commit wait 是 baseline（≈ 2ε ≈ 2-14ms）、跨 region quorum 是額外 dominant cost。</p>
<p>Cloud Monitoring：<code>spanner.googleapis.com/instance/clock_skew_ms</code> 觀察 ε、<code>api/api_request_latencies</code> for <code>Commit</code> 觀察 commit latency 分布；CockroachDB 觀察 <code>sql.txn.restart.serializable</code> 計數（serializable restart 率）。回到 <a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">4.20 Observability Evidence Package</a> 把一致性等級當 release gate 的一部分。</p>
<p>Capacity 觀點：external consistency 的 commit wait 是「無法 scale away 的 latency 支出」、capacity planning 要先扣這部分；跨 region instance 的 quorum RTT 也是物理硬限、不能透過加 node 解。</p>
<h2 id="邊界與整合sibling-路由跟-anti-recommendation">邊界與整合：sibling 路由跟 anti-recommendation</h2>
<h3 id="sibling-deep-articles">Sibling deep articles</h3>
<ul>
<li><a href="../truetime-api-depth/">truetime-api-depth</a>：external consistency 的硬體基礎、TrueTime ε / commit wait 數學、商業邏輯先行 frame</li>
<li><a href="../schema-migration-interleaved-tables/">schema-migration-interleaved-tables</a>：schema change 的版本一致性也用 TrueTime</li>
<li><a href="../migrate-from-cloud-sql-pg/">migrate-from-cloud-sql-pg</a>：Diff 階段要明確標示一致性等級從 SSI 升到 external consistency 的應用層影響</li>
</ul>
<h3 id="ssot-cross-link">SSoT cross-link</h3>
<p>Strong consistency + multi-region 互斥議題的 SSoT 在 <a href="/blog/backend/01-database/vendors/cosmosdb/multi-region-write-conflict/" data-link-title="Cosmos DB Multi-Region Write：active-active、LWW、custom merge、Strong &#43; multi-region 互斥的 AP 取捨" data-link-desc="Multi-region active-active write 的 conflict resolution（LWW / custom merge / conflict feed）、Strong 跟 multi-region write 為什麼互斥、廣告 SLA vs 實測可用性鏈路拆解 — 從 Minecraft Earth &#43; Toyota Connected 切入">Cosmos DB multi-region-write-conflict</a>、本篇不重複展開。</p>
<h3 id="跟-1x-章節的互引">跟 1.x 章節的互引</h3>
<ul>
<li><a href="/blog/backend/01-database/global-distributed-oltp/" data-link-title="1.11 全球分散式 OLTP" data-link-desc="Spanner / Aurora DSQL / Cosmos DB multi-region write / CockroachDB / TiDB 的全球一致性取捨">1.11 全球分散式 OLTP</a>：Spanner 是 PC 系統的代表</li>
<li><a href="/blog/backend/knowledge-cards/transaction-boundary/" data-link-title="Transaction Boundary" data-link-desc="說明哪些資料變更應在同一個交易中一起成功或一起回復">transaction boundary</a>：跨 transaction 順序保證</li>
</ul>
<h3 id="knowledge-card-雙引用">Knowledge card 雙引用</h3>
<ul>
<li><a href="/blog/backend/knowledge-cards/linearizability/" data-link-title="Linearizability" data-link-desc="每次操作看起來都在單一全域順序中即時生效的一致性語意">linearizability</a> — 本文當這張卡的 vendor 應用範例</li>
<li><a href="/blog/backend/knowledge-cards/external-consistency/" data-link-title="External Consistency" data-link-desc="交易可見順序與外部真實時間順序一致的強一致性語意">external-consistency</a> — 本文擴展這張卡的實作機制</li>
<li><a href="/blog/backend/knowledge-cards/isolation-level/" data-link-title="Isolation Level" data-link-desc="說明資料庫交易隔離級別如何影響並發讀寫結果">isolation-level</a> — 本文澄清 isolation level 跟 consistency model 的差異</li>
</ul>
<h3 id="anti-recommendation">Anti-recommendation</h3>
<p>讀者讀完本文應該能判斷：「我們需要強一致」不等於「升級到 Spanner」 — 先問是 single-object 還是 multi-object、是 single region 還是 multi region、real-time 順序是否是產品契約。多數 OLTP workload 用 PostgreSQL serializable 已經夠、為 external consistency 付 GCP lock-in + 跨 region quorum cost 的判準很高。</p>
]]></content:encoded></item><item><title>PostGIS Deep Dive：Geometry / Geography 型別、GiST 空間索引跟 ST_* 函式生態</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/postgis-deep-dive/</link><pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/postgis-deep-dive/</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>PostGIS extension&lt;/em> — PG 變 GIS DB 的標配、跟 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/extension-ecosystem/" data-link-title="PostgreSQL Extension Ecosystem：把 PG 變成 vector DB / time-series / sharded 的 plugin 生態" data-link-desc="PG 的 extension 機制不只是 plugin、是 *結構性產品線擴張* — pgvector 讓 PG 變 vector DB、TimescaleDB 變 time-series、Citus 變 sharded、PostGIS 變 GIS。本文走 PG extension lifecycle、6 個 production-critical extension（pg_stat_statements / pg_partman / pg_repack / pgvector / TimescaleDB / PostGIS）、5 production 踩雷（extension version 跟 PG version 對齊 / managed PG 限制 / upgrade order / shared_preload_libraries 衝突 / extension 跟 logical replication 互動）、cloud vendor 對 extension 的限制">extension-ecosystem&lt;/a> 是 &lt;em>單一 extension 細節 vs ecosystem 全景&lt;/em> 的關係。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="postgis-是-pg-的-gis-specialization">PostGIS 是 PG 的 &lt;em>GIS Specialization&lt;/em>&lt;/h2>
&lt;p>PostGIS 是 PG 最成熟的 extension 之一（2001 年起、25 年歷史）、產業地位等同 OracleSpatial / SQL Server geography：&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="n">EXTENSION&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">postgis&lt;/span>&lt;span class="p">;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>加完後 PG 多兩件事：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>空間型別&lt;/strong>：&lt;code>geometry&lt;/code>（平面）/ &lt;code>geography&lt;/code>（地球曲面）/ &lt;code>raster&lt;/code>（柵格）&lt;/li>
&lt;li>&lt;strong>1000+ 函式&lt;/strong>：&lt;code>ST_Distance&lt;/code> / &lt;code>ST_Within&lt;/code> / &lt;code>ST_Buffer&lt;/code> / &lt;code>ST_Intersects&lt;/code> 等&lt;/li>
&lt;/ol>
&lt;p>用 PostGIS 解的典型 workload：&lt;/p>
&lt;ul>
&lt;li>「離我最近的 N 家店」（k-NN）&lt;/li>
&lt;li>「半徑 1km 內的所有 POI」（radius query）&lt;/li>
&lt;li>「兩個 polygon 是否重疊」（intersection）&lt;/li>
&lt;li>「polyline 總長度」（measurement）&lt;/li>
&lt;li>「行政區包含哪些 point」（containment）&lt;/li>
&lt;/ul>
&lt;h2 id="geometry-vs-geography選錯付學費">Geometry vs Geography：選錯付學費&lt;/h2>
&lt;p>PostGIS 提供兩種空間型別、用途完全不同：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>維度&lt;/th>
 &lt;th>&lt;code>geometry&lt;/code>&lt;/th>
 &lt;th>&lt;code>geography&lt;/code>&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>座標系統&lt;/td>
 &lt;td>平面（笛卡兒）&lt;/td>
 &lt;td>地球曲面（spheroid）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>距離單位&lt;/td>
 &lt;td>座標系統決定（meter / degree）&lt;/td>
 &lt;td>永遠 meter&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>跨經度 180°&lt;/td>
 &lt;td>不處理&lt;/td>
 &lt;td>自動處理&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>適用範圍&lt;/td>
 &lt;td>小區域（單一城市 / 國家）&lt;/td>
 &lt;td>全球&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>函式覆蓋&lt;/td>
 &lt;td>1000+ 函式&lt;/td>
 &lt;td>約 300 函式&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>效能&lt;/td>
 &lt;td>快（平面計算）&lt;/td>
 &lt;td>慢 2-5x（球面計算）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Index 行為&lt;/td>
 &lt;td>GiST 直接&lt;/td>
 &lt;td>GiST 直接&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>選 &lt;code>geography&lt;/code> 的場景&lt;/strong>：&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>PostGIS extension</em> — PG 變 GIS DB 的標配、跟 <a href="/blog/backend/01-database/vendors/postgresql/extension-ecosystem/" data-link-title="PostgreSQL Extension Ecosystem：把 PG 變成 vector DB / time-series / sharded 的 plugin 生態" data-link-desc="PG 的 extension 機制不只是 plugin、是 *結構性產品線擴張* — pgvector 讓 PG 變 vector DB、TimescaleDB 變 time-series、Citus 變 sharded、PostGIS 變 GIS。本文走 PG extension lifecycle、6 個 production-critical extension（pg_stat_statements / pg_partman / pg_repack / pgvector / TimescaleDB / PostGIS）、5 production 踩雷（extension version 跟 PG version 對齊 / managed PG 限制 / upgrade order / shared_preload_libraries 衝突 / extension 跟 logical replication 互動）、cloud vendor 對 extension 的限制">extension-ecosystem</a> 是 <em>單一 extension 細節 vs ecosystem 全景</em> 的關係。</p></blockquote>
<hr>
<h2 id="postgis-是-pg-的-gis-specialization">PostGIS 是 PG 的 <em>GIS Specialization</em></h2>
<p>PostGIS 是 PG 最成熟的 extension 之一（2001 年起、25 年歷史）、產業地位等同 OracleSpatial / SQL Server geography：</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="n">EXTENSION</span><span class="w"> </span><span class="n">postgis</span><span class="p">;</span></span></span></code></pre></div><p>加完後 PG 多兩件事：</p>
<ol>
<li><strong>空間型別</strong>：<code>geometry</code>（平面）/ <code>geography</code>（地球曲面）/ <code>raster</code>（柵格）</li>
<li><strong>1000+ 函式</strong>：<code>ST_Distance</code> / <code>ST_Within</code> / <code>ST_Buffer</code> / <code>ST_Intersects</code> 等</li>
</ol>
<p>用 PostGIS 解的典型 workload：</p>
<ul>
<li>「離我最近的 N 家店」（k-NN）</li>
<li>「半徑 1km 內的所有 POI」（radius query）</li>
<li>「兩個 polygon 是否重疊」（intersection）</li>
<li>「polyline 總長度」（measurement）</li>
<li>「行政區包含哪些 point」（containment）</li>
</ul>
<h2 id="geometry-vs-geography選錯付學費">Geometry vs Geography：選錯付學費</h2>
<p>PostGIS 提供兩種空間型別、用途完全不同：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th><code>geometry</code></th>
          <th><code>geography</code></th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>座標系統</td>
          <td>平面（笛卡兒）</td>
          <td>地球曲面（spheroid）</td>
      </tr>
      <tr>
          <td>距離單位</td>
          <td>座標系統決定（meter / degree）</td>
          <td>永遠 meter</td>
      </tr>
      <tr>
          <td>跨經度 180°</td>
          <td>不處理</td>
          <td>自動處理</td>
      </tr>
      <tr>
          <td>適用範圍</td>
          <td>小區域（單一城市 / 國家）</td>
          <td>全球</td>
      </tr>
      <tr>
          <td>函式覆蓋</td>
          <td>1000+ 函式</td>
          <td>約 300 函式</td>
      </tr>
      <tr>
          <td>效能</td>
          <td>快（平面計算）</td>
          <td>慢 2-5x（球面計算）</td>
      </tr>
      <tr>
          <td>Index 行為</td>
          <td>GiST 直接</td>
          <td>GiST 直接</td>
      </tr>
  </tbody>
</table>
<p><strong>選 <code>geography</code> 的場景</strong>：</p>
<ul>
<li>全球範圍 application（跨國 / 跨大陸）</li>
<li>距離精準度要求高（球面比平面誤差小）</li>
<li>不需要複雜空間運算（geography 函式較少）</li>
</ul>
<p><strong>選 <code>geometry</code> 的場景</strong>：</p>
<ul>
<li>單一城市 / 國家內 application</li>
<li>需要完整 ST_* 函式（90% 函式只支援 geometry）</li>
<li>效能敏感</li>
</ul>
<p>實務多數 production 選 <code>geometry</code> + 適合的 SRID（用 local projection）— 既快又精準。</p>
<h2 id="srid-跟-projection為什麼-4326-vs-3857-是-gis-第一課">SRID 跟 Projection：為什麼 4326 vs 3857 是 GIS 第一課</h2>
<p>SRID（Spatial Reference System Identifier）定義「座標數字怎麼解讀」：</p>
<table>
  <thead>
      <tr>
          <th>SRID</th>
          <th>名稱</th>
          <th>適用</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>4326</td>
          <td>WGS 84（GPS）</td>
          <td>經緯度、最常見、Google Maps API</td>
      </tr>
      <tr>
          <td>3857</td>
          <td>Web Mercator</td>
          <td>Web tile map（OpenStreetMap）</td>
      </tr>
      <tr>
          <td>3826</td>
          <td>TWD97 / TM2 zone 121</td>
          <td>台灣 local projection、米為單位</td>
      </tr>
      <tr>
          <td>2272</td>
          <td>NAD83 / Pennsylvania</td>
          <td>美國 state plane（各州不同）</td>
      </tr>
  </tbody>
</table>
<p><strong>為什麼選 local projection（3826）而不是經緯度（4326）</strong>：</p>
<ul>
<li>經緯度單位是 <em>度</em>、不是距離 — <code>ST_Distance</code> 直接算出來是「度」、不是「米」</li>
<li>距離計算需 <code>ST_DistanceSphere</code> 或 <code>geography</code> cast、計算 cost 高</li>
<li>Local projection 是「平面投影」、<code>ST_Distance</code> 直接是米、<code>ST_Area</code> 直接是平方米</li>
</ul>





<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">-- 4326 經緯度直接算 → 結果不是米
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">ST_Distance</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">ST_SetSRID</span><span class="p">(</span><span class="n">ST_MakePoint</span><span class="p">(</span><span class="mi">121</span><span class="p">.</span><span class="mi">5654</span><span class="p">,</span><span class="w"> </span><span class="mi">25</span><span class="p">.</span><span class="mi">0330</span><span class="p">),</span><span class="w"> </span><span class="mi">4326</span><span class="p">),</span><span class="w">  </span><span class="c1">-- 台北 101
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"></span><span class="w">    </span><span class="n">ST_SetSRID</span><span class="p">(</span><span class="n">ST_MakePoint</span><span class="p">(</span><span class="mi">121</span><span class="p">.</span><span class="mi">5170</span><span class="p">,</span><span class="w"> </span><span class="mi">25</span><span class="p">.</span><span class="mi">0478</span><span class="p">),</span><span class="w"> </span><span class="mi">4326</span><span class="p">)</span><span class="w">   </span><span class="c1">-- 台北車站
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span><span class="p">);</span><span class="w">  </span><span class="c1">-- ~0.05（這是「度」）
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w"></span><span class="c1">-- 轉 3826（台灣本地投影）才是米
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">ST_Distance</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">ST_Transform</span><span class="p">(</span><span class="n">ST_SetSRID</span><span class="p">(</span><span class="n">ST_MakePoint</span><span class="p">(</span><span class="mi">121</span><span class="p">.</span><span class="mi">5654</span><span class="p">,</span><span class="w"> </span><span class="mi">25</span><span class="p">.</span><span class="mi">0330</span><span class="p">),</span><span class="w"> </span><span class="mi">4326</span><span class="p">),</span><span class="w"> </span><span class="mi">3826</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">ST_Transform</span><span class="p">(</span><span class="n">ST_SetSRID</span><span class="p">(</span><span class="n">ST_MakePoint</span><span class="p">(</span><span class="mi">121</span><span class="p">.</span><span class="mi">5170</span><span class="p">,</span><span class="w"> </span><span class="mi">25</span><span class="p">.</span><span class="mi">0478</span><span class="p">),</span><span class="w"> </span><span class="mi">4326</span><span class="p">),</span><span class="w"> </span><span class="mi">3826</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 class="c1">-- ~5300（米）
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w"></span><span class="c1">-- 或用 geography cast
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">ST_Distance</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="n">ST_SetSRID</span><span class="p">(</span><span class="n">ST_MakePoint</span><span class="p">(</span><span class="mi">121</span><span class="p">.</span><span class="mi">5654</span><span class="p">,</span><span class="w"> </span><span class="mi">25</span><span class="p">.</span><span class="mi">0330</span><span class="p">),</span><span class="w"> </span><span class="mi">4326</span><span class="p">)::</span><span class="n">geography</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">ST_SetSRID</span><span class="p">(</span><span class="n">ST_MakePoint</span><span class="p">(</span><span class="mi">121</span><span class="p">.</span><span class="mi">5170</span><span class="p">,</span><span class="w"> </span><span class="mi">25</span><span class="p">.</span><span class="mi">0478</span><span class="p">),</span><span class="w"> </span><span class="mi">4326</span><span class="p">)::</span><span class="n">geography</span><span class="w">
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="w"></span><span class="p">);</span><span class="w">  </span><span class="c1">-- ~5300（米）</span></span></span></code></pre></div><p><strong>典型 schema 設計</strong>（台灣 application）：</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">pois</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">SERIAL</span><span class="w"> </span><span class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</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">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="c1">-- 儲存 4326（跟 Google Maps API 對齊）
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span><span class="w">    </span><span class="n">location_4326</span><span class="w"> </span><span class="n">geometry</span><span class="p">(</span><span class="n">Point</span><span class="p">,</span><span class="w"> </span><span class="mi">4326</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="c1">-- 預計算 3826（給距離 / 面積 query 用）
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"></span><span class="w">    </span><span class="n">location_3826</span><span class="w"> </span><span class="n">geometry</span><span class="p">(</span><span class="n">Point</span><span class="p">,</span><span class="w"> </span><span class="mi">3826</span><span class="p">)</span><span class="w"> </span><span class="k">GENERATED</span><span class="w"> </span><span class="n">ALWAYS</span><span class="w"> </span><span class="k">AS</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">        </span><span class="p">(</span><span class="n">ST_Transform</span><span class="p">(</span><span class="n">location_4326</span><span class="p">,</span><span class="w"> </span><span class="mi">3826</span><span class="p">))</span><span class="w"> </span><span class="n">STORED</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w"></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></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">INDEX</span><span class="w"> </span><span class="n">idx_pois_location_3826</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">pois</span><span class="w"> </span><span class="k">USING</span><span class="w"> </span><span class="n">GIST</span><span class="w"> </span><span class="p">(</span><span class="n">location_3826</span><span class="p">);</span></span></span></code></pre></div><h2 id="gist-空間索引r-tree-的-pg-實作">GiST 空間索引：R-tree 的 PG 實作</h2>
<p>PostGIS 用 PG 內建 GiST 做空間索引（內部是 R-tree 變體）：</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_pois_geom</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">pois</span><span class="w"> </span><span class="k">USING</span><span class="w"> </span><span class="n">GIST</span><span class="w"> </span><span class="p">(</span><span class="n">location_3826</span><span class="p">);</span></span></span></code></pre></div><p>GiST 對空間 query 加速的場景：</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">-- 範圍 query（box overlap）
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">pois</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="n">location_3826</span><span class="w"> </span><span class="o">&amp;&amp;</span><span class="w"> </span><span class="n">ST_MakeEnvelope</span><span class="p">(</span><span class="mi">290000</span><span class="p">,</span><span class="w"> </span><span class="mi">2760000</span><span class="p">,</span><span class="w"> </span><span class="mi">305000</span><span class="p">,</span><span class="w"> </span><span class="mi">2775000</span><span class="p">,</span><span class="w"> </span><span class="mi">3826</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></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w"></span><span class="c1">-- 半徑 query（用 ST_DWithin 才走 index）
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">pois</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">WHERE</span><span class="w"> </span><span class="n">ST_DWithin</span><span class="p">(</span><span class="n">location_3826</span><span class="p">,</span><span class="w"> </span><span class="n">ST_SetSRID</span><span class="p">(</span><span class="n">ST_MakePoint</span><span class="p">(</span><span class="mi">300000</span><span class="p">,</span><span class="w"> </span><span class="mi">2770000</span><span class="p">),</span><span class="w"> </span><span class="mi">3826</span><span class="p">),</span><span class="w"> </span><span class="mi">1000</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></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w"></span><span class="c1">-- k-NN（PostGIS 2.0+ &lt;-&gt; operator）
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="n">name</span><span class="p">,</span><span class="w"> </span><span class="n">location_3826</span><span class="w"> </span><span class="o">&lt;-&gt;</span><span class="w"> </span><span class="n">ST_SetSRID</span><span class="p">(</span><span class="n">ST_MakePoint</span><span class="p">(</span><span class="mi">300000</span><span class="p">,</span><span class="w"> </span><span class="mi">2770000</span><span class="p">),</span><span class="w"> </span><span class="mi">3826</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">dist</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="n">pois</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w"></span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">location_3826</span><span class="w"> </span><span class="o">&lt;-&gt;</span><span class="w"> </span><span class="n">ST_SetSRID</span><span class="p">(</span><span class="n">ST_MakePoint</span><span class="p">(</span><span class="mi">300000</span><span class="p">,</span><span class="w"> </span><span class="mi">2770000</span><span class="p">),</span><span class="w"> </span><span class="mi">3826</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="k">LIMIT</span><span class="w"> </span><span class="mi">10</span><span class="p">;</span></span></span></code></pre></div><p><strong>index 用沒用到的關鍵</strong>：</p>
<table>
  <thead>
      <tr>
          <th>Query 寫法</th>
          <th>走 index？</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>ST_DWithin(a, b, dist)</code></td>
          <td>是</td>
      </tr>
      <tr>
          <td><code>ST_Distance(a, b) &lt; dist</code></td>
          <td>否（必 full scan）</td>
      </tr>
      <tr>
          <td><code>a &amp;&amp; bbox</code></td>
          <td>是</td>
      </tr>
      <tr>
          <td><code>ST_Intersects(a, bbox)</code></td>
          <td>是</td>
      </tr>
      <tr>
          <td><code>a &lt;-&gt; b ORDER BY ... LIMIT n</code></td>
          <td>是（k-NN）</td>
      </tr>
      <tr>
          <td><code>ST_Equals(a, b)</code></td>
          <td>否</td>
      </tr>
  </tbody>
</table>
<p>Production 寫法守則：能用 <code>ST_DWithin</code> 就不用 <code>ST_Distance(...) &lt; ?</code>、語意一樣但 index 行為差很多。</p>
<h2 id="st_-函式生態產業級全套">ST_* 函式生態：產業級全套</h2>
<p>PostGIS 1000+ 函式分類（典型用到的）：</p>
<table>
  <thead>
      <tr>
          <th>類別</th>
          <th>代表函式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>建構</td>
          <td><code>ST_MakePoint</code> / <code>ST_MakeLine</code> / <code>ST_MakePolygon</code></td>
      </tr>
      <tr>
          <td>關係判定</td>
          <td><code>ST_Intersects</code> / <code>ST_Within</code> / <code>ST_Contains</code> / <code>ST_Touches</code></td>
      </tr>
      <tr>
          <td>距離 / 大小</td>
          <td><code>ST_Distance</code> / <code>ST_DWithin</code> / <code>ST_Length</code> / <code>ST_Area</code></td>
      </tr>
      <tr>
          <td>變換</td>
          <td><code>ST_Buffer</code> / <code>ST_Union</code> / <code>ST_Difference</code> / <code>ST_Intersection</code></td>
      </tr>
      <tr>
          <td>投影</td>
          <td><code>ST_Transform</code> / <code>ST_SetSRID</code></td>
      </tr>
      <tr>
          <td>格式轉換</td>
          <td><code>ST_AsGeoJSON</code> / <code>ST_AsKML</code> / <code>ST_AsText</code> / <code>ST_GeomFromGeoJSON</code></td>
      </tr>
      <tr>
          <td>路徑 / 拓樸</td>
          <td><code>ST_ShortestLine</code> / <code>ST_LineMerge</code></td>
      </tr>
      <tr>
          <td>聚合</td>
          <td><code>ST_Collect</code> / <code>ST_ConvexHull</code> / <code>ST_Centroid</code></td>
      </tr>
      <tr>
          <td>簡化</td>
          <td><code>ST_Simplify</code> / <code>ST_SimplifyPreserveTopology</code></td>
      </tr>
  </tbody>
</table>
<p><strong>Web tile 場景</strong>典型 query：</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">-- 給定 z/x/y tile、找這個 tile 內的所有 POI
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="n">name</span><span class="p">,</span><span class="w"> </span><span class="n">ST_AsMVTGeom</span><span class="p">(</span><span class="n">location_3857</span><span class="p">,</span><span class="w"> </span><span class="n">ST_TileEnvelope</span><span class="p">(</span><span class="n">z</span><span class="p">,</span><span class="w"> </span><span class="n">x</span><span class="p">,</span><span class="w"> </span><span class="n">y</span><span class="p">))</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">geom</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="n">pois</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">WHERE</span><span class="w"> </span><span class="n">location_3857</span><span class="w"> </span><span class="o">&amp;&amp;</span><span class="w"> </span><span class="n">ST_TileEnvelope</span><span class="p">(</span><span class="n">z</span><span class="p">,</span><span class="w"> </span><span class="n">x</span><span class="p">,</span><span class="w"> </span><span class="n">y</span><span class="p">);</span></span></span></code></pre></div><p><code>ST_AsMVTGeom</code> + <code>ST_AsMVT</code> 直接產 Mapbox Vector Tile binary、給前端 Leaflet / Mapbox GL JS 用。</p>
<h2 id="5-個-production-踩雷">5 個 Production 踩雷</h2>
<h3 id="case-1geometry-用錯-srid">Case 1：Geometry 用錯 SRID</h3>
<p><strong>情境</strong>：app 寫入時用 4326、query 時用 3826 ST_Transform、忘記給某個 column 設 SRID、index 失效。</p>
<p>修法：</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">-- 確認 SRID
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">ST_SRID</span><span class="p">(</span><span class="k">location</span><span class="p">)</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">pois</span><span class="w"> </span><span class="k">LIMIT</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"> 3</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w"></span><span class="c1">-- 強 type 約束（column type 寫死 SRID）
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span><span class="k">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">pois</span><span class="w"> </span><span class="k">ALTER</span><span class="w"> </span><span class="k">COLUMN</span><span class="w"> </span><span class="k">location</span><span class="w"> </span><span class="k">TYPE</span><span class="w"> </span><span class="n">geometry</span><span class="p">(</span><span class="n">Point</span><span class="p">,</span><span class="w"> </span><span class="mi">4326</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="k">USING</span><span class="w"> </span><span class="n">ST_SetSRID</span><span class="p">(</span><span class="k">location</span><span class="p">,</span><span class="w"> </span><span class="mi">4326</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></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w"></span><span class="c1">-- Check constraint 防錯
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span><span class="k">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">pois</span><span class="w"> </span><span class="k">ADD</span><span class="w"> </span><span class="k">CONSTRAINT</span><span class="w"> </span><span class="n">chk_location_srid</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">CHECK</span><span class="w"> </span><span class="p">(</span><span class="n">ST_SRID</span><span class="p">(</span><span class="k">location</span><span class="p">)</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">4326</span><span class="p">);</span></span></span></code></pre></div><h3 id="case-2geography-不能用所有-st_-函式">Case 2：Geography 不能用所有 ST_* 函式</h3>
<p><strong>情境</strong>：用 <code>geography</code> 想跑 <code>ST_Buffer</code>、報錯或結果不對。</p>
<p><code>ST_Buffer</code> 對 geography 走 spheroid 近似、邊界 case 結果跟 geometry 不一致；很多函式（<code>ST_Voronoi</code> / <code>ST_Delaunay</code> 等）只支援 geometry。</p>
<p>修法：</p>
<ul>
<li>簡單距離 query 用 geography</li>
<li>複雜空間運算用 geometry + 適合 projection</li>
<li>不確定哪些函式支援 geography、看 PostGIS docs <em>Geography Support Functions</em> 清單</li>
</ul>
<h3 id="case-3gist-index-不對-st_distance-生效">Case 3：GiST index 不對 ST_Distance 生效</h3>
<p><strong>情境</strong>：query <code>ST_Distance(location, ?) &lt; 1000</code>、<code>EXPLAIN</code> 顯示 full scan、加 index 也沒用。</p>
<p><code>ST_Distance</code> 算完才 filter、planner 沒辦法用 GiST。</p>
<p>修法：</p>
<ul>
<li>改 <code>ST_DWithin(location, ?, 1000)</code> — 語意一樣、會走 GiST</li>
<li>確認 index 是對 <em>被 query 的 column</em> 建的（不是 transform 後的 expression）</li>
</ul>
<h3 id="case-4cluster-on-geom-後-brin-失效">Case 4：CLUSTER on geom 後 BRIN 失效</h3>
<p><strong>情境</strong>：對 <code>pois</code> 跑 <code>CLUSTER pois USING idx_pois_geom</code> 想加速空間查、但同時對 <code>created_at</code> 用 BRIN index、BRIN 完全失效。</p>
<p>CLUSTER 重組 physical order 跟 GiST 對齊、<code>created_at</code> physical order correlation 從 1.0 變 0.0、BRIN range 沒選擇性。</p>
<p>修法：</p>
<ul>
<li>不要 CLUSTER 大表（一次性、影響其他 column）</li>
<li>換 partition by time + GiST per-partition（取兩者）</li>
<li>看 <a href="/blog/backend/01-database/vendors/postgresql/index-selection/" data-link-title="PostgreSQL Index Selection：B-tree / GIN / GiST / BRIN / Hash 對應 workload 的決策樹" data-link-desc="PG 有 6 種 index method（B-tree / Hash / GIN / GiST / SP-GiST / BRIN）跟 partial / expression / covering 三種變體、不是「都用 B-tree 就好」。每種 index 有自己的 query pattern、儲存代價、write amplification 跟 maintenance 成本。本文走 6 種 index 的適用 workload 對照、決策樹、partial / expression / covering / multi-column 變體、5 production 踩雷（過度 index / partial 條件不對 / B-tree 對 JSON 無效 / BRIN 對非 correlated 資料無效 / multi-column 順序錯）、跟 query-optimization 的 EXPLAIN 互補">index-selection</a> 的 BRIN 段</li>
</ul>
<h3 id="case-5ewkb-vs-wkb-跨工具相容">Case 5：EWKB vs WKB 跨工具相容</h3>
<p><strong>情境</strong>：用 PostGIS export 給其他 GIS 工具（QGIS / Shapely / ogr2ogr）、resort 抱怨格式不對。</p>
<p>PostGIS 內部用 EWKB（Extended Well-Known Binary）— 多帶 SRID。多數 GIS 工具讀 WKB（標準）。</p>
<p>修法：</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">-- Export 標準 WKB
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">ST_AsBinary</span><span class="p">(</span><span class="n">geom</span><span class="p">)</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">pois</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></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="c1">-- 或 GeoJSON（跨工具最相容）
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">ST_AsGeoJSON</span><span class="p">(</span><span class="n">geom</span><span class="p">)</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">pois</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></span><span class="line"><span class="ln">7</span><span class="cl"><span class="w"></span><span class="c1">-- 或 Shapefile via ogr2ogr
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1">-- ogr2ogr -f &#34;ESRI Shapefile&#34; output.shp PG:&#34;...&#34; -sql &#34;SELECT * FROM pois&#34;</span></span></span></code></pre></div><h2 id="跟專業-gis-db-對比">跟專業 GIS DB 對比</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>PostGIS</th>
          <th>Oracle Spatial</th>
          <th>SQL Server geography</th>
          <th>MongoDB GeoJSON</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>函式覆蓋</td>
          <td>1000+</td>
          <td>800+</td>
          <td>200+</td>
          <td>~20</td>
      </tr>
      <tr>
          <td>Raster 支援</td>
          <td>是</td>
          <td>是</td>
          <td>否</td>
          <td>否</td>
      </tr>
      <tr>
          <td>Topology</td>
          <td>是（PostGIS Topology）</td>
          <td>是</td>
          <td>否</td>
          <td>否</td>
      </tr>
      <tr>
          <td>3D 支援</td>
          <td>是（PostGIS SFCGAL）</td>
          <td>是</td>
          <td>部分</td>
          <td>否</td>
      </tr>
      <tr>
          <td>License</td>
          <td>GPL</td>
          <td>商業</td>
          <td>商業</td>
          <td>開源</td>
      </tr>
      <tr>
          <td>Tile generation</td>
          <td>內建（ST_AsMVT）</td>
          <td>否</td>
          <td>否</td>
          <td>否</td>
      </tr>
      <tr>
          <td>跟 PG 整合</td>
          <td>完美</td>
          <td>跟 Oracle 一體</td>
          <td>跟 SQL Server 一體</td>
          <td>獨立</td>
      </tr>
      <tr>
          <td>工業界使用</td>
          <td>OpenStreetMap / 各國國土測繪</td>
          <td>大型企業</td>
          <td>Microsoft 生態</td>
          <td>簡單 location app</td>
      </tr>
  </tbody>
</table>
<p><strong>選 PostGIS 的場景</strong>（90% GIS workload）：</p>
<ul>
<li>Application 已用 PG</li>
<li>需要完整 GIS 函式生態（路網 / 等高線 / 流域分析）</li>
<li>開源 / cost 敏感</li>
<li>跟 OGR / GDAL / QGIS 互通</li>
</ul>
<p><strong>選專業 GIS DB 的場景</strong>：</p>
<ul>
<li>已綁定 Oracle / SQL Server license</li>
<li>極專業 GIS（3D 城市模型 / LIDAR / GPU 加速）</li>
<li>純 location app 不需 relational（MongoDB GeoJSON 足夠）</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/postgresql/extension-ecosystem/" data-link-title="PostgreSQL Extension Ecosystem：把 PG 變成 vector DB / time-series / sharded 的 plugin 生態" data-link-desc="PG 的 extension 機制不只是 plugin、是 *結構性產品線擴張* — pgvector 讓 PG 變 vector DB、TimescaleDB 變 time-series、Citus 變 sharded、PostGIS 變 GIS。本文走 PG extension lifecycle、6 個 production-critical extension（pg_stat_statements / pg_partman / pg_repack / pgvector / TimescaleDB / PostGIS）、5 production 踩雷（extension version 跟 PG version 對齊 / managed PG 限制 / upgrade order / shared_preload_libraries 衝突 / extension 跟 logical replication 互動）、cloud vendor 對 extension 的限制">extension-ecosystem</a>：其他 PG extension</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/index-selection/" data-link-title="PostgreSQL Index Selection：B-tree / GIN / GiST / BRIN / Hash 對應 workload 的決策樹" data-link-desc="PG 有 6 種 index method（B-tree / Hash / GIN / GiST / SP-GiST / BRIN）跟 partial / expression / covering 三種變體、不是「都用 B-tree 就好」。每種 index 有自己的 query pattern、儲存代價、write amplification 跟 maintenance 成本。本文走 6 種 index 的適用 workload 對照、決策樹、partial / expression / covering / multi-column 變體、5 production 踩雷（過度 index / partial 條件不對 / B-tree 對 JSON 無效 / BRIN 對非 correlated 資料無效 / multi-column 順序錯）、跟 query-optimization 的 EXPLAIN 互補">index-selection</a>：GiST 跟其他 index 對比</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>：空間 query 的 EXPLAIN</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/jsonb-deep-dive/" data-link-title="PostgreSQL JSONB Deep Dive：Binary Storage &#43; GIN Index 為什麼是結構性優勢" data-link-desc="PG JSONB（9.4&#43;）是 *binary 儲存的 JSON*、可直接 GIN index、是 PG 在 JSON workload 的結構性優勢、跟 MongoDB / MySQL 8.0 JSON_TABLE 比仍領先。本文走 JSON vs JSONB 差異、GIN index 機制（jsonb_ops vs jsonb_path_ops）、operator &#43; path query、partial JSONB indexing、5 production 踩雷（大 JSONB 跟 TOAST / nested update / index 選錯 op class / jsonb_path_query 跟 jsonb_path_exists 行為差 / partial index 條件搞錯）、何時用 JSONB vs 拆 column">jsonb-deep-dive</a>：POI metadata 用 JSONB 儲存</li>
</ul>
<h2 id="下一步">下一步</h2>
<ul>
<li>看 <a href="/blog/backend/01-database/vendors/postgresql/extension-ecosystem/" data-link-title="PostgreSQL Extension Ecosystem：把 PG 變成 vector DB / time-series / sharded 的 plugin 生態" data-link-desc="PG 的 extension 機制不只是 plugin、是 *結構性產品線擴張* — pgvector 讓 PG 變 vector DB、TimescaleDB 變 time-series、Citus 變 sharded、PostGIS 變 GIS。本文走 PG extension lifecycle、6 個 production-critical extension（pg_stat_statements / pg_partman / pg_repack / pgvector / TimescaleDB / PostGIS）、5 production 踩雷（extension version 跟 PG version 對齊 / managed PG 限制 / upgrade order / shared_preload_libraries 衝突 / extension 跟 logical replication 互動）、cloud vendor 對 extension 的限制">extension-ecosystem</a> 探索其他 PG 擴展可能</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><item><title>Aurora 多 cluster 按業務切分：微服務私有 store、blast radius 隔離與 fleet 治理</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/aurora/multi-cluster-business-split/</link><pubDate>Tue, 02 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/aurora/multi-cluster-business-split/</guid><description>&lt;p>把所有服務的資料塞進一個大 Aurora cluster，平時運維最省事，直到某一天：報表服務跑了一個沒索引的聚合 query、佔滿 connection 與 IOPS、結帳服務跟著變慢、整個平台一起卡。問題的根源是「不相關的業務共用同一個 cluster、彼此沒有隔離」，那個 query 只是觸發點。多 cluster 按業務切分要回答的是：哪些業務該各自獨立 cluster、哪些可以共用、切分後 fleet 怎麼維持治理一致。&lt;/p>
&lt;p>本文不是 Aurora overview（請看 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/aurora/" data-link-title="AWS Aurora" data-link-desc="AWS managed PostgreSQL / MySQL、storage / compute 分離、&amp;#43;75% 效能改善的 production 證據">Aurora vendor 頁&lt;/a>）— 而是 cluster 邊界劃分與多 cluster 治理的實作層教學。&lt;/p>
&lt;h2 id="共用大-cluster-的根本問題blast-radius">共用大 cluster 的根本問題：blast radius&lt;/h2>
&lt;p>單一大 cluster 把多個業務的失敗耦合在一起。一個業務的異常會透過共用資源外溢到其他業務：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>資源競爭&lt;/strong>：connection pool、CPU、IOPS、buffer cache 共用，一個業務的尖峰擠壓其他業務&lt;/li>
&lt;li>&lt;strong>failure blast radius&lt;/strong>：cluster 故障 / 升級 / schema 變更鎖表，影響所有掛在上面的業務&lt;/li>
&lt;li>&lt;strong>容量規劃糾纏&lt;/strong>：要為「所有業務尖峰的總和」規劃容量，無法針對單一業務調整&lt;/li>
&lt;li>&lt;strong>schema change 互相牽制&lt;/strong>：一個業務的 migration 鎖表、其他業務跟著受影響&lt;/li>
&lt;/ul>
&lt;p>按業務切 cluster 的核心價值是把這些耦合切開——每個 cluster 的故障、容量、變更只影響自己的業務範圍。&lt;/p>
&lt;h2 id="切分判斷維度">切分判斷維度&lt;/h2>
&lt;p>不是「每個服務都該有自己的 cluster」（那會走向另一個極端：cluster 數爆炸、運維 surface 失控）。切分依以下維度判斷：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>維度&lt;/th>
 &lt;th>傾向獨立 cluster&lt;/th>
 &lt;th>可共用 cluster&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &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>負載相近、可一起規劃容量&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>故障容忍&lt;/td>
 &lt;td>不能被別的業務拖垮&lt;/td>
 &lt;td>可接受共命運&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>schema 變更頻率&lt;/td>
 &lt;td>高頻 migration、不想牽制別人&lt;/td>
 &lt;td>低頻、變更少&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>合規邊界&lt;/td>
 &lt;td>資料需獨立隔離（PCI / 個資分艙）&lt;/td>
 &lt;td>無特殊合規隔離需求&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;code>9.C23 Netflix&lt;/code> 是這個判斷的 case anchor：Netflix 把過往多套不同 &lt;em>種類&lt;/em> 的關聯式 DB（PostgreSQL / MySQL / Oracle）整合到 Aurora、效能提升最高 75%、成本下降 28%；但整合的是「DB 種類 / 運維 surface」，&lt;em>不是&lt;/em> 把所有資料塞進一個 cluster——Netflix 的微服務各自擁有自己的 Aurora cluster、彼此不共用。兩件事同時成立：減少 DB &lt;em>技術種類&lt;/em> 降低運維知識負擔、同時維持 &lt;em>per-service cluster&lt;/em> 隔離 blast radius。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>Scope warning&lt;/strong>：Netflix 的「+75% 效能 / -28% 成本」是跨多 workload 的最大改善幅度、非每個 workload 都 +75%（case 原文已標明）；且 Netflix 數據層遠不止 Aurora（還有 Cassandra / EVCache / Iceberg），Aurora 承擔的是需要 ACID 的 OLTP。引用時不可外推成「整合到 Aurora 就 +75%」。&lt;/p>&lt;/blockquote>
&lt;h2 id="兩種切分哲學的對照">兩種切分哲學的對照&lt;/h2>
&lt;p>大規模平台的 cluster 切分沒有單一正解，光譜兩端各有代表：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>per-service 私有 store（Netflix 式）&lt;/strong>：每個微服務一個 Aurora cluster、容量規劃變成「每個服務各自規劃」、跨服務 contention 變成 &lt;em>網路議題&lt;/em> 而非 &lt;em>DB lock 議題&lt;/em>&lt;/li>
&lt;li>&lt;strong>高度 consolidation&lt;/strong>：少數大 cluster 承載多業務、運維實例少、但 blast radius 大&lt;/li>
&lt;/ul>
&lt;p>實務多落在中間：核心 / 高關鍵 / 合規敏感業務各自獨立 cluster，低關鍵性的內部服務可數個共用一個 cluster。判斷的是「這群業務能不能接受共命運」。&lt;/p>
&lt;h2 id="fleet-治理切分後的一致性">Fleet 治理：切分後的一致性&lt;/h2>
&lt;p>切成多 cluster 後，運維 surface 從「一個 cluster」變成「N 個 cluster」。若沒有治理一致性，N 個 cluster 各自飄移會比一個大 cluster 更難維護。fleet 治理要把以下標準化：&lt;/p></description><content:encoded><![CDATA[<p>把所有服務的資料塞進一個大 Aurora cluster，平時運維最省事，直到某一天：報表服務跑了一個沒索引的聚合 query、佔滿 connection 與 IOPS、結帳服務跟著變慢、整個平台一起卡。問題的根源是「不相關的業務共用同一個 cluster、彼此沒有隔離」，那個 query 只是觸發點。多 cluster 按業務切分要回答的是：哪些業務該各自獨立 cluster、哪些可以共用、切分後 fleet 怎麼維持治理一致。</p>
<p>本文不是 Aurora overview（請看 <a href="/blog/backend/01-database/vendors/aurora/" data-link-title="AWS Aurora" data-link-desc="AWS managed PostgreSQL / MySQL、storage / compute 分離、&#43;75% 效能改善的 production 證據">Aurora vendor 頁</a>）— 而是 cluster 邊界劃分與多 cluster 治理的實作層教學。</p>
<h2 id="共用大-cluster-的根本問題blast-radius">共用大 cluster 的根本問題：blast radius</h2>
<p>單一大 cluster 把多個業務的失敗耦合在一起。一個業務的異常會透過共用資源外溢到其他業務：</p>
<ul>
<li><strong>資源競爭</strong>：connection pool、CPU、IOPS、buffer cache 共用，一個業務的尖峰擠壓其他業務</li>
<li><strong>failure blast radius</strong>：cluster 故障 / 升級 / schema 變更鎖表，影響所有掛在上面的業務</li>
<li><strong>容量規劃糾纏</strong>：要為「所有業務尖峰的總和」規劃容量，無法針對單一業務調整</li>
<li><strong>schema change 互相牽制</strong>：一個業務的 migration 鎖表、其他業務跟著受影響</li>
</ul>
<p>按業務切 cluster 的核心價值是把這些耦合切開——每個 cluster 的故障、容量、變更只影響自己的業務範圍。</p>
<h2 id="切分判斷維度">切分判斷維度</h2>
<p>不是「每個服務都該有自己的 cluster」（那會走向另一個極端：cluster 數爆炸、運維 surface 失控）。切分依以下維度判斷：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>傾向獨立 cluster</th>
          <th>可共用 cluster</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>業務關鍵性</td>
          <td>核心交易（結帳、帳本）需隔離保護</td>
          <td>內部工具、低關鍵性服務可共用</td>
      </tr>
      <tr>
          <td>負載形狀</td>
          <td>負載差異大、尖峰時段錯開</td>
          <td>負載相近、可一起規劃容量</td>
      </tr>
      <tr>
          <td>故障容忍</td>
          <td>不能被別的業務拖垮</td>
          <td>可接受共命運</td>
      </tr>
      <tr>
          <td>schema 變更頻率</td>
          <td>高頻 migration、不想牽制別人</td>
          <td>低頻、變更少</td>
      </tr>
      <tr>
          <td>合規邊界</td>
          <td>資料需獨立隔離（PCI / 個資分艙）</td>
          <td>無特殊合規隔離需求</td>
      </tr>
  </tbody>
</table>
<p><code>9.C23 Netflix</code> 是這個判斷的 case anchor：Netflix 把過往多套不同 <em>種類</em> 的關聯式 DB（PostgreSQL / MySQL / Oracle）整合到 Aurora、效能提升最高 75%、成本下降 28%；但整合的是「DB 種類 / 運維 surface」，<em>不是</em> 把所有資料塞進一個 cluster——Netflix 的微服務各自擁有自己的 Aurora cluster、彼此不共用。兩件事同時成立：減少 DB <em>技術種類</em> 降低運維知識負擔、同時維持 <em>per-service cluster</em> 隔離 blast radius。</p>
<blockquote>
<p><strong>Scope warning</strong>：Netflix 的「+75% 效能 / -28% 成本」是跨多 workload 的最大改善幅度、非每個 workload 都 +75%（case 原文已標明）；且 Netflix 數據層遠不止 Aurora（還有 Cassandra / EVCache / Iceberg），Aurora 承擔的是需要 ACID 的 OLTP。引用時不可外推成「整合到 Aurora 就 +75%」。</p></blockquote>
<h2 id="兩種切分哲學的對照">兩種切分哲學的對照</h2>
<p>大規模平台的 cluster 切分沒有單一正解，光譜兩端各有代表：</p>
<ul>
<li><strong>per-service 私有 store（Netflix 式）</strong>：每個微服務一個 Aurora cluster、容量規劃變成「每個服務各自規劃」、跨服務 contention 變成 <em>網路議題</em> 而非 <em>DB lock 議題</em></li>
<li><strong>高度 consolidation</strong>：少數大 cluster 承載多業務、運維實例少、但 blast radius 大</li>
</ul>
<p>實務多落在中間：核心 / 高關鍵 / 合規敏感業務各自獨立 cluster，低關鍵性的內部服務可數個共用一個 cluster。判斷的是「這群業務能不能接受共命運」。</p>
<h2 id="fleet-治理切分後的一致性">Fleet 治理：切分後的一致性</h2>
<p>切成多 cluster 後，運維 surface 從「一個 cluster」變成「N 個 cluster」。若沒有治理一致性，N 個 cluster 各自飄移會比一個大 cluster 更難維護。fleet 治理要把以下標準化：</p>
<ul>
<li><strong>配置一致</strong>：engine 版本、parameter group、backup 策略、加密設定用 IaC 統一管理，避免逐個手調漂移</li>
<li><strong>監控一致</strong>：每個 cluster 同一套 CloudWatch alarm 基線（connection / replication lag / CPU / IOPS），不是只盯總量</li>
<li><strong>升級協調</strong>：major version 升級分批跨 fleet，不是一次全升（也不是放任各 cluster 版本散落）</li>
<li><strong>成本歸屬</strong>：按 cluster / 業務 tag 切成本，讓每個業務看見自己的 DB 成本</li>
</ul>
<p>這層治理對應 <a href="/blog/backend/01-database/vendors/aurora/read-replica-scaling/" data-link-title="Aurora Read Replica Scaling：15 replica 上限、lag profile、headroom 預留與 fleet 治理" data-link-desc="Aurora 15 replica 上限、共享 storage 為什麼能養大量 replica、事件型容量分級表、DraftKings headroom 預留判讀、FanDuel 雙 SLO 並行、fleet 治理 3 條 driver（business sharding / microservice / 合規）">read-replica-scaling 的 fleet 治理段</a>——讀副本 fleet 與多 cluster fleet 共用「N 個實例如何維持治理一致」的方法。</p>
<h2 id="失敗模式">失敗模式</h2>
<p>production 常見的踩雷：</p>
<h4 id="case-1共用大-cluster報表-query-拖垮交易">Case 1：共用大 cluster、報表 query 拖垮交易</h4>
<p>分析 / 報表 workload 跟核心交易共用 cluster、一個重 query 佔滿資源、交易延遲飆高。修法：分析類 workload 切到獨立 cluster 或獨立 read replica；核心交易的 cluster 不混入不可控的分析查詢。</p>
<h4 id="case-2cluster-切太細運維-surface-爆炸">Case 2：cluster 切太細、運維 surface 爆炸</h4>
<p>矯枉過正、每個小服務都獨立 cluster、結果幾十個 cluster 各自飄移、升級與監控成本失控。修法：低關鍵性、負載相近、可共命運的服務合併共用 cluster；切分以「blast radius 需求」為準，不是「每個服務都要」。</p>
<h4 id="case-3切分了-cluster-但沒切分-fleet-治理">Case 3：切分了 cluster 但沒切分 fleet 治理</h4>
<p>多 cluster 各自手調 parameter group、版本散落、backup 策略不一、出事才發現某個 cluster 設定漂移。修法：fleet 配置用 IaC 統一、監控基線一致、升級分批協調。</p>
<h4 id="case-4跨-cluster-交易需求才發現切錯邊界">Case 4：跨 cluster 交易需求才發現切錯邊界</h4>
<p>把本該強一致綁在一起的資料切到不同 cluster、結果需要跨 cluster 交易（Aurora 不提供跨 cluster transaction）、application 層自己補償、複雜又易錯。修法：cluster 邊界要對齊 transaction boundary——必須在同一個交易內一起成功失敗的資料，放同一 cluster（對應 <a href="/blog/backend/01-database/transaction-boundary/" data-link-title="1.3 Transaction 與一致性邊界" data-link-desc="交易邊界、isolation level、retry 策略、distributed transaction（2PC、Saga）與跨 region 強一致取捨">1.3 transaction 與一致性邊界</a>）。這是切分前就要確認的邊界，切錯後重切成本高。</p>
<p><strong>Anti-recommendation</strong>：團隊規模小、服務少、無合規隔離需求、且負載總量單一 cluster 撐得住 → 不要預先切成多 cluster；多 cluster 的治理成本只在「blast radius 隔離 / 合規分艙 / 負載差異大」真正需要時才值得。從少到多容易，從多合併回少要資料遷移。</p>
<h2 id="容量與觀測">容量與觀測</h2>
<ul>
<li>每個 cluster 獨立的 CloudWatch 基線：<code>DatabaseConnections</code> / <code>CPUUtilization</code> / <code>AuroraReplicaLag</code> / IOPS</li>
<li>跨 fleet 的成本 dashboard：按 cluster / 業務 tag 歸屬，看哪個業務的 DB 成本成長最快</li>
<li>blast radius 演練：定期確認單一 cluster 故障不會外溢到其他業務（混沌測試）</li>
</ul>
<blockquote>
<p><strong>Scope warning</strong>：本文未引用 production case 的 cluster 數量 / 容量數字；切分維度與治理項屬通用平台工程 + Netflix consolidation 的架構訊號。</p></blockquote>
<p>接回 <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/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">05 部署平台模組</a> 的 service decomposition。</p>
<h2 id="邊界與整合">邊界與整合</h2>
<h3 id="cluster-邊界-vs-微服務邊界">cluster 邊界 vs 微服務邊界</h3>
<p>多 cluster 切分常跟微服務拆分一起發生，但兩者不必一一對應。一個微服務可以擁有一個 cluster（Netflix 式私有 store），數個低關鍵微服務也可共用一個 cluster。判斷錨點是 transaction boundary 與 blast radius，不是「服務數 = cluster 數」。當切分壓力其實來自「不同資料模型」而非「隔離需求」，可能該考慮的是 polyglot persistence（OLTP 用 Aurora、KV 用 DynamoDB、analytics 用數倉），而非切更多 Aurora cluster。</p>
<h3 id="sibling-與-cross-link">Sibling 與 cross-link</h3>
<ul>
<li><a href="/blog/backend/01-database/vendors/aurora/read-replica-scaling/" data-link-title="Aurora Read Replica Scaling：15 replica 上限、lag profile、headroom 預留與 fleet 治理" data-link-desc="Aurora 15 replica 上限、共享 storage 為什麼能養大量 replica、事件型容量分級表、DraftKings headroom 預留判讀、FanDuel 雙 SLO 並行、fleet 治理 3 條 driver（business sharding / microservice / 合規）">read-replica-scaling</a> — fleet 治理方法共用、讀副本 fleet 與多 cluster fleet 同源</li>
<li><a href="/blog/backend/01-database/vendors/aurora/cross-az-failover-rto/" data-link-title="Aurora Cross-AZ Failover：RTO 量測、endpoint routing 與 application reconnect 契約" data-link-desc="Aurora cross-AZ failover lifecycle（detection / promotion / DNS update）、&lt; 30 秒 RTO、application DNS cache 跟 connection pool 對齊、Standard Chartered 受監管場景為什麼用獨立 cluster 而非 Global Database failover">cross-az-failover-rto</a> — 每個 cluster 的 failover 行為、blast radius 隔離後各自獨立</li>
<li><a href="/blog/backend/01-database/vendors/aurora/serverless-v2-scaling/" data-link-title="Aurora Serverless v2 適用判斷：ACU 自動擴縮、混合 cluster 與何時不該用" data-link-desc="Aurora Serverless v2 不是「比較便宜的 Aurora」；本文展開 ACU 計費粒度、秒級自動擴縮機制、min/max ACU 設定、serverless 與 provisioned 同 cluster 混用，以及穩定高負載下 serverless 反而更貴的成本 crossover 邊界">serverless-v2-scaling</a> — 低關鍵 / 間歇負載的 cluster 可用 serverless 降離峰成本</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> — cluster 邊界對齊狀態 ownership</li>
<li>替代路由：切分壓力來自資料模型差異 → polyglot persistence、回 <a href="/blog/backend/00-service-selection/" data-link-title="模組零：後端服務選型" data-link-desc="從需求類型判斷資料庫、快取、訊息佇列、觀測與部署平台的選型方向">00 服務選型模組</a></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%、串流數十億小時">Netflix 9.C23</a> 互引：DB 種類 consolidation + per-service cluster 隔離雙重成立的架構</li>
</ul>
]]></content:encoded></item><item><title>DynamoDB GSI 與 LSI 設計：access pattern 補位、projection、consistency 跟 DAX 補位</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/dynamodb/gsi-lsi-design/</link><pubDate>Wed, 27 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/dynamodb/gsi-lsi-design/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/dynamodb/" data-link-title="DynamoDB" data-link-desc="AWS managed key-value、partition-based scaling、9000 萬 RPS sustained 實戰證據">DynamoDB&lt;/a> overview 的 implementation-layer deep article。寫作參照 &lt;a href="https://tarrragon.github.io/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">vendor deep article methodology&lt;/a>。&lt;/p>&lt;/blockquote>
&lt;p>single-table design 上線後第三個月、PM 提了三個新 query 需求：「依商品分類查訂單」、「依 status 查 user」、「依時間 range 取最近活動」。team 第一反應是加 GSI、結果 GSI 從 1 個變 6 個、cost 跟 latency 一起上升。打開 AWS Cost Explorer 一看、GSI 的 storage + WCU 合計已經超過 base table。這時 team 開始懷疑「single-table 是不是錯了」— 那是 &lt;em>誤判&lt;/em>。GSI 多到 cost 超過 base table 通常是 &lt;em>主 PK 沒設計好&lt;/em>、不是 single-table 錯。本文展開 GSI / LSI 的正確補位、projection 的三型選擇、sparse index、以及 DAX 作為讀峰值補位的觸發條件。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>DynamoDB workload 適配判讀（基本 4 軸）&lt;/strong>：PK 天然均勻 / control plane vs data plane / consistency 可接受 eventual / access pattern 穩定 — 判讀軸詳見 &lt;a href="../single-table-design-pattern/#dynamodb-%e9%81%a9%e7%94%a8%e5%ba%a6%e5%89%8d%e7%bd%ae%e5%88%a4%e8%ae%804-%e8%bb%b8">single-table-design-pattern 開頭 4 軸前置判讀&lt;/a>。本文聚焦 GSI / LSI 補位操作層、是 &lt;em>已選 DynamoDB + access pattern 已穩定&lt;/em> 的 schema 設計議題。&lt;/p>&lt;/blockquote>
&lt;h2 id="核心機制gsi-vs-lsi-的工程差異">核心機制：GSI vs LSI 的工程差異&lt;/h2>
&lt;p>DynamoDB 的兩種 secondary index 解的問題不同：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>屬性&lt;/th>
 &lt;th>GSI（Global Secondary Index）&lt;/th>
 &lt;th>LSI（Local Secondary Index）&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Partition&lt;/td>
 &lt;td>獨立 partition、可選新 PK + SK&lt;/td>
 &lt;td>同 base table partition、同 PK&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>建立時機&lt;/td>
 &lt;td>隨時可加 / 移除&lt;/td>
 &lt;td>只能在 create table 時定義&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Consistency&lt;/td>
 &lt;td>只支援 eventual read&lt;/td>
 &lt;td>支援 strongly consistent read&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Capacity&lt;/td>
 &lt;td>獨立 RCU/WCU、按 base 主表 write 同步收&lt;/td>
 &lt;td>共享 base table capacity&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>數量上限&lt;/td>
 &lt;td>vendor 規格、需 cross-verify AWS doc&lt;/td>
 &lt;td>vendor 規格、需 cross-verify&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>適用場景&lt;/td>
 &lt;td>跨 PK 查詢、需求變動&lt;/td>
 &lt;td>同 PK 內不同 SK + 需 strong read&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;blockquote>
&lt;p>&lt;strong>Scope warning&lt;/strong>：「LSI 數量上限 5 個」、「GSI 數量上限 20」這些具體數字屬 vendor 規格、需在實作時 cross-verify AWS doc 當前數字、本文 case（Disney+ / Capcom / Lemino）沒揭露具體 index 數量。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/dynamodb/" data-link-title="DynamoDB" data-link-desc="AWS managed key-value、partition-based scaling、9000 萬 RPS sustained 實戰證據">DynamoDB</a> overview 的 implementation-layer deep article。寫作參照 <a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">vendor deep article methodology</a>。</p></blockquote>
<p>single-table design 上線後第三個月、PM 提了三個新 query 需求：「依商品分類查訂單」、「依 status 查 user」、「依時間 range 取最近活動」。team 第一反應是加 GSI、結果 GSI 從 1 個變 6 個、cost 跟 latency 一起上升。打開 AWS Cost Explorer 一看、GSI 的 storage + WCU 合計已經超過 base table。這時 team 開始懷疑「single-table 是不是錯了」— 那是 <em>誤判</em>。GSI 多到 cost 超過 base table 通常是 <em>主 PK 沒設計好</em>、不是 single-table 錯。本文展開 GSI / LSI 的正確補位、projection 的三型選擇、sparse index、以及 DAX 作為讀峰值補位的觸發條件。</p>
<blockquote>
<p><strong>DynamoDB workload 適配判讀（基本 4 軸）</strong>：PK 天然均勻 / control plane vs data plane / consistency 可接受 eventual / access pattern 穩定 — 判讀軸詳見 <a href="../single-table-design-pattern/#dynamodb-%e9%81%a9%e7%94%a8%e5%ba%a6%e5%89%8d%e7%bd%ae%e5%88%a4%e8%ae%804-%e8%bb%b8">single-table-design-pattern 開頭 4 軸前置判讀</a>。本文聚焦 GSI / LSI 補位操作層、是 <em>已選 DynamoDB + access pattern 已穩定</em> 的 schema 設計議題。</p></blockquote>
<h2 id="核心機制gsi-vs-lsi-的工程差異">核心機制：GSI vs LSI 的工程差異</h2>
<p>DynamoDB 的兩種 secondary index 解的問題不同：</p>
<table>
  <thead>
      <tr>
          <th>屬性</th>
          <th>GSI（Global Secondary Index）</th>
          <th>LSI（Local Secondary Index）</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Partition</td>
          <td>獨立 partition、可選新 PK + SK</td>
          <td>同 base table partition、同 PK</td>
      </tr>
      <tr>
          <td>建立時機</td>
          <td>隨時可加 / 移除</td>
          <td>只能在 create table 時定義</td>
      </tr>
      <tr>
          <td>Consistency</td>
          <td>只支援 eventual read</td>
          <td>支援 strongly consistent read</td>
      </tr>
      <tr>
          <td>Capacity</td>
          <td>獨立 RCU/WCU、按 base 主表 write 同步收</td>
          <td>共享 base table capacity</td>
      </tr>
      <tr>
          <td>數量上限</td>
          <td>vendor 規格、需 cross-verify AWS doc</td>
          <td>vendor 規格、需 cross-verify</td>
      </tr>
      <tr>
          <td>適用場景</td>
          <td>跨 PK 查詢、需求變動</td>
          <td>同 PK 內不同 SK + 需 strong read</td>
      </tr>
  </tbody>
</table>
<blockquote>
<p><strong>Scope warning</strong>：「LSI 數量上限 5 個」、「GSI 數量上限 20」這些具體數字屬 vendor 規格、需在實作時 cross-verify AWS doc 當前數字、本文 case（Disney+ / Capcom / Lemino）沒揭露具體 index 數量。</p></blockquote>
<p><strong>Projection type</strong> 決定 GSI 儲存哪些 attribute：</p>
<ul>
<li><code>KEYS_ONLY</code>：只存 PK + SK + base key、最省 storage、但讀取後通常還要回 base table 撈 attribute</li>
<li><code>INCLUDE</code>：除了 key、再存指定的 attribute；常用 sweet spot、storage 跟 query 效率平衡</li>
<li><code>ALL</code>：複製 base table 所有 attribute；最方便、最貴</li>
</ul>
<p>讀路徑差異：</p>
<ul>
<li>GSI eventual read：跨 partition、不支援 strong；base table write → GSI replication 通常 &lt; 1s 但無 SLA</li>
<li>LSI strong read：同 partition quorum 內成立、read-your-write 場景適用</li>
</ul>
<p>對應 knowledge card：<a href="/blog/backend/knowledge-cards/hot-partition/" data-link-title="Hot Partition" data-link-desc="說明分散式 KV / OLTP 中、單一 partition 流量遠超其他的容量問題">hot partition</a>、<a href="/blog/backend/knowledge-cards/consistency-level/" data-link-title="Consistency Level" data-link-desc="資料系統對讀寫一致性語意的可選擇層級">consistency level</a>。</p>
<h2 id="dax-作為讀峰值補位">DAX 作為讀峰值補位</h2>
<p>DAX（DynamoDB Accelerator）不是 GSI / LSI 同層方案、不是 DynamoDB 預設配置、是「讀峰值持續高時的補位」。寫進你的設計前先看觸發條件：</p>
<p><strong><code>9.C29 Lemino</code> 揭露</strong>（case fact）：「DAX 是 DynamoDB 讀 cache 的標準解法」、觸發條件是「當讀峰值持續高、加 DAX 減少 DynamoDB 讀次數、降低成本」（熱門節目首播時段、共用 metadata）。Lemino 是 case 直接揭露使用 DAX。</p>
<p><strong><code>9.C19 Capcom</code> 是判讀層 derive、不是 case fact</strong>：原 finding 從「single-digit ms」latency 反推 Capcom 必須用 sub-region cache + DynamoDB DAX、不能單靠 DynamoDB；但 <code>9.C19</code> case <em>沒有公開揭露</em> 使用 DAX。引用 Capcom 時要明示「DAX 是作者判讀層推論、Capcom 沒公開使用」、避免把推論寫成 case 揭露。</p>
<p><strong>跟 GSI / LSI 的職責分離</strong>：</p>
<ul>
<li>GSI / LSI 解「無法用主 PK 查」的問題（access pattern 補位）</li>
<li>DAX 解「同 query 重複打 DynamoDB 太貴或太慢」的問題（讀路徑加速）</li>
<li>兩者不互斥、但解不同問題；不要把 DAX 當 GSI 替代品</li>
</ul>
<p><strong>DAX 適用觸發條件</strong>：</p>
<ul>
<li>讀峰值持續高（熱門節目 / 共用 leaderboard / 全平台共享 metadata / read:write ratio &gt; 10:1）</li>
<li>cache 命中率可預期高（重複讀同一組 key）</li>
</ul>
<p><strong>DAX 不適用情境</strong>：</p>
<ul>
<li>寫密集 workload（cache invalidation 開銷 &gt; cache 收益）</li>
<li>每次讀都不同 key（cache hit rate &lt; 30%、加 DAX 等於白花錢）</li>
<li>read-your-write 場景（DAX 仍是 eventual cache、staleness 視 cache TTL 而定）</li>
</ul>
<h2 id="設計流程">設計流程</h2>
<p>從 access pattern 補位到 DAX 評估的 6 步流程。</p>
<h4 id="step-1標記最小成本路徑">Step 1：標記最小成本路徑</h4>
<p>每個 access pattern 標記能用最便宜路徑解：</p>
<ul>
<li>能用主表 PK/SK 直接 <code>GetItem</code> / <code>Query</code> → 主表（最便宜）</li>
<li>同 PK 內不同 SK 排序 + 需要 strong read → LSI（同 partition、strong）</li>
<li>跨 PK 或 base table 已建好 → GSI（額外 storage + WCU）</li>
</ul>
<h4 id="step-2選-lsi-還是-gsi">Step 2：選 LSI 還是 GSI</h4>
<p>LSI 只能在 create table 時定義、不能後加。team 經常踩雷：上線後想加 strongly consistent 索引、發現只能重建 table。建 table 前列完 access pattern、不確定走 GSI 不走 LSI 是保守選擇（GSI 隨時可加可移）。</p>
<h4 id="step-3projection-設計">Step 3：projection 設計</h4>
<p>每個 GSI 單獨設 projection、不要全用 <code>ALL</code>：</p>
<ul>
<li>query 只要回 key → <code>KEYS_ONLY</code></li>
<li>query 需要常見 3-5 個欄位 → <code>INCLUDE</code>（列出實際 column、storage 跟 query 效率平衡）</li>
<li>用 GSI 直接顯示資料（不回 base table） → <code>ALL</code>（storage 跟 WCU 都翻倍、慎用）</li>
</ul>
<h4 id="step-4sparse-index-pattern">Step 4：sparse index pattern</h4>
<p>GSI PK 只在某 attribute 存在時填、自動「只索引子集」、節省 storage：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">def</span> <span class="nf">write_order</span><span class="p">(</span><span class="n">order_id</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">status</span><span class="p">:</span> <span class="nb">str</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="n">item</span> <span class="o">=</span> <span class="p">{</span><span class="s2">&#34;PK&#34;</span><span class="p">:</span> <span class="sa">f</span><span class="s2">&#34;ORDER#</span><span class="si">{</span><span class="n">order_id</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span> <span class="s2">&#34;SK&#34;</span><span class="p">:</span> <span class="s2">&#34;META&#34;</span><span class="p">,</span> <span class="s2">&#34;status&#34;</span><span class="p">:</span> <span class="n">status</span><span class="p">}</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="c1"># sparse index: 只有 active order 進 GSI</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="k">if</span> <span class="n">status</span> <span class="o">==</span> <span class="s2">&#34;active&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">        <span class="n">item</span><span class="p">[</span><span class="s2">&#34;GSI1PK&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="s2">&#34;STATUS#active&#34;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">        <span class="n">item</span><span class="p">[</span><span class="s2">&#34;GSI1SK&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="n">order_id</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="n">table</span><span class="o">.</span><span class="n">put_item</span><span class="p">(</span><span class="n">Item</span><span class="o">=</span><span class="n">item</span><span class="p">)</span></span></span></code></pre></div><p>GSI1 只索引 active order、archive order 不進 GSI。當 active order 是 10%、storage 節省約 90%。</p>
<blockquote>
<p><strong>Scope warning</strong>：「50-90% storage 節省」具體節省比例屬通用工程估算、依 active subset 比例變動、case 未揭露 sparse index 具體數字。</p></blockquote>
<h4 id="step-5驗證點">Step 5：驗證點</h4>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">response</span> <span class="o">=</span> <span class="n">table</span><span class="o">.</span><span class="n">query</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="n">KeyConditionExpression</span><span class="o">=</span><span class="n">Key</span><span class="p">(</span><span class="s2">&#34;GSI1PK&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">eq</span><span class="p">(</span><span class="s2">&#34;STATUS#active&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="n">IndexName</span><span class="o">=</span><span class="s2">&#34;GSI1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="n">ReturnConsumedCapacity</span><span class="o">=</span><span class="s2">&#34;INDEXES&#34;</span>  <span class="c1"># 看每個 query 走 GSI 還是主表</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="n">response</span><span class="p">[</span><span class="s2">&#34;ConsumedCapacity&#34;</span><span class="p">])</span></span></span></code></pre></div><p>CloudWatch GSI metric：看每個 GSI 的 WCU usage 跟主表的比例；GSI WCU &gt; base table WCU 通常是設計訊號。</p>
<h4 id="step-6dax-評估">Step 6：DAX 評估</h4>
<p>讀峰值持續高 + cache hit rate 可預期、才加 DAX；不要把 DAX 當預設配置（Lemino 揭露的觸發條件）。先觀察 base 路徑的 read pattern、判斷 cache hit rate 預期值、再決定加 DAX。</p>
<p><strong>Rollback boundary</strong>：GSI 可隨時刪、但 deletion 是 async 且不可逆；建議先 application 切回 base table query、觀察 1 週再刪 GSI。DAX 可隨時 detach、application 端把 DAX endpoint 換回 DynamoDB endpoint 即可。</p>
<h2 id="失敗模式">失敗模式</h2>
<p>7 個 production 常見踩雷：</p>
<h4 id="case-1gsi-寫入-throttle-拖累主表-write">Case 1：GSI 寫入 throttle 拖累主表 write</h4>
<p>GSI 用了集中型 PK（如 <code>STATUS#active</code> 所有 active order 集中）、單 partition 上限 1000 WCU 撞牆、GSI replication 失敗、主表 write retry、整體 latency 上升。修法：GSI PK 設計獨立 review、不可繼承主表 PK 的均勻假設（base PK 均勻 ≠ GSI PK 均勻）；GSI PK 也要做 <a href="/blog/backend/01-database/vendors/dynamodb/partition-key-antipatterns/" data-link-title="DynamoDB Partition Key 反模式與 Write Sharding：composite key 修復跟 mode × partition 交叉判讀" data-link-desc="DynamoDB partition 上限 1000 WCU 是 hot partition 的根因；composite key（event_id &#43; shard suffix）跟 calculated shard（hash % N）兩種修法、mode × partition 在 provisioned / on-demand 不同表現，以及 9.C15 Tixcraft 6750x 擴展的工程細節">partition key 均勻度判讀</a>。</p>
<h4 id="case-2gsi-eventual-read-餵錯資料">Case 2：GSI eventual read 餵錯資料</h4>
<p>application 用 GSI 讀「user 最新 status」、code 假設 strong 一致；實際 100-500ms staleness 導致 UI 顯示舊狀態。修法：read-your-write 場景改回主表 query（主表支援 strong）、或加 application-side write-through cache。</p>
<blockquote>
<p><strong>Scope warning</strong>：「100-500ms staleness」具體數字屬通用工程估算、case 未揭露 GSI replication latency 具體 p99 數字。</p></blockquote>
<h4 id="case-3projection-all-把-cost-翻倍">Case 3：projection ALL 把 cost 翻倍</h4>
<p>圖省事所有 GSI 用 <code>ALL</code>、實際 query 只需要 3 個 column；storage + WCU 都浪費。修法：每個 GSI 單獨設 projection、<code>INCLUDE</code> 列出實際 column；只在「用 GSI 直接顯示資料、不回主表」場景才用 <code>ALL</code>。</p>
<blockquote>
<p><strong>Scope warning</strong>：「cost 翻 3 倍」具體數字屬通用工程估算、case 未揭露具體 cost ratio。</p></blockquote>
<h4 id="case-4lsi-用完了才發現要的是-gsi">Case 4：LSI 用完了才發現要的是 GSI</h4>
<p>LSI 上限受 vendor 規格限制（建議 cross-verify AWS doc 當前數字）且建 table 時定、半年後想加 strongly consistent 索引發現要重建 table。修法：建 table 前列完 access pattern、不確定就走 GSI（隨時可加可移）；LSI 留給「明確需要同 PK + strong read」場景。</p>
<h4 id="case-5gsi-反向-scan-取代-query">Case 5：GSI 反向 scan 取代 query</h4>
<p>application 用 GSI 做 <code>Scan</code> 而非 <code>Query</code>、全 GSI 掃過去、cost 跟 latency 都炸。修法：<code>Scan</code> 是 <em>程式碼錯誤訊號</em>、不是 capacity 不夠；review code 看 GSI 為什麼沒被當 query 路徑用、通常是 GSI PK 設計沒對齊 access pattern。</p>
<h4 id="case-6把-dax-當預設配置">Case 6：把 DAX 當預設配置</h4>
<p>寫密集 workload / cache hit rate 低的場景加 DAX、cache invalidation 成本超過 cache 收益、cost 上升 latency 沒降。修法：DAX 是「讀峰值持續高」的補位、不是預設（Lemino 揭露的觸發條件、Capcom 是 derive 不是 case fact）；先觀察 read pattern + 評估 cache hit rate 預期、再決定。</p>
<h4 id="case-7gsi-capacity-mode-跟-base-table-不一致">Case 7：GSI capacity mode 跟 base table 不一致</h4>
<p>GSI 的 capacity mode 跟 base table 是 <em>獨立</em> 設定、不會自動繼承 — base table 是 provisioned + auto-scaling、開新 GSI 預設仍是 provisioned 但 WCU / RCU 預設值跟 base table 不同步、或誤把某個 GSI 切 on-demand 而 base table 維持 provisioned、實際 production 寫入 throttle / 成本失衡都會出現。屬通用工程議題、case 未直接揭露具體 mode 錯配狀況。</p>
<p>徵兆：</p>
<ul>
<li>Base table <code>ConsumedWriteCapacityUnits</code> 健康、卻看到 GSI <code>WriteThrottleEvents</code> 持續觸發、application 端寫入 latency p99 拉高</li>
<li>GSI 切 on-demand 後成本「不知為何」翻 X 倍、查 Cost Explorer 才發現 GSI WCU 計費跟 base table 的 provisioned 是完全不同帳單路徑</li>
<li>Auto-scaling policy 只設了 base table、GSI 沒設、流量上來時 base table 自動擴、GSI 卻 throttle</li>
</ul>
<p>修法：</p>
<ul>
<li>建 GSI 時把 capacity mode 當成獨立決策、不要假設「base 怎麼設、GSI 跟著走」</li>
<li>流量穩定 workload 同時把 base + GSI 都設 provisioned + auto-scaling、auto-scaling target 對齊</li>
<li>Spiky workload 改 on-demand 時整批切（base table + 全部 GSI 同時切）、避免單側切換造成 partial throttle</li>
<li>CloudWatch alarm 對每個 GSI 獨立設 <code>WriteThrottleEvents</code> / <code>ReadThrottleEvents</code>、不要只盯 base table</li>
<li>詳細 mode 切換時機看 sibling <a href="/blog/backend/01-database/vendors/dynamodb/on-demand-vs-provisioned/" data-link-title="DynamoDB On-Demand vs Provisioned：6 軸決策、auto-scaling 邊界與 cost crossover" data-link-desc="capacity mode 選擇不是單軸 peak/avg ratio；本文展開 6 軸決策（peak/avg / 讀寫比 trend / surge 暫時 vs 永久 baseline / predictable-peak vs flash-sale / DBA 工時釋放 / vendor vs 自管 cost crossover），含 Zomato 50% 成本下降、Zoom 30x permanent surge、Amazon Ads sustained workload 等 case 分軸 anchor">on-demand vs provisioned</a></li>
</ul>
<p><strong>Anti-recommendation</strong>：access pattern &lt; 3 個、主表 PK 已能覆蓋 → 不要預先建 GSI；GSI 從少到多容易、從多到少要 application 端配合 cutover。</p>
<h2 id="容量與觀測">容量與觀測</h2>
<p>CloudWatch metric：</p>
<ul>
<li>每個 GSI 獨立 <code>ConsumedReadCapacityUnits</code> / <code>ConsumedWriteCapacityUnits</code></li>
<li><code>ReplicationLatency</code>：GSI async replication 延遲、p99 通常 &lt; 1s（無 SLA）</li>
<li>DAX：<code>CacheHits</code> / <code>CacheMisses</code> / <code>CacheHitRate</code>、<code>ItemCacheHits</code> / <code>QueryCacheHits</code></li>
</ul>
<p><code>ReturnConsumedCapacity</code> flag：query 時帶 <code>INDEXES</code> 看 GSI consumption；<code>TOTAL</code> 看 base + GSI 合計、debug 時切換用。</p>
<p><strong>Cost monitoring</strong>：</p>
<ul>
<li>每個 GSI 都重複收 storage + WCU；GSI 多時 cost 容易超過 base table</li>
<li>用 AWS Cost Explorer 按 GSI 維度看、不是只看 table-level 總 cost</li>
<li>DAX cost 是 instance-hour 計、不是 per-request；只在 read peak 持續高才划算</li>
</ul>
<blockquote>
<p><strong>Scope warning</strong>：「GSI 多時 cost 超過 base table」屬通用工程知識、<code>9.C27 Disney+</code> / <code>9.C19 Capcom</code> case 沒揭露具體 GSI cost ratio。</p></blockquote>
<p><strong>DAX 觀測重點</strong>（新增）：</p>
<ul>
<li><code>CacheHitRate</code> &lt; 70% 應重新評估 DAX 是否該存在</li>
<li>cache size utilization 看 DAX instance class 是否足夠</li>
<li>觀察 cache miss 後 fallback 到 DynamoDB 的 latency、確認 DAX 真的減少 base 路徑壓力</li>
</ul>
<blockquote>
<p><strong>Scope warning</strong>：「70% hit rate 閾值」屬通用工程估算、case 未揭露具體閾值。</p></blockquote>
<p>接回 <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> 的 NoSQL index cost section、<a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">4.20 Observability Evidence Package</a>。</p>
<h2 id="邊界與整合">邊界與整合</h2>
<h3 id="disney--capcom-的-access-pattern-對照">Disney+ / Capcom 的 access pattern 對照</h3>
<p><code>9.C27 Disney+</code> 跟 <code>9.C19 Capcom</code> 是兩種 GSI 用法：</p>
<ul>
<li>Disney+ watchlist + 播放進度 + cross-device sync 全用主表 + 少量 GSI、避免 GSI 爆炸；cross-device sync 透過 <a href="/blog/backend/01-database/vendors/dynamodb/global-tables-conflict/" data-link-title="DynamoDB Global Tables：multi-region active-active、LWW conflict 與 cross-device sync 正向用例" data-link-desc="Global Tables 不只是 conflict 痛點、也是 cross-device sync / global read / DR failover 的正向工程方案；本文展開 B2B SaaS vs B2C 業務 driver、LWW conflict resolution、reconciliation pipeline，含 Genesys 99.999% 跨 15 region 跟 Disney&#43; 跨裝置同步的對照">Global Tables</a> 處理、不是 GSI</li>
<li>Capcom 玩家 leaderboard / 戰績用 GSI 反向查詢（跨遊戲共用平台、player_id 為 base PK、game_id 為 GSI PK）；leaderboard 是否該走 GSI 還是 Redis sorted set 是另一個取捨</li>
</ul>
<p>兩個 case 都 <em>沒有公開揭露</em> 具體 GSI 數量、projection 配置、DAX 是否使用。引用 case 時要分層 — 概念是 case 揭露、實作數字是通用工程估算。</p>
<h3 id="sibling-與-cross-link">Sibling 與 cross-link</h3>
<ul>
<li><a href="/blog/backend/01-database/vendors/dynamodb/single-table-design-pattern/" data-link-title="DynamoDB Single-Table Design：從適用度前置判讀到 access pattern 反推 PK/SK" data-link-desc="DynamoDB single-table 設計不是「資料表越少越好」，而是 access pattern 反推 PK/SK 跟 GSI；本文先做 DynamoDB 適用度 4 軸前置判讀（PK 天然均勻 / control plane vs data plane / consistency / access pattern 穩定），再展開設計流程、failure modes 與 durable queue 正向用例">single-table-design-pattern</a> — GSI 是 single-table 沒覆蓋的 access pattern 補位</li>
<li><a href="/blog/backend/01-database/vendors/dynamodb/partition-key-antipatterns/" data-link-title="DynamoDB Partition Key 反模式與 Write Sharding：composite key 修復跟 mode × partition 交叉判讀" data-link-desc="DynamoDB partition 上限 1000 WCU 是 hot partition 的根因；composite key（event_id &#43; shard suffix）跟 calculated shard（hash % N）兩種修法、mode × partition 在 provisioned / on-demand 不同表現，以及 9.C15 Tixcraft 6750x 擴展的工程細節">partition-key-antipatterns</a> — GSI 自己也會 hot partition、GSI PK 設計獨立 review</li>
<li><a href="/blog/backend/01-database/vendors/dynamodb/consistency-model-optimization/" data-link-title="DynamoDB Strongly Consistent → Eventually Consistent：same protocol, different contract" data-link-desc="DynamoDB consistency model 從 strongly consistent read 改 eventually consistent read 是 50% cost 優化但風險集中在 application contract — 同 vendor / 同 protocol / 同 table / 不同 read consistency；驗證 [#128](/report/data-topology-as-audit-dimension/) self-aware limitation 提出的 consistency axis 候選；涵蓋 read pattern audit / 5 個 production 踩雷">consistency-model-optimization</a> — GSI 強制 eventual、對應 consistency 軸</li>
<li><a href="/blog/backend/01-database/vendors/dynamodb/on-demand-vs-provisioned/" data-link-title="DynamoDB On-Demand vs Provisioned：6 軸決策、auto-scaling 邊界與 cost crossover" data-link-desc="capacity mode 選擇不是單軸 peak/avg ratio；本文展開 6 軸決策（peak/avg / 讀寫比 trend / surge 暫時 vs 永久 baseline / predictable-peak vs flash-sale / DBA 工時釋放 / vendor vs 自管 cost crossover），含 Zomato 50% 成本下降、Zoom 30x permanent surge、Amazon Ads sustained workload 等 case 分軸 anchor">on-demand-vs-provisioned</a> — GSI 多時 cost 跟 mode 互動</li>
<li>替代路由：access pattern 變動頻繁 → 考慮 OpenSearch / Aurora、單純 search 不要拿 GSI 當 inverted index</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%">Capcom 9.C19</a> 互引：leaderboard 用 GSI vs Redis sorted set 的選擇；DAX 是 derive 不是 case fact、引用要明示</li>
<li>跟 <a href="/blog/backend/09-performance-capacity/cases/ntt-docomo-lemino-japanese-streaming/" data-link-title="9.C29 NTT DOCOMO Lemino：3 個月達 500 萬 MAU 的串流後端" data-link-desc="Lemino 用 DynamoDB &#43; AWS Media Services 撐 30 channels live &#43; 5M MAU、工程工時下降 90%">Lemino 9.C29</a> 互引：DAX 作為讀峰值補位的 case 揭露</li>
</ul>
]]></content:encoded></item><item><title>MongoDB Replica Set Read Preference：DB 層 causal session vs cache 層 freshness token</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/mongodb/replica-set-read-preference/</link><pubDate>Wed, 27 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/mongodb/replica-set-read-preference/</guid><description>&lt;p>MongoDB replica set 在小規模時 read preference 五擇一就夠用、&lt;code>primary&lt;/code> 走預設、想分擔 primary 改 &lt;code>secondary&lt;/code> — 直觀但會在 production 反噬。讀者真正撞到的議題分兩層：DB 層的 read-your-own-write（同 client 寫完馬上讀讀不到）跟跨層的 read-after-write（write 進 MongoDB、cache 還是舊資料）。前者用 causal consistency session 解、後者要走 freshness token 跨層協議。Coinbase 1.5M reads/sec 不是純 MongoDB 撐出來、是 DB + cache 跨層合成。本文把 read preference 機制 + 跨層協作講清楚。&lt;/p>
&lt;p>本文不重複 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/mongodb/" data-link-title="MongoDB" data-link-desc="Document database 代表、Atlas managed、跨雲可用、許多大規模平台從 MongoDB 起家">MongoDB vendor overview&lt;/a> 已寫過的 replica set 簡介 — 而是 production 部署 + 跨層協作 + 失敗修復的實作層教學。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>進本文前先確認 MongoDB 已通過適配判讀&lt;/strong>：workload 是否落在 MongoDB 適用區（document shape 主導 / contract layer 該放哪 / 跨雲 hedging 是否需要）— 判讀軸見 &lt;a href="../schema-design-pattern/#%e5%95%8f%e9%a1%8c%e6%83%85%e5%a2%83document-%e8%87%aa%e7%94%b1%e7%9a%84%e5%be%8c%e5%ba%a7%e5%8a%9b">schema-design-pattern 開頭 3 軸前置判讀&lt;/a>。Read scaling 是 &lt;em>已選 MongoDB 後&lt;/em> 的容量決策、判讀通不過時 read preference 修補無法救回 vendor 選錯。&lt;/p>&lt;/blockquote>
&lt;h2 id="問題情境read-scaling-撞牆的兩種長相">問題情境：read scaling 撞牆的兩種長相&lt;/h2>
&lt;p>典型觸發場景：primary 寫入飽和、TL 提議「讀都打 secondary」想橫向擴容。改完後幾個 production 徵兆連環出現：&lt;/p>
&lt;ul>
&lt;li>User 看到「我剛下的訂單怎麼還沒出現」— write 進 primary、立刻 read 打 secondary、secondary 還沒 apply 該寫入、user 看到 stale data&lt;/li>
&lt;li>跨 region replica set：app server 在 Tokyo、primary 在 Singapore、每筆讀走 70ms 跨海 RTT；改 &lt;code>nearest&lt;/code> 後 latency 降但 stale read 出現&lt;/li>
&lt;li>Replication lag 在 backup 期間飆到分鐘級、&lt;code>secondary&lt;/code> read 拿到幾分鐘前的資料、前端報表時間軸對不上&lt;/li>
&lt;li>Failover 期間 read preference 沒寫好、client 一直連舊 primary、&lt;code>SocketTimeout&lt;/code> 直到 driver retry 邏輯介入&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>第二類議題、規模更大&lt;/strong>：把所有 read 打 secondary、replica 數量加到 5-7 仍撐不住 sustained 高 read（&amp;gt;500K reads/sec）；replication lag 升 + secondary CPU 飽和。這時 read preference 已不夠、必須加 cache + 跨層 freshness 機制。&lt;/p>
&lt;p>讀者徵兆：&lt;code>rs.printSecondaryReplicationInfo()&lt;/code> 顯示 lag 分鐘級、application log 出現「我剛寫的資料讀不到」客訴、failover 演練後 connection error 持續 30s+、cache hit rate 跟 read latency 反向相關。&lt;/p></description><content:encoded><![CDATA[<p>MongoDB replica set 在小規模時 read preference 五擇一就夠用、<code>primary</code> 走預設、想分擔 primary 改 <code>secondary</code> — 直觀但會在 production 反噬。讀者真正撞到的議題分兩層：DB 層的 read-your-own-write（同 client 寫完馬上讀讀不到）跟跨層的 read-after-write（write 進 MongoDB、cache 還是舊資料）。前者用 causal consistency session 解、後者要走 freshness token 跨層協議。Coinbase 1.5M reads/sec 不是純 MongoDB 撐出來、是 DB + cache 跨層合成。本文把 read preference 機制 + 跨層協作講清楚。</p>
<p>本文不重複 <a href="/blog/backend/01-database/vendors/mongodb/" data-link-title="MongoDB" data-link-desc="Document database 代表、Atlas managed、跨雲可用、許多大規模平台從 MongoDB 起家">MongoDB vendor overview</a> 已寫過的 replica set 簡介 — 而是 production 部署 + 跨層協作 + 失敗修復的實作層教學。</p>
<blockquote>
<p><strong>進本文前先確認 MongoDB 已通過適配判讀</strong>：workload 是否落在 MongoDB 適用區（document shape 主導 / contract layer 該放哪 / 跨雲 hedging 是否需要）— 判讀軸見 <a href="../schema-design-pattern/#%e5%95%8f%e9%a1%8c%e6%83%85%e5%a2%83document-%e8%87%aa%e7%94%b1%e7%9a%84%e5%be%8c%e5%ba%a7%e5%8a%9b">schema-design-pattern 開頭 3 軸前置判讀</a>。Read scaling 是 <em>已選 MongoDB 後</em> 的容量決策、判讀通不過時 read preference 修補無法救回 vendor 選錯。</p></blockquote>
<h2 id="問題情境read-scaling-撞牆的兩種長相">問題情境：read scaling 撞牆的兩種長相</h2>
<p>典型觸發場景：primary 寫入飽和、TL 提議「讀都打 secondary」想橫向擴容。改完後幾個 production 徵兆連環出現：</p>
<ul>
<li>User 看到「我剛下的訂單怎麼還沒出現」— write 進 primary、立刻 read 打 secondary、secondary 還沒 apply 該寫入、user 看到 stale data</li>
<li>跨 region replica set：app server 在 Tokyo、primary 在 Singapore、每筆讀走 70ms 跨海 RTT；改 <code>nearest</code> 後 latency 降但 stale read 出現</li>
<li>Replication lag 在 backup 期間飆到分鐘級、<code>secondary</code> read 拿到幾分鐘前的資料、前端報表時間軸對不上</li>
<li>Failover 期間 read preference 沒寫好、client 一直連舊 primary、<code>SocketTimeout</code> 直到 driver retry 邏輯介入</li>
</ul>
<p><strong>第二類議題、規模更大</strong>：把所有 read 打 secondary、replica 數量加到 5-7 仍撐不住 sustained 高 read（&gt;500K reads/sec）；replication lag 升 + secondary CPU 飽和。這時 read preference 已不夠、必須加 cache + 跨層 freshness 機制。</p>
<p>讀者徵兆：<code>rs.printSecondaryReplicationInfo()</code> 顯示 lag 分鐘級、application log 出現「我剛寫的資料讀不到」客訴、failover 演練後 connection error 持續 30s+、cache hit rate 跟 read latency 反向相關。</p>
<p>Case anchor：<a href="/blog/backend/09-performance-capacity/cases/coinbase-mongodb-document-platform/" data-link-title="9.C36 Coinbase：MongoDB 撐 Ruby 單體 &#43; 1.5M reads/sec identity 服務" data-link-desc="Coinbase 以 MongoDB 為主資料層、自建 mongobetween connection proxy、users 服務在加密貨幣 surge 時撐 1.5M reads/sec">9.C36 Coinbase</a> 揭露「document model 撐 1.5M reads/sec 靠 cache + freshness token」、含警示「1.5M reads/sec 是 users 服務 <em>加上 cache</em> 的數字、不是 MongoDB cluster 純讀取數字」。跨 region read preference 改 <code>nearest</code> 後 stale read 的具體 incident 細節需未來 case 補完、本文以「常見 failure pattern」處理。</p>
<h2 id="核心機制">核心機制</h2>
<h3 id="mongodb-read-preference--read-concern-兩軸">MongoDB read preference + read concern 兩軸</h3>
<p>Read preference 五種：</p>
<ul>
<li><strong><code>primary</code></strong>（預設）：只打 primary、強一致、primary 飽和時無路可走</li>
<li><strong><code>primaryPreferred</code></strong>：先 primary、primary 不可用 fallback secondary</li>
<li><strong><code>secondary</code></strong>：只打 secondary、永遠拒 primary、failover 期間若所有 secondary 都不行就拋錯</li>
<li><strong><code>secondaryPreferred</code></strong>：先 secondary、secondary 不可用 fallback primary</li>
<li><strong><code>nearest</code></strong>：不是「最近的 secondary」、是「ping latency 最低的 member」（可能是 primary）；driver 用 latency window（預設 15ms）內隨機挑</li>
</ul>
<p>Read concern 是另一軸：</p>
<ul>
<li><strong><code>local</code></strong>：讀本地最新（含未確認）、效能最佳、可能讀到後來 rollback 的資料</li>
<li><strong><code>available</code></strong>：跟 <code>local</code> 類似但對 sharded cluster 有差異</li>
<li><strong><code>majority</code></strong>：讀到「已寫到多數 member」的資料、寫入 commit 後在多數 member 確認後才看得到</li>
<li><strong><code>linearizable</code></strong>：強制最新、必須打 primary、最高 latency</li>
</ul>
<p>Write concern <code>w: &quot;majority&quot;</code> 保證寫入確認後在多數 member 上、但不保證 secondary 馬上 visible — 兩個概念分開。</p>
<h3 id="causal-consistency-sessiondb-層機制">Causal consistency session（DB 層機制）</h3>
<p>Causal consistency session 解的是 <em>單 client</em> 在 <em>MongoDB cluster 內部</em> 的因果一致：</p>
<ul>
<li>Client session 帶 <code>clusterTime</code> + <code>operationTime</code></li>
<li>Driver 把 read 路由到「已 apply 該 operationTime」的 member</li>
<li>實現 read-your-own-write（自己剛寫的、自己讀得到）</li>
</ul>
<p>機制只在「同一 client session」內生效。跨 client 的因果一致（A 寫 → B 讀）不在範圍內。</p>
<p>其他輔助機制：</p>
<ul>
<li><strong>Tag set</strong>：member 標 <code>{region: &quot;ap-tokyo&quot;, role: &quot;analytics&quot;}</code>、read preference 帶 tag 把流量路由到特定 member</li>
<li><strong>Hidden / delayed secondary</strong>：不參與 election、不接 client read、做 backup / DR 用</li>
<li><strong>Election</strong>：primary 失聯後 majority 投票選新 primary、預設 10s 內完成；election 期間所有 primary read 失敗</li>
</ul>
<h3 id="freshness-tokencache-層機制">Freshness token（cache 層機制）</h3>
<p>9.C36 Coinbase 揭露的 <em>跨層</em> 機制 — 解的是 <em>MongoDB + cache 跨層</em> 的 read-after-write、不是 cluster 內部。對應 <a href="/blog/backend/knowledge-cards/freshness-token/" data-link-title="Freshness Token" data-link-desc="DB write 後返回的版本 token、後續 read 帶 token、保證 read 看到的資料 ≥ token 版本、解 DB &#43; cache 跨層 read-after-write">Freshness Token</a> 卡片的 application-level 版本協議定義：</p>
<p><strong>觸發條件</strong>：直接打 MongoDB 不可能撐 1.5M reads/sec（口徑：users 服務應用層觀察、含 cache、非 MongoDB cluster 純讀取）。Coinbase 在 users 服務前加 Memcached query cache、單 document query 先查 cache。</p>
<p><strong>跨層一致性問題</strong>：write 進 MongoDB primary、cache 還是舊資料、client 下次 read 從 cache 拿到舊版。</p>
<p><strong>freshness token 機制</strong>：</p>
<ol>
<li>Write 成功後、server 給 client 一個 token（包含 OCC version / clusterTime）</li>
<li>Client 之後 read 帶這個 token</li>
<li>Server 保證返回的資料版本 ≥ token</li>
<li>若 cache 的版本 &lt; token、bypass cache 直接打 DB</li>
</ol>
<p><strong>跟 causal consistency session 的關係</strong>：兩者解決同一類問題（read-after-write）但作用範圍不同。Causal session 是 DB 層、保證在同一 cluster 內 read-your-own-write；freshness token 是 <em>DB + cache 兩層共用的版本協議</em>、保證跨層 read-your-own-write。</p>
<h3 id="跨層協作三選一">跨層協作三選一</h3>
<p>讀者真實系統的 read 一致性需求要選哪層處理：</p>
<table>
  <thead>
      <tr>
          <th>路徑</th>
          <th>適用情境</th>
          <th>代價</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>只用 DB 層（causal session）</td>
          <td>無 cache 層、讀寫都直接打 MongoDB cluster</td>
          <td>replica scaling 上限約幾十萬 reads/sec</td>
      </tr>
      <tr>
          <td>只用 cache 層（freshness token）</td>
          <td>有 cache、跨層一致性要求高、application 願改</td>
          <td>需設計 token 協議 + cache bypass 邏輯</td>
      </tr>
      <tr>
          <td>兩層並用</td>
          <td>大規模 OLTP、cluster 內也要 causal、跨 cache 也要 freshness</td>
          <td>複雜度最高、但 Coinbase 規模必走此路</td>
      </tr>
  </tbody>
</table>
<p>對應 knowledge card：<a href="/blog/backend/knowledge-cards/stale-read/" data-link-title="Stale Read" data-link-desc="讀取到落後於最新寫入版本的舊資料">stale-read</a>、<a href="/blog/backend/knowledge-cards/replication-lag/" data-link-title="Replication Lag" data-link-desc="說明資料副本落後正式來源多久，以及它如何影響讀取正確性">replication-lag</a>、<a href="/blog/backend/knowledge-cards/session-consistency/" data-link-title="Session Consistency" data-link-desc="同一使用者工作階段內維持讀寫一致、跨工作階段允許短暫不一致">session-consistency</a>、<a href="/blog/backend/knowledge-cards/eventual-consistency/" data-link-title="Eventual Consistency" data-link-desc="允許短暫不一致、最終收斂到同一資料狀態的一致性語意">eventual-consistency</a>。</p>
<h2 id="操作流程">操作流程</h2>
<p><strong>Step 1：read shape 分類</strong>。把所有 read 分成四類：</p>
<ul>
<li>(a) 強一致必須 read-your-own-write（訂單詳情、帳戶餘額）</li>
<li>(b) 容忍秒級 lag（個人資料、商品詳情）</li>
<li>(c) 容忍分鐘級 lag（報表、analytics）</li>
<li>(d) 大規模 read scaling 需 cache + freshness token（用戶資料 / 高頻 product query）</li>
</ul>
<p><strong>Step 2：依分類對映機制</strong>。</p>
<table>
  <thead>
      <tr>
          <th>分類</th>
          <th>Read preference</th>
          <th>Read concern</th>
          <th>跨層機制</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>(a)</td>
          <td>primary</td>
          <td>majority</td>
          <td>causal consistency session</td>
      </tr>
      <tr>
          <td>(b)</td>
          <td>secondaryPreferred</td>
          <td>local</td>
          <td>monitoring lag alarm</td>
      </tr>
      <tr>
          <td>(c)</td>
          <td>secondary（tag set）</td>
          <td>available</td>
          <td>無</td>
      </tr>
      <tr>
          <td>(d)</td>
          <td>secondaryPreferred</td>
          <td>majority</td>
          <td>cache + freshness token + bypass</td>
      </tr>
  </tbody>
</table>
<p><strong>Step 3：driver config</strong>（Node.js / Java / Python 都類似）：</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">mongodb://host1:27017,host2:27017,host3:27017/db?
</span></span><span class="line"><span class="ln">2</span><span class="cl">  replicaSet=rs0&amp;
</span></span><span class="line"><span class="ln">3</span><span class="cl">  readPreference=secondaryPreferred&amp;
</span></span><span class="line"><span class="ln">4</span><span class="cl">  readPreferenceTags=region:ap-tokyo&amp;
</span></span><span class="line"><span class="ln">5</span><span class="cl">  readPreferenceTags=&amp;
</span></span><span class="line"><span class="ln">6</span><span class="cl">  maxStalenessSeconds=90&amp;
</span></span><span class="line"><span class="ln">7</span><span class="cl">  readConcernLevel=majority</span></span></code></pre></div><p><code>readPreferenceTags</code> 寫多個 = fallback chain（先 tokyo 失敗 fallback 任意）。<code>maxStalenessSeconds=90</code> 拒絕 lag &gt; 90s 的 secondary。</p>
<p><strong>Step 4：causal consistency session</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">with</span> <span class="n">client</span><span class="o">.</span><span class="n">start_session</span><span class="p">(</span><span class="n">causal_consistency</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span> <span class="k">as</span> <span class="n">s</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="n">coll</span><span class="o">.</span><span class="n">insert_one</span><span class="p">(</span><span class="n">doc</span><span class="p">,</span> <span class="n">session</span><span class="o">=</span><span class="n">s</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="c1"># 下面這個 find 自動路由到能讀到剛才寫的 member</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="n">coll</span><span class="o">.</span><span class="n">find_one</span><span class="p">({</span><span class="s2">&#34;_id&#34;</span><span class="p">:</span> <span class="n">doc</span><span class="p">[</span><span class="s2">&#34;_id&#34;</span><span class="p">]},</span> <span class="n">session</span><span class="o">=</span><span class="n">s</span><span class="p">)</span></span></span></code></pre></div><p>Session 結束後因果關係結束、下個 session 不繼承。</p>
<p><strong>Step 5：freshness token 設計</strong>（9.C36 Coinbase 模式）：</p>
<ul>
<li>Write API 返回 <code>{result, version_token}</code> — token 含 OCC version 或 MongoDB clusterTime</li>
<li>Read API 接受 optional <code>If-Version-≥</code> header / parameter</li>
<li>Cache lookup 比對 cache entry version 跟 token、低於 token 就 invalidate + bypass 到 MongoDB</li>
<li>DB 層 read 用 <code>readConcern: &quot;majority&quot;</code> 保證返回的 version ≥ token</li>
</ul>
<p><strong>Step 6：staging 驗證</strong>。灌入 replication lag（暫停 secondary apply）驗證 application 行為；灌入 stale cache 驗證 token bypass 邏輯；模擬 failover 驗證 driver retry。</p>
<p>驗證點：</p>
<ul>
<li><code>rs.printSecondaryReplicationInfo()</code> lag &lt; SLO</li>
<li>driver metric <code>readPreferenceUsageCount</code> 分布符合預期</li>
<li>failover drill 後 read recovery &lt; 15s</li>
<li>cache hit rate vs freshness bypass rate 比例監控</li>
</ul>
<p>Rollback boundary：read preference 是 driver-side config、可以 hot-swap；causal consistency session 需 application code 改、需灰度；freshness token 是 application + cache + DB 三方協議、回退需協調。</p>
<h2 id="失敗模式">失敗模式</h2>
<p><strong>Read-after-write 不一致（DB 層）</strong>：寫 primary → 立刻 secondary read、應用 race condition 顯示「資料消失」。修法是 causal consistency session、driver 自動路由到已 apply 該寫入的 member。</p>
<p><strong>Read-after-write 不一致（跨層）</strong>：寫 primary → cache 還是舊資料 → user 看到舊資料。causal session 解不了（cache 在 MongoDB 外）、必須走 freshness token 跨層協議。</p>
<p><strong>Stale read 在 lag 高峰</strong>：backup / DDL / 大量寫入導致 secondary lag 分鐘級、<code>secondary</code> read 拿到舊資料。修法設 <code>maxStalenessSeconds</code> 拒舊 member、driver 自動轉到較新的 member 或 primary。</p>
<p><strong><code>nearest</code> 在跨 region 不穩</strong>：latency 抖動讓 driver 在 primary / secondary 跳、寫一致性與 read latency 同時惡化。修法是不要用 <code>nearest</code> 解跨 region 議題、應該用 tag set 明確路由。</p>
<p><strong>Failover 期間 primary read 全失敗</strong>：election 10s 內所有 primary read 拋錯。修法改 <code>primaryPreferred</code> + driver retry 邏輯吃掉短暫失敗、application 端配 retry policy。</p>
<p><strong>Tag set 失準</strong>：把 <code>region: &quot;ap-tokyo&quot;</code> 的流量路由到 tag 為 tokyo 的 member、但該 member 故障時沒 fallback、流量直接停。修法是 tag 設多層 fallback chain、最後一層留空 tag 表示「任意 member」。</p>
<p><strong>Analytical query 跑 OLTP secondary</strong>：<code>secondaryPreferred</code> 把報表打 OLTP secondary、報表 query 拖垮 OLTP read latency。修法是 analytical workload 用 tag set 路由到專屬 analytics secondary、跟 OLTP read 隔離。</p>
<p><strong>Freshness token 漏寫</strong>：write 沒帶 token 給 client / client 沒帶 token、token 機制 silently 失效、read 走 cache 拿舊資料。修法 token 必須 e2e 強制（middleware 自動帶 / 自動驗證）、不能靠 application 自覺。</p>
<p><strong>Cache bypass 比例失控</strong>：所有 read 都 bypass cache、cache 等於沒裝。修法是 token 失敗率要監控、過高表示 cache invalidation 設計有問題（cache 沒在 write 後 update / invalidate）。</p>
<p>Anti-recommendation：</p>
<ul>
<li>read-heavy 但有強一致需求的場景不要為了 scale 改 secondary read；該換 SQL + read replica 加 application-level cache、或加 sharding 把 primary 寫散開</li>
<li>大規模 OLTP（&gt;500K reads/sec）想單靠 MongoDB read preference 撐 = 拿不到那個量級。Coinbase 案明示「直接打 MongoDB 不可能撐 1.5M reads/sec」、必須 cache + freshness token</li>
</ul>
<h2 id="容量與觀測">容量與觀測</h2>
<p>關鍵 metric：</p>
<ul>
<li><strong>Replica health</strong>：每個 member 的 <code>opcounters</code> 分布、<code>rs.status().members[].optimeDate</code> 推算 lag</li>
<li><strong>Read preference 命中</strong>：driver-side <code>readPreferenceTags</code> 命中率</li>
<li><strong>一致性 SLO</strong>：stale read 比例（causal consistency 拒絕重試次數）</li>
<li><strong>跨層 freshness</strong>：cache hit rate vs freshness bypass rate</li>
</ul>
<p>Mongo command：</p>
<ul>
<li><code>rs.status()</code>：replica set 整體</li>
<li><code>rs.printSecondaryReplicationInfo()</code>：lag 概況</li>
<li><code>db.serverStatus().repl</code>：詳細 replication metric</li>
<li><code>db.adminCommand({replSetGetStatus:1})</code>：完整 status</li>
</ul>
<p>Application observability：APM 看「同一 session 內 write + read 順序對 latency / error 的影響」、SLO 是 read-your-own-write 命中率；跨層還要看 freshness token 流動完整性（write 是否發 token、read 是否帶 token、cache 是否驗 token）。</p>
<p>Lag alarm：lag &gt; 30s 預警、&gt; 90s 觸發 driver <code>maxStalenessSeconds</code> 自動拒讀。</p>
<p>回到 <a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">4.20 observability evidence</a>：把 read preference 命中分布、replication lag time series、failover drill recovery time、freshness token bypass rate 列為 evidence。</p>
<p>回到 <a href="/blog/backend/09-performance-capacity/bottleneck-localization/" data-link-title="9.5 瓶頸定位流程" data-link-desc="從 app 到 DB / cache / broker / 第三方 quota 的逐層瓶頸定位">9.5 bottleneck localization</a>：read latency 異常時要區分 (a) primary 飽和 (b) secondary lag 高 (c) tag routing 把流量集中到單一 member (d) cache hit rate 下降 / bypass 率上升。</p>
<h2 id="邊界與整合">邊界與整合</h2>
<h3 id="frame-5合規邊界--mongodb-用-cluster-per-region-吸收">Frame 5：合規邊界 — MongoDB 用 cluster-per-region 吸收</h3>
<p>MongoDB / Atlas 沒有 <em>row-level locality</em> 機制（不像 CockroachDB 可把單 row pin 在合規 region）— 跨境合規必須以 <em>cluster-per-region</em> 拓樸吸收：每個合規市場開獨立 cluster、application 層做 routing、不靠 replica set / sharded cluster 機制跨 region。</p>
<p>跨 vendor 對照：</p>
<table>
  <thead>
      <tr>
          <th>Vendor</th>
          <th>合規吸收機制</th>
          <th>拓樸特性</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>MongoDB / Cosmos DB</td>
          <td>cluster-per-region（無 row-level locality 等價物）</td>
          <td>各 region 獨立 cluster、application 層做市場 routing</td>
      </tr>
      <tr>
          <td>Aurora</td>
          <td>fleet 拓樸（每市場獨立 cluster、Global Database 在合規場景反指標）</td>
          <td>active-passive per market、跨市場不複製</td>
      </tr>
      <tr>
          <td>CockroachDB</td>
          <td>locality + placement（邏輯一個 cluster + region pinning + Outposts）</td>
          <td>單 logical cluster、physical row 鎖在合規 region</td>
      </tr>
      <tr>
          <td>DynamoDB</td>
          <td>region-pinned Global Tables（按 region 開關 replication、各市場可分離）</td>
          <td>仍 active-active、但 replication 範圍可控</td>
      </tr>
  </tbody>
</table>
<p><strong>MongoDB 在這 frame 的退化點</strong>：read preference 機制本身不解合規 — 即使 <code>readPreferenceTags={region:eu}</code> 把流量路由到歐洲 secondary、但 primary 在亞洲時跨境 replication 仍在跑、合規 audit 不會放行 <em>路由層</em> 控制當作 <em>資料邊界</em> 控制。合規市場必須整 cluster 分離、再用 application 層 routing 把 user 帶到對應 cluster。</p>
<p><strong>Atlas 在合規場景的 fit</strong>：Atlas global cluster（zone sharding 把 shard 鎖在 region）是「跨 region 但 <em>資料 pin 在 zone</em>」的中介選項、適合 GDPR 軟條款（資料在歐洲 EEA 內可流動）；strict 條款（資料不能離開單一國家）仍須走 cluster-per-region。</p>
<h3 id="sibling-與-cross-link">Sibling 與 cross-link</h3>
<p>Sibling deep articles：</p>
<ul>
<li><a href="../shard-key-selection/">shard key selection</a> — read preference 解決不了 write 飽和、要切 shard</li>
<li><a href="../change-streams-kafka/">change streams + Kafka</a> — change stream 預設打 primary、放 secondary 的 trade-off</li>
<li><a href="../aggregation-pipeline-optimization/">aggregation pipeline optimization</a> — 把 analytical aggregation 路由到專屬 secondary</li>
<li><a href="../connection-management-and-cache-layer/">connection management and cache layer</a> — freshness token 是該篇的核心議題之一、本文聚焦 DB 層 vs cache 層機制對照、不展開 cache 部署架構</li>
</ul>
<p>Migration playbook：</p>
<ul>
<li>跨 region 強 consistency 需求 → <a href="/blog/backend/01-database/vendors/cosmosdb/" data-link-title="Azure Cosmos DB" data-link-desc="全球分散式 multi-model DB、5 個 consistency levels、Microsoft 自家 dogfood 證據">→ Cosmos DB MongoDB API</a>（5 consistency level）</li>
<li>跨 region 想保留原生 MongoDB → <a href="/blog/backend/01-database/vendors/mongodb/migrate-to-atlas/" data-link-title="MongoDB → Atlas：Atlas 不是 MongoDB &#43; managed、是另一個 product" data-link-desc="Atlas 號稱「MongoDB managed」但 operational model 完全不同（auto-scaling / VPC peering / IAM-driven access / 內建 backup / billing 模型）；本文採用 Type C operational redesign hybrid 結構、4-phase operational migration &#43; drop-in cutover、5 個 production 踩雷（連線數限制 / IP whitelist / backup retention / IAM token 過期 / billing 暴漲）">→ Atlas global cluster</a></li>
</ul>
<p>跟 1.x 互引：<a href="/blog/backend/01-database/high-concurrency-access/" data-link-title="1.1 高併發下的 SQL 讀寫邊界" data-link-desc="說明高併發服務如何共用資料庫 client、控制 transaction、管理 connection pool、避免資料庫成為瓶頸">1.1 高併發資料存取</a> 處理 read scaling pattern；<a href="/blog/backend/01-database/global-distributed-oltp/" data-link-title="1.11 全球分散式 OLTP" data-link-desc="Spanner / Aurora DSQL / Cosmos DB multi-region write / CockroachDB / TiDB 的全球一致性取捨">1.11 全球分散式 OLTP</a> 處理跨 region 一致性升級路徑。</p>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/mongodb/" data-link-title="MongoDB" data-link-desc="Document database 代表、Atlas managed、跨雲可用、許多大規模平台從 MongoDB 起家">MongoDB vendor overview</a> — 本文是該頁尾「replica set + read preference」backlog 的深度展開</li>
<li><a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章方法論</a></li>
<li><a href="/blog/backend/09-performance-capacity/cases/coinbase-mongodb-document-platform/" data-link-title="9.C36 Coinbase：MongoDB 撐 Ruby 單體 &#43; 1.5M reads/sec identity 服務" data-link-desc="Coinbase 以 MongoDB 為主資料層、自建 mongobetween connection proxy、users 服務在加密貨幣 surge 時撐 1.5M reads/sec">9.C36 Coinbase</a> — freshness token + 1.5M reads/sec（含 cache）</li>
<li>官方：<a href="https://www.mongodb.com/docs/manual/core/read-preference/">MongoDB Read Preference</a>、<a href="https://www.mongodb.com/docs/manual/reference/read-concern/">Read Concern</a>、<a href="https://www.mongodb.com/docs/manual/core/causal-consistency-read-write-concerns/">Causal Consistency</a></li>
</ul>
]]></content:encoded></item><item><title>Spanner Schema Migration Without Downtime + Interleaved Tables</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/spanner/schema-migration-interleaved-tables/</link><pubDate>Wed, 27 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/spanner/schema-migration-interleaved-tables/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/spanner/" data-link-title="Google Cloud Spanner" data-link-desc="全球分散式 strong-consistency OLTP、TrueTime API、線性擴展到 10 億 req/sec">Cloud Spanner&lt;/a> overview 的 implementation-layer deep article。Overview 已說明 Spanner 在全球 OLTP 譜系的定位、本文聚焦 &lt;em>schema migration without downtime + interleaved tables&lt;/em> — Spanner 兩個跟傳統 SQL 差異最大的 schema 機制。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="問題情境ddl-不停機跟-parent-child-物理-layout-的兩個疑問">問題情境：DDL 不停機跟 parent-child 物理 layout 的兩個疑問&lt;/h2>
&lt;p>傳統 PostgreSQL / MySQL DDL 拿 ACCESS EXCLUSIVE / metadata lock、線上跑 ALTER TABLE 動輒鎖表幾分鐘、大型 schema change 要 pt-osc / gh-ost / pg_repack 等外掛工具。Spanner 宣稱「schema change 不停機」、但團隊不知道實際機制跟邊界。讀者徵兆通常從這幾個地方浮現：「Spanner ALTER 真的不卡寫入嗎」「INDEX backfill 跑了 12 小時是正常嗎」「parent-child 的 INTERLEAVE IN PARENT 是什麼黑魔法」「ON DELETE CASCADE 在 interleaved table 為什麼是 storage-level 而不是 application-level」。&lt;/p>
&lt;p>真實壓力：multi-tenant SaaS 要對 100 億 row 的 orders 表加 column + 加 index、不能停機、不能讓 p99 write latency 超過 SLA。團隊以為「Spanner schema change 不停機」等同於「DDL 瞬間完成」、實際 ALTER 是 long-running operation、index backfill 在大表上跑數小時到數天、capacity 規劃要把 backfill 期間的 CPU 升幅算進去。&lt;/p>
&lt;p>Case anchor：&lt;strong>缺案例&lt;/strong>。9.C10 是 Google internal dogfood case、未展開 schema migration 細節、且 9.C10 不是 customer-facing capacity reference。本文用通用 pattern + 官方文件 + 反向回 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/online-schema-change/" data-link-title="PostgreSQL Online Schema Change：先用 ALTER 內建特性、不能解才 pg_repack / pg-osc" data-link-desc="PostgreSQL ALTER TABLE 對多數變更已是 *fast catalog-only*（add column nullable / drop column / 改 default），不必走 ghost table tool。本文走 PG 內建 fast DDL 行為、何時必須走 pg_repack / pg-osc、兩工具機制對比（trigger-based vs WAL-shipping）、配置 step-by-step、5 production 踩雷（lock 升級 / VACUUM FULL 誤用 / pg_repack version mismatch / concurrent index 失敗清理 / generated stored column 不能 online）、跟 MySQL gh-ost / pt-osc sibling 對比">PostgreSQL Online Schema Change&lt;/a> 對照、待後續 customer case audit 補強。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/spanner/" data-link-title="Google Cloud Spanner" data-link-desc="全球分散式 strong-consistency OLTP、TrueTime API、線性擴展到 10 億 req/sec">Cloud Spanner</a> overview 的 implementation-layer deep article。Overview 已說明 Spanner 在全球 OLTP 譜系的定位、本文聚焦 <em>schema migration without downtime + interleaved tables</em> — Spanner 兩個跟傳統 SQL 差異最大的 schema 機制。</p></blockquote>
<hr>
<h2 id="問題情境ddl-不停機跟-parent-child-物理-layout-的兩個疑問">問題情境：DDL 不停機跟 parent-child 物理 layout 的兩個疑問</h2>
<p>傳統 PostgreSQL / MySQL DDL 拿 ACCESS EXCLUSIVE / metadata lock、線上跑 ALTER TABLE 動輒鎖表幾分鐘、大型 schema change 要 pt-osc / gh-ost / pg_repack 等外掛工具。Spanner 宣稱「schema change 不停機」、但團隊不知道實際機制跟邊界。讀者徵兆通常從這幾個地方浮現：「Spanner ALTER 真的不卡寫入嗎」「INDEX backfill 跑了 12 小時是正常嗎」「parent-child 的 INTERLEAVE IN PARENT 是什麼黑魔法」「ON DELETE CASCADE 在 interleaved table 為什麼是 storage-level 而不是 application-level」。</p>
<p>真實壓力：multi-tenant SaaS 要對 100 億 row 的 orders 表加 column + 加 index、不能停機、不能讓 p99 write latency 超過 SLA。團隊以為「Spanner schema change 不停機」等同於「DDL 瞬間完成」、實際 ALTER 是 long-running operation、index backfill 在大表上跑數小時到數天、capacity 規劃要把 backfill 期間的 CPU 升幅算進去。</p>
<p>Case anchor：<strong>缺案例</strong>。9.C10 是 Google internal dogfood case、未展開 schema migration 細節、且 9.C10 不是 customer-facing capacity reference。本文用通用 pattern + 官方文件 + 反向回 <a href="/blog/backend/01-database/vendors/postgresql/online-schema-change/" data-link-title="PostgreSQL Online Schema Change：先用 ALTER 內建特性、不能解才 pg_repack / pg-osc" data-link-desc="PostgreSQL ALTER TABLE 對多數變更已是 *fast catalog-only*（add column nullable / drop column / 改 default），不必走 ghost table tool。本文走 PG 內建 fast DDL 行為、何時必須走 pg_repack / pg-osc、兩工具機制對比（trigger-based vs WAL-shipping）、配置 step-by-step、5 production 踩雷（lock 升級 / VACUUM FULL 誤用 / pg_repack version mismatch / concurrent index 失敗清理 / generated stored column 不能 online）、跟 MySQL gh-ost / pt-osc sibling 對比">PostgreSQL Online Schema Change</a> 對照、待後續 customer case audit 補強。</p>
<h2 id="核心機制ddl-是-long-runningtruetime-對齊-schema-version">核心機制：DDL 是 long-running、TrueTime 對齊 schema version</h2>
<h3 id="schema-change-的-lifecycle">Schema change 的 lifecycle</h3>
<p>Spanner DDL 不是同步 ALTER、是 <em>long-running operation</em>。TrueTime 給每次 schema change 分配一個 version timestamp、所有 read / write 用各自 transaction timestamp 對應「當下看到哪個 schema version」。讀者要理解的核心是：DDL 不是「鎖表→改→解鎖」、是「廣播新 schema version、讓現有 transaction 用舊 schema、新 transaction 用新 schema、背景 backfill 物理資料」。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">時間軸：
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">T0 (DDL 開始)
</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">     | ──── 舊 schema 仍可用、新 schema metadata 廣播 ────
</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">T1 (metadata 完成)
</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">     | ──── 新 transaction 用新 schema、舊 transaction 完成自己 ────
</span></span><span class="line"><span class="ln">10</span><span class="cl">     | ──── backfill 開始（背景）────
</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">T2 (backfill 完成)
</span></span><span class="line"><span class="ln">13</span><span class="cl">     |
</span></span><span class="line"><span class="ln">14</span><span class="cl">     | ──── 新 schema fully serve ────</span></span></code></pre></div><p>DDL 本身瞬間完成的部分是 <em>metadata 廣播</em>（毫秒到秒級）、慢的部分是 <em>backfill</em>（依資料量、可能數小時到數天）。讀者常見誤解是把 metadata 完成當「DDL 完成」、實際 query 還沒走新 index 因為 backfill 沒跑完。</p>
<h3 id="不停機的關鍵不同-ddl-的兩階段行為">不停機的關鍵：不同 DDL 的兩階段行為</h3>
<table>
  <thead>
      <tr>
          <th>DDL 類型</th>
          <th>metadata 行為</th>
          <th>backfill 行為</th>
          <th>阻塞？</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>ADD COLUMN</code>（無 NOT NULL）</td>
          <td>metadata-only、瞬間生效</td>
          <td>不需 backfill（新 column 預設 NULL）</td>
          <td>不阻塞 write</td>
      </tr>
      <tr>
          <td><code>ADD COLUMN</code>（NOT NULL）</td>
          <td>必須兩階段：先 ADD COLUMN with default、後 ADD CONSTRAINT</td>
          <td>兩階段間需 backfill default</td>
          <td>不阻塞 write、但兩階段不能合</td>
      </tr>
      <tr>
          <td><code>CREATE INDEX</code></td>
          <td>metadata 立即</td>
          <td>背景 backfill、不阻塞 write；backfill 完才 serve query</td>
          <td>不阻塞 write、阻塞「該 index 的 query」</td>
      </tr>
      <tr>
          <td><code>DROP COLUMN</code></td>
          <td>metadata 立即</td>
          <td>背景 GC dead column</td>
          <td>不阻塞</td>
      </tr>
      <tr>
          <td><code>ALTER COLUMN TYPE</code></td>
          <td>限制多、查最新文件</td>
          <td>-</td>
          <td>-</td>
      </tr>
  </tbody>
</table>
<p>讀者要記的是：<strong>index backfill 完成前、query 該 index 會 fallback 到 table scan</strong>、用 <code>EXPLAIN</code> 確認 query plan 走新 index 才算真正完成。沒做這層驗證、團隊會以為 CREATE INDEX 已經成功、實際 p99 query latency 還在表掃描的數量級。</p>
<h3 id="interleaved-table-的設計">Interleaved table 的設計</h3>
<p><a href="/blog/backend/knowledge-cards/interleaved-table/" data-link-title="Interleaved Table" data-link-desc="Spanner 把 parent / child table row 物理交錯儲存、parent &#43; child JOIN 不跨 split">Interleaved Table</a> 把 parent table（如 <code>Customer</code>）跟 child table（如 <code>Order</code>）的 row 在 storage 層 <em>物理上交錯儲存</em> — child row 跟對應 parent row 在同一個 split。不是純 foreign key、是 storage layout：</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">傳統 PostgreSQL FK 設計（兩張獨立表）：
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">Customer table:  [c1, c2, c3, ...]  → 一張表、一段 storage range
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">Order table:     [o1, o2, o3, ...]  → 另一張表、另一段 storage range
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">FK 由 planner 在 JOIN 時拼接、可能跨 page / 跨 segment
</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">Spanner Interleaved 設計（物理交錯）：
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">Storage layout: [c1, c1.o1, c1.o2, c2, c2.o1, c2.o2, c2.o3, c3, ...]
</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">                  c1 + 其 child           c2 + 其 child
</span></span><span class="line"><span class="ln">10</span><span class="cl">                  在同一個 split          在同一個 split</span></span></code></pre></div><p>Interleaved 的效果：parent + child JOIN 在同一個 <a href="/blog/backend/knowledge-cards/range-sharding/" data-link-title="Range Sharding" data-link-desc="分散式 SQL 把 key space 切成可自動 split / merge 的 range、每個 range 自己的 consensus group、application 透明">Range Sharding</a> split 完成、不跨 split = 不跨 Paxos group = 低延遲 transaction。這條設計把「FK 是 logical constraint」翻成「parent-child access pattern 是 physical co-location」、對 access pattern 固定的 workload（customer → orders、user → posts、tenant → records）是巨大 latency benefit。</p>
<h3 id="interleaved-的硬限">Interleaved 的硬限</h3>
<table>
  <thead>
      <tr>
          <th>限制</th>
          <th>影響</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>必須以 parent primary key 為 prefix</td>
          <td>child PK 第一段必須是 parent PK、不能完全自由</td>
      </tr>
      <tr>
          <td>最深 7 層</td>
          <td>深巢狀關係要選層級</td>
      </tr>
      <tr>
          <td><code>ON DELETE</code> 只能 CASCADE 或 NO ACTION</td>
          <td>不像 PG FK 有 SET NULL / SET DEFAULT</td>
      </tr>
      <tr>
          <td>一旦建立、無法直接 ALTER 改 interleave</td>
          <td>要改 → export + recreate + import、不是 ALTER</td>
      </tr>
  </tbody>
</table>
<p>最後一條是讀者最容易踩的雷 — 一開始沒設 interleaved、後悔時要 export-import 100 億 row、是大工程、不是 ALTER。Schema 設計階段要先 audit access pattern、決定哪些 parent-child 該 interleave。</p>
<h3 id="跟通用-fk-概念的差異">跟通用 FK 概念的差異</h3>
<p>PostgreSQL FK 是 logical constraint、JOIN 由 planner 處理；Spanner interleaved 是 physical layout、JOIN cost 跟 single-table access 接近。對應 <a href="/blog/backend/knowledge-cards/transaction-boundary/" data-link-title="Transaction Boundary" data-link-desc="說明哪些資料變更應在同一個交易中一起成功或一起回復">transaction-boundary</a> 卡 — interleaved 讓 transaction boundary 跟 storage boundary 對齊、跨 split transaction 變少、commit wait + Paxos round-trip 也省。</p>
<h2 id="操作流程ddl-跟-interleaved-table-的具體步驟">操作流程：DDL 跟 interleaved table 的具體步驟</h2>
<h3 id="加-column">加 column</h3>





<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">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">Orders</span><span class="w"> </span><span class="k">ADD</span><span class="w"> </span><span class="k">COLUMN</span><span class="w"> </span><span class="n">tax_amount</span><span class="w"> </span><span class="n">FLOAT64</span><span class="p">;</span></span></span></code></pre></div><p>執行後拿 long-running operation id、用 <code>gcloud spanner operations list</code> 觀察狀態：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">gcloud spanner operations list --instance<span class="o">=</span>prod --database<span class="o">=</span>app
</span></span><span class="line"><span class="ln">2</span><span class="cl">gcloud spanner operations describe projects/.../operations/&lt;op-id&gt;</span></span></code></pre></div><p>驗證點：operation 顯示 <code>done: true</code> 後、跑 <code>SELECT tax_amount FROM Orders LIMIT 1</code> 確認 column 可查。</p>
<h3 id="加-index">加 index</h3>





<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">OrdersByCustomer</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">Orders</span><span class="p">(</span><span class="n">customer_id</span><span class="p">);</span></span></span></code></pre></div><p>拿 operation id → 用 Monitoring metric <code>spanner.googleapis.com/instance/indexes/backfill_progress</code>（或對應的最新 metric、查官方文件）追蹤進度。Backfill 完成前 query 不會走新 index、要用 <code>EXPLAIN</code> 確認：</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">EXPLAIN</span><span class="w"> </span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">Orders</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">customer_id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;c123&#39;</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="c1">-- 應看到 plan 用 OrdersByCustomer index、不是 table scan</span></span></span></code></pre></div><h3 id="創建-interleaved-table">創建 interleaved table</h3>





<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="o">`</span><span class="k">Order</span><span class="o">`</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">customer_id</span><span class="w"> </span><span class="n">INT64</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">3</span><span class="cl"><span class="w">    </span><span class="n">order_id</span><span class="w"> </span><span class="n">INT64</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">4</span><span class="cl"><span class="w">    </span><span class="n">amount</span><span class="w"> </span><span class="n">FLOAT64</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">created_at</span><span class="w"> </span><span class="k">TIMESTAMP</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 class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</span><span class="w"> </span><span class="p">(</span><span class="n">customer_id</span><span class="p">,</span><span class="w"> </span><span class="n">order_id</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">INTERLEAVE</span><span class="w"> </span><span class="k">IN</span><span class="w"> </span><span class="n">PARENT</span><span class="w"> </span><span class="n">Customer</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="k">DELETE</span><span class="w"> </span><span class="k">CASCADE</span><span class="p">;</span></span></span></code></pre></div><p>關鍵約束：</p>
<ul>
<li>child PK <code>(customer_id, order_id)</code> 第一段是 parent PK</li>
<li><code>ON DELETE CASCADE</code> 是 storage-level — 刪 parent row 自動刪 child row、Spanner 內部處理、不是 trigger</li>
</ul>
<h3 id="從-non-interleaved-改成-interleaved">從 non-interleaved 改成 interleaved</h3>
<p><em>無法直接 ALTER</em>、要走 export-recreate-import：</p>
<ol>
<li>用 Dataflow / <code>gcloud spanner databases export</code> 把舊表 export 到 GCS</li>
<li>建新表（interleaved schema）</li>
<li>用 Dataflow / <code>gcloud spanner databases import</code> 把資料倒回</li>
<li>應用層 cutover（feature flag / dual write）</li>
</ol>
<p>這個流程是 mini-migration、要走完整 <a href="../migrate-from-cloud-sql-pg/">migration playbook</a> 的 phase plan。Schema 設計階段就決定好 interleave、避免後悔成本。</p>
<h3 id="rollback-boundary">Rollback boundary</h3>
<p>DDL 完成前可 <code>gcloud spanner operations cancel</code> 取消；完成後加 index 要 DROP、加 column 要 DROP COLUMN（同樣是 long-running）。讀者要先確認自己在 DDL 哪個階段、cancel 跟 reverse DDL 是兩條不同路徑。</p>
<h2 id="失敗模式5-個-production-踩雷">失敗模式：5 個 production 踩雷</h2>
<h3 id="backfill-時間沒估event-window-撞牆">Backfill 時間沒估、event window 撞牆</h3>
<p>100 億 row 加 index、預期 1 小時、實際 12 小時 — 沒先用 <code>cost</code> 估 + 沒監控進度 metric。事故場景：團隊在 black friday 前一週開 CREATE INDEX、以為週末跑完、實際週末仍在 backfill、event 期間 CPU 升、query latency 退化。</p>
<p>修法：</p>
<ul>
<li>DDL 前用小表 benchmark backfill 速度（rows/sec）、推估大表時間</li>
<li>DDL 期間監控 <code>instance/cpu/smoothed_utilization</code>、若 &gt; 80% 暫停或降流量</li>
<li>大 DDL 排在 capacity headroom 充足的時段、避開 event window</li>
</ul>
<h3 id="interleaved-table-一開始沒設後悔時要-recreate">Interleaved table 一開始沒設、後悔時要 recreate</h3>
<p>100 億 row export-import + cutover 是大工程、不是 ALTER。事故場景：團隊一開始把 Customer / Order 設成獨立表、上線一年後發現 customer → orders access pattern 是 99% 的 query、JOIN 跨 split 付 commit wait + Paxos cost、想改 interleaved、發現要 mini-migration。</p>
<p>修法：</p>
<ul>
<li>Schema 設計階段就 audit access pattern、決定哪些 parent-child 該 interleave</li>
<li>寫 ADR 把 interleave 決策跟業務 access pattern 綁定、避免後悔成本</li>
</ul>
<h3 id="把-interleaved-跟-fk-混為一談">把 interleaved 跟 FK 混為一談</h3>
<p>interleaved 的 <code>ON DELETE CASCADE</code> 是 storage-level、刪 parent 自動刪 child；非 interleaved FK 要 application 或 trigger 處理。事故場景：團隊以為「我加了 FK 就會 CASCADE」、實際非 interleaved table 只是 constraint check、刪 parent 時 child orphan、對帳爆炸。</p>
<p>修法：</p>
<ul>
<li>Schema 設計時明確分類：interleaved（storage-level CASCADE）vs FK constraint（只檢查、不 CASCADE）</li>
<li>非 interleaved 的 parent-child 刪除邏輯放應用層、寫入對帳測試</li>
</ul>
<h3 id="加-not-null-一步到位">加 NOT NULL 一步到位</h3>
<p>直接 <code>ALTER ADD COLUMN x INT64 NOT NULL</code> 會失敗、必須兩階段。事故場景：開發環境 schema 是新建空表、<code>ADD COLUMN NOT NULL</code> OK；production 表有資料、ADD 失敗、團隊以為 Spanner 不支援、回退。</p>
<p>修法：</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">-- Phase 1: ADD with default
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">Orders</span><span class="w"> </span><span class="k">ADD</span><span class="w"> </span><span class="k">COLUMN</span><span class="w"> </span><span class="n">tax_amount</span><span class="w"> </span><span class="n">FLOAT64</span><span class="w"> </span><span class="k">DEFAULT</span><span class="w"> </span><span class="mi">0</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="c1">-- 等 backfill 完成
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="c1">-- Phase 2: ADD CONSTRAINT
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span><span class="k">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">Orders</span><span class="w"> </span><span class="k">ALTER</span><span class="w"> </span><span class="k">COLUMN</span><span class="w"> </span><span class="n">tax_amount</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="p">;</span></span></span></code></pre></div><h3 id="schema-change-期間舊-client-還在用舊-schema">Schema change 期間舊 client 還在用舊 schema</h3>
<p>TrueTime 保證 read 看到自己 timestamp 對應的 schema version、但 client SDK cache schema 過期會 retry — 沒處理會看到 transient error。事故場景：DDL 完成後、舊 client session 看到 transient <code>FAILED_PRECONDITION</code>、團隊以為 DDL 失敗、回退。</p>
<p>修法：</p>
<ul>
<li>應用層處理 transient retry（指數退避）</li>
<li>DDL 完成後重新 deploy app instance、避免長期 stale schema cache</li>
</ul>
<h2 id="容量與觀測backfill-是-cpu--io-的額外負載">容量與觀測：Backfill 是 CPU + I/O 的額外負載</h2>
<p>必看 metric：</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">spanner.googleapis.com/instance/cpu/smoothed_utilization
</span></span><span class="line"><span class="ln">2</span><span class="cl">   → backfill 期間 CPU 升幅、判讀是否撞 headroom
</span></span><span class="line"><span class="ln">3</span><span class="cl">api/api_request_count for ExecuteSql
</span></span><span class="line"><span class="ln">4</span><span class="cl">   → application traffic 是否受 backfill 影響
</span></span><span class="line"><span class="ln">5</span><span class="cl">long-running operation API progress
</span></span><span class="line"><span class="ln">6</span><span class="cl">   → DDL 自身進度（不是 query 進度）</span></span></code></pre></div><p>Backfill 期間的 capacity impact：DDL 跑在 background priority、但仍佔 CPU、需要在 instance 有足夠 headroom（建議 &lt; 65% CPU baseline 才開大 backfill）。capacity 規劃要把 schema migration 列入 buffer、回 <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>。</p>
<p>Observability evidence：backfill 開始 timestamp、operation id、predicted duration、實際 duration、CPU peak — 全進 incident decision log、回 <a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">4.20 Observability Evidence Package</a>。</p>
<p>監控盲點：DDL operation 失敗 silent fail 在 <code>gcloud operations describe</code> 才能看到、Cloud Monitoring 沒有直接 alert。團隊要寫自己的 polling script、operation 失敗時主動 alert、不靠 Cloud Monitoring default。</p>
<h2 id="邊界與整合何時不用-interleaved怎麼跟-pg-對照">邊界與整合：何時不用 interleaved、怎麼跟 PG 對照</h2>
<h3 id="何時不用-interleaved">何時不用 interleaved</h3>
<ul>
<li>小 table（&lt; 1M row、單機可放）：不需要 interleave、用 standard FK 就好</li>
<li>過度 interleave 7 層：把 split 變窄、反而 hot、得不償失</li>
<li>access pattern 不是 parent-child JOIN：interleave 沒 benefit、純粹給 schema 加複雜度</li>
</ul>
<h3 id="跟-postgresql-的對照">跟 PostgreSQL 的對照</h3>
<p><a href="/blog/backend/01-database/vendors/postgresql/online-schema-change/" data-link-title="PostgreSQL Online Schema Change：先用 ALTER 內建特性、不能解才 pg_repack / pg-osc" data-link-desc="PostgreSQL ALTER TABLE 對多數變更已是 *fast catalog-only*（add column nullable / drop column / 改 default），不必走 ghost table tool。本文走 PG 內建 fast DDL 行為、何時必須走 pg_repack / pg-osc、兩工具機制對比（trigger-based vs WAL-shipping）、配置 step-by-step、5 production 踩雷（lock 升級 / VACUUM FULL 誤用 / pg_repack version mismatch / concurrent index 失敗清理 / generated stored column 不能 online）、跟 MySQL gh-ost / pt-osc sibling 對比">PostgreSQL Online Schema Change</a> 用 pg_repack / pt-osc workflow 模擬「不停機」 — 實際是用 trigger + 影子表 + cutover 把 lock 時間壓到秒級、不是真正瞬間。Spanner 是原生支援 DDL long-running operation、不需要外掛工具、但 backfill 時間在大表上仍長、跟 pg_repack 在大表上的執行時間量級接近。</p>
<p>差異點：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>PostgreSQL（pg_repack / pt-osc）</th>
          <th>Spanner</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Lock 時間</td>
          <td>秒級（cutover 時短鎖）</td>
          <td>毫秒（metadata 廣播）</td>
      </tr>
      <tr>
          <td>Backfill 時間</td>
          <td>數小時</td>
          <td>數小時</td>
      </tr>
      <tr>
          <td>工具</td>
          <td>外掛</td>
          <td>原生</td>
      </tr>
      <tr>
          <td>Schema version</td>
          <td>單版</td>
          <td>TrueTime timestamp 對齊多版並存</td>
      </tr>
      <tr>
          <td>大表加 NOT NULL</td>
          <td>一步到位（搭配 default）</td>
          <td>必須兩階段</td>
      </tr>
  </tbody>
</table>
<p>讀者選 Spanner 不是為了「DDL 更快」、是為了「不依賴外掛 + 多版本並存」。實際在大表上的耗時兩邊差不多。</p>
<h3 id="sibling-deep-articles">Sibling deep articles</h3>
<ul>
<li><a href="../truetime-api-depth/">truetime-api-depth</a>：schema version 也是 TrueTime timestamp、跟 transaction timestamp 同層機制</li>
<li><a href="../migrate-from-cloud-sql-pg/">migrate-from-cloud-sql-pg</a>：target schema 設計含 interleaved、Phase 1 必讀本文</li>
<li><a href="../consistency-models-comparison/">consistency-models-comparison</a>：schema change 期間多版本並存的一致性保證</li>
</ul>
<h3 id="跟-1x-章節">跟 1.x 章節</h3>
<p><a href="/blog/backend/01-database/schema-design/" data-link-title="1.2 Schema Design 與資料建模" data-link-desc="整理 table、index、key、partition、denormalization 與命名規則">Schema Design</a> — interleaved 是 schema 設計的物理層決策、不是純 logical design。對照 <a href="/blog/backend/01-database/schema-migration-rollout-evidence/" data-link-title="1.7 Schema Migration Rollout 證據（Schema Migration Rollout Evidence）實作示範" data-link-desc="以訂單付款狀態欄位演進示範 schema migration 如何產出 evidence、release gate 與 incident decision log。">schema-migration-rollout-evidence</a> 看 schema rollout 的 evidence 收集模式。</p>
<h3 id="anti-recommendation">Anti-recommendation</h3>
<p>讀者讀完本文應該能判斷：interleaved 不是「強制使用」的 feature、是「access pattern 固定時的 latency benefit」。小規模 OLTP、access pattern 不確定的 workload、用 standard PostgreSQL FK 就好、為 interleaved 付 schema 後悔成本的判準很高。</p>
]]></content:encoded></item><item><title>PostgreSQL autovacuum tuning：為什麼你的 autovacuum 永遠追不上 bloat</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/autovacuum-tuning/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/autovacuum-tuning/</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 已說明 PostgreSQL MVCC 的 vacuum 必要性、本文聚焦 &lt;em>autovacuum 在 production write-heavy workload 為什麼追不上&lt;/em> 的根因 + 各維度 tuning。&lt;/p>&lt;/blockquote>
&lt;h2 id="你的-autovacuum-永遠追不上-bloat--為什麼">你的 autovacuum 永遠追不上 bloat — 為什麼&lt;/h2>
&lt;p>write-heavy table 的常見故事：上線時表 10GB、3 個月後 30GB、6 個月 80GB；DBA 看 &lt;code>pg_stat_user_tables&lt;/code> 發現 &lt;code>n_dead_tup&lt;/code> 比 &lt;code>n_live_tup&lt;/code> 還多、&lt;code>pg_stat_progress_vacuum&lt;/code> 顯示 autovacuum 一直在跑、但 dead tuple 從沒清乾淨。表本身才 5M row、實際磁碟卻佔 80GB。&lt;/p>
&lt;p>這不是 PostgreSQL bug、是 autovacuum &lt;em>cost-based throttling 預設保守&lt;/em> 的設計意圖 — autovacuum 不該影響 OLTP query 性能、所以每跑一段就 sleep。預設 &lt;code>autovacuum_vacuum_cost_limit=200&lt;/code> + &lt;code>autovacuum_vacuum_cost_delay=2ms&lt;/code> 在 write-heavy 表（每秒幾千 UPDATE）下、清理速度 &lt;em>永遠慢於&lt;/em> dead tuple 產生速度。預設配置適合 read-heavy / write-light workload；OLTP write-heavy 必須調。&lt;/p>
&lt;h2 id="mvcc-跟-dead-tuplevacuum-在解什麼">MVCC 跟 dead tuple：vacuum 在解什麼&lt;/h2>
&lt;p>PostgreSQL MVCC：每次 UPDATE 都是 &lt;em>insert new row + mark old row as deleted&lt;/em>；DELETE 是 &lt;em>mark as deleted、不立刻釋放空間&lt;/em>。dead tuple 在 disk 上佔位、但不能被 query 讀到。autovacuum 的責任：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>回收 dead tuple 空間&lt;/strong> 供新 row reuse（不縮 table 大小、是 free space map）&lt;/li>
&lt;li>&lt;strong>更新 visibility map&lt;/strong> 讓 index-only scan 跳過 heap fetch&lt;/li>
&lt;li>&lt;strong>凍結老 row 的 xid&lt;/strong>（freeze）避免 xid wraparound 災難&lt;/li>
&lt;li>&lt;strong>重整 index B-tree&lt;/strong> 標記 dead pointer（不刪 index page）&lt;/li>
&lt;/ol>
&lt;p>Vacuum 不縮表 — 真要縮要跑 &lt;code>VACUUM FULL&lt;/code>（全表 exclusive lock、production 不能跑）或 &lt;code>pg_repack&lt;/code>（online repack tool）。預期 vacuum 只能 &lt;em>讓表停止長大&lt;/em>、不能 &lt;em>讓表變小&lt;/em>。&lt;/p>
&lt;h2 id="tuningcost-based-throttle-跟-trigger-threshold">Tuning：cost-based throttle 跟 trigger threshold&lt;/h2>
&lt;h3 id="cost-based-throttle全-instance">Cost-based throttle（全 instance）&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-ini" data-lang="ini">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># postgresql.conf&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="na">autovacuum_vacuum_cost_limit&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s">2000 # 預設 200、production 拉 5-10 倍&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="na">autovacuum_vacuum_cost_delay&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s">2ms # 預設 2ms、不太需要動&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="na">autovacuum_max_workers&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s">6 # 預設 3、CPU 多時拉到 6-10&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="na">maintenance_work_mem&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s">1GB # 預設 64MB、單一 vacuum 用的記憶體&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>直覺：&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 已說明 PostgreSQL MVCC 的 vacuum 必要性、本文聚焦 <em>autovacuum 在 production write-heavy workload 為什麼追不上</em> 的根因 + 各維度 tuning。</p></blockquote>
<h2 id="你的-autovacuum-永遠追不上-bloat--為什麼">你的 autovacuum 永遠追不上 bloat — 為什麼</h2>
<p>write-heavy table 的常見故事：上線時表 10GB、3 個月後 30GB、6 個月 80GB；DBA 看 <code>pg_stat_user_tables</code> 發現 <code>n_dead_tup</code> 比 <code>n_live_tup</code> 還多、<code>pg_stat_progress_vacuum</code> 顯示 autovacuum 一直在跑、但 dead tuple 從沒清乾淨。表本身才 5M row、實際磁碟卻佔 80GB。</p>
<p>這不是 PostgreSQL bug、是 autovacuum <em>cost-based throttling 預設保守</em> 的設計意圖 — autovacuum 不該影響 OLTP query 性能、所以每跑一段就 sleep。預設 <code>autovacuum_vacuum_cost_limit=200</code> + <code>autovacuum_vacuum_cost_delay=2ms</code> 在 write-heavy 表（每秒幾千 UPDATE）下、清理速度 <em>永遠慢於</em> dead tuple 產生速度。預設配置適合 read-heavy / write-light workload；OLTP write-heavy 必須調。</p>
<h2 id="mvcc-跟-dead-tuplevacuum-在解什麼">MVCC 跟 dead tuple：vacuum 在解什麼</h2>
<p>PostgreSQL MVCC：每次 UPDATE 都是 <em>insert new row + mark old row as deleted</em>；DELETE 是 <em>mark as deleted、不立刻釋放空間</em>。dead tuple 在 disk 上佔位、但不能被 query 讀到。autovacuum 的責任：</p>
<ol>
<li><strong>回收 dead tuple 空間</strong> 供新 row reuse（不縮 table 大小、是 free space map）</li>
<li><strong>更新 visibility map</strong> 讓 index-only scan 跳過 heap fetch</li>
<li><strong>凍結老 row 的 xid</strong>（freeze）避免 xid wraparound 災難</li>
<li><strong>重整 index B-tree</strong> 標記 dead pointer（不刪 index page）</li>
</ol>
<p>Vacuum 不縮表 — 真要縮要跑 <code>VACUUM FULL</code>（全表 exclusive lock、production 不能跑）或 <code>pg_repack</code>（online repack tool）。預期 vacuum 只能 <em>讓表停止長大</em>、不能 <em>讓表變小</em>。</p>
<h2 id="tuningcost-based-throttle-跟-trigger-threshold">Tuning：cost-based throttle 跟 trigger threshold</h2>
<h3 id="cost-based-throttle全-instance">Cost-based throttle（全 instance）</h3>





<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="c1"># postgresql.conf</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="na">autovacuum_vacuum_cost_limit</span> <span class="o">=</span> <span class="s">2000          # 預設 200、production 拉 5-10 倍</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="na">autovacuum_vacuum_cost_delay</span> <span class="o">=</span> <span class="s">2ms            # 預設 2ms、不太需要動</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="na">autovacuum_max_workers</span> <span class="o">=</span> <span class="s">6                    # 預設 3、CPU 多時拉到 6-10</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">1GB                    # 預設 64MB、單一 vacuum 用的記憶體</span></span></span></code></pre></div><p>直覺：</p>
<ul>
<li><code>cost_limit</code> 是每個 cycle 能消費多少「cost」、cost 由 page read / dirty / hit 加總；拉高 = 每次 cycle 處理更多 page</li>
<li>拉 <code>cost_limit</code> 比 <code>cost_delay</code> 直接 — delay 太低（&lt; 1ms）OS scheduler 抖動就無效</li>
<li><code>max_workers</code> 限同時跑的 vacuum；partition 多時容易爆滿、要拉</li>
<li><code>maintenance_work_mem</code> 影響 index vacuum 速度、SSD 環境 1-2GB 是 sweet spot</li>
</ul>
<h3 id="per-table-override精準到-hot-table">Per-table override（精準到 hot table）</h3>





<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">-- 對 hot write-heavy 表加強
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">ALTER</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="k">SET</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">autovacuum_vacuum_scale_factor</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">0</span><span class="p">.</span><span class="mi">05</span><span class="p">,</span><span class="w">      </span><span class="c1">-- 預設 0.2、5% dead 就觸發
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"></span><span class="w">  </span><span class="n">autovacuum_vacuum_threshold</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">1000</span><span class="p">,</span><span class="w">          </span><span class="c1">-- 預設 50、絕對值底線
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span><span class="w">  </span><span class="n">autovacuum_vacuum_cost_limit</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">5000</span><span class="p">,</span><span class="w">         </span><span class="c1">-- 該表獨立 cost_limit
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"></span><span class="w">  </span><span class="n">autovacuum_analyze_scale_factor</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">0</span><span class="p">.</span><span class="mi">05</span><span class="p">,</span><span class="w">      </span><span class="c1">-- analyze 也跟著
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"></span><span class="w">  </span><span class="n">autovacuum_freeze_max_age</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">100000000</span><span class="w">        </span><span class="c1">-- anti-wraparound 提前
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"></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></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w"></span><span class="c1">-- 對 append-only 表（log table）降頻
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></span><span class="k">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">audit_log</span><span class="w"> </span><span class="k">SET</span><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 class="n">autovacuum_vacuum_scale_factor</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">0</span><span class="p">.</span><span class="mi">5</span><span class="p">,</span><span class="w">        </span><span class="c1">-- 50% dead 才觸發（極少 UPDATE / DELETE）
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1"></span><span class="w">  </span><span class="n">autovacuum_freeze_max_age</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">1000000000</span><span class="w">       </span><span class="c1">-- freeze 延後
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1"></span><span class="p">);</span></span></span></code></pre></div><p>關鍵：<em>hot table 比 default 緊、cold table 比 default 鬆</em>、不要把所有表用同套配置。Production cluster 通常 5-20 個 hot table 需要 per-table tuning。</p>
<h2 id="production-故障演練">Production 故障演練</h2>
<h3 id="case-1write-heavy-hot-tableautovacuum-永遠跑不完">Case 1：write-heavy hot table，autovacuum 永遠跑不完</h3>
<p><strong>徵兆</strong>：<code>pg_stat_user_tables.n_dead_tup</code> 持續高於 <code>n_live_tup</code>、<code>pg_stat_progress_vacuum</code> 顯示某表 vacuum 跑了 6+ 小時還在 <code>scanning heap</code>、表 size 持續長大。</p>
<p><strong>根因</strong>：default <code>cost_limit=200</code> 對該表 write rate（~5000 UPDATE/s）下、vacuum 處理速度 &lt; dead tuple 產生速度；單次 autovacuum 跑完整表要 12 小時、但表 5% bloat 觸發又啟動下一輪。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>對該表 <code>ALTER TABLE ... SET (autovacuum_vacuum_cost_limit = 10000)</code> — 該表 vacuum 不受全 instance 限制</li>
<li><code>maintenance_work_mem</code> 拉到 2GB（單 vacuum）</li>
<li>短期：手動 <code>VACUUM (VERBOSE, ANALYZE) events;</code> 在 maintenance window 跑、catch up</li>
<li>長期：考慮 partitioning — partition 後 vacuum 只動最近 partition、不掃整表</li>
</ol>
<h3 id="case-2長-transaction-卡住-vacuum-的-xmin-horizon">Case 2：長 transaction 卡住 vacuum 的 xmin horizon</h3>
<p><strong>徵兆</strong>：autovacuum 看似有跑、但 <code>n_dead_tup</code> 不降；<code>pg_stat_activity</code> 看到一個跑了 8 小時的 SELECT（report query 或 idle in transaction）。</p>
<p><strong>根因</strong>：vacuum 只能回收「不會被任何 active transaction 看到」的 dead tuple；長 transaction 的 xmin 鎖死 vacuum 能回收的範圍、即使 autovacuum 不停跑、能回收的 row 數為 0。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>預防</strong>：application 端用 <code>statement_timeout</code> + <code>idle_in_transaction_session_timeout</code>（30 分鐘）強制終止 long transaction</li>
<li><strong>偵測</strong>：<code>SELECT pid, now() - xact_start FROM pg_stat_activity WHERE state = 'idle in transaction'</code> 定期掃</li>
<li><strong>臨時</strong>：kill 長 transaction（<code>pg_cancel_backend(pid)</code> / <code>pg_terminate_backend(pid)</code>）、autovacuum 下次跑就能回收</li>
<li><strong>架構</strong>：報表 query 跑在 standby、不要在 primary 開 long transaction</li>
</ol>
<h3 id="case-3anti-wraparound-vacuum-在-peak-觸發">Case 3：Anti-wraparound vacuum 在 peak 觸發</h3>
<p><strong>徵兆</strong>：production 流量高峰時 PostgreSQL CPU 100%、<code>pg_stat_progress_vacuum</code> 顯示 anti-wraparound vacuum 正在跑、application latency 暴漲；log 出現 <code>database &quot;myapp&quot; must be vacuumed within X transactions</code>。</p>
<p><strong>根因</strong>：autovacuum_freeze_max_age（預設 200M）到了、PostgreSQL <em>強制</em> 跑 anti-wraparound vacuum（即使在 peak）；這個 vacuum <em>不受 cost_limit 限制</em>、跑到完才停、表大時要幾小時、跟 OLTP query 搶 IO。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>預防</strong>：<code>autovacuum_freeze_max_age</code> 拉到 1B（10 億）、給 freeze 更多時間在 off-peak 自然發生</li>
<li><strong>per-table freeze</strong>：hot table 設 <code>autovacuum_freeze_max_age = 100M</code>（提前在 off-peak freeze）、cold table 設 800M（避免不必要 freeze）</li>
<li><strong>緊急</strong>：手動跑 <code>VACUUM (FREEZE, VERBOSE) table_name;</code> 在 maintenance window 預先 freeze</li>
<li><strong>監測</strong>：<code>SELECT relname, age(relfrozenxid) FROM pg_class WHERE relkind = 'r' ORDER BY age(relfrozenxid) DESC LIMIT 20;</code> 看哪些表逼近 wraparound</li>
</ol>
<h3 id="case-4partition-table-把-autovacuum_max_workers-跑滿">Case 4：Partition table 把 autovacuum_max_workers 跑滿</h3>
<p><strong>徵兆</strong>：partition 後（時間 partition、12 個月分區）、autovacuum 跑很慢、<code>pg_stat_activity</code> 看到 3 個 autovacuum worker 都在跑 partition 表、其他 hot table queue 等很久。</p>
<p><strong>根因</strong>：<code>autovacuum_max_workers=3</code> 預設、每個 partition 算獨立 table；100 個 partition 中 50 個都需要 vacuum、worker 滿、其他 table 排隊。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>拉 <code>autovacuum_max_workers</code> 到 6-10（依 CPU core 數）</li>
<li>cold partition 設 <code>autovacuum_enabled = false</code>（已不寫的舊 partition）、減少 worker 競爭</li>
<li>partition 數量本身要克制 — 100+ partition 是訊號該重新評估 partition strategy</li>
</ol>
<h3 id="case-5index-bloat-沒被-vacuum-處理">Case 5：Index bloat 沒被 vacuum 處理</h3>
<p><strong>徵兆</strong>：表 vacuum 跑完了、<code>n_dead_tup</code> 為 0、但 index size 持續長大；query 用該 index 越來越慢、跟 sequential scan 差不多。</p>
<p><strong>根因</strong>：autovacuum 只處理 <em>heap</em>（table data）跟 <em>index leaf pages</em>；index B-tree 內部結構 fragmentation 不被 vacuum 處理。dead pointer 留在 index leaf page、查詢仍 traverse 過、IO 多。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><code>REINDEX CONCURRENTLY</code> 線上重建 index（PG 12+）、不鎖表</li>
<li>監測 index bloat：<code>pgstattuple_approx</code> extension 或 <code>pg_repack</code></li>
<li>預防：B-tree index 設計避免 high cardinality + 大量 UPDATE 同欄位（typical 場景：status column update）；考慮 <em>partial index</em> 或 <em>hash index</em>（PG 10+ logged）</li>
<li>大量 bloat index 用 <code>pg_repack</code> 重建（不需要 superuser、不鎖表）</li>
</ol>
<h2 id="容量規劃">容量規劃</h2>
<p>vacuum capacity 用 <em>跟得上 dead tuple 產生速度</em> 衡量：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>估算方式</th>
          <th>警戒</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>dead tuple 產生 rate</td>
          <td><code>UPDATE/s + DELETE/s + ~10% INSERT/s（HOT update miss）</code></td>
          <td>跟 vacuum rate 對比</td>
      </tr>
      <tr>
          <td>vacuum 處理 rate</td>
          <td><code>cost_limit / cost_delay × page_size</code>、~MB/s 數量級</td>
          <td>跟 dead tuple rate 對比</td>
      </tr>
      <tr>
          <td>autovacuum_max_workers</td>
          <td>partition 數 + hot table 數 / 3-5</td>
          <td>100+ partition 必須拉 worker</td>
      </tr>
      <tr>
          <td>maintenance_work_mem</td>
          <td>1-2GB / vacuum worker</td>
          <td>全 worker 跑時的記憶體上限要 sizing</td>
      </tr>
      <tr>
          <td>anti-wraparound 觸發頻率</td>
          <td>預設 200M xid、write-heavy ~ 1-2 週觸發一次</td>
          <td>拉到 1B 後 ~ 2-3 月一次</td>
      </tr>
      <tr>
          <td>Bloat ratio</td>
          <td><code>pg_stat_user_tables.n_dead_tup / n_live_tup</code></td>
          <td>&gt; 50% 表示 vacuum 追不上</td>
      </tr>
  </tbody>
</table>
<p>實務 default：</p>
<ul>
<li>OLTP write-heavy（事件 / 訂單）：cost_limit 2000-5000、scale_factor 0.05、freeze_max_age 100M</li>
<li>OLTP read-heavy（user / config）：default 即可</li>
<li>Append-only log：scale_factor 0.5、freeze_max_age 800M、<code>autovacuum_enabled = false</code> for cold partition</li>
</ul>
<h2 id="整合--下一步">整合 / 下一步</h2>
<h3 id="跟-partitioning-整合">跟 <a href="/blog/backend/01-database/vendors/postgresql/declarative-partitioning/" data-link-title="PostgreSQL declarative partitioning：partition 不是切表、是讓 planner pruning" data-link-desc="Declarative partitioning 的真實價值是 query planner pruning &#43; maintenance scope 縮小、不是「把大表切小」；RANGE / LIST / HASH 取捨、partition key 選法、5 個 production 踩雷（key 選錯不 prune / unique 不 enforce 跨 partition / ATTACH 鎖太久 / partition 數爆 / DETACH 不 reclaim 空間）、跟 autovacuum &#43; index 設計整合">partitioning</a> 整合</h3>
<p>partitioning 是 vacuum 問題的長期解：</p>
<ul>
<li>大表（&gt; 100GB）vacuum 時間隨 size 線性、partition 後 vacuum 只動最近 partition</li>
<li>Cold partition <code>autovacuum_enabled = false</code> 完全停掉、新數據只在 hot partition</li>
<li>缺點：partition 數量爆炸時、autovacuum_max_workers 也要拉</li>
</ul>
<h3 id="跟-monitoring-整合">跟 monitoring 整合</h3>
<p>關鍵 metric：</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">-- bloat 比例
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">relname</span><span class="p">,</span><span class="w"> </span><span class="n">n_dead_tup</span><span class="p">,</span><span class="w"> </span><span class="n">n_live_tup</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">round</span><span class="p">(</span><span class="n">n_dead_tup</span><span class="p">::</span><span class="nb">numeric</span><span class="w"> </span><span class="o">/</span><span class="w"> </span><span class="k">nullif</span><span class="p">(</span><span class="n">n_live_tup</span><span class="p">,</span><span class="w"> </span><span class="mi">0</span><span class="p">)</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="mi">100</span><span class="p">,</span><span class="w"> </span><span class="mi">1</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">dead_pct</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">FROM</span><span class="w"> </span><span class="n">pg_stat_user_tables</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">WHERE</span><span class="w"> </span><span class="n">n_live_tup</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="mi">1000</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w"></span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">n_dead_tup</span><span class="w"> </span><span class="k">DESC</span><span class="w"> </span><span class="k">LIMIT</span><span class="w"> </span><span class="mi">20</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></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w"></span><span class="c1">-- vacuum 進度
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">pg_stat_progress_vacuum</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></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w"></span><span class="c1">-- xid wraparound 距離
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">datname</span><span class="p">,</span><span class="w"> </span><span class="n">age</span><span class="p">(</span><span class="n">datfrozenxid</span><span class="p">)</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">pg_database</span><span class="w"> </span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">age</span><span class="w"> </span><span class="k">DESC</span><span class="p">;</span></span></span></code></pre></div><p>Prometheus alert 三條：<code>dead_pct &gt; 30</code>、<code>vacuum_running_seconds &gt; 3600</code>、<code>xid_age &gt; 500000000</code>。</p>
<h3 id="跟-backup-window">跟 backup window</h3>
<p>VACUUM FREEZE 在 backup 前跑能減少 backup size（freeze tuple 不需要 special handling）：</p>
<ol>
<li>每週 maintenance window 跑 <code>VACUUM (FREEZE, ANALYZE) hot_table</code> — 預先 freeze + 更新 stats</li>
<li>backup 前避免長 transaction、確保 vacuum 能跑</li>
</ol>
<h3 id="下一步議題">下一步議題</h3>
<ul>
<li><strong>HOT update 跟 fillfactor</strong>：UPDATE 同頁可重用空間、fillfactor 80 為 hot table 留 20% buffer</li>
<li><strong><code>pg_repack</code> vs <code>VACUUM FULL</code></strong>：online vs offline、長期維護工具選擇</li>
<li><strong>PostgreSQL 14+ parallel vacuum</strong>：index vacuum 平行化、大表受益明顯</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li>上游 vendor 頁：<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></li>
<li>上游 chapter：<a href="/blog/backend/01-database/high-concurrency-access/" data-link-title="1.1 高併發下的 SQL 讀寫邊界" data-link-desc="說明高併發服務如何共用資料庫 client、控制 transaction、管理 connection pool、避免資料庫成為瓶頸">High Concurrency Access</a> — vacuum 是 concurrency 治理一環</li>
<li>平行 deep article：<a href="/blog/backend/01-database/vendors/postgresql/patroni-ha/" data-link-title="PostgreSQL Patroni HA：從 leader 失聯到 client 重連的 5 段 failover lifecycle" data-link-desc="Patroni 把 PostgreSQL HA 拆成 detection / election / promotion / reconfiguration / recovery 五段 lifecycle、每段都有獨立配置跟 failure mode；DCS quorum &#43; watchdog 防 split-brain、async/sync replication 取捨、5 個 production 踩雷、跟 PgBouncer / HAProxy / cert-manager 整合">Patroni HA</a> / <a href="/blog/backend/01-database/vendors/postgresql/declarative-partitioning/" data-link-title="PostgreSQL declarative partitioning：partition 不是切表、是讓 planner pruning" data-link-desc="Declarative partitioning 的真實價值是 query planner pruning &#43; maintenance scope 縮小、不是「把大表切小」；RANGE / LIST / HASH 取捨、partition key 選法、5 個 production 踩雷（key 選錯不 prune / unique 不 enforce 跨 partition / ATTACH 鎖太久 / partition 數爆 / DETACH 不 reclaim 空間）、跟 autovacuum &#43; index 設計整合">Declarative Partitioning</a> / <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>（為什麼會有 dead tuple、跟 lock 互動）</li>
<li>Methodology：<a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章的寫作方法論</a></li>
</ul>
]]></content:encoded></item><item><title>Aurora RDS Proxy 與連線管理：connection multiplexing、pinning 陷阱與 failover 加速</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/aurora/rds-proxy-connection-pooling/</link><pubDate>Tue, 02 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/aurora/rds-proxy-connection-pooling/</guid><description>&lt;p>Lambda 函式在流量尖峰被同時拉起幾百個實例、每個各自開一條到 Aurora 的連線、Aurora 的 connection 上限瞬間被打爆、新請求拿不到連線、整批失敗。根因是 &lt;em>連線管理&lt;/em> 缺位、Aurora 容量本身夠用——serverless 與高並發短連線 workload 製造的連線數遠超過資料庫該同時維持的後端連線。RDS Proxy 在 application 與 Aurora 之間做 connection multiplexing，把大量 client 連線收斂成少量後端連線。但它不是「連上去就自動省」——某些 session 操作會讓連線被 pin 住、multiplexing 失效。&lt;/p>
&lt;p>本文不是 Aurora overview（請看 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/aurora/" data-link-title="AWS Aurora" data-link-desc="AWS managed PostgreSQL / MySQL、storage / compute 分離、&amp;#43;75% 效能改善的 production 證據">Aurora vendor 頁&lt;/a>）— 而是 RDS Proxy 連線管理機制與陷阱的實作層教學。&lt;/p>
&lt;h2 id="核心機制connection-multiplexing">核心機制：connection multiplexing&lt;/h2>
&lt;p>RDS Proxy 維護一個到 Aurora 的後端連線池，多個 client 連線共享這些後端連線。當 client 連線閒置（交易之間沒有活動），proxy 可以把對應的後端連線釋放回池子給其他 client 用：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>沒有 proxy&lt;/th>
 &lt;th>有 RDS Proxy&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>每個 client 連線 = 一條後端連線&lt;/td>
 &lt;td>多個 client 連線共享少量後端連線&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Lambda 並發 N → 後端 N 條連線&lt;/td>
 &lt;td>Lambda 並發 N → 後端遠少於 N 條&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>failover 時 client 連線斷、要重連&lt;/td>
 &lt;td>proxy 保持 client 連線、後端切換對 client 透明&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>連線建立開銷由 application 承擔&lt;/td>
 &lt;td>proxy 維持暖連線池、省去反覆建立&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>multiplexing 生效的前提是 client 連線「閒置時可以被借走」。這只在連線處於 &lt;em>交易之間&lt;/em> 的乾淨狀態時成立——一旦連線帶了交易內狀態，proxy 不能把它借給別人，這就是 pinning。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>Scope warning&lt;/strong>：「RDS Proxy 支援的 engine / 連線數上限 / IAM 認證細節」屬 AWS vendor 規格、實作時 cross-verify 官方 doc 當前值。本文不含 production case 揭露的 proxy 配置數字。&lt;/p>&lt;/blockquote>
&lt;p>對應 knowledge card：&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/connection-pool/" data-link-title="Connection Pool" data-link-desc="說明連線池如何限制下游資源並影響服務容量">connection pool&lt;/a>。&lt;/p>
&lt;h2 id="pinningmultiplexing-失效的主因">Pinning：multiplexing 失效的主因&lt;/h2>
&lt;p>Pinning 是 RDS Proxy 最常被忽略、卻直接決定省連線效果的機制。當 client 在連線上做了「跨交易持續的 session 狀態」操作，proxy 無法安全地把這條後端連線借給其他 client，於是把它 &lt;em>pin&lt;/em>（綁定）到該 client 直到連線關閉——這條後端連線在 pin 期間不參與 multiplexing。&lt;/p>
&lt;p>常見觸發 pinning 的操作：&lt;/p>
&lt;ul>
&lt;li>session 層級的變數設定（&lt;code>SET&lt;/code> 某些 session variable）&lt;/li>
&lt;li>建立 temp table&lt;/li>
&lt;li>prepared statement（某些情況）&lt;/li>
&lt;li>advisory lock、保持開啟的交易&lt;/li>
&lt;li>部分 session 層級的設定語句&lt;/li>
&lt;/ul>
&lt;p>pinning 的後果是「明明裝了 RDS Proxy、後端連線數卻沒降下來」。若大量 client 都觸發 pinning，等於退化回「一個 client 一條後端連線」、proxy 白裝。&lt;/p>
&lt;p>&lt;strong>判讀與修法方向&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>監控 &lt;code>DatabaseConnectionsCurrentlySessionPinned&lt;/code>，看 pinning 比例&lt;/li>
&lt;li>application 端避免不必要的 session 狀態（少用 session variable、temp table；改用交易內可清理的方式）&lt;/li>
&lt;li>真的需要 session 狀態的 workload，接受該連線會 pin、或評估這類 workload 是否適合走 proxy&lt;/li>
&lt;/ul>
&lt;blockquote>
&lt;p>&lt;strong>Scope warning&lt;/strong>：「哪些具體語句觸發 pinning」隨 RDS Proxy 版本與 engine 演進、實作時以 AWS doc 當前清單為準；本段列舉是常見類型、非完整或固定清單。&lt;/p></description><content:encoded><![CDATA[<p>Lambda 函式在流量尖峰被同時拉起幾百個實例、每個各自開一條到 Aurora 的連線、Aurora 的 connection 上限瞬間被打爆、新請求拿不到連線、整批失敗。根因是 <em>連線管理</em> 缺位、Aurora 容量本身夠用——serverless 與高並發短連線 workload 製造的連線數遠超過資料庫該同時維持的後端連線。RDS Proxy 在 application 與 Aurora 之間做 connection multiplexing，把大量 client 連線收斂成少量後端連線。但它不是「連上去就自動省」——某些 session 操作會讓連線被 pin 住、multiplexing 失效。</p>
<p>本文不是 Aurora overview（請看 <a href="/blog/backend/01-database/vendors/aurora/" data-link-title="AWS Aurora" data-link-desc="AWS managed PostgreSQL / MySQL、storage / compute 分離、&#43;75% 效能改善的 production 證據">Aurora vendor 頁</a>）— 而是 RDS Proxy 連線管理機制與陷阱的實作層教學。</p>
<h2 id="核心機制connection-multiplexing">核心機制：connection multiplexing</h2>
<p>RDS Proxy 維護一個到 Aurora 的後端連線池，多個 client 連線共享這些後端連線。當 client 連線閒置（交易之間沒有活動），proxy 可以把對應的後端連線釋放回池子給其他 client 用：</p>
<table>
  <thead>
      <tr>
          <th>沒有 proxy</th>
          <th>有 RDS Proxy</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>每個 client 連線 = 一條後端連線</td>
          <td>多個 client 連線共享少量後端連線</td>
      </tr>
      <tr>
          <td>Lambda 並發 N → 後端 N 條連線</td>
          <td>Lambda 並發 N → 後端遠少於 N 條</td>
      </tr>
      <tr>
          <td>failover 時 client 連線斷、要重連</td>
          <td>proxy 保持 client 連線、後端切換對 client 透明</td>
      </tr>
      <tr>
          <td>連線建立開銷由 application 承擔</td>
          <td>proxy 維持暖連線池、省去反覆建立</td>
      </tr>
  </tbody>
</table>
<p>multiplexing 生效的前提是 client 連線「閒置時可以被借走」。這只在連線處於 <em>交易之間</em> 的乾淨狀態時成立——一旦連線帶了交易內狀態，proxy 不能把它借給別人，這就是 pinning。</p>
<blockquote>
<p><strong>Scope warning</strong>：「RDS Proxy 支援的 engine / 連線數上限 / IAM 認證細節」屬 AWS vendor 規格、實作時 cross-verify 官方 doc 當前值。本文不含 production case 揭露的 proxy 配置數字。</p></blockquote>
<p>對應 knowledge card：<a href="/blog/backend/knowledge-cards/connection-pool/" data-link-title="Connection Pool" data-link-desc="說明連線池如何限制下游資源並影響服務容量">connection pool</a>。</p>
<h2 id="pinningmultiplexing-失效的主因">Pinning：multiplexing 失效的主因</h2>
<p>Pinning 是 RDS Proxy 最常被忽略、卻直接決定省連線效果的機制。當 client 在連線上做了「跨交易持續的 session 狀態」操作，proxy 無法安全地把這條後端連線借給其他 client，於是把它 <em>pin</em>（綁定）到該 client 直到連線關閉——這條後端連線在 pin 期間不參與 multiplexing。</p>
<p>常見觸發 pinning 的操作：</p>
<ul>
<li>session 層級的變數設定（<code>SET</code> 某些 session variable）</li>
<li>建立 temp table</li>
<li>prepared statement（某些情況）</li>
<li>advisory lock、保持開啟的交易</li>
<li>部分 session 層級的設定語句</li>
</ul>
<p>pinning 的後果是「明明裝了 RDS Proxy、後端連線數卻沒降下來」。若大量 client 都觸發 pinning，等於退化回「一個 client 一條後端連線」、proxy 白裝。</p>
<p><strong>判讀與修法方向</strong>：</p>
<ul>
<li>監控 <code>DatabaseConnectionsCurrentlySessionPinned</code>，看 pinning 比例</li>
<li>application 端避免不必要的 session 狀態（少用 session variable、temp table；改用交易內可清理的方式）</li>
<li>真的需要 session 狀態的 workload，接受該連線會 pin、或評估這類 workload 是否適合走 proxy</li>
</ul>
<blockquote>
<p><strong>Scope warning</strong>：「哪些具體語句觸發 pinning」隨 RDS Proxy 版本與 engine 演進、實作時以 AWS doc 當前清單為準；本段列舉是常見類型、非完整或固定清單。</p></blockquote>
<h2 id="failover-加速">Failover 加速</h2>
<p>RDS Proxy 的第二個價值是縮短 failover 對 application 的中斷。沒有 proxy 時，writer failover 會讓所有 client 連線斷掉、application 要偵測、重連、重建連線池；有 proxy 時，proxy 保持與 client 的連線、在後端把流量切到新 writer，client 端感知到的中斷時間縮短。</p>
<p>這對連線建立成本高、或 failover 期間不能大量重連的 workload 特別有價值。但 proxy 不消除 failover 本身——in-flight 的交易仍會失敗、application 仍要有 retry；proxy 縮短的是「重建連線」這段，不是「交易不中斷」。</p>
<h2 id="操作流程">操作流程</h2>
<p>從連線壓力判讀到上線的 6 步流程。</p>
<h4 id="step-1確認是不是連線問題">Step 1：確認是不是連線問題</h4>
<p>先區分「Aurora 容量不夠」vs「連線管理問題」。看 <code>DatabaseConnections</code> 是否逼近上限、且 CPU/IOPS 還有餘量——後者是典型的連線數問題、proxy 能解；若是 CPU/IOPS 飽和，proxy 不解。</p>
<h4 id="step-2判斷-workload-是否適合-proxy">Step 2：判斷 workload 是否適合 proxy</h4>
<ul>
<li>serverless / Lambda / 高並發短連線 → 適合（連線爆炸是主問題）</li>
<li>少量長連線、穩定的 application server → proxy 效益有限（連線數本就可控）</li>
<li>大量 session 狀態 workload → pinning 會吃掉 multiplexing 效益、要先評估</li>
</ul>
<h4 id="step-3建立-proxy">Step 3：建立 proxy</h4>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">aws rds create-db-proxy <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --db-proxy-name my-aurora-proxy <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --engine-family POSTGRESQL <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --auth ... <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  --role-arn ... <span class="se">\
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="se"></span>  --vpc-subnet-ids ...</span></span></code></pre></div><p>application 連到 proxy endpoint 而非直連 cluster endpoint。</p>
<h4 id="step-4減少-pinning">Step 4：減少 pinning</h4>
<p>review application 的 session 狀態使用、移除不必要的 <code>SET</code> / temp table；連線池設定避免長時間持有閒置連線。</p>
<h4 id="step-5驗證-multiplexing-生效">Step 5：驗證 multiplexing 生效</h4>





<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"># 對照後端連線數：裝 proxy 後 Aurora 的 DatabaseConnections 應顯著低於 client 並發數
</span></span><span class="line"><span class="ln">2</span><span class="cl"># 看 DatabaseConnectionsCurrentlySessionPinned：pinning 比例高代表 multiplexing 沒發揮</span></span></code></pre></div><h4 id="step-6驗證-failover-行為">Step 6：驗證 failover 行為</h4>
<p>主動觸發一次 failover、測量 application 感知到的中斷時間、確認 retry 邏輯能吸收 in-flight 交易失敗。</p>
<p><strong>Rollback boundary</strong>：application 可在 proxy endpoint 與直連 cluster endpoint 間切換、proxy 出問題時改回直連（但直連會回到連線爆炸風險，要先確認後端撐得住）。</p>
<h2 id="失敗模式">失敗模式</h2>
<p>production 常見的 5 個踩雷：</p>
<h4 id="case-1裝了-proxy-但-pinning-比例高連線沒降">Case 1：裝了 proxy 但 pinning 比例高、連線沒降</h4>
<p>application 大量用 session variable / temp table、多數連線被 pin、後端連線數沒降、proxy 白裝。修法：監控 pinning 比例、減少 session 狀態；理解 proxy 的省連線前提是連線可被借走。</p>
<h4 id="case-2把-proxy-當aurora-容量擴充">Case 2：把 proxy 當「Aurora 容量擴充」</h4>
<p>連線數沒問題、是 CPU/IOPS 飽和、卻裝 proxy 期待變快。修法：proxy 解連線管理、不解運算容量；容量問題要擴 instance / 加 replica。</p>
<h4 id="case-3以為-proxy-讓-failover-零中斷">Case 3：以為 proxy 讓 failover 零中斷</h4>
<p>裝了 proxy 就拿掉 application 的 retry、failover 時 in-flight 交易失敗沒處理。修法：proxy 縮短重連時間、不保證交易不中斷；application 仍要 retry in-flight 交易。</p>
<h4 id="case-4少量長連線-workload-強裝-proxy">Case 4：少量長連線 workload 強裝 proxy</h4>
<p>穩定的 application server 連線數本就可控、裝 proxy 多一跳延遲、效益有限。修法：proxy 的價值在連線爆炸場景（serverless / 高並發短連線）；連線可控的 workload 不必加。</p>
<h4 id="case-5proxy-與自管-pooler-疊加未理清責任">Case 5：proxy 與自管 pooler 疊加未理清責任</h4>
<p>application 已有自管連線池（如語言層 pool）、又加 RDS Proxy、兩層 pool 互相打架、連線數行為難預測。修法：理清兩層職責——application 層 pool 管「app 到 proxy」、proxy 管「proxy 到 Aurora」；兩層設定要協調、不是各設各的。</p>
<p><strong>Anti-recommendation</strong>：連線數本就可控的少量長連線 workload、或 workload 大量依賴 session 狀態（pinning 會吃掉效益）→ 不必上 RDS Proxy；它的價值集中在 serverless / Lambda / 高並發短連線的連線爆炸場景。</p>
<h2 id="容量與觀測">容量與觀測</h2>
<p>CloudWatch metric：</p>
<ul>
<li><code>DatabaseConnections</code>（Aurora 端）：裝 proxy 後應顯著低於 client 並發數</li>
<li><code>DatabaseConnectionsCurrentlySessionPinned</code>：pinning 數、判斷 multiplexing 效益</li>
<li><code>ClientConnections</code>（proxy 端）：client 側連線數、對照後端收斂比例</li>
<li><code>QueryDatabaseResponseLatency</code>：proxy 多一跳的延遲影響</li>
</ul>
<p><strong>判讀</strong>：</p>
<ul>
<li>後端連線數沒因 proxy 下降 → pinning 比例高或 workload 不適合</li>
<li>pinning 數持續高 → application session 狀態過多、需 review</li>
<li>proxy 延遲明顯 → 評估這一跳對延遲敏感路徑是否值得</li>
</ul>
<blockquote>
<p><strong>Scope warning</strong>：本文未引用 production case 的 proxy metric 數字；上述指標與判讀屬 vendor 規格 + 通用連線管理工程。</p></blockquote>
<p>接回 <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>、<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>
<h3 id="rds-proxy-vs-自管-pgbouncer">RDS Proxy vs 自管 pgbouncer</h3>
<p>兩者都是 connection pooler，責任切分在「managed vs 自管」：</p>
<ul>
<li><strong>RDS Proxy</strong>：AWS managed、跟 Aurora / IAM / Secrets Manager 整合、零運維、含 failover 加速；綁 AWS</li>
<li><strong>自管 pgbouncer / pgcat</strong>：自己部署運維、pooling 模式（session / transaction / statement）可細調、跨雲可攜；運維責任自負</li>
</ul>
<p>PostgreSQL 的通用連線池機制與 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> 與 <a href="/blog/backend/01-database/vendors/postgresql/connection-pooler-comparison/" data-link-title="PostgreSQL Connection Pooler Comparison" data-link-desc="PostgreSQL PgBouncer、Odyssey、RDS Proxy、application pool 與 transaction pooling 的選型比較">connection-pooler-comparison</a>；本篇聚焦 RDS Proxy 這個 AWS managed 方案的機制與 pinning 陷阱。要細調 pooling 模式、或需要跨雲可攜 → 評估自管 pooler；要零運維 + Aurora 原生整合 + failover 加速 → RDS Proxy。</p>
<h3 id="sibling-與-cross-link">Sibling 與 cross-link</h3>
<ul>
<li><a href="/blog/backend/01-database/vendors/aurora/serverless-v2-scaling/" data-link-title="Aurora Serverless v2 適用判斷：ACU 自動擴縮、混合 cluster 與何時不該用" data-link-desc="Aurora Serverless v2 不是「比較便宜的 Aurora」；本文展開 ACU 計費粒度、秒級自動擴縮機制、min/max ACU 設定、serverless 與 provisioned 同 cluster 混用，以及穩定高負載下 serverless 反而更貴的成本 crossover 邊界">serverless-v2-scaling</a> — serverless + Lambda 場景的連線管理常與 RDS Proxy 一起出現</li>
<li><a href="/blog/backend/01-database/vendors/aurora/cross-az-failover-rto/" data-link-title="Aurora Cross-AZ Failover：RTO 量測、endpoint routing 與 application reconnect 契約" data-link-desc="Aurora cross-AZ failover lifecycle（detection / promotion / DNS update）、&lt; 30 秒 RTO、application DNS cache 跟 connection pool 對齊、Standard Chartered 受監管場景為什麼用獨立 cluster 而非 Global Database failover">cross-az-failover-rto</a> — proxy 縮短 failover 重連時間、與 RTO 目標結合</li>
<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> / <a href="/blog/backend/01-database/vendors/postgresql/connection-pooler-comparison/" data-link-title="PostgreSQL Connection Pooler Comparison" data-link-desc="PostgreSQL PgBouncer、Odyssey、RDS Proxy、application pool 與 transaction pooling 的選型比較">connection-pooler-comparison</a> — 通用連線池 SSoT、自管方案對照</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> — 連線池與 transaction 範圍控制</li>
<li>替代路由：需要細調 pooling 模式 / 跨雲 → 自管 pgbouncer</li>
</ul>
]]></content:encoded></item><item><title>DynamoDB On-Demand vs Provisioned：6 軸決策、auto-scaling 邊界與 cost crossover</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/dynamodb/on-demand-vs-provisioned/</link><pubDate>Wed, 27 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/dynamodb/on-demand-vs-provisioned/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/dynamodb/" data-link-title="DynamoDB" data-link-desc="AWS managed key-value、partition-based scaling、9000 萬 RPS sustained 實戰證據">DynamoDB&lt;/a> overview 的 implementation-layer deep article。寫作參照 &lt;a href="https://tarrragon.github.io/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">vendor deep article methodology&lt;/a>。&lt;/p>&lt;/blockquote>
&lt;p>quarterly review 看 DynamoDB bill 突然漲 80%、追查發現是 dev team 把所有 table 切 on-demand「省 capacity 管理」。finance 反問「於是省了多少 SRE 工時、又多花多少 cost」、team 答不出來。反向情境：Black Friday 前一週 provisioned table auto-scaling 上限是日常 5 倍、但開賣瞬間流量是 50 倍、auto-scaling 反應週期 5 分鐘、前 10 分鐘大量 throttle。兩個 production 痛點指向同一件事 — capacity mode 選擇不能只看「peak/avg ratio &amp;gt; 5x」單軸閾值。&lt;/p>
&lt;p>本文展開 6 軸決策（peak/avg / 讀寫比 trend / surge 性質 / 事件分級 / DBA 工時釋放 / vendor crossover），把單軸決策樹擴成完整判讀框架。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>DynamoDB 適用度前置判讀&lt;/strong>：本篇假設 workload 已通過 DynamoDB 適用度 4 軸（PK 天然均勻 / control plane vs data plane / consistency 可接受 eventual / access pattern 穩定）— 詳見 &lt;a href="../single-table-design-pattern/#dynamodb-%e9%81%a9%e7%94%a8%e5%ba%a6%e5%89%8d%e7%bd%ae%e5%88%a4%e8%ae%804-%e8%bb%b8">single-table-design-pattern 開頭 4 軸前置判讀&lt;/a>、本篇不重複展開。Capacity mode 選擇是 &lt;em>已選 DynamoDB 後&lt;/em> 的成本決策；若 workload 不適用 DynamoDB、mode 選擇無法救回 vendor 選錯的成本。&lt;/p>&lt;/blockquote>
&lt;h2 id="核心機制兩種-mode-的工程差異">核心機制：兩種 mode 的工程差異&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>屬性&lt;/th>
 &lt;th>Provisioned&lt;/th>
 &lt;th>On-demand&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>計費方式&lt;/td>
 &lt;td>預先買 RCU/WCU、按 hour 計&lt;/td>
 &lt;td>按 request 計、無 capacity 預設&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Auto-scaling&lt;/td>
 &lt;td>動態調整、target utilization 70%、min / max&lt;/td>
 &lt;td>自動 scale、仍受單 partition 1000 WCU / 3000 RCU 上限&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Throttle 表現&lt;/td>
 &lt;td>&lt;code>WriteThrottleEvents&lt;/code> 立即可見、exception 拋出&lt;/td>
 &lt;td>不顯示 throttle、表現為 latency spike（hot partition 隱藏）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Cost 模型&lt;/td>
 &lt;td>可預測、低基礎 rate&lt;/td>
 &lt;td>按用量、cost-per-request 約 provisioned base rate 的 6-7 倍&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Mode 切換限制&lt;/td>
 &lt;td>24 小時內只能切一次&lt;/td>
 &lt;td>同左&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Auto-scaling 內部機制&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>CloudWatch alarm 觸發 → scaling activity → 1-5 分鐘調整 capacity&lt;/li>
&lt;li>target utilization 70%（建議值、留 buffer 給 scale latency）&lt;/li>
&lt;li>連續 spike 仍可能 throttle（auto-scaling 反應週期 &amp;gt; spike 速度）&lt;/li>
&lt;/ul>
&lt;p>對應 knowledge card：&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/peak-forecast/" data-link-title="Peak Forecast" data-link-desc="說明預期峰值流量的預測方法 — 容量規劃的第一個輸入">peak forecast&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/cost-per-request/" data-link-title="Cost Per Request" data-link-desc="把雲端成本拆到單一 API 請求的 unit economics 模型">cost per request&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/scheduled-scaling/" data-link-title="Scheduled Scaling" data-link-desc="說明按已知時間表預先擴容的 autoscaler 模式">scheduled scaling&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/dynamodb/" data-link-title="DynamoDB" data-link-desc="AWS managed key-value、partition-based scaling、9000 萬 RPS sustained 實戰證據">DynamoDB</a> overview 的 implementation-layer deep article。寫作參照 <a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">vendor deep article methodology</a>。</p></blockquote>
<p>quarterly review 看 DynamoDB bill 突然漲 80%、追查發現是 dev team 把所有 table 切 on-demand「省 capacity 管理」。finance 反問「於是省了多少 SRE 工時、又多花多少 cost」、team 答不出來。反向情境：Black Friday 前一週 provisioned table auto-scaling 上限是日常 5 倍、但開賣瞬間流量是 50 倍、auto-scaling 反應週期 5 分鐘、前 10 分鐘大量 throttle。兩個 production 痛點指向同一件事 — capacity mode 選擇不能只看「peak/avg ratio &gt; 5x」單軸閾值。</p>
<p>本文展開 6 軸決策（peak/avg / 讀寫比 trend / surge 性質 / 事件分級 / DBA 工時釋放 / vendor crossover），把單軸決策樹擴成完整判讀框架。</p>
<blockquote>
<p><strong>DynamoDB 適用度前置判讀</strong>：本篇假設 workload 已通過 DynamoDB 適用度 4 軸（PK 天然均勻 / control plane vs data plane / consistency 可接受 eventual / access pattern 穩定）— 詳見 <a href="../single-table-design-pattern/#dynamodb-%e9%81%a9%e7%94%a8%e5%ba%a6%e5%89%8d%e7%bd%ae%e5%88%a4%e8%ae%804-%e8%bb%b8">single-table-design-pattern 開頭 4 軸前置判讀</a>、本篇不重複展開。Capacity mode 選擇是 <em>已選 DynamoDB 後</em> 的成本決策；若 workload 不適用 DynamoDB、mode 選擇無法救回 vendor 選錯的成本。</p></blockquote>
<h2 id="核心機制兩種-mode-的工程差異">核心機制：兩種 mode 的工程差異</h2>
<table>
  <thead>
      <tr>
          <th>屬性</th>
          <th>Provisioned</th>
          <th>On-demand</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>計費方式</td>
          <td>預先買 RCU/WCU、按 hour 計</td>
          <td>按 request 計、無 capacity 預設</td>
      </tr>
      <tr>
          <td>Auto-scaling</td>
          <td>動態調整、target utilization 70%、min / max</td>
          <td>自動 scale、仍受單 partition 1000 WCU / 3000 RCU 上限</td>
      </tr>
      <tr>
          <td>Throttle 表現</td>
          <td><code>WriteThrottleEvents</code> 立即可見、exception 拋出</td>
          <td>不顯示 throttle、表現為 latency spike（hot partition 隱藏）</td>
      </tr>
      <tr>
          <td>Cost 模型</td>
          <td>可預測、低基礎 rate</td>
          <td>按用量、cost-per-request 約 provisioned base rate 的 6-7 倍</td>
      </tr>
      <tr>
          <td>Mode 切換限制</td>
          <td>24 小時內只能切一次</td>
          <td>同左</td>
      </tr>
  </tbody>
</table>
<p><strong>Auto-scaling 內部機制</strong>：</p>
<ul>
<li>CloudWatch alarm 觸發 → scaling activity → 1-5 分鐘調整 capacity</li>
<li>target utilization 70%（建議值、留 buffer 給 scale latency）</li>
<li>連續 spike 仍可能 throttle（auto-scaling 反應週期 &gt; spike 速度）</li>
</ul>
<p>對應 knowledge card：<a href="/blog/backend/knowledge-cards/peak-forecast/" data-link-title="Peak Forecast" data-link-desc="說明預期峰值流量的預測方法 — 容量規劃的第一個輸入">peak forecast</a>、<a href="/blog/backend/knowledge-cards/cost-per-request/" data-link-title="Cost Per Request" data-link-desc="把雲端成本拆到單一 API 請求的 unit economics 模型">cost per request</a>、<a href="/blog/backend/knowledge-cards/scheduled-scaling/" data-link-title="Scheduled Scaling" data-link-desc="說明按已知時間表預先擴容的 autoscaler 模式">scheduled scaling</a>。</p>
<h2 id="6-軸決策框架">6 軸決策框架</h2>
<p>mode 選擇不是單軸 peak/avg ratio。下面 6 軸是 9 個 production case（Zomato / Zoom / Amazon Ads / Disney+ / Tixcraft / Capcom / Lemino / Genesys / PayPay）跨 case 揭露的真實決策維度。</p>
<h3 id="軸-1peak--average-流量-ratio">軸 1：peak / average 流量 ratio</h3>
<p>最直覺的軸、但是單軸誤判的根源。基本判讀：</p>
<ul>
<li>高 ratio（spiky / flash-sale）傾向 on-demand</li>
<li>穩定 ratio（sustained / 平緩）傾向 provisioned + auto-scaling</li>
</ul>
<blockquote>
<p><strong>Scope warning</strong>：「peak/avg &gt; 5x → on-demand」、「provisioned base rate × 6-7 = on-demand rate」這些具體閾值是經驗值 / 通用工程估算、<code>9.C5</code> / <code>9.C20</code> case 都沒給具體 ratio 數字。實際 crossover 點隨 region pricing + workload shape 變動、不要照搬本文數字。</p></blockquote>
<p>軸 1 單獨不夠用、要跟軸 2-6 合成判讀。</p>
<h3 id="軸-2讀寫比-trend-變化">軸 2：讀寫比 trend 變化</h3>
<p><code>9.C5 Amazon Ads</code> 揭露的觀測軸：「讀寫比 <em>變化</em> 比讀寫比本身更重要」。</p>
<ul>
<li>絕對讀寫比對容量規劃不是最重要（C5 是 18:1、C27 推估 5:1、絕對值各家不同）</li>
<li>業務邏輯改變（新增即時報表 / 新增推播 / 新增分析 query）會讓讀寫比跳一個量級</li>
<li>觀測上加 metric：read / write ratio 7-day rolling average、超過 ±30% 偏移觸發 review</li>
</ul>
<p>把 trend 變化當 capacity mode 重新評估的訊號 — 不是固定週期 review、是 <em>trend 偏移</em> 觸發 review。</p>
<h3 id="軸-3surge-是-暫時-還是-永久-baseline-上移">軸 3：surge 是 <em>暫時</em> 還是 <em>永久 baseline 上移</em></h3>
<p><code>9.C18 Zoom</code> COVID 30x DAU surge 揭露的軸：surge 後 baseline 永久上移、不會回去。</p>
<ul>
<li>暫時 surge（單日活動 / 季節高峰）：on-demand 划算、活動結束 mode 不用調</li>
<li>永久上移後（Zoom COVID、社會行為改變）：原 on-demand 設計會持續燒錢、要重新算 crossover、考慮切回 provisioned</li>
</ul>
<p><strong>Tripwire</strong>：surge 結束後 4-8 週仍維持 surge 期間 baseline 的 70%+、判定為「永久 baseline 上移」、重評 mode。</p>
<blockquote>
<p><strong>Scope warning</strong>：「4-8 週 / 70% 閾值」屬通用工程估算、9.C18 Zoom case 揭露「surge 後 baseline 不會回去」概念、未揭露具體閾值。</p></blockquote>
<h3 id="軸-4predictable-peak-vs-flash-sale">軸 4：predictable-peak vs flash-sale</h3>
<p><code>9.C27 Disney+</code> 跟 <code>9.C15 Tixcraft</code> 對比揭露的軸：兩種 event-driven peak 不是同一類。</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>predictable-peak（Disney+ 新片發布）</th>
          <th>flash-sale（拓元售票）</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>時間 lead</td>
          <td>已知日期、提前 1-2 天可預備</td>
          <td>已知時刻、提前 1-5 分鐘有效</td>
      </tr>
      <tr>
          <td>峰值倍數</td>
          <td>metadata 3-5x、持續數小時</td>
          <td>6750x in seconds、t=0 起跳 / t=300 結束</td>
      </tr>
      <tr>
          <td>Scale 方式</td>
          <td>scheduled scaling 預先升 baseline</td>
          <td>scheduled scaling 太慢、必須 pre-provision + composite PK</td>
      </tr>
      <tr>
          <td>Auto-scaling</td>
          <td>跟得上（事件持續時間長）</td>
          <td>完全跟不上（事件時間 &lt; scaling 反應週期）</td>
      </tr>
      <tr>
          <td>後續調回</td>
          <td>事件結束後 scheduled scaling 降回</td>
          <td>結束後立即降回、避免燒錢</td>
      </tr>
  </tbody>
</table>
<p><code>9.C27 Disney+</code>（Marvel / Star Wars 首日 metadata 流量 3-5 倍、持續時段較長）可以提前 1-2 天 pre-scale、scheduled scaling 合適。<code>9.C15 Tixcraft</code> 6750x in seconds，scheduled scaling 太慢、必須事前 pre-provision baseline 拉到極高、或用 on-demand + composite partition key 雙保險。</p>
<p>兩者都不是「peak/avg &gt; 5x → on-demand」單軸決策能解。</p>
<blockquote>
<p><strong>Scope warning</strong>：「scheduled scaling 30-60 分鐘前升 capacity」這個具體 lead time 是經驗值、case 未揭露具體時間。pre-scale 的 lead time 依事件性質決定、不是固定 30-60 分鐘。</p></blockquote>
<h3 id="軸-5dba--sre-工時釋放">軸 5：DBA / SRE 工時釋放</h3>
<p><code>9.C19 Capcom</code> 跟 <code>9.C29 Lemino</code> 揭露的成本軸：DynamoDB 真實成本不只看 monthly bill。</p>
<ul>
<li><code>9.C19 Capcom</code>：30% 成本下降的本質是「工程資源從 DB 運維轉到遊戲品質」、Capcom 是遊戲公司不是 IT 公司、把 DBA 時間從 Postgres patching / replication 設定 / backup 排程釋放到遊戲機制設計</li>
<li><code>9.C29 Lemino</code>：90% 工程工時下降（DBA + connection management + capacity planning 統包）</li>
</ul>
<p><strong>評估公式</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">總成本 = direct cost (monthly bill)
</span></span><span class="line"><span class="ln">2</span><span class="cl">       + 工程工時機會成本 (DBA 從 patch/replication/backup 釋放出來做的事)</span></span></code></pre></div><p>on-demand 的 6-7x base rate 在 DBA 工時釋放下、實質 ROI 可能仍正向（特別在小團隊 / 非 IT 主業公司）。但要算總成本、不是只看 bill。</p>
<h3 id="軸-6dynamodb-vs-自管-cluster-cost-crossover">軸 6：DynamoDB vs 自管 cluster cost crossover</h3>
<p><code>9.C20 Zomato</code> 警惕段揭露的最上層決策軸：mode 選擇之上還有 vendor 選擇。</p>
<ul>
<li><code>9.C20 Zomato</code>：「成本降 50% 是 <em>當下流量</em> 的對照」、未來流量繼續成長、DynamoDB cost-per-request 成長率比 TiDB 自管 cluster 高、某流量規模後 crossover、自管 cluster 反而便宜</li>
<li>不是只在 on-demand vs provisioned 之間挑、是要算「未來 12-24 個月在預期流量下、DynamoDB（不論 mode）vs 自管 cluster 的成本曲線」</li>
</ul>
<p>判讀分層：</p>
<ul>
<li><strong>小 / 中流量 startup</strong>：DynamoDB on-demand 簡單划算、不用糾結</li>
<li><strong>大流量 + 流量可預測 + DBA 團隊已存在</strong>：自管 cluster crossover 點可能成立、值得算</li>
<li><strong>大流量 + 流量不可預測 + 小團隊</strong>：DynamoDB managed 仍划算（軸 5 加成）</li>
</ul>
<p>本軸是 mode 選擇之上的更上層決策、不是每次都展開、但寫進邊界判讀條件。</p>
<h2 id="操作流程">操作流程</h2>
<p>從 workload profiling 到 mode 切換的 8 步流程。</p>
<h4 id="step-1workload-profiling">Step 1：workload profiling</h4>
<p>用 CloudWatch 過去 30 天 RCU/WCU、算 p50 / p95 / p99 peak、求 peak/avg ratio（軸 1 輸入）+ read/write ratio rolling avg（軸 2 輸入）。</p>
<h4 id="step-2surge-性質判讀">Step 2：surge 性質判讀</h4>
<ul>
<li>是暫時 surge 還是永久 baseline 上移（軸 3）— 看 surge 結束後 4-8 週的 baseline trend</li>
<li>是 predictable-peak 還是 flash-sale（軸 4）— 看事件時間跟 auto-scaling 反應週期的比例</li>
</ul>
<h4 id="step-36-軸合成決策">Step 3：6 軸合成決策</h4>





<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">軸 1（peak/avg）+ 軸 2（讀寫比 trend）+ 軸 3（surge 性質）
</span></span><span class="line"><span class="ln">2</span><span class="cl">+ 軸 4（事件分級）+ 軸 5（工時機會成本）+ 軸 6（vendor crossover）
</span></span><span class="line"><span class="ln">3</span><span class="cl">→ provisioned + auto-scaling / on-demand / scheduled scaling 三選一</span></span></code></pre></div><p>不是任一軸獨自決定、是 6 軸合成；軸間衝突時優先序：軸 6（vendor）&gt; 軸 5（工時）&gt; 軸 3（surge 永久 vs 暫時）&gt; 軸 4（事件分級）&gt; 軸 1（peak/avg）&gt; 軸 2（讀寫比 trend）。</p>
<h4 id="step-4provisioned-配-auto-scaling">Step 4：provisioned 配 auto-scaling</h4>





<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">BillingMode</span><span class="p">:</span><span class="w"> </span><span class="l">PROVISIONED</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">ProvisionedThroughput</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="nt">ReadCapacityUnits</span><span class="p">:</span><span class="w"> </span><span class="m">100</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">WriteCapacityUnits</span><span class="p">:</span><span class="w"> </span><span class="m">50</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w"></span><span class="nt">AutoScalingSettings</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="nt">TargetTrackingScalingPolicy</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="nt">TargetValue</span><span class="p">:</span><span class="w"> </span><span class="m">70.0</span><span class="w">  </span><span class="c"># target utilization</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">    </span><span class="nt">ScaleOutCooldown</span><span class="p">:</span><span class="w"> </span><span class="m">60</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">    </span><span class="nt">ScaleInCooldown</span><span class="p">:</span><span class="w"> </span><span class="m">60</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">  </span><span class="nt">MinCapacity</span><span class="p">:</span><span class="w"> </span><span class="m">50</span><span class="w">      </span><span class="c"># baseline</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">  </span><span class="nt">MaxCapacity</span><span class="p">:</span><span class="w"> </span><span class="m">1000</span><span class="w">    </span><span class="c"># baseline × 預期 surge multiplier</span></span></span></code></pre></div><p>target utilization 70% 留 buffer 給 scale latency；alarm 設 5 分鐘觀察窗。</p>
<h4 id="step-5scheduled-scaling">Step 5：scheduled scaling</h4>
<p>已知大事件（黑五、開票、新片發布）前預先提升 min capacity、事件後回原值：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 黑五前 24 小時把 min capacity 拉到日常 10 倍</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">client</span><span class="o">.</span><span class="n">put_scheduled_action</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="n">ResourceId</span><span class="o">=</span><span class="s2">&#34;table/orders&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="n">ScheduledActionName</span><span class="o">=</span><span class="s2">&#34;black-friday-pre-scale&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="n">Schedule</span><span class="o">=</span><span class="s2">&#34;cron(0 0 * * ? *)&#34;</span><span class="p">,</span>  <span class="c1"># 時間 lead 依事件性質決定、非固定 30-60 分鐘</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="n">ScalableTargetAction</span><span class="o">=</span><span class="p">{</span><span class="s2">&#34;MinCapacity&#34;</span><span class="p">:</span> <span class="mi">5000</span><span class="p">,</span> <span class="s2">&#34;MaxCapacity&#34;</span><span class="p">:</span> <span class="mi">50000</span><span class="p">}</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">)</span></span></span></code></pre></div><h4 id="step-6mode-switch">Step 6：mode switch</h4>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">aws dynamodb update-table <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --table-name orders <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --billing-mode-summary <span class="nv">BillingMode</span><span class="o">=</span>PAY_PER_REQUEST</span></span></code></pre></div><p>每張 table 24 小時內只能切一次、要計畫 maintenance window。</p>
<h4 id="step-7驗證點">Step 7：驗證點</h4>
<p>切換後第一週對比 cost + throttle metric、確認方向正確：</p>
<ul>
<li>cost 變化方向跟預期一致（on-demand 應該變貴 / provisioned 應該變便宜）</li>
<li>throttle rate 沒上升</li>
<li>latency p99 沒退化</li>
</ul>
<h4 id="step-8總成本評估軸-5--軸-6">Step 8：總成本評估（軸 5 + 軸 6）</h4>
<p>直接 cost + 工時機會成本 + 對照自管 cluster 的 cost crossover 曲線。Quarterly review 用這個公式、不是只看 monthly bill。</p>
<p><strong>Rollback boundary</strong>：on-demand → provisioned 隨時可切、但 baseline 要先 sized 好；切錯方向第一個月可逆、長期累積 cost 不可逆。</p>
<h2 id="失敗模式">失敗模式</h2>
<p>production 觀察到的 6 個典型 anti-pattern：</p>
<h4 id="case-1on-demand-後-cost-翻-3-倍">Case 1：on-demand 後 cost 翻 3 倍</h4>
<p>dev team 切 on-demand「不用管 capacity」、但 workload 是 sustained constant、on-demand 6-7x base rate 全付出來。<code>9.C5 Amazon Ads</code> 明示「sustained workload 用 provisioned + auto-scaling」。修法：穩定 workload 用 provisioned + auto-scaling（軸 1 + 軸 2）。</p>
<h4 id="case-2auto-scaling-跟不上-spike">Case 2：auto-scaling 跟不上 spike</h4>
<p>流量 1 分鐘內 10x、auto-scaling alarm 5 分鐘才觸發、前 4 分鐘全 throttle。修法：peak/avg 高且 spike 突然 → on-demand、或 scheduled scaling 預先升配（軸 1 + 軸 4）；flash-sale 場景 auto-scaling 不夠快、必須 pre-provision。</p>
<h4 id="case-3on-demand-hot-partition-隱藏">Case 3：on-demand hot partition 隱藏</h4>
<p>on-demand 不顯示 throttle、latency 從 5ms 變 50ms、application timeout retry 加劇問題。修法：on-demand 仍要看 partition-level metric（Contributor Insights）、不能假設 mode 解決設計問題（跟 <a href="/blog/backend/01-database/vendors/dynamodb/partition-key-antipatterns/" data-link-title="DynamoDB Partition Key 反模式與 Write Sharding：composite key 修復跟 mode × partition 交叉判讀" data-link-desc="DynamoDB partition 上限 1000 WCU 是 hot partition 的根因；composite key（event_id &#43; shard suffix）跟 calculated shard（hash % N）兩種修法、mode × partition 在 provisioned / on-demand 不同表現，以及 9.C15 Tixcraft 6750x 擴展的工程細節">partition-key-antipatterns</a> cross-link）；mode × partition 交叉判讀。</p>
<h4 id="case-4provisioned-target-utilization-設太高">Case 4：provisioned target utilization 設太高</h4>
<p>target = 90% 看似省、實際每次 spike 都先 throttle 再 scale。修法：70% buffer 給 scale latency、不要為了省 cost 把 utilization 推到極限。</p>
<h4 id="case-5頻繁切-mode-撞-24h-限制">Case 5：頻繁切 mode 撞 24h 限制</h4>
<p>team 想「白天 provisioned 晚上 on-demand」省 cost、但 mode 切換 24h 一次、計畫破產。修法：白天 provisioned + 晚上把 capacity 設低、不切 mode；用 scheduled scaling 處理日週期、不用 mode switch。</p>
<h4 id="case-6surge-後沒重評-mode長期燒錢軸-3-對應">Case 6：surge 後沒重評 mode、長期燒錢（軸 3 對應）</h4>
<p>Zoom 式 30x permanent baseline 上移後、原 on-demand 設計成本爆炸。修法：surge 結束 4-8 週後重評、若 baseline 維持 70%+ 改 provisioned；把「surge 後 mode review」寫進 runbook、不是 ad-hoc 才想到。</p>
<p><strong>Anti-recommendation</strong>：流量 &lt; 100 RPS、cost &lt; $50/月的小 table 不用糾結 mode、on-demand 簡單；workload 穩定且 cost 高才值得做 provisioned + auto-scaling 的工程投入。</p>
<h2 id="容量與觀測">容量與觀測</h2>
<p>CloudWatch metric：</p>
<ul>
<li><code>ConsumedReadCapacityUnits</code> / <code>ConsumedWriteCapacityUnits</code>：基本用量</li>
<li><code>ProvisionedReadCapacityUnits</code> / <code>ProvisionedWriteCapacityUnits</code>：provisioned 預設值</li>
<li><code>ThrottledRequests</code>：provisioned mode 直接訊號、on-demand 為零不代表沒問題</li>
<li><code>SuccessfulRequestLatency</code> p99：on-demand mode 下 hot partition 訊號</li>
</ul>
<p><strong>新增的觀測軸</strong>（軸 2 / 軸 3 對應）：</p>
<ul>
<li>read/write ratio 7-day rolling avg、超過 ±30% 偏移觸發 review</li>
<li>surge baseline 4-week rolling avg、判斷 surge 是暫時還是永久</li>
<li>AWS Cost Explorer 按 table + mode 切 cost trend、月對比</li>
</ul>
<p>Auto-scaling activity log：CloudWatch alarm history + scaling activity，觀察 scaling 是否頻繁但 utilization 仍低（表示 alarm 設太敏感）。</p>
<p><strong>指標口徑紀律</strong>：引用 case 數字時明示口徑 — <code>9.C5</code> 90M reads/sec 是「年度峰值最高一秒、非平均」、<code>9.C20</code> 90% latency 降可能只 p50 不是 p99/p999、<code>9.C18</code> 30x DAU 是「permanent baseline 上移」非單日 peak。讀 vendor case 數字要分「最大瞬時 / 99 百分位 / 常態 / 滾動」四個口徑、不是混用。</p>
<p>Cost gate：每月 finance review 把 DynamoDB cost 對齊 access pattern volume、不只看絕對數字；軸 5 工時釋放跟軸 6 vendor crossover 也納入。</p>
<p>接回 <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/01-database/kv-document-capacity-planning/" data-link-title="1.10 KV / Document DB 容量規劃" data-link-desc="DynamoDB / Cosmos DB / Bigtable / MongoDB 等 KV / Document DB 的容量設計、partition key 取捨、capacity mode 選擇">1.10 KV / Document DB 容量規劃</a>。</p>
<h2 id="邊界與整合">邊界與整合</h2>
<h3 id="frame-8-event-driven-scaling-5-種模式">Frame 8 event-driven scaling 5 種模式</h3>
<p><code>9.C5</code> / <code>9.C15</code> / <code>9.C18</code> / <code>9.C24</code> / <code>9.C27</code> 跨 case 揭露 event-driven scaling 至少 5 種形狀：</p>
<ul>
<li><strong>flash-sale spike</strong>：拓元 6750x in seconds（軸 4 走 pre-provision + composite PK）</li>
<li><strong>predictable peak</strong>：Disney+ 新片首發（軸 4 走 scheduled scaling）</li>
<li><strong>sustained growth</strong>：Amazon Ads / Capcom（軸 1 + 軸 5 → provisioned + auto-scaling）</li>
<li><strong>surge baseline permanent shift</strong>：Zoom 30x DAU 不會回去（軸 3 → 重評 mode）</li>
<li><strong>B2B sustained + 高可用</strong>：Genesys 99.999%（軸 5 + 軸 6 → managed 工時釋放比 cost 重要）</li>
</ul>
<p>不是用「peak/avg &gt; 5x」單一閾值決策、是事件型分類 × 軸合成。</p>
<h3 id="sibling-與-cross-link">Sibling 與 cross-link</h3>
<ul>
<li><a href="/blog/backend/01-database/vendors/dynamodb/partition-key-antipatterns/" data-link-title="DynamoDB Partition Key 反模式與 Write Sharding：composite key 修復跟 mode × partition 交叉判讀" data-link-desc="DynamoDB partition 上限 1000 WCU 是 hot partition 的根因；composite key（event_id &#43; shard suffix）跟 calculated shard（hash % N）兩種修法、mode × partition 在 provisioned / on-demand 不同表現，以及 9.C15 Tixcraft 6750x 擴展的工程細節">partition-key-antipatterns</a> — capacity mode 不解 hot partition、mode × partition 交叉判讀</li>
<li><a href="/blog/backend/01-database/vendors/dynamodb/single-table-design-pattern/" data-link-title="DynamoDB Single-Table Design：從適用度前置判讀到 access pattern 反推 PK/SK" data-link-desc="DynamoDB single-table 設計不是「資料表越少越好」，而是 access pattern 反推 PK/SK 跟 GSI；本文先做 DynamoDB 適用度 4 軸前置判讀（PK 天然均勻 / control plane vs data plane / consistency / access pattern 穩定），再展開設計流程、failure modes 與 durable queue 正向用例">single-table-design-pattern</a> — access pattern 影響 peak/avg ratio 跟 read/write ratio</li>
<li><a href="/blog/backend/01-database/vendors/dynamodb/gsi-lsi-design/" data-link-title="DynamoDB GSI 與 LSI 設計：access pattern 補位、projection、consistency 跟 DAX 補位" data-link-desc="GSI / LSI 是 single-table 沒覆蓋的 access pattern 補位、不是萬靈丹；本文涵蓋 projection 三型選擇、sparse index、GSI 自己會 hot partition、DAX 讀峰值補位的觸發條件（含 Capcom 是 derive vs Lemino 是 case fact 的分層）">gsi-lsi-design</a> — GSI 多時 cost 跟 mode 互動</li>
<li><a href="/blog/backend/01-database/vendors/dynamodb/global-tables-conflict/" data-link-title="DynamoDB Global Tables：multi-region active-active、LWW conflict 與 cross-device sync 正向用例" data-link-desc="Global Tables 不只是 conflict 痛點、也是 cross-device sync / global read / DR failover 的正向工程方案；本文展開 B2B SaaS vs B2C 業務 driver、LWW conflict resolution、reconciliation pipeline，含 Genesys 99.999% 跨 15 region 跟 Disney&#43; 跨裝置同步的對照">global-tables-conflict</a> — 多 region capacity 規劃放大、軸 5 工時釋放在 multi-region 更顯著</li>
<li>Migration playbook：跨 vendor cost optimization（如 <a href="/blog/backend/09-performance-capacity/cases/zomato-tidb-to-dynamodb-migration/" data-link-title="9.C20 Zomato：從 TiDB 遷移到 DynamoDB、吞吐 4 倍、延遲降 90%、成本減 50%" data-link-desc="Zomato 帳單系統從 TiDB 遷移到 DynamoDB、吞吐 2K→8K RPM、延遲降 90%、成本減 50%">Zomato TiDB → DynamoDB</a>）對應 type C operational hybrid</li>
<li>替代路由：cost 極度敏感 + 流量穩定 + DBA 團隊已存在 → 自管 PostgreSQL / MySQL 可能更便宜（軸 6 crossover）、回 <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 vendor</a></li>
<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 撐住會議後端">Zoom 9.C18</a> 互引：30x permanent surge 後的 mode 重評（軸 3 主案例）</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%">Capcom 9.C19</a> + <a href="/blog/backend/09-performance-capacity/cases/ntt-docomo-lemino-japanese-streaming/" data-link-title="9.C29 NTT DOCOMO Lemino：3 個月達 500 萬 MAU 的串流後端" data-link-desc="Lemino 用 DynamoDB &#43; AWS Media Services 撐 30 channels live &#43; 5M MAU、工程工時下降 90%">Lemino 9.C29</a> 互引：DBA 工時釋放（軸 5 主案例）</li>
<li>跟 <a href="/blog/backend/01-database/vendors/aurora/read-replica-scaling/" data-link-title="Aurora Read Replica Scaling：15 replica 上限、lag profile、headroom 預留與 fleet 治理" data-link-desc="Aurora 15 replica 上限、共享 storage 為什麼能養大量 replica、事件型容量分級表、DraftKings headroom 預留判讀、FanDuel 雙 SLO 並行、fleet 治理 3 條 driver（business sharding / microservice / 合規）">Aurora read-replica-scaling</a> 共軸 cross-link：本篇從 KV 層 mode 選擇切入、5 模式分類在本篇主寫；Aurora 從 SQL 讀副本視角切入、事件分級表（FanDuel 平日 / playoff / championship / Super Bowl）跟雙 SLO 並行（DraftKings 讀寫雙峰錯位）+ fleet 治理在 Aurora 端主寫、本篇不重複展開</li>
</ul>
]]></content:encoded></item><item><title>Migration Playbook：Cloud SQL for PostgreSQL → Cloud Spanner</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/spanner/migrate-from-cloud-sql-pg/</link><pubDate>Wed, 27 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/spanner/migrate-from-cloud-sql-pg/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/spanner/" data-link-title="Google Cloud Spanner" data-link-desc="全球分散式 strong-consistency OLTP、TrueTime API、線性擴展到 10 億 req/sec">Cloud Spanner&lt;/a> overview 的 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/migration/" data-link-title="Migration" data-link-desc="說明系統如何把資料、流量或結構從舊狀態移到新狀態">migration&lt;/a> playbook。走 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendor-article-spec/" data-link-title="資料庫 Vendor 文章撰寫規格" data-link-desc="把 PostgreSQL 與 MySQL batch 的正文經驗整理成資料庫 vendor overview、deep article 與 migration playbook 的撰寫規格">vendor-article-spec&lt;/a> Migration Playbook 規格 + &lt;a href="https://tarrragon.github.io/blog/posts/migration-playbook-%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84stage-0-variant-%E8%A6%8F%E5%8A%83%E6%8A%8A-collapse-%E7%8E%87%E5%BE%9E-60-%E9%99%8D%E5%88%B0-0/" data-link-title="Migration Playbook 方法論的演化紀錄：Stage 0 variant 規劃把 collapse 率從 60% 降到 0%" data-link-desc="跨 vendor migration playbook 需要獨立寫作方法論的依據，以及這套方法論從三輪 batch dogfood 中演化出來的驗證證據。">migration-playbook-methodology&lt;/a> Type E（paradigm shift）。每階段切換用 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/migration-gate/" data-link-title="Migration Gate" data-link-desc="說明遷移流程何時可以進入下一階段或正式切換">migration gate&lt;/a> 把關 — Evidence 段列的證據是 gate 通過條件、不是 nice-to-have。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="driver為什麼遷什麼條件不該遷">Driver：為什麼遷、什麼條件不該遷&lt;/h2>
&lt;h3 id="啟動壓力">啟動壓力&lt;/h3>
&lt;p>single-region Cloud SQL PostgreSQL primary 觸到容量上限（connection、write throughput、storage IOPS、region 故障風險）、產品要求跨 region active-active write、external consistency 是契約而非 nice-to-have。讀者要先確認自己面對的是「real 跨 region write residency」、不是「想用更強的技術」 — driver 段的核心責任是排除空泛動機。&lt;/p>
&lt;h3 id="主要-driver-候選">主要 driver 候選&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Global write residency&lt;/strong>：用戶分散全球、各地寫入本地 region、跨 region 一致性是產品要求&lt;/li>
&lt;li>&lt;strong>External consistency 對帳契約&lt;/strong>：跨 region 交易順序錯誤會導致對帳爆炸（金融、計費、ticketing）&lt;/li>
&lt;li>&lt;strong>單 primary 容量天花板&lt;/strong>：Cloud SQL 最大 instance 仍撐不住、應用層 sharding 是大工程&lt;/li>
&lt;li>&lt;strong>跨 region read latency&lt;/strong>：read 從各地直接打本地 replica、Cloud SQL read replica 受 single-primary 寫入 throughput 限制&lt;/li>
&lt;/ul>
&lt;h3 id="no-go-condition基礎">No-go condition（基礎）&lt;/h3>
&lt;p>流量集中單 region、跨 region 只是 DR 需求 → 維持 Cloud SQL + read replica + cross-region async DR 更便宜。這條 no-go 不複雜、但團隊常被 marketing 推著跳過 — 在自家 traffic dashboard 上 audit 一遍「write 來自哪些 region、各占比多少」、若 90%+ 來自單 region、Spanner 沒有 benefit。&lt;/p>
&lt;h3 id="no-go-conditionsizing-barrier">No-go condition（sizing barrier）&lt;/h3>
&lt;p>小 / 中型 PostgreSQL workload 的成本門檻 — Spanner 早期最小單位 100 processing units（≈ 1 node）對中小負載偏貴、過去是 sizing barrier；2021+ 推出 100 pu 起跳的 granular sizing 後雖然可從小開始、但 100 pu × per-pu monthly cost 加上跨 region replication 仍可能比 Cloud SQL HA 設定貴數倍。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/spanner/" data-link-title="Google Cloud Spanner" data-link-desc="全球分散式 strong-consistency OLTP、TrueTime API、線性擴展到 10 億 req/sec">Cloud Spanner</a> overview 的 <a href="/blog/backend/knowledge-cards/migration/" data-link-title="Migration" data-link-desc="說明系統如何把資料、流量或結構從舊狀態移到新狀態">migration</a> playbook。走 <a href="/blog/backend/01-database/vendor-article-spec/" data-link-title="資料庫 Vendor 文章撰寫規格" data-link-desc="把 PostgreSQL 與 MySQL batch 的正文經驗整理成資料庫 vendor overview、deep article 與 migration playbook 的撰寫規格">vendor-article-spec</a> Migration Playbook 規格 + <a href="/blog/posts/migration-playbook-%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84stage-0-variant-%E8%A6%8F%E5%8A%83%E6%8A%8A-collapse-%E7%8E%87%E5%BE%9E-60-%E9%99%8D%E5%88%B0-0/" data-link-title="Migration Playbook 方法論的演化紀錄：Stage 0 variant 規劃把 collapse 率從 60% 降到 0%" data-link-desc="跨 vendor migration playbook 需要獨立寫作方法論的依據，以及這套方法論從三輪 batch dogfood 中演化出來的驗證證據。">migration-playbook-methodology</a> Type E（paradigm shift）。每階段切換用 <a href="/blog/backend/knowledge-cards/migration-gate/" data-link-title="Migration Gate" data-link-desc="說明遷移流程何時可以進入下一階段或正式切換">migration gate</a> 把關 — Evidence 段列的證據是 gate 通過條件、不是 nice-to-have。</p></blockquote>
<hr>
<h2 id="driver為什麼遷什麼條件不該遷">Driver：為什麼遷、什麼條件不該遷</h2>
<h3 id="啟動壓力">啟動壓力</h3>
<p>single-region Cloud SQL PostgreSQL primary 觸到容量上限（connection、write throughput、storage IOPS、region 故障風險）、產品要求跨 region active-active write、external consistency 是契約而非 nice-to-have。讀者要先確認自己面對的是「real 跨 region write residency」、不是「想用更強的技術」 — driver 段的核心責任是排除空泛動機。</p>
<h3 id="主要-driver-候選">主要 driver 候選</h3>
<ul>
<li><strong>Global write residency</strong>：用戶分散全球、各地寫入本地 region、跨 region 一致性是產品要求</li>
<li><strong>External consistency 對帳契約</strong>：跨 region 交易順序錯誤會導致對帳爆炸（金融、計費、ticketing）</li>
<li><strong>單 primary 容量天花板</strong>：Cloud SQL 最大 instance 仍撐不住、應用層 sharding 是大工程</li>
<li><strong>跨 region read latency</strong>：read 從各地直接打本地 replica、Cloud SQL read replica 受 single-primary 寫入 throughput 限制</li>
</ul>
<h3 id="no-go-condition基礎">No-go condition（基礎）</h3>
<p>流量集中單 region、跨 region 只是 DR 需求 → 維持 Cloud SQL + read replica + cross-region async DR 更便宜。這條 no-go 不複雜、但團隊常被 marketing 推著跳過 — 在自家 traffic dashboard 上 audit 一遍「write 來自哪些 region、各占比多少」、若 90%+ 來自單 region、Spanner 沒有 benefit。</p>
<h3 id="no-go-conditionsizing-barrier">No-go condition（sizing barrier）</h3>
<p>小 / 中型 PostgreSQL workload 的成本門檻 — Spanner 早期最小單位 100 processing units（≈ 1 node）對中小負載偏貴、過去是 sizing barrier；2021+ 推出 100 pu 起跳的 granular sizing 後雖然可從小開始、但 100 pu × per-pu monthly cost 加上跨 region replication 仍可能比 Cloud SQL HA 設定貴數倍。</p>
<p><strong>來源 9.C10「判讀」段第 3 點</strong>：Spanner 早期 100 pu 起跳是 sizing barrier、後來推出 granular sizing 才讓中小負載可從小開始。<strong>Dogfood 邊界明示</strong>：9.C10 case 揭露的 sizing 結構是 Google 內部 dogfood 的 capacity 規劃語言、不是 customer-facing pricing 承諾；客戶實際成本要看當期 Spanner pricing + region + replication config。</p>
<p>觸發 sizing no-go 的條件：</p>
<table>
  <thead>
      <tr>
          <th>信號</th>
          <th>判讀</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>workload row count &lt; 數百萬</td>
          <td>100 pu 對這個資料量過 over-provision</td>
      </tr>
      <tr>
          <td>QPS &lt; 1000</td>
          <td>100 pu 容量遠超實際 traffic、cost / QPS ratio 高</td>
      </tr>
      <tr>
          <td>單 region 即可滿足合規</td>
          <td>跨 region replication cost 是純浪費</td>
      </tr>
      <tr>
          <td>Cloud SQL HA 設定已 cover SLA</td>
          <td>升 Spanner 沒 marginal benefit</td>
      </tr>
  </tbody>
</table>
<p>觸發任一條 → 強烈建議走 Cloud SQL HA、不升 Spanner。判讀時要把 Cloud SQL HA cost vs Spanner 100 pu cost 對比清楚、避免讀者「想用新技術」而升級。</p>
<h3 id="no-go-condition應用層延遲容忍">No-go condition（應用層延遲容忍）</h3>
<p>應用層延遲容忍 &lt; 50ms write 的 workload 不該升 Spanner — 跨 region Spanner write 在物理光速硬限下達 100-200ms（<a href="../consistency-models-comparison/">consistency-models-comparison</a> 的 cross-region quorum 段）。延遲敏感 workload 升級後會在 p99 直接撞牆、回退時資料已經寫進 Spanner、roll back 成本巨大。</p>
<p><strong>來源 9.C10「判讀」段第 2 點 + 「策略」段第 3 點</strong>：「external consistency 必須等多區 quorum、跨洲交易延遲可達 100-200ms」。<strong>Dogfood 邊界明示</strong>：9.C10 揭露的數量級是 Google internal observation、客戶實際 latency 隨 voting region 配置變化、引用時要附條件。</p>
<p>觸發 latency no-go 的場景：</p>
<ul>
<li>實時報價系統（毫秒級回應）</li>
<li>高頻交易（HFT）</li>
<li>遊戲 leaderboard 寫入</li>
<li>低延遲 OLTP（金融下單、支付路由）</li>
</ul>
<p>觸發任一條 → 強烈建議走 Cloud SQL 單 region、或考慮把 <em>跨 region 一致性需求</em> 重新審視（是否真的需要強一致、能不能改 event-driven async reconcile）。</p>
<h3 id="替代方案排除">替代方案排除</h3>
<ul>
<li><strong>Aurora DSQL</strong>：AWS 生態、若團隊在 GCP、跨雲不合</li>
<li><strong>CockroachDB</strong>：要自管或想 PostgreSQL wire 但不選 GCP 託管時可考慮、本 playbook 不對照</li>
<li><strong>Citus on Cloud SQL</strong>：multi-region write 不是強項、不解 cross-region external consistency 需求</li>
</ul>
<h3 id="case-anchor--dogfood-邊界">Case anchor + dogfood 邊界</h3>
<p><strong>無強 customer case</strong>。9.C10 是 Google 內部 dogfood、不是公開遷移 case；本 playbook 用 Spanner overview 的 PostgreSQL dialect 路徑 + 官方 migration guide + 通用 pattern。引用時必須明示「9.C10 揭露的線性 scaling / line-rate 設計目標是 Spanner 設計依據、不等於客戶遷移後可獲得的 capacity」。</p>
<p>對照 case：<a href="/blog/backend/09-performance-capacity/cases/standard-chartered-aurora-banking/" data-link-title="9.C14 Standard Chartered：受監管銀行的 Aurora 4000 TPS 容量提升" data-link-desc="Standard Chartered 銀行遷移到 Aurora 後吞吐量提升 10 倍至 4000 TPS、跨 7 個受監管市場">9.C14 Standard Chartered Aurora 受監管 banking</a> — 雖然是 Aurora、不是 Spanner、但揭露「受監管 OLTP 遷移要算合規 lead time」「資料駐留限制 = 容量規劃 per-市場」這兩條結論在 Spanner 遷移同樣適用。讀者若是受監管產業、跨 region instance config 還要疊上 voting region 是否落在合規市場的 audit。</p>
<h2 id="diff-audit6-規格面--sizing--cost-第-7-面">Diff Audit（6 規格面 + sizing / cost 第 7 面）</h2>
<h3 id="schema-diff">Schema diff</h3>
<p>PostgreSQL DDL → Spanner PostgreSQL dialect 對照：</p>
<table>
  <thead>
      <tr>
          <th>PostgreSQL 特性</th>
          <th>Spanner 對應</th>
          <th>動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>SERIAL</code></td>
          <td>bit-reversed sequence</td>
          <td>改 primary key 策略、避免 hot split</td>
      </tr>
      <tr>
          <td><code>JSONB</code></td>
          <td><code>JSON</code> type</td>
          <td>大部分相容、複雜 path query 重寫</td>
      </tr>
      <tr>
          <td><code>ARRAY</code></td>
          <td><code>ARRAY</code></td>
          <td>OK</td>
      </tr>
      <tr>
          <td><code>PARTITION BY</code></td>
          <td>不直接支援</td>
          <td>改成 interleaved table 或單表</td>
      </tr>
      <tr>
          <td><code>FOREIGN KEY</code></td>
          <td>保留 FK constraint + 考慮 <a href="/blog/backend/knowledge-cards/interleaved-table/" data-link-title="Interleaved Table" data-link-desc="Spanner 把 parent / child table row 物理交錯儲存、parent &#43; child JOIN 不跨 split">Interleaved Table</a></td>
          <td>parent-child access pattern 改 interleaved</td>
      </tr>
      <tr>
          <td><code>B-tree INDEX</code></td>
          <td>OK</td>
          <td>直接遷</td>
      </tr>
      <tr>
          <td><code>GIN / GiST INDEX</code></td>
          <td>不支援</td>
          <td>用 <code>STORING</code> column 取代部分需求、其餘改應用層</td>
      </tr>
      <tr>
          <td><code>CHECK constraint</code></td>
          <td>部分支援（time-sensitive、查最新文件）</td>
          <td>audit 每條 constraint</td>
      </tr>
      <tr>
          <td><code>UDF / stored procedure</code></td>
          <td>少數支援</td>
          <td>改應用層或 client-side compute</td>
      </tr>
      <tr>
          <td><code>TRIGGER</code></td>
          <td>不支援</td>
          <td>改 application 層或 Spanner change streams</td>
      </tr>
  </tbody>
</table>
<p>interleaved table 設計參考 <a href="../schema-migration-interleaved-tables/">schema-migration-interleaved-tables</a>。讀者要在 schema audit 階段就決定哪些 parent-child 該 interleave、避免後悔成本。</p>
<h3 id="operational-diff">Operational diff</h3>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>Cloud SQL</th>
          <th>Spanner</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>基礎架構</td>
          <td>VM-based</td>
          <td>API-based</td>
      </tr>
      <tr>
          <td>認證</td>
          <td>postgres user / role</td>
          <td>IAM role / service account</td>
      </tr>
      <tr>
          <td>備份</td>
          <td>pg_dump / pgBackRest</td>
          <td>point-in-time backup（PITR）</td>
      </tr>
      <tr>
          <td>監控</td>
          <td>postgres-flavor（pg_stat_*）</td>
          <td>Cloud Monitoring <code>spanner.*</code></td>
      </tr>
      <tr>
          <td>Connection pool</td>
          <td>PgBouncer</td>
          <td>SDK 內 gRPC pool</td>
      </tr>
      <tr>
          <td>Vacuum</td>
          <td>必要</td>
          <td>不存在（MVCC 機制不同）</td>
      </tr>
      <tr>
          <td>Replication lag</td>
          <td>需監控</td>
          <td>不存在 single-primary 概念</td>
      </tr>
  </tbody>
</table>
<p>不再需要的 Cloud SQL 責任：vacuum、autovacuum tuning、connection pool（PgBouncer）、replication lag 監控、Patroni HA。</p>
<p>新增 Spanner 責任：processing unit capacity 預測、TrueTime ε 觀測（<a href="../truetime-api-depth/">truetime-api-depth</a>）、long-running schema operation 跟蹤、IAM 細粒度權限。</p>
<h3 id="paradigm-diff">Paradigm diff</h3>
<p>從 single-primary OLTP → 跨 region distributed SQL：</p>
<ul>
<li>transaction commit latency：&lt; 5ms → 50-200ms（跨洲、含 <a href="/blog/backend/knowledge-cards/commit-wait/" data-link-title="Commit Wait" data-link-desc="Spanner external consistency 的核心機制 — read-write transaction 拿 commit timestamp s 後等到 TT.after(s) 才 ACK、wait ≈ 2ε、付 latency tax 換 commit 順序 = real-time 順序">Commit Wait</a> + cross-region quorum）</li>
<li>external consistency 是 default（不再是 isolation level 選擇題）</li>
<li>transaction 上限：Cloud SQL 無硬限 → Spanner 10s timeout、要重構成短交易</li>
<li>read consistency：default eventual → default strong、需顯式選 bounded staleness</li>
</ul>
<p>詳細 consistency model 差異看 <a href="../consistency-models-comparison/">consistency-models-comparison</a>。</p>
<h3 id="component-diff">Component diff</h3>
<p>退役：</p>
<ul>
<li>PgBouncer / pgcat（connection pool）</li>
<li>Cloud SQL HA / Patroni cluster</li>
<li>pgBackRest（備份外掛）</li>
<li>Citus extension（若有用）</li>
<li>各種 postgres extension（時間敏感、逐個 audit 是否 Spanner 支援等效）</li>
</ul>
<p>新增：</p>
<ul>
<li>Spanner client library（Go / Java / Node / Python）</li>
<li>Dataflow（用於 bulk export-import）</li>
<li>Datastream / Database Migration Service（用於 CDC catch-up）</li>
<li>Spanner Studio（query UI）</li>
</ul>
<h3 id="application-diff">Application diff</h3>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>Cloud SQL（PostgreSQL client）</th>
          <th>Spanner</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>ORM</td>
          <td>全 PG ORM 相容</td>
          <td>PostgreSQL dialect 相容部分 ORM、查最新 dialect 支援列表</td>
      </tr>
      <tr>
          <td>Connection model</td>
          <td>process-per-connection（postgres）</td>
          <td>stateless gRPC client（SDK 內 pool）</td>
      </tr>
      <tr>
          <td>Transaction model</td>
          <td>可長交易</td>
          <td>10s timeout、需短交易</td>
      </tr>
      <tr>
          <td>Timestamp 使用</td>
          <td>app 內 <code>now()</code> / <code>CURRENT_TIMESTAMP</code></td>
          <td>改用 <code>PENDING_COMMIT_TIMESTAMP</code> sentinel</td>
      </tr>
      <tr>
          <td>Cursor / prepared statement</td>
          <td>全支援</td>
          <td>部分支援、查 SDK 文件</td>
      </tr>
      <tr>
          <td>Stored procedure</td>
          <td>全支援</td>
          <td>少數支援、業務邏輯改應用層</td>
      </tr>
  </tbody>
</table>
<p>ORM 兼容性是 time-sensitive claim — JPA / Hibernate / SQLAlchemy 在 Spanner PostgreSQL dialect 上的行為隨 dialect 版本演進、實作前查最新 vendor docs。讀者要把 ORM 兼容測試放 Phase 0、不能假設「PostgreSQL ORM 直接搬到 Spanner」。</p>
<h3 id="data-topology-diff">Data topology diff</h3>
<ul>
<li>Single primary（write）+ read replica → multi-region voting + read-only replica</li>
<li>Primary key 設計：避免單調遞增（SERIAL）造成 hot split、改 UUID 或 bit-reversed</li>
<li>Partition：PostgreSQL declarative partition → Spanner 不需要顯式 partition（自動 split）</li>
</ul>
<h3 id="sizing--cost-diff第-7-規格面">Sizing / cost diff（第 7 規格面）</h3>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>Cloud SQL</th>
          <th>Spanner</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>計費單位</td>
          <td>instance class（vCPU / RAM）+ storage IOPS + HA add-on</td>
          <td>100 processing units 起跳 ≈ 1 node</td>
      </tr>
      <tr>
          <td>起跳成本</td>
          <td>小型 instance 月成本可控（小型 HA $50-200/月）</td>
          <td>100 pu × per-pu monthly rate、月成本是 Cloud SQL 小型 HA 的數倍</td>
      </tr>
      <tr>
          <td>Storage</td>
          <td>獨立計費（GB / month）</td>
          <td>含在 node count 內、無單獨 storage charge</td>
      </tr>
      <tr>
          <td>Throughput cap</td>
          <td>隨 instance class</td>
          <td>隨 pu 線性擴展</td>
      </tr>
      <tr>
          <td>跨 region replication</td>
          <td>額外 read replica cost</td>
          <td>含在 multi-region instance config 內</td>
      </tr>
      <tr>
          <td>Egress</td>
          <td>跨 region 額外</td>
          <td>跨 region 額外</td>
      </tr>
  </tbody>
</table>
<p>觸發 sizing audit 的時機：workload 行數、QPS、跨 region 需求都明確後、把「Cloud SQL HA monthly bill」對「Spanner 100 pu × monthly rate + egress」做 cost crossover 分析、無法 cost crossover 證明 → 不升。</p>
<p>Cost crossover 不是「Spanner 成本必須低於 Cloud SQL」、是「Spanner 多付的成本要對應到具體 benefit」：</p>
<ul>
<li>若 benefit 是 multi-region write residency、Spanner 多付的 cost 換得跨 region 一致性 — 對齊</li>
<li>若 benefit 只是「更新的技術」、Spanner 多付的 cost 沒對應產品價值 — 不升</li>
</ul>
<h3 id="type-判定">Type 判定</h3>
<p><strong>Type E（paradigm shift）</strong>、不是 drop-in。schema / app / operation / data topology / cost 五軸都動、不能用 Type B（drop-in）思路規劃 phase。詳細 type 判定方法看 <a href="/blog/posts/migration-playbook-%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84stage-0-variant-%E8%A6%8F%E5%8A%83%E6%8A%8A-collapse-%E7%8E%87%E5%BE%9E-60-%E9%99%8D%E5%88%B0-0/" data-link-title="Migration Playbook 方法論的演化紀錄：Stage 0 variant 規劃把 collapse 率從 60% 降到 0%" data-link-desc="跨 vendor migration playbook 需要獨立寫作方法論的依據，以及這套方法論從三輪 batch dogfood 中演化出來的驗證證據。">migration-playbook-methodology</a>。</p>
<h2 id="phase-plan9-段每段有驗證門檻">Phase Plan：9 段、每段有驗證門檻</h2>
<h3 id="phase-0--compatibility-audit--sizing-audit">Phase 0 — Compatibility audit + sizing audit</h3>
<p>跑 schema-converter（pgloader / Spanner migration tool）、列出 incompatible feature、決定哪些改 schema、哪些改 app。hot key 風險評估（SERIAL primary key、單調遞增 timestamp）。</p>
<p>同時跑 sizing audit：</p>
<ul>
<li>估 target Spanner pu 數（基於 QPS、storage size、cross-region replication factor）</li>
<li>做 Cloud SQL HA cost vs Spanner cost crossover 分析</li>
<li>若 cost crossover 證明不出來 → halt migration、回到 driver 段重審</li>
</ul>
<p>Phase 0 是 migration 的決策閘門 — 不過閘門就停、不浪費 Phase 1+ 的 engineering effort。</p>
<h3 id="phase-1--target-schema-design">Phase 1 — Target schema design</h3>
<ul>
<li>interleaved table 設計（base on Phase 0 access pattern audit）</li>
<li>Index 重寫（GIN / GiST 用 STORING column 替代、其他用 B-tree）</li>
<li>Primary key 反序（避免 hot split）</li>
<li>Storing column 選擇（trade-off：query latency vs index size）</li>
</ul>
<p>Output 是 target DDL、跟原 PostgreSQL schema 並排 diff 文件、給 application 團隊審。</p>
<h3 id="phase-2--application-dual-target-preparation">Phase 2 — Application dual-target preparation</h3>
<ul>
<li>抽象 DB layer（repository pattern、避免直接呼 SQL）</li>
<li>SDK 並存（go-pg + Spanner client）</li>
<li>Feature flag 控制讀寫路徑（read-from-pg / read-from-spanner / dual-write）</li>
<li>Transaction 模式 audit（長交易拆短）</li>
</ul>
<h3 id="phase-3--bulk-initial-load">Phase 3 — Bulk initial load</h3>
<p>Cloud SQL → Cloud Storage（CSV / Avro）→ Dataflow → Spanner。Row count + checksum 驗證、column-level diff sample。</p>
<h3 id="phase-4--cdc-catch-up">Phase 4 — CDC catch-up</h3>
<p>Datastream from Cloud SQL → Dataflow → Spanner。Replication lag &lt; 1s 為前進門檻、sustained 24h。</p>
<h3 id="phase-5--shadow-read">Phase 5 — Shadow read</h3>
<p>Production read 同時打 Cloud SQL 跟 Spanner、diff log 異常。至少 7 天觀察、divergence rate &lt; 0.1%、p99 latency Spanner &lt; 1.5x Cloud SQL。</p>
<h3 id="phase-6--dual-write">Phase 6 — Dual write</h3>
<p>Cloud SQL 為 source-of-truth、Spanner 為 mirror。偵測 dual write divergence、評估是否提早升 source-of-truth。</p>
<h3 id="phase-7--cutover">Phase 7 — Cutover</h3>
<p>read-only window（&lt; 5 min）→ 最後 catch-up → switch source-of-truth → cutover application write。</p>
<h3 id="phase-8--cleanup">Phase 8 — Cleanup</h3>
<p>退役 Cloud SQL primary、保留 backup、清 PgBouncer / Patroni / 監控 dashboard。</p>
<h3 id="stage-0-variant-規劃">Stage 0 variant 規劃</h3>
<p>若 read-only window 不可接受（24/7 不能停機的金融 / 醫療系統）、Phase 6 dual write 期間做 conflict resolution（last-writer-wins + manual reconcile）、進入 fail-forward 模式、不走 read-only cutover。</p>
<h2 id="evidence每階段驗證材料">Evidence：每階段驗證材料</h2>
<table>
  <thead>
      <tr>
          <th>Phase</th>
          <th>Evidence</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Phase 0</td>
          <td>incompatible feature 清單、預估改動 SP、hot key 風險 row count、<strong>sizing audit 報告</strong>（target pu 數估算 + Cloud SQL HA vs Spanner cost crossover 月 / 年成本對比）</td>
      </tr>
      <tr>
          <td>Phase 1</td>
          <td>DDL diff report、預估 backfill 時間（基於 row count + Spanner 文件）</td>
      </tr>
      <tr>
          <td>Phase 3</td>
          <td>row count 對齊、column-level checksum、payload sample diff</td>
      </tr>
      <tr>
          <td>Phase 4</td>
          <td>CDC lag &lt; 1s sustained 24h、error rate &lt; 0.01%</td>
      </tr>
      <tr>
          <td>Phase 5</td>
          <td>shadow read divergence rate &lt; 0.1%、p99 latency Spanner &lt; 1.5x Cloud SQL</td>
      </tr>
      <tr>
          <td>Phase 6</td>
          <td>dual write divergence &lt; 0.01%、reconcile queue 不積壓</td>
      </tr>
      <tr>
          <td>Phase 7</td>
          <td>cutover window 內 write 一致性、回到 Phase 6 的條件（rollback path）</td>
      </tr>
  </tbody>
</table>
<p><strong>Cost crossover 報告</strong>（Phase 0 必交付）：</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">Item                          | Cloud SQL HA | Spanner 100 pu | Delta
</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">Compute monthly               | $X           | $Y             | $Y-X
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">Storage monthly               | $A           | (included)     | -$A
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">Cross-region replication      | $B           | (included)     | -$B
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">Egress (est)                  | $C           | $C             | $0
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">Total monthly                 | $X+A+B+C     | $Y+C           | $Y-X-A-B
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">Annual                        | 12*above     | 12*above       | -
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">Benefit (qualitative)         | -            | multi-region write residency / external consistency | -
</span></span><span class="line"><span class="ln">10</span><span class="cl">Crossover verdict             | -            | proceed / halt | -</span></span></code></pre></div><p>Verdict = <code>proceed</code> 才進 Phase 1；<code>halt</code> → 回到 Driver 段重審 driver 是否成立。</p>
<p>所有 evidence 進 incident decision log、回 <a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">4.20 Observability Evidence Package</a>。</p>
<h2 id="cutover決策與-rollback">Cutover：決策與 rollback</h2>
<h3 id="cutover-window">Cutover window</h3>
<p>選用戶最低流量時段、&lt; 5 min read-only freeze、預先通知。受監管產業（對照 <a href="/blog/backend/09-performance-capacity/cases/standard-chartered-aurora-banking/" data-link-title="9.C14 Standard Chartered：受監管銀行的 Aurora 4000 TPS 容量提升" data-link-desc="Standard Chartered 銀行遷移到 Aurora 後吞吐量提升 10 倍至 4000 TPS、跨 7 個受監管市場">9.C14 Standard Chartered</a>）要算合規 lead time、每市場各自審。</p>
<h3 id="decision-owner">Decision owner</h3>
<p>DB lead + product lead + on-call SRE 共同 sign-off。受監管產業多加合規 owner。</p>
<h3 id="rollback-condition">Rollback condition</h3>
<ul>
<li>cutover 後 30 min 內 p99 write latency 持續 &gt; SLA 2x → rollback</li>
<li>error rate &gt; 1% sustained 5 min → rollback</li>
<li>對帳系統發現 divergence &gt; 0.1% → rollback</li>
</ul>
<h3 id="rollback-機制">Rollback 機制</h3>
<p>保留 Cloud SQL 為 read-only mirror 14 天、Spanner 改 read-only、reverse CDC（Spanner → Cloud SQL）需事先準備。Reverse CDC 在 Phase 4-6 期間就要 dry-run 過、不能 cutover 才第一次試。</p>
<p>連結 <a href="/blog/backend/knowledge-cards/rollback-window/" data-link-title="Rollback Window" data-link-desc="說明變更進入 production 後還能用哪種方式回退或改路線的時間與條件">rollback-window</a>、<a href="/blog/backend/knowledge-cards/rollback-condition/" data-link-title="Rollback Condition" data-link-desc="說明決策執行後出現哪些訊號時要撤回、回退或改路線">rollback-condition</a>。</p>
<h2 id="cleanup退役清單跟保留責任">Cleanup：退役清單跟保留責任</h2>
<h3 id="退役清單">退役清單</h3>
<ul>
<li>Cloud SQL primary instance</li>
<li>PgBouncer 配置</li>
<li>Patroni cluster</li>
<li>pgBackRest backup job（保留歸檔 90 天、依產業合規）</li>
<li>Datastream pipeline</li>
<li>Dataflow job</li>
</ul>
<h3 id="監控清理">監控清理</h3>
<p>postgres-specific dashboard（exporter / wal lag / autovacuum）改成 Spanner dashboard（commit_latencies / clock_skew_ms / cpu_utilization_by_priority）。</p>
<h3 id="文件--runbook-更新">文件 / runbook 更新</h3>
<p>postgres operation runbook 標記 deprecated、Spanner runbook 上線。新 runbook 含：</p>
<ul>
<li>DDL long-running operation 監控</li>
<li>TrueTime ε 異常處理</li>
<li>Cross-region instance failover drill</li>
<li>Cost monitoring alert</li>
</ul>
<h3 id="稽核--合規">稽核 / 合規</h3>
<p>保留 final pg_dump 7 年（依產業）、incident write-back 完成、合規市場各自留檔（對照 Standard Chartered case 的 per-市場合規 lead time）。</p>
<h2 id="邊界與整合sibling對照anti-recommendation">邊界與整合：sibling、對照、anti-recommendation</h2>
<h3 id="sibling-deep-articles">Sibling deep articles</h3>
<ul>
<li><a href="../truetime-api-depth/">truetime-api-depth</a>：app 對 timestamp 假設審計（Phase 2 必讀）</li>
<li><a href="../schema-migration-interleaved-tables/">schema-migration-interleaved-tables</a>：Phase 1 target schema 設計</li>
<li><a href="../consistency-models-comparison/">consistency-models-comparison</a>：Phase 0 應用層一致性要求釐清、Driver 段 latency no-go 的物理硬限</li>
</ul>
<h3 id="跟其他-migration-對照">跟其他 migration 對照</h3>
<ul>
<li><a href="/blog/backend/01-database/vendors/postgresql/migrate-to-aurora-dsql/" data-link-title="PostgreSQL → Aurora DSQL Migration：PG wire-compatible Distributed SQL 的 Paradigm Shift" data-link-desc="Aurora DSQL（2024-12 re:Invent preview / 2025-05 GA）是 AWS 推的 PG wire-compatible *active-active distributed SQL*、跟 self-managed PG / Aurora PG 不同 paradigm（OCC &#43; snapshot isolation &#43; multi-region strong consistency）。Migration 結構是 *protocol drop-in &#43; paradigm shift*：app SQL 不太改、但 transaction retry / extension 缺位 / 多 region 一致性需重設計。本文走 DSQL vs Aurora PG vs self-managed PG 三軸對比、為什麼遷的三條 driver（global write / operational zero-touch / region resiliency）、Type E phased plan、5 production 踩雷（transaction retry 沒處理 / extension 缺位 / sequence throughput 限制 / Aurora PG 直升 DSQL 不可行 / region failover semantic）、跟 PG → Aurora 跟 PG → CockroachDB 對比">PostgreSQL → Aurora DSQL Migration</a>：兩者都是 PostgreSQL → distributed SQL paradigm shift、選 GCP / AWS 看生態</li>
<li><a href="/blog/backend/01-database/large-scale-db-migration/" data-link-title="1.12 大規模 DB 遷移實戰" data-link-desc="跨 DB 遷移的 dual-write、[shadow read](/backend/knowledge-cards/shadow-read/)、cutover、rollback 流程 — 從實戰案例提煉的工程做法">1.12 大規模 DB 遷移實戰</a>：通用大規模遷移方法論</li>
</ul>
<h3 id="跟-case-對照">跟 case 對照</h3>
<ul>
<li><a href="/blog/backend/09-performance-capacity/cases/spanner-planetary-scale-database-gcp/" data-link-title="9.C10 Cloud Spanner：每秒 10 億請求的全球一致性資料庫" data-link-desc="Google Cloud Spanner 內部峰值 10 億 req/sec、跨地區強一致 — 全球分散式 OLTP 容量參考">9.C10 Cloud Spanner planetary scale</a>：dogfood case、揭露 Spanner 設計目標、不是 customer-facing capacity reference</li>
<li><a href="/blog/backend/09-performance-capacity/cases/standard-chartered-aurora-banking/" data-link-title="9.C14 Standard Chartered：受監管銀行的 Aurora 4000 TPS 容量提升" data-link-desc="Standard Chartered 銀行遷移到 Aurora 後吞吐量提升 10 倍至 4000 TPS、跨 7 個受監管市場">9.C14 Standard Chartered Aurora banking</a>：受監管產業遷移要算合規 lead time、per-市場容量規劃</li>
</ul>
<h3 id="anti-recommendation">Anti-recommendation</h3>
<p>讀者讀完本文應該能判斷：</p>
<ul>
<li>若 driver 只是「想用新技術」→ 回 Cloud SQL</li>
<li>若 workload 小（QPS &lt; 1000、行數 &lt; 數百萬）→ Cloud SQL HA 更划算</li>
<li>若應用層延遲容忍 &lt; 50ms write → Cloud SQL 單 region</li>
<li>若 cost crossover 證明不出來 → halt migration、不升</li>
</ul>
<p>Driver 是真正跨 region write residency / external consistency 對帳契約 / 單 primary 容量天花板 → 才升。Migration playbook 的目標不是把所有 Cloud SQL workload 升到 Spanner、是把「適合升」的部分用低風險路徑遷過去。</p>
]]></content:encoded></item><item><title>MongoDB Connection Management and Cache Layer：driver × 部署模型 × cache × predictive scaling</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/mongodb/connection-management-and-cache-layer/</link><pubDate>Wed, 27 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/mongodb/connection-management-and-cache-layer/</guid><description>&lt;p>MongoDB 大規模 OLTP 的真實架構不是「一個 driver pool 直連 cluster」、是 driver / proxy 層 + cache + freshness token 層 + scaling trigger 層三層協作。讀者最常的誤解是「Coinbase 用 MongoDB 撐 1.5M reads/sec」— 實際是這個合成架構撐出來的量級、單靠 MongoDB cluster 拿不到那個數字。本文把三層各自議題跟整合操作流程講清楚、並對 mongobetween 的部署模型適用範圍給出明確邊界。&lt;/p>
&lt;p>本文不重複 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/mongodb/" data-link-title="MongoDB" data-link-desc="Document database 代表、Atlas managed、跨雲可用、許多大規模平台從 MongoDB 起家">MongoDB vendor overview&lt;/a> 的 Atlas / 容量規劃簡介 — 而是 production 部署 + 跨層協作 + 失敗修復的實作層教學。&lt;/p>
&lt;h2 id="問題情境大規模-oltp-撞三道牆">問題情境：大規模 OLTP 撞三道牆&lt;/h2>
&lt;p>MongoDB 部署規模從中型撐到大規模時、會連環撞三道牆：&lt;/p>
&lt;p>&lt;strong>Connection ceiling&lt;/strong>：應用層 deploy 規模一上來、單一 MongoDB cluster 看到 connection storm。9.C36 Coinbase 揭露具體：Ruby + GVL + blue-green 部署把 instance 數 ×2、連線數隨之 ×2、單一 cluster 看到 60K connections / 分鐘（口徑：Coinbase 特定環境 CRuby + GVL 部署模型）。MongoDB cluster 的 connection limit 撞牆、新 deploy 連不上、線上服務 cascade 失敗。&lt;/p>
&lt;p>&lt;strong>Read scaling ceiling&lt;/strong>：讀者把所有 read 都打 secondary、replica 加到 5-7 仍撐不住 sustained 高 read（&amp;gt;500K reads/sec）。Replication lag 升 + secondary CPU 飽和；單靠 MongoDB cluster 內機制（replica scaling + read preference）拿不到大規模量級。&lt;/p>
&lt;p>&lt;strong>Scaling reaction lag&lt;/strong>：MongoDB cluster 擴容是天級議題、不是即時擴容。9.C36 Coinbase 揭露 reactive scaling 起點到完成 ~70 分鐘（口徑：Coinbase 特定環境、cluster tier / 資料量 / Atlas API 條件下、非 MongoDB 普遍承諾）。Surge 開始時才動來不及、預測性流量必須提前出手。&lt;/p>
&lt;p>Surge 形狀又不規則：加密貨幣 surge（隨外部市場波動）/ 媒體爆量（事件驅動）/ IoT 緊急通報（雙模式並存）— 都不適合單純 reactive auto-scaling 接住、必須 predictive + reactive 兩段式。&lt;/p>
&lt;p>讀者徵兆：&lt;/p>
&lt;ul>
&lt;li>MongoDB Atlas console 看到 connection count 在 deploy 後 spike 到上限&lt;/li>
&lt;li>p99 read latency 在事件時段集體爬&lt;/li>
&lt;li>Atlas auto-scaling event log 顯示 &lt;em>triggered too late&lt;/em>&lt;/li>
&lt;li>Cache hit rate 跟 read latency 反向相關&lt;/li>
&lt;/ul>
&lt;p>Case anchor：&lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/coinbase-mongodb-document-platform/" data-link-title="9.C36 Coinbase：MongoDB 撐 Ruby 單體 &amp;#43; 1.5M reads/sec identity 服務" data-link-desc="Coinbase 以 MongoDB 為主資料層、自建 mongobetween connection proxy、users 服務在加密貨幣 surge 時撐 1.5M reads/sec">9.C36 Coinbase&lt;/a> 是 rich case，含具體數字（deploy 尖峰 &lt;em>connection event rate&lt;/em> ~60K connections / 分鐘 / mongobetween 後 &lt;em>steady-state concurrent connections&lt;/em> 由 ~30K 降到 ~2K — 兩者口徑不同、不是同一數字的連續變化；1.5M reads/sec 含 cache / 70 → 25 分鐘擴容）；&lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/toyota-connected-mongodb-telematics-iot/" data-link-title="9.C38 Toyota Connected：MongoDB Atlas 撐 900 萬車輛 telematics、月 180 億 transaction" data-link-desc="Toyota Connected 用 MongoDB Atlas 撐 Safety Connect 900 萬車、月 180 億 transaction、緊急訊號 3 秒內到 agent">9.C38 Toyota Connected&lt;/a> 雙模式負載敘事（持續 sensor + 緊急事件）、&lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/forbes-mongodb-atlas-multi-cloud-migration/" data-link-title="9.C37 Forbes：自管 MongoDB → Atlas on GCP、build 時間 25 → 9 分鐘" data-link-desc="Forbes 把自管 MongoDB 遷到 Atlas on Google Cloud、6 個月完成、build 25 → 9 分鐘、120M 不重複訪客單月承接">9.C37 Forbes&lt;/a> 媒體爆量形狀。&lt;/p></description><content:encoded><![CDATA[<p>MongoDB 大規模 OLTP 的真實架構不是「一個 driver pool 直連 cluster」、是 driver / proxy 層 + cache + freshness token 層 + scaling trigger 層三層協作。讀者最常的誤解是「Coinbase 用 MongoDB 撐 1.5M reads/sec」— 實際是這個合成架構撐出來的量級、單靠 MongoDB cluster 拿不到那個數字。本文把三層各自議題跟整合操作流程講清楚、並對 mongobetween 的部署模型適用範圍給出明確邊界。</p>
<p>本文不重複 <a href="/blog/backend/01-database/vendors/mongodb/" data-link-title="MongoDB" data-link-desc="Document database 代表、Atlas managed、跨雲可用、許多大規模平台從 MongoDB 起家">MongoDB vendor overview</a> 的 Atlas / 容量規劃簡介 — 而是 production 部署 + 跨層協作 + 失敗修復的實作層教學。</p>
<h2 id="問題情境大規模-oltp-撞三道牆">問題情境：大規模 OLTP 撞三道牆</h2>
<p>MongoDB 部署規模從中型撐到大規模時、會連環撞三道牆：</p>
<p><strong>Connection ceiling</strong>：應用層 deploy 規模一上來、單一 MongoDB cluster 看到 connection storm。9.C36 Coinbase 揭露具體：Ruby + GVL + blue-green 部署把 instance 數 ×2、連線數隨之 ×2、單一 cluster 看到 60K connections / 分鐘（口徑：Coinbase 特定環境 CRuby + GVL 部署模型）。MongoDB cluster 的 connection limit 撞牆、新 deploy 連不上、線上服務 cascade 失敗。</p>
<p><strong>Read scaling ceiling</strong>：讀者把所有 read 都打 secondary、replica 加到 5-7 仍撐不住 sustained 高 read（&gt;500K reads/sec）。Replication lag 升 + secondary CPU 飽和；單靠 MongoDB cluster 內機制（replica scaling + read preference）拿不到大規模量級。</p>
<p><strong>Scaling reaction lag</strong>：MongoDB cluster 擴容是天級議題、不是即時擴容。9.C36 Coinbase 揭露 reactive scaling 起點到完成 ~70 分鐘（口徑：Coinbase 特定環境、cluster tier / 資料量 / Atlas API 條件下、非 MongoDB 普遍承諾）。Surge 開始時才動來不及、預測性流量必須提前出手。</p>
<p>Surge 形狀又不規則：加密貨幣 surge（隨外部市場波動）/ 媒體爆量（事件驅動）/ IoT 緊急通報（雙模式並存）— 都不適合單純 reactive auto-scaling 接住、必須 predictive + reactive 兩段式。</p>
<p>讀者徵兆：</p>
<ul>
<li>MongoDB Atlas console 看到 connection count 在 deploy 後 spike 到上限</li>
<li>p99 read latency 在事件時段集體爬</li>
<li>Atlas auto-scaling event log 顯示 <em>triggered too late</em></li>
<li>Cache hit rate 跟 read latency 反向相關</li>
</ul>
<p>Case anchor：<a href="/blog/backend/09-performance-capacity/cases/coinbase-mongodb-document-platform/" data-link-title="9.C36 Coinbase：MongoDB 撐 Ruby 單體 &#43; 1.5M reads/sec identity 服務" data-link-desc="Coinbase 以 MongoDB 為主資料層、自建 mongobetween connection proxy、users 服務在加密貨幣 surge 時撐 1.5M reads/sec">9.C36 Coinbase</a> 是 rich case，含具體數字（deploy 尖峰 <em>connection event rate</em> ~60K connections / 分鐘 / mongobetween 後 <em>steady-state concurrent connections</em> 由 ~30K 降到 ~2K — 兩者口徑不同、不是同一數字的連續變化；1.5M reads/sec 含 cache / 70 → 25 分鐘擴容）；<a href="/blog/backend/09-performance-capacity/cases/toyota-connected-mongodb-telematics-iot/" data-link-title="9.C38 Toyota Connected：MongoDB Atlas 撐 900 萬車輛 telematics、月 180 億 transaction" data-link-desc="Toyota Connected 用 MongoDB Atlas 撐 Safety Connect 900 萬車、月 180 億 transaction、緊急訊號 3 秒內到 agent">9.C38 Toyota Connected</a> 雙模式負載敘事（持續 sensor + 緊急事件）、<a href="/blog/backend/09-performance-capacity/cases/forbes-mongodb-atlas-multi-cloud-migration/" data-link-title="9.C37 Forbes：自管 MongoDB → Atlas on GCP、build 時間 25 → 9 分鐘" data-link-desc="Forbes 把自管 MongoDB 遷到 Atlas on Google Cloud、6 個月完成、build 25 → 9 分鐘、120M 不重複訪客單月承接">9.C37 Forbes</a> 媒體爆量形狀。</p>
<h2 id="核心機制三層合成-frame">核心機制：三層合成 frame</h2>
<p>跨案合成 frame（本章合成、case 原文沒這個 frame）：應用層連 MongoDB cluster 在大規模 production 是 <em>三層協作</em>、不是 driver 一個元件：</p>
<table>
  <thead>
      <tr>
          <th>層次</th>
          <th>角色</th>
          <th>9.C36 Coinbase 對應元件</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Driver / Proxy</td>
          <td>連線多工、應用 process 跟 cluster 的橋接</td>
          <td>MongoDB driver + mongobetween proxy</td>
      </tr>
      <tr>
          <td>Cache + freshness token</td>
          <td>read scaling 主路、跨層一致性協議</td>
          <td>Memcached + freshness token + OCC version</td>
      </tr>
      <tr>
          <td>Scaling trigger</td>
          <td>cluster 擴容啟動時機</td>
          <td>ML predictive scaling + reactive fallback</td>
      </tr>
  </tbody>
</table>
<p>三層缺一都會在大規模時撞牆。本文聚焦這三層如何協作、單一層的深度議題（read preference 機制、schema 治理、aggregation pipeline）推到 sibling。</p>
<h3 id="driver--proxy-層">Driver / Proxy 層</h3>
<p>MongoDB driver 原生 connection 模式：driver 在 application process 內維護 connection pool、每個 process 跟 MongoDB cluster 開固定數量 socket。但 driver <strong>沒跨 process pool</strong> — 多個 process 共用同一台機器、每個 process 自己一份 pool、cluster 看到的是 N 倍 connection。跟 PostgreSQL 走 pgbouncer 是同樣需求。</p>
<p>Connection storm 的具體 trigger：</p>
<ul>
<li><strong>部署模型放大 process 數</strong>：CRuby + GVL 強制每 CPU core 一 process、blue-green 部署 instance 數 ×2、連線數隨之 ×2（9.C36 Coinbase 揭露：單 cluster 看到 60K connections/min）</li>
<li><strong>微服務數量多</strong>：50+ microservice 各自連 cluster、每服務 connection 加總後撞上限（9.C37 Forbes 50+ 微服務情境對照）</li>
</ul>
<p>mongobetween proxy（Coinbase 自建）：把多 application process 的連線合成少量到 MongoDB cluster 的連線。9.C36 揭露兩個獨立口徑、不是同一數字的連續變化：deploy 尖峰時 <em>connection event rate</em> 是 ~60K connections / 分鐘（unique connection 事件量、rate）；mongobetween 介入後 <em>steady-state concurrent connection 數</em> 由 ~30K 降到 ~2K（瞬時量、前後對比、一個量級）。引用時把 rate 跟瞬時 concurrent count 分開、不要壓成「60K 收斂到 2K」。</p>
<p><strong>Scope warning（必明示）</strong>：mongobetween 是 Coinbase 為 Ruby + GVL 需求自建、case 自承「Go / Java / Node.js 應用因原生支援連線多工、通常不需要這層 proxy」。寫進設計文件時不可寫成「MongoDB 在大規模都需要 mongobetween」、要寫成「特定部署模型才需要」。</p>
<h3 id="cache--freshness-token-層">Cache + freshness token 層</h3>
<p>直接打 MongoDB 不可能撐 1.5M reads/sec（口徑：users 服務應用層觀察、含 cache、非 MongoDB cluster 純讀取）。Coinbase 在 users 服務前面加 Memcached query cache、單 document query 先查 cache。</p>
<p>跨層一致性問題：write 進 MongoDB primary、cache 還是舊版、user 下次 read 拿到舊資料。</p>
<p><a href="/blog/backend/knowledge-cards/freshness-token/" data-link-title="Freshness Token" data-link-desc="DB write 後返回的版本 token、後續 read 帶 token、保證 read 看到的資料 ≥ token 版本、解 DB &#43; cache 跨層 read-after-write">Freshness Token</a> 機制：</p>
<ol>
<li>Write 成功後給 client token（含 OCC version / clusterTime）</li>
<li>Client read 帶 token</li>
<li>Server 保證返回的資料版本 ≥ token</li>
<li>必要時 bypass cache 直接打 DB</li>
</ol>
<p>跟 DB 層 causal consistency session 對照：causal session 解 MongoDB 內 read-your-own-write、freshness token 解 <em>DB + cache 跨層</em> read-your-own-write。機制細節見 <a href="../replica-set-read-preference/">replica set read preference</a>、本文不重複展開。</p>
<p><strong>Scope warning（必明示）</strong>：1.5M reads/sec 是 <em>users 服務 + cache</em> 合成數字、不是 MongoDB cluster 純讀取 benchmark。寫進設計文件必須明示口徑、避免讀者把 1.5M reads/sec 當成「MongoDB 單獨能撐」。</p>
<h3 id="scaling-trigger-層">Scaling trigger 層</h3>
<p>MongoDB cluster 擴容時間：傳統 reactive scaling 起點到完成 ~70 分鐘（9.C36 Coinbase 揭露口徑：含 instance provisioning + 資料 sync + balancer rebalance、特定 Atlas tier / 資料量條件）。</p>
<p>Reactive 為主撐不住快變流量：CPU / queue 觸發 reactive scaling 在 surge 開始時才動、來不及；surge 已經結束擴容才到位。</p>
<p>Predictive scaling 機制（Coinbase 揭露）：</p>
<ul>
<li>用外部訊號（加密貨幣價格、賽事行程、票務開賣時間）訓練 ML 模型</li>
<li>提前 60 分鐘預測流量</li>
<li>預先擴容</li>
<li>把擴容啟動時間從 70 分鐘壓到 25 分鐘（口徑：trigger 提前、不是擴容本身變快）</li>
</ul>
<p><strong>Scope warning（必明示）</strong>：case 警示「ML 預測有 false positive / false negative、Coinbase 沒揭露準確率、所以仍保留 reactive scaling 作為 safety net」。寫進設計文件要明示兩段式設計、不可寫成「Predictive scaling 取代 reactive scaling」。</p>
<p>對應 knowledge card：<a href="/blog/backend/knowledge-cards/connection-pool/" data-link-title="Connection Pool" data-link-desc="說明連線池如何限制下游資源並影響服務容量">connection-pool</a>、<a href="/blog/backend/knowledge-cards/stale-read/" data-link-title="Stale Read" data-link-desc="讀取到落後於最新寫入版本的舊資料">stale-read</a>、<a href="/blog/backend/knowledge-cards/session-consistency/" data-link-title="Session Consistency" data-link-desc="同一使用者工作階段內維持讀寫一致、跨工作階段允許短暫不一致">session-consistency</a>、<a href="/blog/backend/knowledge-cards/hot-partition/" data-link-title="Hot Partition" data-link-desc="說明分散式 KV / OLTP 中、單一 partition 流量遠超其他的容量問題">hot-partition</a>（cache 失效時打穿 DB 的 hot key）。</p>
<h2 id="操作流程">操作流程</h2>
<p><strong>Step 1：connection ceiling audit</strong>。量測現有 deploy 在 peak 的 connection count、推算 deploy ×2 / 微服務新增時 connection 走勢；對照 MongoDB cluster 的 hard limit（Atlas tier 決定、典型 1500-32000）。</p>
<p><strong>Step 2：部署模型判讀</strong>。</p>
<table>
  <thead>
      <tr>
          <th>部署模型</th>
          <th>是否需 proxy 層</th>
          <th>原因</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>CRuby + GVL（process-per-core）</td>
          <td>需要</td>
          <td>每 core 一 process、連線隨 process 線性升</td>
      </tr>
      <tr>
          <td>大量微服務（50+）+ 各自 deploy</td>
          <td>需要</td>
          <td>微服務 connection 加總撞 cluster limit</td>
      </tr>
      <tr>
          <td>Blue-green 部署（雙環境並存）</td>
          <td>需要</td>
          <td>部署期間連線 ×2、容易撞 cluster ceiling</td>
      </tr>
      <tr>
          <td>Go / Java / Node.js 單一 binary + 多 thread</td>
          <td>通常不需要</td>
          <td>原生 driver pool 跨 thread 共用、收斂效率高</td>
      </tr>
  </tbody>
</table>
<p><strong>Step 3：proxy 選型</strong>。Coinbase mongobetween 是參考實作、社群還有 mongoproxy / DocumentDB 內建 connection multiplexer。自建 proxy 是 Coinbase 規模才合理、中型團隊先評估 Atlas tier 升級。</p>
<p><strong>Step 4：cache layer 設計</strong>（read scaling 主路）：</p>
<ul>
<li>前置 Memcached / Redis、cache key = collection + document id + version</li>
<li>Write API 返回 <code>{result, version_token}</code> — token 含 OCC version 或 MongoDB clusterTime</li>
<li>Read API 接受 optional version token、cache lookup 比對 entry version 跟 token、低於就 invalidate + bypass</li>
<li>DB 層 fallback <code>readConcern: &quot;majority&quot;</code> 保證返回 version ≥ token</li>
</ul>
<p><strong>Step 5：predictive scaling 設計</strong>（適用「外部訊號可預測流量」）：</p>
<ul>
<li><strong>識別 driver 訊號</strong>：加密貨幣價格 / 賽事行程 / 票務開賣 / 促銷活動 / IoT 緊急事件預警</li>
<li><strong>訓練 ML</strong>：用歷史流量 vs 訊號 correlation 訓練、輸出未來 30-60 分鐘流量預測</li>
<li><strong>觸發擴容</strong>：預測超 threshold 時主動 trigger Atlas scaling API、不等 reactive metric</li>
<li><strong>保留 reactive safety net</strong>：ML failure 時 reactive scaling 仍會接、不可拿掉</li>
</ul>
<p><strong>Step 6：全鏈路驗證</strong>。Staging 灌入 deploy ×2 模擬 connection storm、灌入 stale cache 驗證 freshness token bypass、放假流量驗證 predictive scaling trigger。</p>
<p>驗證點：</p>
<ul>
<li>Connection count 在 deploy 後不爆 cluster limit</li>
<li>Cache hit rate vs freshness bypass rate 比例正常（cache hit &gt; 90% + bypass &lt; 5% 屬通用工程估算、case 未揭露具體數字）</li>
<li>Predictive scaling 領先窗 ≥ 30 分鐘</li>
<li>Reactive scaling 仍保留作 safety</li>
</ul>
<p>Rollback boundary：</p>
<ul>
<li>Proxy 層可下線（流量改直連 cluster、但短時 connection storm 風險回來）</li>
<li>Cache 層可下線（read 全部打 DB、需 cluster 容量能撐）</li>
<li>Predictive scaling 可下線（退回純 reactive、但快變 surge 接不住）</li>
<li>三層都要設計 graceful degradation、不是全有全無</li>
</ul>
<h2 id="失敗模式">失敗模式</h2>
<p><strong>Connection storm during deploy</strong>：blue-green 部署 instance 數 ×2、connection 隨之爆、新 deploy 連不上 cluster、cascade 失敗。修法是 proxy 層 + cluster connection limit 預留 headroom（典型留 30% buffer、屬通用工程估算）。</p>
<p><strong>Proxy 變成單點瓶頸</strong>：mongobetween / pgbouncer 風格 proxy 自己變熱點、proxy 故障時下游全死。修法是 proxy 叢集 + health check + 客戶端 retry、跟 application 同 region 共部署降低 proxy ↔ application 的網路 RTT。</p>
<p><strong>Cache hit rate 崩塌</strong>：cache 失效 + 大量 read bypass、DB 突然吃 100% 流量、cluster 飽和。修法是 freshness token 設計時要監控 bypass rate、過高表示 cache invalidation 邏輯有問題、cache 沒在 write 後 update / invalidate。</p>
<p><strong>Freshness token 漏寫</strong>：write 沒帶 token / client 沒帶 token、token silently 失效、user 拿到舊資料。修法是 protocol 強制（middleware 攔截 write / read、自動帶 token）、不能靠 application 自覺。</p>
<p><strong>Predictive scaling false positive 浪費容量</strong>：ML 預測 surge 但實際沒來、cluster 預先擴容後閒置。接受成本、保留 ML model retraining、定期評估 precision / recall。</p>
<p><strong>Predictive scaling false negative 漏接 surge</strong>：ML 沒預測到、cluster 沒提前擴、surge 來時 reactive scaling 開始動但 70 分鐘來不及。修法是 reactive safety net + 服務降級（限流 / 部分 read 降級拿舊資料 + freshness token 告警）。</p>
<p><strong>三層協作脫節</strong>：proxy 擋住 connection storm 但 cluster 內部 read scaling 沒設計、application 仍打爆。三層必須一起設計、不是各自獨立。</p>
<p>Anti-recommendation：</p>
<ul>
<li>中小流量（&lt; 100K reads/sec、單 deploy &lt; 50 instance）不需要這三層；Atlas tier 升級 + cluster 內 replica + 簡單 cache 就夠</li>
<li>mongobetween 風格 proxy 只在 Ruby + GVL / 類似部署模型才必要、Go / Java / Node.js 通常不需要（case 自承）</li>
<li>Predictive scaling 只在外部訊號可預測時有效；無預測訊號的純隨機 surge 還是回 reactive + headroom</li>
<li>大規模 OLTP 不該為了省成本拿掉 cache 層；read scaling 主路就是 cache、單靠 MongoDB cluster 拿不到 1.5M reads/sec 量級</li>
</ul>
<h2 id="容量與觀測">容量與觀測</h2>
<p>關鍵 metric：</p>
<ul>
<li><strong>Connection 層</strong>：cluster connection count / Atlas tier limit / proxy 到 cluster 的 connection multiplex 比、deploy 前後 connection 走勢</li>
<li><strong>Cache 層</strong>：cache hit rate、freshness token bypass rate、cache key collision rate</li>
<li><strong>Scaling 層</strong>：predictive scaling trigger event count / 領先窗、reactive scaling fallback 觸發頻率、實際擴容啟動到完成時間、ML 預測準確率（precision / recall）</li>
</ul>
<p>Mongo / Atlas command：</p>
<ul>
<li><code>db.serverStatus().connections</code>：cluster 當前 connection 統計</li>
<li><code>db.currentOp({})</code>：看 connection 使用</li>
<li>Atlas API：cluster scaling event log</li>
<li>Proxy admin metric：connection multiplex 比、上下游 latency</li>
</ul>
<p>Application observability：APM 看 connection acquire latency、cache hit rate time series、freshness token 流動完整性（write 是否發 token、read 是否帶 token、cache 是否驗 token）。</p>
<p>回到 <a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">4.20 observability evidence</a>：把 connection storm event、cache hit rate / bypass rate、scaling trigger leadtime 列為跨層 evidence 三件套。</p>
<p>回到 <a href="/blog/backend/09-performance-capacity/bottleneck-localization/" data-link-title="9.5 瓶頸定位流程" data-link-desc="從 app 到 DB / cache / broker / 第三方 quota 的逐層瓶頸定位">9.5 bottleneck localization</a>：大規模 OLTP 撞牆時要區分 (a) connection ceiling (b) cache hit rate 下降 (c) cluster 內 replica 飽和 (d) scaling 跟不上。</p>
<h2 id="邊界與整合">邊界與整合</h2>
<p>Sibling deep articles：</p>
<ul>
<li><a href="../replica-set-read-preference/">replica set read preference</a> — DB 層 causal session 機制、freshness token 跨層協議；本文聚焦三層協作、那篇聚焦 DB 層機制</li>
<li><a href="../shard-key-selection/">shard key selection</a> — cluster 擴容是天級議題、是 scaling layer 的 trigger；單 cluster vs 多 cluster 切分</li>
<li><a href="../schema-design-pattern/">schema design pattern</a> — app-layer abstraction 跟本文 cache + freshness token 同層協作、contract layer 三選一</li>
<li><a href="../aggregation-pipeline-optimization/">aggregation pipeline optimization</a> — report dashboard 跑爆 primary 的補位路徑是本文的 cache + read scaling、不是讓 aggregation 自己優化</li>
</ul>
<p>Migration playbook：</p>
<ul>
<li><strong>Federated DB 模式</strong>（9.C36 Coinbase 揭露：MongoDB + DynamoDB）— 不是「全用 MongoDB」、document-shaped 用 MongoDB、access pattern 固定的 KV 用 DynamoDB；對應 <a href="/blog/backend/01-database/vendors/dynamodb/" data-link-title="DynamoDB" data-link-desc="AWS managed key-value、partition-based scaling、9000 萬 RPS sustained 實戰證據">DynamoDB vendor page</a> 跨 vendor 對照</li>
<li><strong>跨雲 hedging</strong>（9.C37 Forbes 跨雲彈性）— Atlas 跨 AWS / GCP / Azure 是規避未來雲商鎖定的 selection 訊號</li>
</ul>
<p>跟 1.x 互引：</p>
<ul>
<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 高併發資料存取</a> — connection storm 通用模式（pgbouncer / mongobetween 對應）</li>
<li><a href="/blog/backend/01-database/kv-document-capacity-planning/" data-link-title="1.10 KV / Document DB 容量規劃" data-link-desc="DynamoDB / Cosmos DB / Bigtable / MongoDB 等 KV / Document DB 的容量設計、partition key 取捨、capacity mode 選擇">1.10 KV / Document DB 容量規劃</a> — 三層架構列為大規模 OLTP 容量規劃必看點</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> — predictive scaling 的 ML 訓練紀律</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/mongodb/" data-link-title="MongoDB" data-link-desc="Document database 代表、Atlas managed、跨雲可用、許多大規模平台從 MongoDB 起家">MongoDB vendor overview</a> — 本文是該頁尾「connection management + Atlas scaling」backlog 的深度展開</li>
<li><a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章方法論</a></li>
<li><a href="/blog/backend/09-performance-capacity/cases/coinbase-mongodb-document-platform/" data-link-title="9.C36 Coinbase：MongoDB 撐 Ruby 單體 &#43; 1.5M reads/sec identity 服務" data-link-desc="Coinbase 以 MongoDB 為主資料層、自建 mongobetween connection proxy、users 服務在加密貨幣 surge 時撐 1.5M reads/sec">9.C36 Coinbase</a> — 三層合成 rich case</li>
<li><a href="/blog/backend/09-performance-capacity/cases/forbes-mongodb-atlas-multi-cloud-migration/" data-link-title="9.C37 Forbes：自管 MongoDB → Atlas on GCP、build 時間 25 → 9 分鐘" data-link-desc="Forbes 把自管 MongoDB 遷到 Atlas on Google Cloud、6 個月完成、build 25 → 9 分鐘、120M 不重複訪客單月承接">9.C37 Forbes</a> — 媒體爆量形狀</li>
<li><a href="/blog/backend/09-performance-capacity/cases/toyota-connected-mongodb-telematics-iot/" data-link-title="9.C38 Toyota Connected：MongoDB Atlas 撐 900 萬車輛 telematics、月 180 億 transaction" data-link-desc="Toyota Connected 用 MongoDB Atlas 撐 Safety Connect 900 萬車、月 180 億 transaction、緊急訊號 3 秒內到 agent">9.C38 Toyota Connected</a> — IoT 雙模式負載</li>
<li>官方：<a href="https://www.mongodb.com/docs/manual/reference/connection-string-options/">MongoDB Connection Pool Options</a>、<a href="https://www.mongodb.com/docs/atlas/cluster-autoscaling/">Atlas Auto-Scaling</a>、<a href="https://github.com/coinbase/mongobetween">mongobetween GitHub</a></li>
</ul>
]]></content:encoded></item><item><title>PostgreSQL declarative partitioning：partition 不是切表、是讓 planner pruning</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/declarative-partitioning/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/declarative-partitioning/</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 已說明大表（&amp;gt; 1TB）需要 partitioning、本文聚焦 &lt;em>partition 真實價值在哪、為什麼多數人第一次 partition 都做錯&lt;/em>。&lt;/p>&lt;/blockquote>
&lt;h2 id="partition-不是把大表切小是讓-planner-pruning--縮小-maintenance-scope">Partition 不是「把大表切小」、是「讓 planner pruning + 縮小 maintenance scope」&lt;/h2>
&lt;p>剛開始學 partitioning 的人多半從「表太大、切小一點」直覺出發；切了之後發現 — &lt;em>query 變慢&lt;/em>（planner 還在看所有 partition）、&lt;em>INSERT 變慢&lt;/em>（trigger / partition routing overhead）、&lt;em>backup 沒變短&lt;/em>（總資料量沒變）。直覺錯了：partition 的工程價值來自兩個機制、跟「切小」沒直接關係：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Query planner pruning&lt;/strong>：planner 在 planning 階段 &lt;em>跳過&lt;/em> 不可能命中 partition key 的 partition、查詢只 scan 相關 partition；前提是 &lt;em>WHERE 條件含 partition key&lt;/em>、否則 planner 看完所有 partition、效能反而比單表差&lt;/li>
&lt;li>&lt;strong>Maintenance scope 縮小&lt;/strong>：vacuum / index rebuild / DROP / archive 只動單一 partition、不掃整表；vacuum 12 小時變 30 分鐘 / DROP 老資料 0.01 秒、是 partition 真正回本的地方&lt;/li>
&lt;/ol>
&lt;p>partition 是 &lt;em>為了 maintenance 跟 planner pruning&lt;/em> 設計、不是「表變小」設計。漏掉這個 framing、partition 配置會錯。&lt;/p>
&lt;h2 id="range--list--hashpartition-策略對應業務形狀">RANGE / LIST / HASH：partition 策略對應業務形狀&lt;/h2>





&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="c1">-- RANGE: 時間序列、log、event（最常見）
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&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"> 3&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">bigint&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="n">event_time&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">timestamptz&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">payload&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">jsonb&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="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">PARTITION&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">RANGE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">event_time&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>&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="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_2026_05&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">PARTITION&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">OF&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">events&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="k">FOR&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">VALUES&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;2026-05-01&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TO&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;2026-06-01&amp;#39;&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>&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="c1">-- LIST: tenant ID / region / status enum
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&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">orders&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">13&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">bigint&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="n">tenant_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">int&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">15&lt;/span>&lt;span class="cl">&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">16&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">PARTITION&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">LIST&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">tenant_id&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>&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="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">orders_tenant_premium&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">PARTITION&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">OF&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">orders&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="k">FOR&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">VALUES&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">IN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">1001&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">1002&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">1003&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">20&lt;/span>&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- HASH: 均勻散落（無自然 partition key）
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&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">users&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">23&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">user_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">bigint&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">24&lt;/span>&lt;span class="cl">&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">25&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">PARTITION&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">HASH&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">user_id&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">26&lt;/span>&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">27&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&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">users_0&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">PARTITION&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">OF&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">users&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">28&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">FOR&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">VALUES&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WITH&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">MODULUS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">4&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">REMAINDER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>策略選擇關鍵：&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 已說明大表（&gt; 1TB）需要 partitioning、本文聚焦 <em>partition 真實價值在哪、為什麼多數人第一次 partition 都做錯</em>。</p></blockquote>
<h2 id="partition-不是把大表切小是讓-planner-pruning--縮小-maintenance-scope">Partition 不是「把大表切小」、是「讓 planner pruning + 縮小 maintenance scope」</h2>
<p>剛開始學 partitioning 的人多半從「表太大、切小一點」直覺出發；切了之後發現 — <em>query 變慢</em>（planner 還在看所有 partition）、<em>INSERT 變慢</em>（trigger / partition routing overhead）、<em>backup 沒變短</em>（總資料量沒變）。直覺錯了：partition 的工程價值來自兩個機制、跟「切小」沒直接關係：</p>
<ol>
<li><strong>Query planner pruning</strong>：planner 在 planning 階段 <em>跳過</em> 不可能命中 partition key 的 partition、查詢只 scan 相關 partition；前提是 <em>WHERE 條件含 partition key</em>、否則 planner 看完所有 partition、效能反而比單表差</li>
<li><strong>Maintenance scope 縮小</strong>：vacuum / index rebuild / DROP / archive 只動單一 partition、不掃整表；vacuum 12 小時變 30 分鐘 / DROP 老資料 0.01 秒、是 partition 真正回本的地方</li>
</ol>
<p>partition 是 <em>為了 maintenance 跟 planner pruning</em> 設計、不是「表變小」設計。漏掉這個 framing、partition 配置會錯。</p>
<h2 id="range--list--hashpartition-策略對應業務形狀">RANGE / LIST / HASH：partition 策略對應業務形狀</h2>





<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">-- RANGE: 時間序列、log、event（最常見）
</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">events</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">id</span><span class="w"> </span><span class="nb">bigint</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="n">event_time</span><span class="w"> </span><span class="n">timestamptz</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">payload</span><span class="w"> </span><span class="n">jsonb</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 class="n">PARTITION</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">RANGE</span><span class="w"> </span><span class="p">(</span><span class="n">event_time</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></span><span class="line"><span class="ln"> 8</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">events_2026_05</span><span class="w"> </span><span class="n">PARTITION</span><span class="w"> </span><span class="k">OF</span><span class="w"> </span><span class="n">events</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">FOR</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="p">(</span><span class="s1">&#39;2026-05-01&#39;</span><span class="p">)</span><span class="w"> </span><span class="k">TO</span><span class="w"> </span><span class="p">(</span><span class="s1">&#39;2026-06-01&#39;</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></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w"></span><span class="c1">-- LIST: tenant ID / region / status enum
</span></span></span><span class="line"><span class="ln">12</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">orders</span><span class="w"> </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">id</span><span class="w"> </span><span class="nb">bigint</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="n">tenant_id</span><span class="w"> </span><span class="nb">int</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">15</span><span class="cl"><span class="w">  </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="p">)</span><span class="w"> </span><span class="n">PARTITION</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">LIST</span><span class="w"> </span><span class="p">(</span><span class="n">tenant_id</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></span><span class="line"><span class="ln">18</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">orders_tenant_premium</span><span class="w"> </span><span class="n">PARTITION</span><span class="w"> </span><span class="k">OF</span><span class="w"> </span><span class="n">orders</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">FOR</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="k">IN</span><span class="w"> </span><span class="p">(</span><span class="mi">1001</span><span class="p">,</span><span class="w"> </span><span class="mi">1002</span><span class="p">,</span><span class="w"> </span><span class="mi">1003</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">-- HASH: 均勻散落（無自然 partition key）
</span></span></span><span class="line"><span class="ln">22</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">users</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="n">user_id</span><span class="w"> </span><span class="nb">bigint</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">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="p">)</span><span class="w"> </span><span class="n">PARTITION</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">HASH</span><span class="w"> </span><span class="p">(</span><span class="n">user_id</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">26</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">27</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">users_0</span><span class="w"> </span><span class="n">PARTITION</span><span class="w"> </span><span class="k">OF</span><span class="w"> </span><span class="n">users</span><span class="w">
</span></span></span><span class="line"><span class="ln">28</span><span class="cl"><span class="w">  </span><span class="k">FOR</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="k">WITH</span><span class="w"> </span><span class="p">(</span><span class="n">MODULUS</span><span class="w"> </span><span class="mi">4</span><span class="p">,</span><span class="w"> </span><span class="n">REMAINDER</span><span class="w"> </span><span class="mi">0</span><span class="p">);</span></span></span></code></pre></div><p>策略選擇關鍵：</p>
<ul>
<li><strong>RANGE</strong> 適合 <em>時間 / 有序值</em> — query 多半帶 <code>WHERE event_time &gt;= X</code>、prune 效率最高；archive / drop 老資料是 <code>DROP PARTITION</code> 0.01 秒</li>
<li><strong>LIST</strong> 適合 <em>離散 enum / tenant</em> — query 帶 <code>WHERE tenant_id = X</code> prune；缺點是 tenant 增長要手動 ALTER ADD PARTITION</li>
<li><strong>HASH</strong> 適合 <em>均勻分散、沒自然 key</em> — query 多半 by-PK lookup、HASH 讓單 partition 大小均勻；prune 只在 <code>WHERE hash_key = X</code> 等值查詢觸發</li>
</ul>
<h3 id="選錯-partition-key-是最常見的錯誤">選錯 partition key 是最常見的錯誤</h3>
<p>例：events 表用 <code>user_id</code> HASH partition、但 query 多半 <code>WHERE event_time BETWEEN ...</code>、<code>user_id</code> 不在 WHERE — planner 沒法 prune、掃所有 partition、效能比單表更差（多了 partition routing overhead）。</p>
<p>partition key <em>必須</em> 對應 query 最常用的 WHERE filter；錯了就退化成 <em>維護面有好處、查詢面有壞處</em> 的尷尬狀態。</p>
<h2 id="partition-pruningplanner-怎麼決定跳過">Partition pruning：planner 怎麼決定跳過</h2>





<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">EXPLAIN</span><span class="w"> </span><span class="p">(</span><span class="k">ANALYZE</span><span class="p">,</span><span class="w"> </span><span class="n">BUFFERS</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="k">SELECT</span><span class="w"> </span><span class="o">*</span><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">3</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="n">event_time</span><span class="w"> </span><span class="o">&gt;=</span><span class="w"> </span><span class="s1">&#39;2026-05-01&#39;</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="n">event_time</span><span class="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="s1">&#39;2026-05-15&#39;</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></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="c1">-- 期望輸出包含：
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1">--  Append (cost=...)
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1">--    -&gt; Seq Scan on events_2026_05  (cost=...)
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1">-- (只 scan 一個 partition、其他 partition pruned)</span></span></span></code></pre></div><p>pruning 觸發條件：</p>
<ol>
<li>WHERE 含 partition key 的 <em>constant expression</em>（<code>WHERE x = 5</code> 觸發；<code>WHERE x = some_function()</code> 不觸發 planning-time prune、但 PG 11+ execution-time prune 可救）</li>
<li>PG 11+ 支援 <em>execution-time pruning</em> — query plan 內含 partition key、runtime 才知道值（prepared statement / NestedLoop join）</li>
<li>partition key 不在 WHERE 時 — <em>全部 partition 掃</em>、是反指標、表示 partition strategy 不對</li>
</ol>
<h3 id="partition-wise-join--aggregate-pg-11">Partition-wise join / aggregate (PG 11+)</h3>





<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">SET</span><span class="w"> </span><span class="n">enable_partitionwise_join</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">on</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="k">SET</span><span class="w"> </span><span class="n">enable_partitionwise_aggregate</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">on</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></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="c1">-- 兩個同 partition 策略的表 JOIN 時、planner 可 partition-wise 平行做
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</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="n">e</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="n">events_metadata</span><span class="w"> </span><span class="n">m</span><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w">  </span><span class="k">ON</span><span class="w"> </span><span class="n">e</span><span class="p">.</span><span class="n">event_time</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">m</span><span class="p">.</span><span class="n">event_time</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">WHERE</span><span class="w"> </span><span class="n">e</span><span class="p">.</span><span class="n">event_time</span><span class="w"> </span><span class="o">&gt;=</span><span class="w"> </span><span class="s1">&#39;2026-05-01&#39;</span><span class="p">;</span></span></span></code></pre></div><p>需要兩個表 <em>partition strategy 完全一致</em>（同 partition key + 同 partition boundary）— 設計時對齊、後期不容易調整。</p>
<h2 id="production-故障演練">Production 故障演練</h2>
<h3 id="case-1partition-key-選錯query-變慢">Case 1：partition key 選錯，query 變慢</h3>
<p><strong>徵兆</strong>：partition 後特定查詢從 200ms 變成 2000ms；EXPLAIN 顯示 <code>Append</code> 下面所有 partition 都被 scan、沒 partition 被 prune。</p>
<p><strong>根因</strong>：partition by <code>user_id</code> HASH、但 query 多用 <code>WHERE created_at BETWEEN X AND Y</code>；planner 不知道 user 在哪個 partition、必須掃全部。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>驗證 step</strong>：partition 前先 <code>pg_stat_statements</code> 看 top 10 query 的 WHERE pattern、partition key 必須對應其中 80% 流量的 filter</li>
<li><strong>修正</strong>：DROP partition strategy、改 partition by <code>created_at</code> RANGE；遷移用 <code>pg_dump --section=data</code> per-partition 重灌</li>
<li><strong>避免</strong>：partitioning 不可逆、設計階段 query pattern 沒看清楚不要動</li>
</ol>
<h3 id="case-2cross-partition-unique-constraint-不-enforce">Case 2：cross-partition unique constraint 不 enforce</h3>
<p><strong>徵兆</strong>：partition 後發現 application code 寫死 duplicate user_email、但 unique constraint 沒擋；DB 內有同 email 多筆。</p>
<p><strong>根因</strong>：PostgreSQL partition table 的 <code>UNIQUE</code> constraint <em>必須包含 partition key</em> — <code>UNIQUE (email)</code> 在 partition by <code>tenant_id</code> 的表上 <em>無法 enforce</em>（PostgreSQL 拒建）；workaround 用 <code>UNIQUE (email, tenant_id)</code>、但業務語意是「email 全域唯一」、PG 無法保證。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>架構</strong>：跨 partition 唯一性必須在 <em>application 層</em> enforce（lock + check 模式）</li>
<li><strong>替代</strong>：用 <em>non-partitioned</em> 表存唯一性目標（user_email_registry）、做寫入前 lookup</li>
<li><strong>設計階段檢查</strong>：partition by X、unique constraint 必須含 X；若業務要求 unique 不含 X、partition strategy 錯</li>
</ol>
<h3 id="case-3attach-partition-鎖表太久">Case 3：ATTACH PARTITION 鎖表太久</h3>
<p><strong>徵兆</strong>：新 month partition <code>ATTACH PARTITION</code> 跑 30 秒、期間整個 events 表 read 阻塞、application timeout 大量。</p>
<p><strong>根因</strong>：<code>ATTACH PARTITION</code> 預設加 <code>ACCESS EXCLUSIVE</code> lock 在 parent table、scan 整個新 partition 驗證 CHECK constraint；大 partition + 沒 CHECK constraint 預先驗證 → 鎖時間爆。</p>
<p><strong>修法</strong>：</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">-- 1. 先把要 attach 的 partition 加 CHECK constraint，用 NOT VALID 不掃描
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">events_2026_06</span><span class="w"> </span><span class="k">ADD</span><span class="w"> </span><span class="k">CONSTRAINT</span><span class="w"> </span><span class="n">events_2026_06_range</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">  </span><span class="k">CHECK</span><span class="w"> </span><span class="p">(</span><span class="n">event_time</span><span class="w"> </span><span class="o">&gt;=</span><span class="w"> </span><span class="s1">&#39;2026-06-01&#39;</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="n">event_time</span><span class="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="s1">&#39;2026-07-01&#39;</span><span class="p">)</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">VALID</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></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w"></span><span class="c1">-- 2. VALIDATE 用 SHARE UPDATE EXCLUSIVE lock、允許讀寫
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"></span><span class="k">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">events_2026_06</span><span class="w"> </span><span class="n">VALIDATE</span><span class="w"> </span><span class="k">CONSTRAINT</span><span class="w"> </span><span class="n">events_2026_06_range</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></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w"></span><span class="c1">-- 3. ATTACH 不再需要 scan（CHECK 已 VALIDATE 過）
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span><span class="k">ALTER</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="n">ATTACH</span><span class="w"> </span><span class="n">PARTITION</span><span class="w"> </span><span class="n">events_2026_06</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">FOR</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="p">(</span><span class="s1">&#39;2026-06-01&#39;</span><span class="p">)</span><span class="w"> </span><span class="k">TO</span><span class="w"> </span><span class="p">(</span><span class="s1">&#39;2026-07-01&#39;</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="c1">-- ATTACH 變 instant</span></span></span></code></pre></div><h3 id="case-4partition-數爆炸planner-planning-time-爆">Case 4：partition 數爆炸，planner planning time 爆</h3>
<p><strong>徵兆</strong>：partition 累積到 500+（daily partition 跑 1-2 年）、簡單 query EXPLAIN 顯示 planning_time 從 1ms 漲到 200ms、application response 變慢。</p>
<p><strong>根因</strong>：partition 越多 planner 要評估的 partition 越多、即使有 pruning、planning 階段也要 walk 全部 partition table；500+ partition 是 planning overhead 明顯的閾值。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>架構</strong>：partition granularity 對應 retention — 不要 daily partition 留 2 年（→ weekly / monthly）</li>
<li><strong>archive 老 partition</strong>：DETACH 老 partition、轉成 cold storage 表、planner 不再看</li>
<li><strong><code>enable_partition_pruning</code></strong> 預設 on、確保啟用</li>
<li><strong>PG 12+</strong>：planner 對 partition table 的 list 處理優化、planning time 上限拉高、但仍要控</li>
</ol>
<h3 id="case-5detach-後磁碟空間沒回收">Case 5：DETACH 後磁碟空間沒回收</h3>
<p><strong>徵兆</strong>：DETACH PARTITION 後 <code>pg_database_size</code> 沒下降、預期釋放 50GB；磁碟仍滿。</p>
<p><strong>根因</strong>：DETACH 只是把 partition 從 parent table <em>分離</em>、partition 自己仍是獨立表存在；要真釋放需要 <code>DROP TABLE detached_partition</code>。SRE 以為 DETACH = 刪掉。</p>
<p><strong>修法</strong>：</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">ALTER</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="n">DETACH</span><span class="w"> </span><span class="n">PARTITION</span><span class="w"> </span><span class="n">events_2024_01</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="c1">-- events_2024_01 仍存在、佔磁碟
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="c1">-- 確認沒 query 在用後
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span><span class="k">DROP</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">events_2024_01</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="c1">-- 才釋放磁碟</span></span></span></code></pre></div><h3 id="routinearchive-workflow">Routine：archive workflow</h3>





<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">-- 1. detach 13 個月前的 partition
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span><span class="k">ALTER</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="n">DETACH</span><span class="w"> </span><span class="n">PARTITION</span><span class="w"> </span><span class="n">events_2025_04</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></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="c1">-- 2. dump 到 cold storage
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span><span class="err">\</span><span class="k">COPY</span><span class="w"> </span><span class="n">events_2025_04</span><span class="w"> </span><span class="k">TO</span><span class="w"> </span><span class="s1">&#39;/cold/events_2025_04.csv&#39;</span><span class="w"> </span><span class="p">(</span><span class="n">FORMAT</span><span class="w"> </span><span class="n">CSV</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></span><span class="line"><span class="ln">8</span><span class="cl"><span class="w"></span><span class="c1">-- 3. drop 釋放磁碟
</span></span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="c1"></span><span class="k">DROP</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">events_2025_04</span><span class="p">;</span></span></span></code></pre></div><h2 id="容量規劃">容量規劃</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>估算</th>
          <th>警戒</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>單 partition size</td>
          <td>跟單表 vacuum 上限對齊（10-100GB sweet spot）</td>
          <td>&gt; 200GB 時考慮 sub-partition 或細化 granularity</td>
      </tr>
      <tr>
          <td>Partition 數量</td>
          <td>對應 retention × granularity</td>
          <td>&gt; 200 partition 時 planning time 開始浮現</td>
      </tr>
      <tr>
          <td>Partition key cardinality</td>
          <td>LIST：&lt; 100 / HASH：自定 modulus / RANGE：時間 + 維度</td>
          <td>太多獨立 partition value 用 HASH</td>
      </tr>
      <tr>
          <td>Cross-partition query 比例</td>
          <td>EXPLAIN 看 partition scan 數</td>
          <td>&gt; 30% query 掃 &gt; 50% partition 表示 key 選錯</td>
      </tr>
      <tr>
          <td>Maintenance window</td>
          <td>DROP / DETACH / ATTACH 各 partition 各自管</td>
          <td>hot partition 維護仍在 maintenance window</td>
      </tr>
  </tbody>
</table>
<p>實務 default：</p>
<ul>
<li>時間序列（events / log）：monthly RANGE partition、retention 12-24 個月</li>
<li>Multi-tenant（orders / records）：tenant_id LIST partition + 大 tenant 各自獨立 partition</li>
<li>均勻散落（user / metric）：8-16 個 HASH partition、單 partition 50-100GB</li>
</ul>
<h2 id="整合--下一步">整合 / 下一步</h2>
<h3 id="跟-autovacuum-tuning-整合">跟 <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> 整合</h3>
<p>partitioning 是 autovacuum 問題的長期解：</p>
<ol>
<li>Hot partition autovacuum 緊（scale_factor 0.05、cost_limit 5000）</li>
<li>Cold partition <code>autovacuum_enabled = false</code></li>
<li>但 partition 數爆會把 <code>autovacuum_max_workers</code> 跑滿、需要拉</li>
</ol>
<h3 id="跟-index-設計整合">跟 index 設計整合</h3>
<p>partition table 的 index 處理：</p>
<ol>
<li>PG 11+ 全域 index：<code>CREATE INDEX ON partitioned_table (...)</code> 自動在每 partition 建 local index</li>
<li><strong>不存在跨 partition unique</strong> — 只能 partition-local</li>
<li><strong>partition-wise index scan</strong>：PG 11+ 跟 partition-wise join 一起、index lookup 平行</li>
</ol>
<h3 id="跟-backup--pitr">跟 backup / PITR</h3>
<p>partition 不是 backup 替代品 — 但能加速 <em>partial restore</em>：</p>
<ol>
<li>只 restore 特定時段的 partition、不用 restore 整個表</li>
<li>對應 <a href="/blog/backend/01-database/vendors/postgresql/pitr-wal-archiving/" data-link-title="PostgreSQL PITR &#43; WAL archiving：從 base backup 到 point-in-time recovery 的完整鏈" data-link-desc="Base backup &#43; WAL archive 構成 PITR 的雙軌資料、archive_command &#43; restore_command 配置、用 pgBackRest / WAL-G 替代手寫腳本、5 個 production 踩雷（archive 靜默失敗 / archive lag / 錯誤 target time / base backup 過期未清 / timeline 分歧 recovery 模糊）、跟 Patroni &#43; monitoring 整合">PITR + WAL archiving</a> 的 partial recovery scenario</li>
</ol>
<h3 id="下一步議題">下一步議題</h3>
<ul>
<li><strong>Sub-partitioning</strong>：partition 內再 partition（時間 + tenant）、適合 multi-tenant + 時間序列</li>
<li><strong>pg_partman extension</strong>：自動建月 partition、不用 cron</li>
<li><strong>Foreign key to partitioned table</strong> (PG 12+)：跨 partition FK enforce、但 cascade 限制多</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li>上游 vendor 頁：<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></li>
<li>上游 chapter：<a href="/blog/backend/01-database/schema-design/" data-link-title="1.2 Schema Design 與資料建模" data-link-desc="整理 table、index、key、partition、denormalization 與命名規則">Schema Design</a> — partition 是 schema 決策</li>
<li>平行 deep article：<a href="/blog/backend/01-database/vendors/postgresql/patroni-ha/" data-link-title="PostgreSQL Patroni HA：從 leader 失聯到 client 重連的 5 段 failover lifecycle" data-link-desc="Patroni 把 PostgreSQL HA 拆成 detection / election / promotion / reconfiguration / recovery 五段 lifecycle、每段都有獨立配置跟 failure mode；DCS quorum &#43; watchdog 防 split-brain、async/sync replication 取捨、5 個 production 踩雷、跟 PgBouncer / HAProxy / cert-manager 整合">Patroni HA</a> / <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> / <a href="/blog/backend/01-database/vendors/postgresql/timescaledb-deep-dive/" data-link-title="TimescaleDB Deep Dive：Hypertable / Continuous Aggregate / Compression 把 PG 變 Time-Series DB" data-link-desc="TimescaleDB 是 PG extension（不是 fork）、用 *hypertable* 自動 partition by time、加 *continuous aggregate* 做 incremental materialized view、加 *compression* 對舊 chunk 壓 90%&#43;、把 PG 變成 InfluxDB / Prometheus 級 time-series DB。本文走 hypertable 機制、continuous aggregate 跟普通 MV 差異、compression policy、retention policy、5 production 踩雷（chunk size 不對 / CAGG refresh 落後 / compression 後 update 限制 / hypertable 不能加 FK / TimescaleDB 跟 PG 主版本對齊）、跟 PG 原生 partitioning 對比">TimescaleDB Deep Dive</a>（hypertable 是 partition 自動化）</li>
<li>後續路由：<a href="/blog/backend/01-database/vendors/postgresql/partition-redesign/" data-link-title="PostgreSQL Partition Redesign：當 monthly partition 越跑越慢" data-link-desc="PostgreSQL partition redesign 是 Type F「topology re-layout」第 2 個 dogfood — 從 monthly partition 改 daily / 從 range 改 list / 從單軸改 sub-partition；6 維 audit 皆 Low &#43; topology 軸 High；涵蓋 partition 不平衡偵測、ATTACH/DETACH 線上重劃、5 個 production 踩雷、跟 partition_pruning &#43; autovacuum 整合">Partition Redesign</a>（重排 partition strategy 的 migration playbook）</li>
<li>Methodology：<a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章的寫作方法論</a></li>
</ul>
]]></content:encoded></item><item><title>Aurora PG/MySQL vs Aurora DSQL 取捨：何時 single-region managed 夠用、何時跨到 distributed</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/aurora/aurora-vs-dsql-tradeoff/</link><pubDate>Tue, 02 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/aurora/aurora-vs-dsql-tradeoff/</guid><description>&lt;blockquote>
&lt;p>本文是 Aurora family 內的決策取捨文章。聚焦 &lt;em>standard Aurora（Aurora PostgreSQL / MySQL，single-region managed SQL）&lt;/em> 跟 &lt;em>Aurora DSQL（active-active distributed SQL）&lt;/em> 之間的升級門檻判斷。兩個既有 SSoT 不在本篇重複：「PG → DSQL 怎麼遷」見 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/migrate-to-aurora-dsql/" data-link-title="PostgreSQL → Aurora DSQL Migration：PG wire-compatible Distributed SQL 的 Paradigm Shift" data-link-desc="Aurora DSQL（2024-12 re:Invent preview / 2025-05 GA）是 AWS 推的 PG wire-compatible *active-active distributed SQL*、跟 self-managed PG / Aurora PG 不同 paradigm（OCC &amp;#43; snapshot isolation &amp;#43; multi-region strong consistency）。Migration 結構是 *protocol drop-in &amp;#43; paradigm shift*：app SQL 不太改、但 transaction retry / extension 缺位 / 多 region 一致性需重設計。本文走 DSQL vs Aurora PG vs self-managed PG 三軸對比、為什麼遷的三條 driver（global write / operational zero-touch / region resiliency）、Type E phased plan、5 production 踩雷（transaction retry 沒處理 / extension 缺位 / sequence throughput 限制 / Aurora PG 直升 DSQL 不可行 / region failover semantic）、跟 PG → Aurora 跟 PG → CockroachDB 對比">migrate-to-aurora-dsql&lt;/a>；「DSQL vs Spanner vs CockroachDB 三方 distributed SQL 選型」見 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/cockroachdb/aurora-dsql-spanner-decision-tree/" data-link-title="CockroachDB vs Aurora DSQL vs Spanner：撞牆訊號分型 &amp;#43; 七問題決策樹" data-link-desc="Distributed SQL 三選一決策樹。先用撞牆訊號分型識別 driver path（DoorDash 單主寫入撞牆 / Netflix Cassandra 缺口 / Hard Rock 合規驅動）、再走七問題（跨雲 / 雲商生態 / 風險預算 / PG 相容 / 管理負擔 / team size / vendor sizing barrier）。PostgreSQL 相容性 audit checklist 4 項、Spanner 100 pu sizing barrier、Hard Rock 「省 10-20 工程師」機會成本警示、Netflix Database Platform Team 規模">aurora-dsql-spanner-decision-tree&lt;/a>。本篇只回答「standard Aurora 夠不夠、要不要跨過去」。&lt;/p>&lt;/blockquote>
&lt;p>多數團隊不需要 Aurora DSQL。Aurora PostgreSQL / MySQL 已經是 managed SQL、storage / compute 分離、跨 AZ 高可用、read replica 擴讀——絕大多數 OLTP workload 在這層就解決了。Aurora DSQL 是 2024-12 re:Invent preview、2025-05 GA 的 &lt;em>不同 paradigm&lt;/em> 產品：PG wire-compatible 但底層是 active-active distributed、OCC + snapshot isolation、multi-region strong consistency。它解的是 standard Aurora &lt;em>解不了&lt;/em> 的特定問題，代價是放棄一部分 PostgreSQL 相容性與交易自由度。要不要跨過去，看 workload 是否真的撞到 standard Aurora 的結構上限。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 Aurora family 內的決策取捨文章。聚焦 <em>standard Aurora（Aurora PostgreSQL / MySQL，single-region managed SQL）</em> 跟 <em>Aurora DSQL（active-active distributed SQL）</em> 之間的升級門檻判斷。兩個既有 SSoT 不在本篇重複：「PG → DSQL 怎麼遷」見 <a href="/blog/backend/01-database/vendors/postgresql/migrate-to-aurora-dsql/" data-link-title="PostgreSQL → Aurora DSQL Migration：PG wire-compatible Distributed SQL 的 Paradigm Shift" data-link-desc="Aurora DSQL（2024-12 re:Invent preview / 2025-05 GA）是 AWS 推的 PG wire-compatible *active-active distributed SQL*、跟 self-managed PG / Aurora PG 不同 paradigm（OCC &#43; snapshot isolation &#43; multi-region strong consistency）。Migration 結構是 *protocol drop-in &#43; paradigm shift*：app SQL 不太改、但 transaction retry / extension 缺位 / 多 region 一致性需重設計。本文走 DSQL vs Aurora PG vs self-managed PG 三軸對比、為什麼遷的三條 driver（global write / operational zero-touch / region resiliency）、Type E phased plan、5 production 踩雷（transaction retry 沒處理 / extension 缺位 / sequence throughput 限制 / Aurora PG 直升 DSQL 不可行 / region failover semantic）、跟 PG → Aurora 跟 PG → CockroachDB 對比">migrate-to-aurora-dsql</a>；「DSQL vs Spanner vs CockroachDB 三方 distributed SQL 選型」見 <a href="/blog/backend/01-database/vendors/cockroachdb/aurora-dsql-spanner-decision-tree/" data-link-title="CockroachDB vs Aurora DSQL vs Spanner：撞牆訊號分型 &#43; 七問題決策樹" data-link-desc="Distributed SQL 三選一決策樹。先用撞牆訊號分型識別 driver path（DoorDash 單主寫入撞牆 / Netflix Cassandra 缺口 / Hard Rock 合規驅動）、再走七問題（跨雲 / 雲商生態 / 風險預算 / PG 相容 / 管理負擔 / team size / vendor sizing barrier）。PostgreSQL 相容性 audit checklist 4 項、Spanner 100 pu sizing barrier、Hard Rock 「省 10-20 工程師」機會成本警示、Netflix Database Platform Team 規模">aurora-dsql-spanner-decision-tree</a>。本篇只回答「standard Aurora 夠不夠、要不要跨過去」。</p></blockquote>
<p>多數團隊不需要 Aurora DSQL。Aurora PostgreSQL / MySQL 已經是 managed SQL、storage / compute 分離、跨 AZ 高可用、read replica 擴讀——絕大多數 OLTP workload 在這層就解決了。Aurora DSQL 是 2024-12 re:Invent preview、2025-05 GA 的 <em>不同 paradigm</em> 產品：PG wire-compatible 但底層是 active-active distributed、OCC + snapshot isolation、multi-region strong consistency。它解的是 standard Aurora <em>解不了</em> 的特定問題，代價是放棄一部分 PostgreSQL 相容性與交易自由度。要不要跨過去，看 workload 是否真的撞到 standard Aurora 的結構上限。</p>
<blockquote>
<p><strong>時間錨點</strong>：Aurora DSQL 2024-12 preview、2025-05 GA。vendor 能力持續演進、實際決策前以 AWS docs 當前狀態為準。</p></blockquote>
<h2 id="核心差異single-writer-vs-active-active">核心差異：single-writer vs active-active</h2>
<p>兩者的根本差異在寫入架構：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>Aurora PG / MySQL（standard）</th>
          <th>Aurora DSQL</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>寫入架構</td>
          <td>single writer（一個 region 一個 writer）</td>
          <td>active-active（多 region 同時可寫）</td>
      </tr>
      <tr>
          <td>一致性</td>
          <td>單 region 強一致、跨 region 非同步</td>
          <td>multi-region strong consistency</td>
      </tr>
      <tr>
          <td>SQL 相容</td>
          <td>完整 PostgreSQL / MySQL</td>
          <td>PG wire-compatible <em>子集</em>、無多數 extension</td>
      </tr>
      <tr>
          <td>交易模型</td>
          <td>標準 PG/MySQL transaction、長交易</td>
          <td>OCC + snapshot isolation、需處理 retry</td>
      </tr>
      <tr>
          <td>寫入擴展</td>
          <td>受 single writer instance 上限約束</td>
          <td>水平擴展、無 single writer 瓶頸</td>
      </tr>
      <tr>
          <td>運維</td>
          <td>managed、但仍要管 instance / failover</td>
          <td>serverless、zero-touch、無 instance 概念</td>
      </tr>
  </tbody>
</table>
<p>standard Aurora 的 storage 層雖然分散，<em>compute 寫入仍是 single writer</em>——這是它的結構上限。DSQL 把寫入也分散，代價是 SQL 相容性縮窄（PG 子集、extension 缺位）與交易語意改變（OCC，衝突要 application retry）。</p>
<h2 id="該跨到-dsql-的訊號">該跨到 DSQL 的訊號</h2>
<p>只有撞到 standard Aurora 結構上限的特定需求，才值得跨 paradigm：</p>
<ul>
<li><strong>global write（多 region 都要低延遲寫入）</strong>：standard Aurora 跨 region 只有非同步副本、寫入要回到單一 writer region；真正需要多 region active-active 寫入 → DSQL</li>
<li><strong>single-writer 寫入上限撞牆</strong>：寫入量大到單一 writer instance（即使最大 instance class）撐不住、且無法用 sharding 簡單解 → DSQL 的水平寫入擴展</li>
<li><strong>region resiliency（單 region 失效仍要可寫）</strong>：standard Aurora 的跨 region failover 有 RPO/RTO 與寫入中斷；要求單 region 失效時其他 region 仍持續接受寫入 → DSQL active-active</li>
<li><strong>operational zero-touch</strong>：不想管 instance / failover / 容量 → DSQL serverless 模型（但這單項不足以跨 paradigm、要搭配上面的結構需求）</li>
</ul>
<h2 id="不該跨的訊號standard-aurora-夠用">不該跨的訊號（standard Aurora 夠用）</h2>
<p>以下情況跨 DSQL 是過度工程、且會付出相容性代價：</p>
<ul>
<li><strong>single-region 夠用</strong>：寫入集中在一個 region、跨 region 只需要讀副本或 DR → standard Aurora</li>
<li><strong>需要 PostgreSQL extension</strong>：依賴 PostGIS / pgvector / 特定 extension → DSQL 子集不支援、留 standard Aurora</li>
<li><strong>複雜 / 長交易</strong>：依賴長交易、複雜多語句交易、特定 isolation 行為 → standard Aurora 的完整交易模型</li>
<li><strong>寫入量 standard Aurora 撐得住</strong>：single writer 還有餘量 → 不必為「未來可能」預先跨 paradigm</li>
</ul>
<p><code>9.C14 Standard Chartered</code> 與 <code>9.C4 DraftKings</code> 是反向佐證：金融帳本 / 博彩這類高一致性、高關鍵 OLTP workload，在 <em>standard Aurora</em> 上就能同時拿到韌性與性能（DraftKings replication lag 降到 10-30ms 級、Standard Chartered 把韌性與性能當單一目標）。它們沒有跨到 distributed SQL——因為 single-region 強一致 + 跨 AZ 高可用已滿足需求。多數金融 OLTP 不需要 active-active multi-region write。</p>
<blockquote>
<p><strong>Scope warning</strong>：Standard Chartered / DraftKings 的 case 揭露其用 standard Aurora 達成韌性 + 性能（見 <a href="/blog/backend/01-database/vendors/aurora/storage-architecture/" data-link-title="Aurora Storage Architecture：quorum-based 分散式 log 與韌性即性能設計" data-link-desc="Aurora storage / compute 分離、6-way 跨 AZ replication、4-of-6 write / 3-of-6 read quorum、韌性投資自動 amortize 成 read 性能、DraftKings 6ms 寫 / &lt;1ms 讀 production reference">storage-architecture</a>）；「它們不需要 DSQL」是本文基於其 single-region 強一致需求的推論、非 case 明文比較 DSQL。引用為「standard Aurora 已足夠多數高一致 OLTP」的訊號、不當 DSQL 對比的 case fact。</p></blockquote>
<h2 id="升級門檻決策流程">升級門檻決策流程</h2>
<p>從需求判讀到路徑選擇的流程：</p>
<h4 id="step-1確認是不是-global-write-需求">Step 1：確認是不是 global write 需求</h4>
<p>寫入是否真的需要多 region 同時低延遲？還是只需要多 region 讀 + 單 region 寫？後者 standard Aurora（+ Global Database 讀副本）就解。</p>
<h4 id="step-2確認-single-writer-是否真的撞牆">Step 2：確認 single-writer 是否真的撞牆</h4>
<p>當前寫入量 vs 最大 instance class 上限、是否已嘗試過 read/write 分離、是否能用 application 層 sharding。撞牆才考慮 DSQL；沒撞牆是過早優化。</p>
<h4 id="step-3檢查相容性代價">Step 3：檢查相容性代價</h4>
<p>清點對 PG extension、長交易、特定 SQL 功能的依賴。依賴重 → DSQL 相容性子集會擋路、留 standard Aurora。</p>
<h4 id="step-4若決定跨走既有-ssot">Step 4：若決定跨，走既有 SSoT</h4>
<ul>
<li>「PG → DSQL 怎麼遷」（protocol drop-in + paradigm shift、transaction retry 處理、extension 缺位）→ <a href="/blog/backend/01-database/vendors/postgresql/migrate-to-aurora-dsql/" data-link-title="PostgreSQL → Aurora DSQL Migration：PG wire-compatible Distributed SQL 的 Paradigm Shift" data-link-desc="Aurora DSQL（2024-12 re:Invent preview / 2025-05 GA）是 AWS 推的 PG wire-compatible *active-active distributed SQL*、跟 self-managed PG / Aurora PG 不同 paradigm（OCC &#43; snapshot isolation &#43; multi-region strong consistency）。Migration 結構是 *protocol drop-in &#43; paradigm shift*：app SQL 不太改、但 transaction retry / extension 缺位 / 多 region 一致性需重設計。本文走 DSQL vs Aurora PG vs self-managed PG 三軸對比、為什麼遷的三條 driver（global write / operational zero-touch / region resiliency）、Type E phased plan、5 production 踩雷（transaction retry 沒處理 / extension 缺位 / sequence throughput 限制 / Aurora PG 直升 DSQL 不可行 / region failover semantic）、跟 PG → Aurora 跟 PG → CockroachDB 對比">migrate-to-aurora-dsql</a></li>
<li>「DSQL vs Spanner vs CockroachDB 哪個 distributed SQL」→ <a href="/blog/backend/01-database/vendors/cockroachdb/aurora-dsql-spanner-decision-tree/" data-link-title="CockroachDB vs Aurora DSQL vs Spanner：撞牆訊號分型 &#43; 七問題決策樹" data-link-desc="Distributed SQL 三選一決策樹。先用撞牆訊號分型識別 driver path（DoorDash 單主寫入撞牆 / Netflix Cassandra 缺口 / Hard Rock 合規驅動）、再走七問題（跨雲 / 雲商生態 / 風險預算 / PG 相容 / 管理負擔 / team size / vendor sizing barrier）。PostgreSQL 相容性 audit checklist 4 項、Spanner 100 pu sizing barrier、Hard Rock 「省 10-20 工程師」機會成本警示、Netflix Database Platform Team 規模">aurora-dsql-spanner-decision-tree</a></li>
</ul>
<p><strong>Rollback boundary</strong>：跨 paradigm 是高成本決策——DSQL 子集相容性與 OCC 交易模型改變了 application 契約，回退到 standard Aurora 不是改 connection string 就好。決策前用一個非關鍵 workload 試點、確認相容性與 retry 行為，再擴大。</p>
<h2 id="邊界與整合">邊界與整合</h2>
<h3 id="為什麼這是升級門檻而非遷移">為什麼這是「升級門檻」而非「遷移」</h3>
<p>standard Aurora → DSQL 不是版本升級、是 paradigm 切換。Aurora PG/MySQL 用得好好的，不代表「升級到 DSQL 會更好」——多數情況會更差（失去 extension、交易要改、相容性縮窄）。只有 workload 真的需要 active-active multi-region write 或撞到 single-writer 上限，跨過去才划算。這跟「PostgreSQL major version upgrade」（同 paradigm、向後相容）是完全不同性質的決策。</p>
<h3 id="sibling-與-cross-link">Sibling 與 cross-link</h3>
<ul>
<li><a href="/blog/backend/01-database/vendors/aurora/storage-architecture/" data-link-title="Aurora Storage Architecture：quorum-based 分散式 log 與韌性即性能設計" data-link-desc="Aurora storage / compute 分離、6-way 跨 AZ replication、4-of-6 write / 3-of-6 read quorum、韌性投資自動 amortize 成 read 性能、DraftKings 6ms 寫 / &lt;1ms 讀 production reference">storage-architecture</a> — standard Aurora 的 storage 分散但 compute single-writer 的結構上限根源</li>
<li><a href="/blog/backend/01-database/vendors/aurora/global-database-multi-region/" data-link-title="Aurora Global Database：跨 region async replication、&lt; 1 秒 lag 與合規 anti-recommendation" data-link-desc="Aurora Global Database 跨 region storage-level async replication、&lt; 1 秒 typical lag、planned vs unplanned failover RTO 數量級對比、Standard Chartered 合規禁止跨境複製為什麼讓 Global Database 變反指標">global-database-multi-region</a> — standard Aurora 的多 region 方案（非同步副本）、global write 需求前先確認這層夠不夠</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/migrate-to-aurora-dsql/" data-link-title="PostgreSQL → Aurora DSQL Migration：PG wire-compatible Distributed SQL 的 Paradigm Shift" data-link-desc="Aurora DSQL（2024-12 re:Invent preview / 2025-05 GA）是 AWS 推的 PG wire-compatible *active-active distributed SQL*、跟 self-managed PG / Aurora PG 不同 paradigm（OCC &#43; snapshot isolation &#43; multi-region strong consistency）。Migration 結構是 *protocol drop-in &#43; paradigm shift*：app SQL 不太改、但 transaction retry / extension 缺位 / 多 region 一致性需重設計。本文走 DSQL vs Aurora PG vs self-managed PG 三軸對比、為什麼遷的三條 driver（global write / operational zero-touch / region resiliency）、Type E phased plan、5 production 踩雷（transaction retry 沒處理 / extension 缺位 / sequence throughput 限制 / Aurora PG 直升 DSQL 不可行 / region failover semantic）、跟 PG → Aurora 跟 PG → CockroachDB 對比">migrate-to-aurora-dsql</a> — 決定跨之後的遷移 playbook（SSoT）</li>
<li><a href="/blog/backend/01-database/vendors/cockroachdb/aurora-dsql-spanner-decision-tree/" data-link-title="CockroachDB vs Aurora DSQL vs Spanner：撞牆訊號分型 &#43; 七問題決策樹" data-link-desc="Distributed SQL 三選一決策樹。先用撞牆訊號分型識別 driver path（DoorDash 單主寫入撞牆 / Netflix Cassandra 缺口 / Hard Rock 合規驅動）、再走七問題（跨雲 / 雲商生態 / 風險預算 / PG 相容 / 管理負擔 / team size / vendor sizing barrier）。PostgreSQL 相容性 audit checklist 4 項、Spanner 100 pu sizing barrier、Hard Rock 「省 10-20 工程師」機會成本警示、Netflix Database Platform Team 規模">aurora-dsql-spanner-decision-tree</a> — 三方 distributed SQL 選型（SSoT）</li>
<li>替代路由：single-region 夠 → 留 standard Aurora；KV access pattern → <a href="/blog/backend/01-database/vendors/dynamodb/" data-link-title="DynamoDB" data-link-desc="AWS managed key-value、partition-based scaling、9000 萬 RPS sustained 實戰證據">DynamoDB</a></li>
<li>跟 <a href="/blog/backend/09-performance-capacity/cases/standard-chartered-aurora-banking/" data-link-title="9.C14 Standard Chartered：受監管銀行的 Aurora 4000 TPS 容量提升" data-link-desc="Standard Chartered 銀行遷移到 Aurora 後吞吐量提升 10 倍至 4000 TPS、跨 7 個受監管市場">Standard Chartered 9.C14</a> / <a href="/blog/backend/09-performance-capacity/cases/draftkings-aurora-financial-ledger/" data-link-title="9.C4 DraftKings：Aurora 撐 100 萬 ops/min 的體育博彩金融帳本" data-link-desc="DraftKings 用 Aurora MySQL 跑體育博彩金融帳本、Super Bowl 流量 &#43;50% 不影響延遲">DraftKings 9.C4</a> 互引：高一致 OLTP 在 standard Aurora 已足夠的訊號</li>
</ul>
]]></content:encoded></item><item><title>Spanner Change Streams (CDC)：捕捉資料變更、watch partition、下游整合與 DynamoDB Streams 對照</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/spanner/change-streams-cdc/</link><pubDate>Tue, 02 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/spanner/change-streams-cdc/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/spanner/" data-link-title="Google Cloud Spanner" data-link-desc="全球分散式 strong-consistency OLTP、TrueTime API、線性擴展到 10 億 req/sec">Cloud Spanner&lt;/a> overview 的 implementation-layer deep article、寫作參照 &lt;a href="https://tarrragon.github.io/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">vendor deep article methodology&lt;/a>。Overview 已說明 Spanner 在全球 OLTP 譜系的定位、本文聚焦 &lt;em>Change Streams&lt;/em> — Spanner 把 commit 後的 row mutation 變成下游可消費事件流的 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/change-data-capture/" data-link-title="Change Data Capture" data-link-desc="說明資料變更如何被捕捉並傳送到其他系統">CDC&lt;/a> 機制。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="問題情境oltp-的變更要餵給搜尋快取分析三個下游">問題情境：OLTP 的變更要餵給搜尋、快取、分析三個下游&lt;/h2>
&lt;p>Change Streams 的責任是把 Spanner 內已 commit 的 row mutation 變成有序、可重放、攜帶 commit timestamp 的事件流、讓搜尋索引、快取、分析倉儲三類下游不用反覆 full-table scan 就能跟上主資料庫。OLTP 主庫負責正確寫入、下游各自負責自己的 query shape、兩邊之間需要一條「只送變更、不送全表」的管線、這條管線就是 CDC 的職責。&lt;/p>
&lt;p>讀者徵兆通常從這幾個地方浮現：搜尋團隊每 5 分鐘跑一次 full scan 把 orders 重灌進 Elasticsearch、Spanner CPU 被掃表打到 70%；快取層靠 TTL 過期被動失效、使用者看到舊價格;分析團隊想做近即時 dashboard、卻只有每日 batch export。共同壓力是「主庫的變更沒有一條乾淨的出口」、每個下游各自發明輪子去 poll 主庫。&lt;/p>
&lt;p>真實壓力場景：全球電商把訂單寫進 Spanner multi-region instance、需要把每筆訂單狀態變更同時推給 (1) 搜尋索引更新庫存可售性、(2) Pub/Sub 通知履約系統、(3) BigQuery 做近即時營收儀表板。三個下游對延遲、順序、retention 的要求不同、但都需要從同一條變更流取得資料。&lt;/p>
&lt;p>Case anchor：&lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/spanner-planetary-scale-database-gcp/" data-link-title="9.C10 Cloud Spanner：每秒 10 億請求的全球一致性資料庫" data-link-desc="Google Cloud Spanner 內部峰值 10 億 req/sec、跨地區強一致 — 全球分散式 OLTP 容量參考">9.C10 Cloud Spanner planetary scale&lt;/a> 提供「全球大規模 OLTP 寫入」的壓力 anchor — Google Ads / Play 計費的寫入量級說明為什麼下游不能靠 full scan 跟上。&lt;strong>dogfood 邊界明示&lt;/strong>：9.C10 是 Google 內部 dogfood case、未展開 change streams 實作細節；本文 change stream 的物件模型、partition 行為與 retention 上限均來自 GCP vendor 規格、不是 9.C10 case 揭露。&lt;/p>
&lt;h2 id="核心機制data-change-recordpartition-tokencommit-timestamp">核心機制：data change record、partition token、commit timestamp&lt;/h2>
&lt;p>Change Stream 是一個用 DDL 建立、綁定到特定 table / column 集合的 schema 物件、commit 後 Spanner 把對應 row 的 mutation 寫成 &lt;em>data change record&lt;/em> 供消費。它跟「在 application 層自己寫 outbox table」最大的差異是：change record 由 Spanner 內部跟 transaction commit 綁定產生、攜帶該 mutation 的 commit timestamp、繼承 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/external-consistency/" data-link-title="External Consistency" data-link-desc="交易可見順序與外部真實時間順序一致的強一致性語意">external consistency&lt;/a> 的全序性質、不需要 application 額外保證原子性。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/spanner/" data-link-title="Google Cloud Spanner" data-link-desc="全球分散式 strong-consistency OLTP、TrueTime API、線性擴展到 10 億 req/sec">Cloud Spanner</a> overview 的 implementation-layer deep article、寫作參照 <a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">vendor deep article methodology</a>。Overview 已說明 Spanner 在全球 OLTP 譜系的定位、本文聚焦 <em>Change Streams</em> — Spanner 把 commit 後的 row mutation 變成下游可消費事件流的 <a href="/blog/backend/knowledge-cards/change-data-capture/" data-link-title="Change Data Capture" data-link-desc="說明資料變更如何被捕捉並傳送到其他系統">CDC</a> 機制。</p></blockquote>
<hr>
<h2 id="問題情境oltp-的變更要餵給搜尋快取分析三個下游">問題情境：OLTP 的變更要餵給搜尋、快取、分析三個下游</h2>
<p>Change Streams 的責任是把 Spanner 內已 commit 的 row mutation 變成有序、可重放、攜帶 commit timestamp 的事件流、讓搜尋索引、快取、分析倉儲三類下游不用反覆 full-table scan 就能跟上主資料庫。OLTP 主庫負責正確寫入、下游各自負責自己的 query shape、兩邊之間需要一條「只送變更、不送全表」的管線、這條管線就是 CDC 的職責。</p>
<p>讀者徵兆通常從這幾個地方浮現：搜尋團隊每 5 分鐘跑一次 full scan 把 orders 重灌進 Elasticsearch、Spanner CPU 被掃表打到 70%；快取層靠 TTL 過期被動失效、使用者看到舊價格;分析團隊想做近即時 dashboard、卻只有每日 batch export。共同壓力是「主庫的變更沒有一條乾淨的出口」、每個下游各自發明輪子去 poll 主庫。</p>
<p>真實壓力場景：全球電商把訂單寫進 Spanner multi-region instance、需要把每筆訂單狀態變更同時推給 (1) 搜尋索引更新庫存可售性、(2) Pub/Sub 通知履約系統、(3) BigQuery 做近即時營收儀表板。三個下游對延遲、順序、retention 的要求不同、但都需要從同一條變更流取得資料。</p>
<p>Case anchor：<a href="/blog/backend/09-performance-capacity/cases/spanner-planetary-scale-database-gcp/" data-link-title="9.C10 Cloud Spanner：每秒 10 億請求的全球一致性資料庫" data-link-desc="Google Cloud Spanner 內部峰值 10 億 req/sec、跨地區強一致 — 全球分散式 OLTP 容量參考">9.C10 Cloud Spanner planetary scale</a> 提供「全球大規模 OLTP 寫入」的壓力 anchor — Google Ads / Play 計費的寫入量級說明為什麼下游不能靠 full scan 跟上。<strong>dogfood 邊界明示</strong>：9.C10 是 Google 內部 dogfood case、未展開 change streams 實作細節；本文 change stream 的物件模型、partition 行為與 retention 上限均來自 GCP vendor 規格、不是 9.C10 case 揭露。</p>
<h2 id="核心機制data-change-recordpartition-tokencommit-timestamp">核心機制：data change record、partition token、commit timestamp</h2>
<p>Change Stream 是一個用 DDL 建立、綁定到特定 table / column 集合的 schema 物件、commit 後 Spanner 把對應 row 的 mutation 寫成 <em>data change record</em> 供消費。它跟「在 application 層自己寫 outbox table」最大的差異是：change record 由 Spanner 內部跟 transaction commit 綁定產生、攜帶該 mutation 的 commit timestamp、繼承 <a href="/blog/backend/knowledge-cards/external-consistency/" data-link-title="External Consistency" data-link-desc="交易可見順序與外部真實時間順序一致的強一致性語意">external consistency</a> 的全序性質、不需要 application 額外保證原子性。</p>
<p>建立語法是 DDL：</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="n">CHANGE</span><span class="w"> </span><span class="n">STREAM</span><span class="w"> </span><span class="n">everything_stream</span><span class="w"> </span><span class="k">FOR</span><span class="w"> </span><span class="k">ALL</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></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w"></span><span class="c1">-- 只監看特定 table 的特定欄位
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span><span class="k">CREATE</span><span class="w"> </span><span class="n">CHANGE</span><span class="w"> </span><span class="n">STREAM</span><span class="w"> </span><span class="n">orders_stream</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">  </span><span class="k">FOR</span><span class="w"> </span><span class="n">orders</span><span class="p">(</span><span class="n">status</span><span class="p">,</span><span class="w"> </span><span class="n">total_amount</span><span class="p">),</span><span class="w"> </span><span class="n">inventory</span><span class="p">(</span><span class="n">available_qty</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">OPTIONS</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="n">retention_period</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;7d&#39;</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">value_capture_type</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;NEW_AND_OLD_VALUES&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">  </span><span class="p">);</span></span></span></code></pre></div><p><code>value_capture_type</code> 決定 record 攜帶多少資料、三個選項對下游的意義不同：</p>
<table>
  <thead>
      <tr>
          <th>value_capture_type</th>
          <th>record 攜帶內容</th>
          <th>適合下游</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>OLD_AND_NEW_VALUES</code></td>
          <td>變更前後完整 row</td>
          <td>需要 diff / 審計 / 反向補償的下游</td>
      </tr>
      <tr>
          <td><code>NEW_VALUES</code></td>
          <td>變更後的值 + key</td>
          <td>搜尋索引、快取 upsert（只要最新狀態）</td>
      </tr>
      <tr>
          <td><code>NEW_ROW</code></td>
          <td>變更後完整 row（含未改欄位）</td>
          <td>不想自己拼 row 的下游、犧牲 record 體積</td>
      </tr>
  </tbody>
</table>
<h3 id="data-change-record-的關鍵欄位">Data change record 的關鍵欄位</h3>
<p>每筆 data change record 攜帶 commit timestamp、record sequence、transaction tag、mod type（INSERT / UPDATE / DELETE）、以及 primary key 與依 capture type 決定的 value payload。下游靠 commit timestamp + record sequence 在同一個 transaction 內重建變更順序、跨 transaction 則靠 commit timestamp 的全序。這條順序保證是 Spanner CDC 跟「自己 poll updated_at column」的根本差異：poll updated_at 在 clock skew 下會漏序、change stream 的順序由 TrueTime 撐住。</p>
<h3 id="watch-partitionchange-stream-的-partition-模型">Watch partition：change stream 的 partition 模型</h3>
<p>Change stream 的讀取單位是 <em>partition</em>、不是整條流。Spanner 把 change stream 依底層 key range 切成多個 partition、每個 partition 用一個 <em>partition token</em> 標識、消費者對每個 token 各開一個 <code>read</code> 呼叫並行讀。當底層資料 split 或 merge（Spanner 自動 re-balance key range）、partition 會產生 <em>child partition</em> — 父 partition 的 record 讀到結束時回傳 child partition token、消費者要接著去讀 child token、才不會漏掉 split 後的變更。</p>
<p>這個 child partition 的接力機制是 change stream 消費的核心複雜度。手刻消費者必須維護一張 partition token 的 watermark 表、處理 parent 結束 → child 開始的交棒、保證每個 token 只被一個 worker 讀。多數團隊不該手刻這層、應走 Dataflow connector（下節）讓它代管 partition 生命週期。</p>
<blockquote>
<p><strong>Scope warning</strong>：本節 data change record 欄位、value_capture_type 選項、child partition 接力語意均屬 GCP Spanner change streams 規格、實作前 cross-verify <a href="https://cloud.google.com/spanner/docs/change-streams">Spanner change streams 官方文件</a>。retention_period、partition 切分行為隨版本演進、非 9.C10 case 揭露。</p></blockquote>
<h2 id="操作流程建立-change-stream-到-dataflow-下游">操作流程：建立 change stream 到 Dataflow 下游</h2>
<h3 id="step-1建立-change-stream-並驗證">Step 1：建立 change stream 並驗證</h3>
<p>用 DDL 建立 change stream 後、用 information schema 確認它存在、並用 metadata 查詢確認監看範圍正確。</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="n">CHANGE</span><span class="w"> </span><span class="n">STREAM</span><span class="w"> </span><span class="n">orders_stream</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w">  </span><span class="k">FOR</span><span class="w"> </span><span class="n">orders</span><span class="p">,</span><span class="w"> </span><span class="n">inventory</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w">  </span><span class="k">OPTIONS</span><span class="w"> </span><span class="p">(</span><span class="n">retention_period</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;7d&#39;</span><span class="p">);</span></span></span></code></pre></div><p>驗證：查 <code>INFORMATION_SCHEMA.CHANGE_STREAMS</code> 確認 stream 已建立、查 <code>CHANGE_STREAM_TABLES</code> 確認監看的 table 集合符合預期。若監看範圍寫錯（漏了某 table）、下游會靜默漏掉那張表的變更、這是高代價的靜默失敗、必須在這步驗證。</p>
<h3 id="step-2選消費路徑--dataflow-connector-為預設">Step 2：選消費路徑 — Dataflow connector 為預設</h3>
<p>消費 change stream 有三條路徑、對應不同的下游能力與運維成本：</p>
<table>
  <thead>
      <tr>
          <th>路徑</th>
          <th>partition 管理</th>
          <th>適合場景</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Dataflow + Apache Beam SpannerIO connector</td>
          <td>connector 代管</td>
          <td>串到 BigQuery / GCS / Pub/Sub、需 exactly-once</td>
      </tr>
      <tr>
          <td>Pub/Sub via Dataflow template</td>
          <td>template 代管</td>
          <td>fan-out 給多個事件驅動下游</td>
      </tr>
      <tr>
          <td>直接用 client library 讀 partition</td>
          <td>自己維護 token watermark</td>
          <td>客製化邏輯、能承擔 partition 生命週期工程</td>
      </tr>
  </tbody>
</table>
<p>Dataflow connector 是預設路徑、因為它代管 partition token 的 split / merge 接力、提供 checkpoint 與 exactly-once 到下游 sink。</p>
<h3 id="step-3部署-dataflow-pipeline-並驗證-end-to-end">Step 3：部署 Dataflow pipeline 並驗證 end-to-end</h3>
<p>用官方 Spanner-to-BigQuery 或 Spanner-to-PubSub Dataflow template 部署。驗證 end-to-end：在 Spanner 寫一筆變更、量它多久出現在下游、確認 commit timestamp 在下游被保留、確認 INSERT / UPDATE / DELETE 三種 mod type 都被正確處理（DELETE 特別容易在下游被漏掉、要專門測）。</p>
<h3 id="step-4rollback-boundary">Step 4：rollback boundary</h3>
<p>Change stream 是可加可刪的 schema 物件、<code>DROP CHANGE STREAM orders_stream</code> 即停止捕捉、不影響主表寫入。rollback boundary 在「停掉 Dataflow pipeline + 標記下游資料為 stale」、不是「改主庫 schema」 — change stream 本身對 OLTP write path 的影響極小、刪除它不需要 cutover window。</p>
<h2 id="失敗模式retention-過期下游慢於-retentiondelete-漏處理">失敗模式：retention 過期、下游慢於 retention、DELETE 漏處理</h2>
<h3 id="retention-窗口過期導致-partition-不可讀">Retention 窗口過期導致 partition 不可讀</h3>
<p>change stream 的 record 只保留 retention_period（預設 1 天、上限數天、查官方文件確認當前上限）。若下游消費者停機超過 retention 窗口、過期 partition 的 record 被 GC、消費者重啟後讀到 partition token 已失效的錯誤、那段變更永久漏掉。徵兆是消費者重啟後報 partition not found、下游資料出現一段空洞。修法是 retention_period 設成大於「最壞情況下游停機 + 重啟趕上」的時間、並對 change stream 的 consumer lag 設告警、lag 接近 retention 一半就 page。</p>
<blockquote>
<p><strong>Scope warning</strong>：retention_period 的預設值與上限屬 GCP 規格、隨版本變動、cross-verify 官方文件。本段 lag 告警閾值（retention 一半）是通用工程估算、不是 9.C10 case 揭露的數字。</p></blockquote>
<h3 id="下游消費吞吐慢於主庫寫入速率">下游消費吞吐慢於主庫寫入速率</h3>
<p>主庫 write rate 持續高於下游消費速率、consumer lag 單調上升、最終撞 retention 窗口漏資料。這在全球大規模 OLTP 寫入下是真實壓力 — 對應 9.C10 揭露的 Google internal dogfood 寫入量級（<strong>dogfood 邊界</strong>：該量級是 Google 全使用者加總、不是單一 instance 配額）。修法是擴 Dataflow worker、確認 partition 數足夠讓消費並行、必要時把單一 change stream 依 table 拆成多條降低單條負載。判讀訊號是 Dataflow backlog metric 持續成長、不是偶發 spike。</p>
<h3 id="delete-變更在下游被漏處理">DELETE 變更在下游被漏處理</h3>
<p>下游 pipeline 只處理 INSERT / UPDATE 的 upsert、忘了處理 DELETE 的 tombstone、導致下游索引 / 快取殘留已刪除的資料。徵兆是搜尋結果出現主庫已不存在的項目、對帳發現下游 row count 高於主庫。修法是 pipeline 顯式 handle mod type = DELETE、依 capture type 決定能否拿到 old values 來反向補償；若用 <code>NEW_VALUES</code> capture、DELETE record 只攜帶 key、下游必須靠 key 刪除、不能假設拿得到完整 old row。</p>
<h3 id="把-change-stream-當可靠-message-queue-用">把 change stream 當可靠 message queue 用</h3>
<p>change stream 是 <em>變更捕捉</em>、不是 general-purpose message bus。團隊若把它當成「任意事件都塞進來」的 queue、會發現它只能攜帶 row mutation、不能攜帶 application 自定義事件、且 retention 比專用 message broker 短。<strong>Anti-recommendation（何時不用）</strong>：需要長期保留、任意 payload、複雜 routing 的事件流、用 Pub/Sub 或 Kafka 當 SSoT、change stream 只負責「資料庫變更」這一類來源；把 application 業務事件硬塞進 change stream 是把 CDC 機制誤用成 event bus。</p>
<h2 id="容量與觀測consumer-lag-是核心健康訊號">容量與觀測：consumer lag 是核心健康訊號</h2>
<p>Change stream 的容量壓力集中在「下游能不能跟上主庫寫入」、核心 metric 是 consumer lag 與 partition 並行度。</p>
<p>必看 metric：</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">Dataflow data freshness / system lag   → 下游落後主庫 commit 的時間
</span></span><span class="line"><span class="ln">2</span><span class="cl">Dataflow backlog bytes / elements      → 未消費的 record 積壓量
</span></span><span class="line"><span class="ln">3</span><span class="cl">Spanner change stream partition count  → 並行讀取單位、隨底層 split 變化
</span></span><span class="line"><span class="ln">4</span><span class="cl">Spanner CPU utilization                → change stream 讀取也消耗主 instance CPU</span></span></code></pre></div><p>Change stream 的讀取消耗主 instance 的 CPU 與 read capacity、不是免費旁路。容量規劃要把「change stream 消費」當成額外 read workload 算進 instance sizing、回 <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/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">4.20 Observability Evidence Package</a> 把 consumer lag 跟 Spanner CPU 配成 evidence pair：lag 上升且 CPU 飽和、是 instance 容量不足；lag 上升但 CPU 有餘、是 Dataflow worker 不足。</p>
<p>Alert 建議：</p>
<table>
  <thead>
      <tr>
          <th>Metric</th>
          <th>Warn</th>
          <th>Page</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Dataflow data freshness</td>
          <td>&gt; retention 的 1/4</td>
          <td>&gt; retention 的 1/2</td>
      </tr>
      <tr>
          <td>Dataflow backlog 成長趨勢</td>
          <td>持續成長 30 分鐘</td>
          <td>持續成長 2 小時</td>
      </tr>
      <tr>
          <td>Spanner CPU（含 stream 讀取）</td>
          <td>&gt; 65%</td>
          <td>&gt; 80%</td>
      </tr>
  </tbody>
</table>
<blockquote>
<p><strong>Scope warning</strong>：上述閾值為通用工程估算、依各團隊 retention 設定與 SLA 調整、非 9.C10 case 揭露的 production 數字。</p></blockquote>
<h2 id="邊界與整合跟-dynamodb-streams-對照何時不用-change-streams">邊界與整合：跟 DynamoDB Streams 對照、何時不用 change streams</h2>
<h3 id="跟-dynamodb-streams-的對照">跟 DynamoDB Streams 的對照</h3>
<p>Change Streams 跟 DynamoDB Streams 都是 managed CDC、但 partition 模型、ordering 範圍、retention 的設計取捨不同、選型時這三軸最關鍵。</p>
<table>
  <thead>
      <tr>
          <th>軸</th>
          <th>Spanner Change Streams</th>
          <th>DynamoDB Streams</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Ordering 範圍</td>
          <td>commit timestamp 全序（繼承 external consistency）</td>
          <td>每個 shard / partition key 內有序、跨 partition 無全序</td>
      </tr>
      <tr>
          <td>Partition 模型</td>
          <td>隨底層 key range split / merge、child partition 接力</td>
          <td>對應 DynamoDB partition、shard 隨 partition 變化</td>
      </tr>
      <tr>
          <td>Retention</td>
          <td>retention_period 可設（天級、查官方上限）</td>
          <td>固定 24 小時</td>
      </tr>
      <tr>
          <td>消費路徑</td>
          <td>Dataflow / Pub/Sub / client library</td>
          <td>Lambda trigger / Kinesis Adapter</td>
      </tr>
      <tr>
          <td>Payload 控制</td>
          <td>value_capture_type 三選</td>
          <td>StreamViewType 四選（KEYS_ONLY / NEW / OLD / BOTH）</td>
      </tr>
  </tbody>
</table>
<p>關鍵差異在 ordering：Spanner change stream 繼承 external consistency、跨 partition 的 record 可用 commit timestamp 排出全序;DynamoDB Streams 只保證單 partition key 內有序、跨 partition 重組需要下游自己處理。retention 上 DynamoDB Streams 固定 24 小時、Spanner 可設更長、對「下游可能長時間停機」的場景 Spanner 較有彈性。消費模型上 DynamoDB Streams 跟 Lambda 整合最順、Spanner 跟 Dataflow / BigQuery 生態整合最順。</p>
<blockquote>
<p><strong>Scope warning</strong>：DynamoDB Streams 24 小時 retention 與 StreamViewType 屬 AWS 規格、Spanner retention 上限屬 GCP 規格、兩者均隨版本演進、cross-verify 各自官方文件。</p></blockquote>
<h3 id="何時不用-change-streams">何時不用 change streams</h3>
<p>單純需要「下游讀到最新狀態、不在意中間每筆變更」、且主庫變更率低、定期 batch export 反而更簡單、不必引入 change stream + Dataflow 的運維成本。對延遲不敏感的分析、走 BigQuery federation 直接查 Spanner（見 sibling）比建 CDC 管線更省。Anti-recommendation 的判準是：若下游不需要「每一筆變更的順序」、只需要「定期最新快照」、CDC 是過度工程。</p>
<h3 id="sibling-deep-articles-路由">Sibling deep articles 路由</h3>
<ul>
<li><a href="../bigquery-federation/">bigquery-federation</a>：不想建 CDC 管線、直接 federated query 查 Spanner 的 OLAP 路徑、跟 change stream → BigQuery 是兩條互補的整合方式</li>
<li><a href="../truetime-api-depth/">truetime-api-depth</a>：change stream 的 commit timestamp 全序來自 TrueTime、理解順序保證的物理基礎</li>
<li><a href="../consistency-models-comparison/">consistency-models-comparison</a>：change stream 繼承 external consistency、跟 DynamoDB Streams 的 per-partition ordering 對照回 linearizability 定義</li>
</ul>
<h3 id="跟-knowledge-card-的互引">跟 knowledge card 的互引</h3>
<ul>
<li><a href="/blog/backend/knowledge-cards/change-data-capture/" data-link-title="Change Data Capture" data-link-desc="說明資料變更如何被捕捉並傳送到其他系統">change-data-capture</a> — 本文是這張卡的 Spanner 實作範例</li>
<li><a href="/blog/backend/knowledge-cards/external-consistency/" data-link-title="External Consistency" data-link-desc="交易可見順序與外部真實時間順序一致的強一致性語意">external-consistency</a> — change stream 的全序保證來源</li>
</ul>
<h3 id="跟-04--09-章節的互引">跟 04 / 09 章節的互引</h3>
<ul>
<li><a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">4.20 Observability Evidence Package</a>：consumer lag × Spanner CPU 的 evidence pair</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>：change stream 讀取當額外 read workload 算進 sizing</li>
</ul>
]]></content:encoded></item><item><title>DynamoDB Global Tables：multi-region active-active、LWW conflict 與 cross-device sync 正向用例</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/dynamodb/global-tables-conflict/</link><pubDate>Wed, 27 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/dynamodb/global-tables-conflict/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/dynamodb/" data-link-title="DynamoDB" data-link-desc="AWS managed key-value、partition-based scaling、9000 萬 RPS sustained 實戰證據">DynamoDB&lt;/a> overview 的 implementation-layer deep article。寫作參照 &lt;a href="https://tarrragon.github.io/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">vendor deep article methodology&lt;/a>。&lt;/p>&lt;/blockquote>
&lt;p>B2B SaaS 跟客戶 SLA 寫 99.99%、單 region 跑了一年遇過兩次 region-level outage、合計 downtime 已逼近 SLA 上限。team 要把核心 table 改 Global Tables active-active、首問是「multi-region write 之後資料還會一致嗎」。這個問題的答案是：&lt;em>不會、但有工程解法&lt;/em>；DynamoDB Global Tables 用 LWW（Last Writer Wins）跨 region async 同步、conflict 偵測跟 reconciliation 要 application 自己加。&lt;/p>
&lt;p>但 Global Tables 不只是 conflict 痛點。Disney+ 用同一個機制處理 cross-device sync（手機看一半回家用電視繼續）、Genesys 用同一個機制做 15 region B2B 客服平台的 99.999% 可用性。本文先講正向 access pattern（避免讓讀者誤以為 Global Tables 只是「跨 region 寫入會 conflict、所以痛苦」）、再展開 conflict resolution 跟 reconciliation 設計。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>Workload 適配本 vendor 才繼續&lt;/strong>：DynamoDB 4 軸判讀（PK 天然均勻 / control plane vs data plane / consistency 可接受 eventual / access pattern 穩定）軸見 &lt;a href="../single-table-design-pattern/#dynamodb-%e9%81%a9%e7%94%a8%e5%ba%a6%e5%89%8d%e7%bd%ae%e5%88%a4%e8%ae%804-%e8%bb%b8">single-table-design-pattern 開頭 4 軸前置判讀&lt;/a>。Global Tables 是 &lt;em>已選 DynamoDB 後&lt;/em> 的拓樸決策；strong global consistency 必要的 workload 應走 Spanner / Cosmos DB strong consistency level、不是用 LWW 補。&lt;/p>&lt;/blockquote>
&lt;h2 id="b2b-saas-vs-b2c-業務-driver-對比">B2B SaaS vs B2C 業務 driver 對比&lt;/h2>
&lt;p>Global Tables 不是預設選擇、是 &lt;em>業務性質&lt;/em> 決定的工程投資。&lt;code>9.C24 Genesys&lt;/code> 揭露兩條關鍵 frame — 可用性目標的業務 driver、跟每多一個 9 的 cost 指數成長。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>業務性質&lt;/th>
 &lt;th>典型可用性目標&lt;/th>
 &lt;th>年停機容忍&lt;/th>
 &lt;th>Multi-region 投資邏輯&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>B2C 大型網站&lt;/td>
 &lt;td>99.9%&lt;/td>
 &lt;td>8.76 小時&lt;/td>
 &lt;td>通常單 region + PITR / cross-region backup 划算&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>B2B SaaS&lt;/td>
 &lt;td>99.95% 或 99.99%（合約）&lt;/td>
 &lt;td>4.4 小時 / 52.6 分鐘&lt;/td>
 &lt;td>合約義務、客戶 SLA 違約有金錢損失、ROI 正向&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>客服平台類&lt;/td>
 &lt;td>99.999%（合約客戶）&lt;/td>
 &lt;td>5.26 分鐘&lt;/td>
 &lt;td>客戶停線損失極大、15 region 投資合理（Genesys）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>B2C 大型網站&lt;/strong>通常 99.9% SLA、年停機 8.76 小時可接受、單 region + PITR + cross-region backup 是常見配置；改 Global Tables 邊際成本高、ROI 通常不正向。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/dynamodb/" data-link-title="DynamoDB" data-link-desc="AWS managed key-value、partition-based scaling、9000 萬 RPS sustained 實戰證據">DynamoDB</a> overview 的 implementation-layer deep article。寫作參照 <a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">vendor deep article methodology</a>。</p></blockquote>
<p>B2B SaaS 跟客戶 SLA 寫 99.99%、單 region 跑了一年遇過兩次 region-level outage、合計 downtime 已逼近 SLA 上限。team 要把核心 table 改 Global Tables active-active、首問是「multi-region write 之後資料還會一致嗎」。這個問題的答案是：<em>不會、但有工程解法</em>；DynamoDB Global Tables 用 LWW（Last Writer Wins）跨 region async 同步、conflict 偵測跟 reconciliation 要 application 自己加。</p>
<p>但 Global Tables 不只是 conflict 痛點。Disney+ 用同一個機制處理 cross-device sync（手機看一半回家用電視繼續）、Genesys 用同一個機制做 15 region B2B 客服平台的 99.999% 可用性。本文先講正向 access pattern（避免讓讀者誤以為 Global Tables 只是「跨 region 寫入會 conflict、所以痛苦」）、再展開 conflict resolution 跟 reconciliation 設計。</p>
<blockquote>
<p><strong>Workload 適配本 vendor 才繼續</strong>：DynamoDB 4 軸判讀（PK 天然均勻 / control plane vs data plane / consistency 可接受 eventual / access pattern 穩定）軸見 <a href="../single-table-design-pattern/#dynamodb-%e9%81%a9%e7%94%a8%e5%ba%a6%e5%89%8d%e7%bd%ae%e5%88%a4%e8%ae%804-%e8%bb%b8">single-table-design-pattern 開頭 4 軸前置判讀</a>。Global Tables 是 <em>已選 DynamoDB 後</em> 的拓樸決策；strong global consistency 必要的 workload 應走 Spanner / Cosmos DB strong consistency level、不是用 LWW 補。</p></blockquote>
<h2 id="b2b-saas-vs-b2c-業務-driver-對比">B2B SaaS vs B2C 業務 driver 對比</h2>
<p>Global Tables 不是預設選擇、是 <em>業務性質</em> 決定的工程投資。<code>9.C24 Genesys</code> 揭露兩條關鍵 frame — 可用性目標的業務 driver、跟每多一個 9 的 cost 指數成長。</p>
<table>
  <thead>
      <tr>
          <th>業務性質</th>
          <th>典型可用性目標</th>
          <th>年停機容忍</th>
          <th>Multi-region 投資邏輯</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>B2C 大型網站</td>
          <td>99.9%</td>
          <td>8.76 小時</td>
          <td>通常單 region + PITR / cross-region backup 划算</td>
      </tr>
      <tr>
          <td>B2B SaaS</td>
          <td>99.95% 或 99.99%（合約）</td>
          <td>4.4 小時 / 52.6 分鐘</td>
          <td>合約義務、客戶 SLA 違約有金錢損失、ROI 正向</td>
      </tr>
      <tr>
          <td>客服平台類</td>
          <td>99.999%（合約客戶）</td>
          <td>5.26 分鐘</td>
          <td>客戶停線損失極大、15 region 投資合理（Genesys）</td>
      </tr>
  </tbody>
</table>
<p><strong>B2C 大型網站</strong>通常 99.9% SLA、年停機 8.76 小時可接受、單 region + PITR + cross-region backup 是常見配置；改 Global Tables 邊際成本高、ROI 通常不正向。</p>
<p><strong>B2B SaaS</strong> 99.95% 或 99.99% SLA 多半寫進合約、違約有具體金錢損失；Global Tables 的 N region cost 對比 SLA 違約成本通常 ROI 正向。critical 的是 <em>合約義務</em> 不是 <em>技術完美</em>。</p>
<p><strong>客服平台類</strong> 99.999% 是極端可用性目標、年停機 5.26 分鐘、Genesys 撐 8000+ orgs 的客服平台、客戶停線損失極大、跨 15 region 的 active-active 是合理投資。但 <em>不是每個 SaaS 都該追 99.999%</em>、是 <em>業務性質決定下限</em>。</p>
<p><strong>成本對比</strong>（<code>9.C24</code> 揭露）：15 region 成本約 = 1 region 的 15x（base table cost）+ 跨 region replication WCU。每多一個 9、容量規劃跟運維成本指數成長。</p>
<blockquote>
<p><strong>Scope warning（指標口徑紀律）</strong>：99.999% 是「12 個月滾動歷史值、不代表未來持續達成」（<code>9.C24</code> 警惕段第 1 條）。可用性是滾動指標、不是恆久承諾。引用 Genesys 99.999% 數字時要明示口徑（滾動 / customer-facing），不要寫成「DynamoDB 保證 99.999%」。</p></blockquote>
<h2 id="正向-access-pattern不只-conflict-議題">正向 access pattern：不只 conflict 議題</h2>
<p>Global Tables 不只是 DR / availability、也是正向 access pattern 的工程方案。先建立正向用例的判讀、再進 conflict 細節。</p>
<p><strong>Cross-device sync</strong>（<code>9.C27 Disney+</code> 揭露）：用戶在手機看到一半、晚上回家用電視繼續、播放進度跨裝置同步。Global Tables 自然解這個 access pattern — 用戶在不同 region 登入同帳號、寫入自動同步、最終一致性可接受場景。</p>
<p><strong>Global read（latency 優化）</strong>：跨地域用戶讀取就近 region 副本、latency 從 200ms 降到 &lt; 10ms。read 比 write 多很多倍的 workload（feed / catalog / user profile）受益最大。</p>
<p><strong>DR failover</strong>：region-level outage 時 application 切到 secondary region 繼續服務、RTO 通常 &lt; 5 分鐘（DNS / routing 切換時間、不含 application 端 reconnect）。</p>
<p><strong>B2C 也可能划算的場景</strong>：cross-device sync 是 <em>user-facing experience</em>、不是合規 / SLA driver。B2C 大規模平台（Disney+ / Spotify 類）也可能投資 Global Tables。判讀軸是「sync 體驗是否核心 UX」、不只「合約 SLA」。</p>
<h2 id="核心機制lww-conflict-resolution">核心機制：LWW conflict resolution</h2>
<p>Global Tables 的 first-class concept：</p>
<ul>
<li><strong>Multi-region active-active</strong>：每個 region 都能寫、async replication；typical replication latency &lt; 1s 但 <em>無 SLA</em></li>
<li><strong>LWW by wall clock</strong>：conflict 由 attribute <code>aws:rep:updatetime</code> 決定、純物理時間；不是 logical clock、不是 vector clock</li>
<li><strong>同 region read-your-write</strong>：本 region 寫立即可讀（同 region quorum 內）、其他 region 看到要等 replication</li>
<li><strong>Capacity 獨立</strong>：每個 region 自己的 RCU/WCU、<code>ReplicatedWriteCapacityUnits</code> 是跨 region replication 額外 WCU、按 region 數倍計</li>
</ul>
<p>對應 knowledge card：<a href="/blog/backend/knowledge-cards/consistency-level/" data-link-title="Consistency Level" data-link-desc="資料系統對讀寫一致性語意的可選擇層級">consistency level</a>、<a href="/blog/backend/knowledge-cards/rto/" data-link-title="RTO" data-link-desc="說明恢復時間目標如何約束事故回復策略">rto</a>、<a href="/blog/backend/knowledge-cards/rpo/" data-link-title="RPO" data-link-desc="說明恢復點目標如何定義可接受資料損失範圍">rpo</a>。</p>
<h2 id="設計流程">設計流程</h2>
<p>從 access pattern 分類到 reconciliation pipeline 的 6 步流程。</p>
<h4 id="step-1access-pattern-分類">Step 1：access pattern 分類</h4>
<p>把 table 中的資料分兩類：</p>
<ul>
<li><strong>region-pinned data</strong>：user 主要 region（合規 / 地理 affinity）；不啟用 Global Tables、用 region-pinned cluster</li>
<li><strong>global data</strong>：跨 region read / cross-device sync；啟用 Global Tables</li>
</ul>
<p>不是所有 table 都該上 Global Tables；user profile 跨 region 同步、但用戶交易紀錄可能該 pin 在合規 region。</p>
<h4 id="step-2啟用-global-tables">Step 2：啟用 Global Tables</h4>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">aws dynamodb update-table <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --table-name orders <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --replica-updates <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  <span class="s1">&#39;[{&#34;Create&#34;: {&#34;RegionName&#34;: &#34;us-east-1&#34;}}]&#39;</span></span></span></code></pre></div><p>加 region 後 vendor 自動 backfill；backfill 期間 capacity 雙倍（原 region + 新 region 同步流量）、要預留 capacity buffer。</p>
<h4 id="step-3application-寫入策略">Step 3：application 寫入策略</h4>
<p>兩種寫入策略：</p>
<ul>
<li><strong>home region write</strong>：每 user 固定一個 home region 寫、避免 conflict；user 跨 region 漫遊時透過 routing 仍寫 home</li>
<li><strong>nearest region write</strong>：latency 優先、user 寫就近 region；conflict 機率高、必須加 idempotency 跟 reconciliation</li>
</ul>
<p>選擇：</p>
<table>
  <thead>
      <tr>
          <th>場景</th>
          <th>寫入策略</th>
          <th>理由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>user profile / 設定</td>
          <td>home region write</td>
          <td>conflict 少、簡單</td>
      </tr>
      <tr>
          <td>cross-device sync</td>
          <td>nearest region write</td>
          <td>用戶在不同裝置同時操作、容忍 LWW</td>
      </tr>
      <tr>
          <td>訂單 / 金流</td>
          <td>home region write</td>
          <td>業務不容許 conflict 損失</td>
      </tr>
  </tbody>
</table>
<h4 id="step-4idempotency-設計">Step 4：idempotency 設計</h4>
<p>每筆 write 加 <code>request_id</code> 或 <code>client_timestamp</code>、application 端去重：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">def</span> <span class="nf">write_with_idempotency</span><span class="p">(</span><span class="n">user_id</span><span class="p">,</span> <span class="n">action</span><span class="p">,</span> <span class="n">request_id</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="n">table</span><span class="o">.</span><span class="n">put_item</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="n">Item</span><span class="o">=</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">            <span class="s2">&#34;PK&#34;</span><span class="p">:</span> <span class="sa">f</span><span class="s2">&#34;USER#</span><span class="si">{</span><span class="n">user_id</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">            <span class="s2">&#34;SK&#34;</span><span class="p">:</span> <span class="sa">f</span><span class="s2">&#34;ACTION#</span><span class="si">{</span><span class="n">action</span><span class="si">}</span><span class="s2">#</span><span class="si">{</span><span class="n">request_id</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">            <span class="s2">&#34;ts&#34;</span><span class="p">:</span> <span class="n">datetime</span><span class="o">.</span><span class="n">utcnow</span><span class="p">()</span><span class="o">.</span><span class="n">isoformat</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">            <span class="s2">&#34;request_id&#34;</span><span class="p">:</span> <span class="n">request_id</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="p">},</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="n">ConditionExpression</span><span class="o">=</span><span class="s2">&#34;attribute_not_exists(request_id)&#34;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="p">)</span></span></span></code></pre></div><p><code>ConditionExpression</code> 在同一 region 內擋重複；跨 region eventual 仍可能 race，conflict 落到 LWW + reconciliation。</p>
<blockquote>
<p><strong>Scope warning（重要）</strong>：「加 request_id 或 client_timestamp」具體實作屬通用工程知識、<code>9.C26 PayPay</code> case 揭露「通知不可丟失」的需求分層、<em>沒有</em> 揭露具體 idempotency 實作。引用 PayPay 時要降溫成「PayPay 揭露需求分層（通知 vs 訊息）、idempotency 為通用工程實作」、不寫成「PayPay 使用 request_id」（陷阱 4：把通用工程實作寫成 case 揭露）。</p></blockquote>
<h4 id="step-5conflict-detection">Step 5：conflict detection</h4>
<p>DynamoDB Streams 訂閱、Lambda 比較 <code>aws:rep:updatetime</code> 跟 application timestamp、抓出可疑 conflict 進 reconciliation queue：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">def</span> <span class="nf">detect_conflict</span><span class="p">(</span><span class="n">stream_event</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="n">new_image</span> <span class="o">=</span> <span class="n">stream_event</span><span class="p">[</span><span class="s2">&#34;dynamodb&#34;</span><span class="p">][</span><span class="s2">&#34;NewImage&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="n">repl_time</span> <span class="o">=</span> <span class="n">new_image</span><span class="p">[</span><span class="s2">&#34;aws:rep:updatetime&#34;</span><span class="p">][</span><span class="s2">&#34;S&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="n">app_time</span> <span class="o">=</span> <span class="n">new_image</span><span class="p">[</span><span class="s2">&#34;client_timestamp&#34;</span><span class="p">][</span><span class="s2">&#34;S&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">if</span> <span class="nb">abs</span><span class="p">(</span><span class="n">parse</span><span class="p">(</span><span class="n">repl_time</span><span class="p">)</span> <span class="o">-</span> <span class="n">parse</span><span class="p">(</span><span class="n">app_time</span><span class="p">))</span> <span class="o">&gt;</span> <span class="n">timedelta</span><span class="p">(</span><span class="n">seconds</span><span class="o">=</span><span class="mi">5</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="c1"># 可疑 conflict、進 reconciliation</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="n">sqs</span><span class="o">.</span><span class="n">send_message</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">            <span class="n">QueueUrl</span><span class="o">=</span><span class="n">RECONCILIATION_QUEUE</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">            <span class="n">MessageBody</span><span class="o">=</span><span class="n">json</span><span class="o">.</span><span class="n">dumps</span><span class="p">(</span><span class="n">stream_event</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="p">)</span></span></span></code></pre></div><blockquote>
<p><strong>Scope warning</strong>：DynamoDB Streams 用法屬通用工程實作、<code>9.C26 PayPay</code> case <em>沒有</em> 明示用 Streams、引用時要分層（PayPay 揭露需求、Streams 是工程實作的標準解）。</p></blockquote>
<h4 id="step-6reconciliation-pipeline">Step 6：reconciliation pipeline</h4>





<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">Conflict event → SQS queue → Lambda / human review → merge logic → write back</span></span></code></pre></div><p>merge logic 視業務而定：</p>
<ul>
<li>訂單金額 conflict：抓最大值（避免少收）</li>
<li>用戶設定 conflict：抓最新（user-facing 行為一致）</li>
<li>watchlist conflict：union（兩裝置加的都保留）</li>
</ul>
<p><strong>驗證點</strong>：DR drill 演 region outage、確認 secondary region 接管後 read / write 都正常；<code>ReplicationLatency</code> p99 &lt; 1s。</p>
<p><strong>Rollback boundary</strong>：region 可逐個移除、但 active-active 改 active-passive 期間 application 需配合路由切換；先 application 切再移 region、不可同時做。</p>
<h2 id="失敗模式">失敗模式</h2>
<p>實際部署常見的 5 種失敗：</p>
<h4 id="case-1lww-默默吃掉-write">Case 1：LWW 默默吃掉 write</h4>
<p>跨 region 同一 record concurrent update、後到的 write 因 timestamp 較大蓋過先到的；business 看到「我送出的更新沒了」、稽核 log 才發現 conflict。修法：critical write 加 <code>ConditionExpression</code> 比較 <code>version</code> attribute、conflict 時 application 端 retry + merge；不要依賴 LWW 作為 conflict 解。</p>
<h4 id="case-2clock-skew-讓-lww-倒置">Case 2：Clock skew 讓 LWW 倒置</h4>
<p>region A 寫入 timestamp 因 NTP skew 比 region B 後寫快 200ms、結果舊資料贏。修法：依靠 application timestamp + monotonic counter、不依賴 server wall clock；critical write 用 conditional version + retry。</p>
<blockquote>
<p><strong>Scope warning</strong>：「200ms NTP skew」具體數字屬通用工程估算、case 未揭露具體 skew 範圍。</p></blockquote>
<h4 id="case-3replication-lag-撞-slo">Case 3：Replication lag 撞 SLO</h4>
<p>大 batch write 期間 replication lag 從 1s 變 30s、跨 region read 看到 30s 前資料、application 端 user 操作異常。修法：偵測 <code>ReplicationLatency</code> 升高時 application 端切 home region read、避免跨 region eventual read；把 replication lag 加進 SLO 監控、設 alarm。</p>
<h4 id="case-4dr-切換後-stale-data-持續-propagate">Case 4：DR 切換後 stale data 持續 propagate</h4>
<p>primary region outage 切到 secondary、舊 primary 恢復後仍把 outdated data 推回去、覆蓋 secondary 期間的新寫入。修法：DR runbook 含「舊 primary 恢復後人工 reconciliation 或重建」step、不可全自動 catch-up；舊 primary 恢復前先確認 replication 方向是「從 secondary catch up」而非「推舊資料回 secondary」。</p>
<h4 id="case-5跨-region-transaction-失敗">Case 5：跨 region transaction 失敗</h4>
<p>application 試圖跨 region <code>TransactWriteItems</code>、API 不支援跨 region transaction、原子性破裂。修法：transaction 限同 region 內、跨 region 用 <a href="/blog/backend/knowledge-cards/saga/" data-link-title="Saga" data-link-desc="處理跨服務分散事務的補償型 transaction 序列、用最終一致換 ACID atomic">saga</a> + idempotent + reconciliation；不要把同 region 的 transaction 假設搬到跨 region。</p>
<p><strong>Anti-recommendation</strong>：single-region availability 已達 99.95% + RTO 可接受 1 小時 + 預算敏感（特別 B2C 場景）→ 用 PITR + 跨 region backup 而非 Global Tables；Global Tables cost = N × single region cost 不止（對應 B2B vs B2C driver 對比）。</p>
<h2 id="容量與觀測">容量與觀測</h2>
<p>CloudWatch metric：</p>
<ul>
<li><code>ReplicationLatency</code>：p99 通常 &lt; 1s、建議 SLO 設 5s alarm</li>
<li><code>PendingReplicationCount</code>：積壓量、batch write 期間會升高</li>
<li><code>ReplicatedWriteCapacityUnits</code>：跨 region replication 額外 WCU、按 region 數倍計</li>
</ul>
<p>DynamoDB Streams + Lambda：抓 conflict event、寫進獨立 audit table；reconciliation job 從 audit table 跑、不直接動 base table。</p>
<p><strong>Region-level dashboard</strong>：每個 region 獨立 capacity / latency / error rate panel；DR drill 看是否能在 RTO 內切換。</p>
<p><strong>Cost monitoring</strong>：</p>
<ul>
<li>Global Tables cost ≈ N region × base cost + replication WCU</li>
<li>4 region 成本約 4.5x single region；15 region（Genesys 規模）約 15x</li>
<li>每多一個 region 都要重新算 ROI（軸 6 vendor crossover 的延伸）</li>
</ul>
<p><strong>指標口徑紀律</strong>（重要）：99.99% / 99.999% SLA 是 <em>滾動指標 + 歷史值</em>、不是永久承諾；引用 Genesys 99.999% 時明示「12 個月滾動 / customer-facing」、不寫成「DynamoDB 保證 99.999%」。</p>
<p>接回 <a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">4.20 Observability Evidence Package</a>、<a href="/blog/backend/09-performance-capacity/capacity-planning/" data-link-title="9.6 容量規劃模型" data-link-desc="peak forecast、headroom budget、growth curve、autoscaling sizing">9.6 容量規劃模型</a>。</p>
<h2 id="邊界與整合">邊界與整合</h2>
<h3 id="frame-5region-pinned-global-tables-吸收合規邊界">Frame 5：region-pinned Global Tables 吸收合規邊界</h3>
<p>Global Tables 不只是高可用工具、也是 <em>合規邊界</em>（<a href="/blog/backend/knowledge-cards/data-residency/" data-link-title="Data Residency" data-link-desc="合規要求資料留在特定地理邊界內、跨境複製違反合規、推動 fleet 拓樸決策">Data Residency</a> 拓樸）的吸收層。DynamoDB 在 vendor capability 層級支援 <em>region-pinned replication</em> — 每張 table 可獨立決定哪些 region 參與 replication group、部分 region 可不加入。這個 capability 同時服務三類場景：合規分離（受監管市場資料不跨境）、cost / latency 取捨（資料只在主要服務 region 同步）、災備拓樸（少數 region 純讀備援）。<code>9.C24 Genesys</code> 15 region 揭露的是 <em>延遲就近接入</em> 的 B2B SaaS 拓樸（客戶服務延遲敏感、必須在客戶所在地有 region）— case 原文沒明示合規應用、但 region-pinned capability 在 Genesys 規模下天然能容納合規市場分離、是同 capability 的 <em>可能應用維度</em>、不是 case 已驗證的具體實踐。</p>
<p>跨 vendor 對照：</p>
<table>
  <thead>
      <tr>
          <th>Vendor</th>
          <th>合規吸收機制</th>
          <th>拓樸特性</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>DynamoDB</td>
          <td>region-pinned Global Tables（按 region 開關 replication、各市場可分離）</td>
          <td>仍是 active-active、但 replication 範圍可控</td>
      </tr>
      <tr>
          <td>Aurora</td>
          <td>fleet 拓樸（每市場獨立 cluster、合規禁止跨境 = Global Database 反指標）</td>
          <td>active-passive per market、跨市場不複製</td>
      </tr>
      <tr>
          <td>CockroachDB</td>
          <td>locality + placement（邏輯一個 cluster + region pinning + Outposts）</td>
          <td>單 logical cluster、physical row 鎖在合規 region</td>
      </tr>
      <tr>
          <td>MongoDB / Cosmos DB</td>
          <td>cluster-per-region（無 row-level locality 等價物、整 cluster 切割）</td>
          <td>各 region 獨立 cluster、application 層做市場 routing</td>
      </tr>
  </tbody>
</table>
<p><strong>為什麼 DynamoDB 在這個 frame 退化得最輕</strong>：Global Tables 的 region 開關是 <em>attribute 級</em> 設計（每張 table 可獨立決定哪些 region 參與）、不像 Aurora 必須整 cluster 拆。讀者要把「跨境合規 + 高可用」雙重需求兼顧時、DynamoDB 是最少結構性改造的路徑 — 但代價是 LWW conflict 跟 reconciliation 設計仍要自己做。</p>
<p><strong>何時 region-pinned 而非 active-active</strong>：受監管金融 / 個資跨境禁止的市場（如 GDPR strict 條款區、中國個資法 PIPL、巴西 LGPD）— 該 region 仍開 DynamoDB table、但 <em>不加入 Global Tables replication group</em>、跟其他 region 完全切割。capability 設計上支援這種按 region 開關 replication 的拓樸；具體是否套用、要看 <em>讀者自己的市場合規清單</em>、不是把 Genesys 規模當必然證據（Genesys case 揭露的是延遲就近接入、未明示合規分離實踐）。</p>
<h3 id="disney-vs-genesys兩種-global-tables-工程動機">Disney+ vs Genesys：兩種 Global Tables 工程動機</h3>
<p><code>9.C27 Disney+</code> 跟 <code>9.C24 Genesys</code> 是 Global Tables 兩種不同的工程動機：</p>
<ul>
<li><strong>Disney+</strong>：cross-device sync 是 user-facing UX、watchlist + 播放進度跨裝置同步、B2C 但 sync 是 core experience</li>
<li><strong>Genesys</strong>：99.999% B2B SaaS 合約義務、15 region active-active、客服平台停線損失極大</li>
</ul>
<p>兩個 case 都用 Global Tables、但動機完全不同 — Disney+ 是 UX driver、Genesys 是合約 driver。寫進你自己的設計時要明示自己屬哪一型，因為兩種型別的 cost 容忍度跟 conflict 容忍度完全不同。</p>
<h3 id="sibling-與-cross-link">Sibling 與 cross-link</h3>
<ul>
<li><a href="/blog/backend/01-database/vendors/dynamodb/consistency-model-optimization/" data-link-title="DynamoDB Strongly Consistent → Eventually Consistent：same protocol, different contract" data-link-desc="DynamoDB consistency model 從 strongly consistent read 改 eventually consistent read 是 50% cost 優化但風險集中在 application contract — 同 vendor / 同 protocol / 同 table / 不同 read consistency；驗證 [#128](/report/data-topology-as-audit-dimension/) self-aware limitation 提出的 consistency axis 候選；涵蓋 read pattern audit / 5 個 production 踩雷">consistency-model-optimization</a> — 同 region eventual / strong 取捨、本篇是跨 region 延伸</li>
<li><a href="/blog/backend/01-database/vendors/dynamodb/on-demand-vs-provisioned/" data-link-title="DynamoDB On-Demand vs Provisioned：6 軸決策、auto-scaling 邊界與 cost crossover" data-link-desc="capacity mode 選擇不是單軸 peak/avg ratio；本文展開 6 軸決策（peak/avg / 讀寫比 trend / surge 暫時 vs 永久 baseline / predictable-peak vs flash-sale / DBA 工時釋放 / vendor vs 自管 cost crossover），含 Zomato 50% 成本下降、Zoom 30x permanent surge、Amazon Ads sustained workload 等 case 分軸 anchor">on-demand-vs-provisioned</a> — 多 region capacity 規劃放大、軸 5 工時釋放在 multi-region 更顯著</li>
<li><a href="/blog/backend/01-database/vendors/dynamodb/partition-key-antipatterns/" data-link-title="DynamoDB Partition Key 反模式與 Write Sharding：composite key 修復跟 mode × partition 交叉判讀" data-link-desc="DynamoDB partition 上限 1000 WCU 是 hot partition 的根因；composite key（event_id &#43; shard suffix）跟 calculated shard（hash % N）兩種修法、mode × partition 在 provisioned / on-demand 不同表現，以及 9.C15 Tixcraft 6750x 擴展的工程細節">partition-key-antipatterns</a> — hot partition 跨 region 同樣存在、每個 region 的 partition 都要均勻</li>
<li><a href="/blog/backend/01-database/vendors/dynamodb/single-table-design-pattern/" data-link-title="DynamoDB Single-Table Design：從適用度前置判讀到 access pattern 反推 PK/SK" data-link-desc="DynamoDB single-table 設計不是「資料表越少越好」，而是 access pattern 反推 PK/SK 跟 GSI；本文先做 DynamoDB 適用度 4 軸前置判讀（PK 天然均勻 / control plane vs data plane / consistency / access pattern 穩定），再展開設計流程、failure modes 與 durable queue 正向用例">single-table-design-pattern</a> — single-table 設計在 multi-region 仍適用、access pattern 反推 PK/SK 不變</li>
<li>替代路由：global strong consistency 必要 → Spanner / Cosmos DB strong consistency level</li>
<li>Migration playbook：single-region → Global Tables 屬 topology re-layout、對應 <a href="/blog/posts/migration-playbook-%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84stage-0-variant-%E8%A6%8F%E5%8A%83%E6%8A%8A-collapse-%E7%8E%87%E5%BE%9E-60-%E9%99%8D%E5%88%B0-0/" data-link-title="Migration Playbook 方法論的演化紀錄：Stage 0 variant 規劃把 collapse 率從 60% 降到 0%" data-link-desc="跨 vendor migration playbook 需要獨立寫作方法論的依據，以及這套方法論從三輪 batch dogfood 中演化出來的驗證證據。">migration playbook methodology</a> Type F</li>
<li>跟 <a href="/blog/backend/09-performance-capacity/cases/genesys-dynamodb-99999-availability/" data-link-title="9.C24 Genesys：用 DynamoDB 在 15 region 跑出 99.999% 可用性" data-link-desc="Genesys 客服平台用 DynamoDB 為預設資料層、跨 15 主 region &#43; 5 衛星 region、達成 12 個月 99.999% 可用性">Genesys 9.C24</a> 互引：15 region 5 個 9 可用性的工程實踐 + B2B SaaS 業務 driver</li>
<li>跟 <a href="/blog/backend/09-performance-capacity/cases/disney-plus-content-metadata/" data-link-title="9.C27 Disney&#43;：DynamoDB 撐每日數十億動作的觀看歷史" data-link-desc="Disney&#43; 用 DynamoDB 撐每日數十億動作的觀看歷史、watchlist、播放進度等串流 metadata">Disney+ 9.C27</a> 互引：cross-device sync 作為正向 access pattern</li>
<li>跟 <a href="/blog/backend/09-performance-capacity/cases/paypay-mobile-payment-messaging/" data-link-title="9.C26 PayPay：行動支付每日 3 億訊息的 DynamoDB 後端" data-link-desc="日本最大行動支付 PayPay 每日 3 億訊息、用 DynamoDB 處理通知與訊息功能、支撐次秒級反應">PayPay 9.C26</a> 互引：揭露需求分層（通知 vs 訊息）、idempotency / Streams 為通用工程實作、PayPay 未公開揭露具體實作</li>
</ul>
]]></content:encoded></item><item><title>MongoDB Aggregation Pipeline Optimization：stage 順序、index 配合與 memory 邊界</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/mongodb/aggregation-pipeline-optimization/</link><pubDate>Wed, 27 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/mongodb/aggregation-pipeline-optimization/</guid><description>&lt;p>MongoDB aggregation pipeline 是 document model 做 analytical query 的主要介面、stage stream 設計直觀但 production 容易踩雷 — 上線時 200ms、半年後資料量翻倍變 8s、加 index 沒用；profiler 顯示 stage 之間在 memory 累積上百 MB temp data。Aggregation pipeline 的最佳化跟 RDBMS 的 SQL planner 完全不同邏輯 — RDBMS 靠 planner 自動重排 join / filter、MongoDB 靠寫 query 的人手動排 stage 順序。本文把 stage 機制、index 配合、memory 邊界、cross-shard 限制講清楚、並對「report dashboard 跑爆 primary」這個常見 anti-pattern 給治理路徑。&lt;/p>
&lt;p>本文不重複 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/mongodb/" data-link-title="MongoDB" data-link-desc="Document database 代表、Atlas managed、跨雲可用、許多大規模平台從 MongoDB 起家">MongoDB vendor overview&lt;/a> 已寫過的 aggregation 簡介 — 而是 production tuning + 失敗修復的實作層教學。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>前置閱讀&lt;/strong>：MongoDB workload 適配判讀（document shape 主導 / contract layer 該放哪 / 跨雲 hedging 是否需要）見 &lt;a href="../schema-design-pattern/#%e5%95%8f%e9%a1%8c%e6%83%85%e5%a2%83document-%e8%87%aa%e7%94%b1%e7%9a%84%e5%be%8c%e5%ba%a7%e5%8a%9b">schema-design-pattern 開頭 3 軸前置判讀&lt;/a>。本文聚焦 aggregation pipeline 操作層、是 &lt;em>已選 MongoDB 後&lt;/em> 的 query 層工程議題、不重複前置判讀。&lt;/p>&lt;/blockquote>
&lt;h2 id="問題情境aggregation-是-hot-path-的反模式">問題情境：aggregation 是 hot path 的反模式&lt;/h2>
&lt;p>典型觸發場景：報表 pipeline 上線時 200ms、半年後資料量翻倍變 8s、加 index 沒用；profiler 顯示 stage 之間在 memory 累積上百 MB temp data。&lt;/p>
&lt;p>進一步徵兆：&lt;/p>
&lt;ul>
&lt;li>「OLTP collection 上跑 analytical query」的混合 workload：把 &lt;code>$group + $lookup + $sort&lt;/code> 接成長 pipeline、aggregation 把整個 working set 從 cache 擠走&lt;/li>
&lt;li>Sharded cluster 上跑 cross-shard aggregation：&lt;code>$group&lt;/code> / &lt;code>$sort&lt;/code> 必須在 mongos 合併、mongos 變單點瓶頸&lt;/li>
&lt;li>&lt;code>$lookup&lt;/code> 出現在 hot path：每筆 input doc 都要去另一個 collection 查、嚴格意義上是 N+1&lt;/li>
&lt;li>&lt;code>db.serverStatus().metrics.aggStageCounters&lt;/code> 飆、&lt;code>executionStats.executionTimeMillis&lt;/code> 跟 doc 數線性增長&lt;/li>
&lt;li>Profiler 報 &lt;code>usedDisk: true&lt;/code>、aggregation OOM kill &lt;code>QueryExceededMemoryLimitNoDiskUseAllowed&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>Case anchor：report dashboard 跑爆 primary 的具體 incident 細節需未來 case 補完、本文以「常見 anti-pattern」處理、不憑空編造 incident 數字。側面引用 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/microsoft-365-cosmos-db-analytics/" data-link-title="9.C30 Microsoft 365：從 MongoDB 遷移到 Cosmos DB 的分析平台" data-link-desc="Microsoft 365 把使用分析平台從 MongoDB 遷移到 Cosmos DB、planet-scale 全球分散式分析">9.C30 Microsoft 365&lt;/a> — 從 MongoDB 把 analytics 分離出來的 driver。&lt;/p></description><content:encoded><![CDATA[<p>MongoDB aggregation pipeline 是 document model 做 analytical query 的主要介面、stage stream 設計直觀但 production 容易踩雷 — 上線時 200ms、半年後資料量翻倍變 8s、加 index 沒用；profiler 顯示 stage 之間在 memory 累積上百 MB temp data。Aggregation pipeline 的最佳化跟 RDBMS 的 SQL planner 完全不同邏輯 — RDBMS 靠 planner 自動重排 join / filter、MongoDB 靠寫 query 的人手動排 stage 順序。本文把 stage 機制、index 配合、memory 邊界、cross-shard 限制講清楚、並對「report dashboard 跑爆 primary」這個常見 anti-pattern 給治理路徑。</p>
<p>本文不重複 <a href="/blog/backend/01-database/vendors/mongodb/" data-link-title="MongoDB" data-link-desc="Document database 代表、Atlas managed、跨雲可用、許多大規模平台從 MongoDB 起家">MongoDB vendor overview</a> 已寫過的 aggregation 簡介 — 而是 production tuning + 失敗修復的實作層教學。</p>
<blockquote>
<p><strong>前置閱讀</strong>：MongoDB workload 適配判讀（document shape 主導 / contract layer 該放哪 / 跨雲 hedging 是否需要）見 <a href="../schema-design-pattern/#%e5%95%8f%e9%a1%8c%e6%83%85%e5%a2%83document-%e8%87%aa%e7%94%b1%e7%9a%84%e5%be%8c%e5%ba%a7%e5%8a%9b">schema-design-pattern 開頭 3 軸前置判讀</a>。本文聚焦 aggregation pipeline 操作層、是 <em>已選 MongoDB 後</em> 的 query 層工程議題、不重複前置判讀。</p></blockquote>
<h2 id="問題情境aggregation-是-hot-path-的反模式">問題情境：aggregation 是 hot path 的反模式</h2>
<p>典型觸發場景：報表 pipeline 上線時 200ms、半年後資料量翻倍變 8s、加 index 沒用；profiler 顯示 stage 之間在 memory 累積上百 MB temp data。</p>
<p>進一步徵兆：</p>
<ul>
<li>「OLTP collection 上跑 analytical query」的混合 workload：把 <code>$group + $lookup + $sort</code> 接成長 pipeline、aggregation 把整個 working set 從 cache 擠走</li>
<li>Sharded cluster 上跑 cross-shard aggregation：<code>$group</code> / <code>$sort</code> 必須在 mongos 合併、mongos 變單點瓶頸</li>
<li><code>$lookup</code> 出現在 hot path：每筆 input doc 都要去另一個 collection 查、嚴格意義上是 N+1</li>
<li><code>db.serverStatus().metrics.aggStageCounters</code> 飆、<code>executionStats.executionTimeMillis</code> 跟 doc 數線性增長</li>
<li>Profiler 報 <code>usedDisk: true</code>、aggregation OOM kill <code>QueryExceededMemoryLimitNoDiskUseAllowed</code></li>
</ul>
<p>Case anchor：report dashboard 跑爆 primary 的具體 incident 細節需未來 case 補完、本文以「常見 anti-pattern」處理、不憑空編造 incident 數字。側面引用 <a href="/blog/backend/09-performance-capacity/cases/microsoft-365-cosmos-db-analytics/" data-link-title="9.C30 Microsoft 365：從 MongoDB 遷移到 Cosmos DB 的分析平台" data-link-desc="Microsoft 365 把使用分析平台從 MongoDB 遷移到 Cosmos DB、planet-scale 全球分散式分析">9.C30 Microsoft 365</a> — 從 MongoDB 把 analytics 分離出來的 driver。</p>
<h2 id="核心機制">核心機制</h2>
<p>Aggregation pipeline 是 stage 序列：每個 stage 接 stream of document、產出 stream of document。Stage 順序直接決定後續 stage 處理量 — 第一個 stage 是 IXSCAN 還是 COLLSCAN、<code>$match</code> 推到前面還是後面、<code>$project</code> 早 drop 還是晚 drop、都會放大或縮小後續 cost。</p>
<p><strong>Optimizer rewrite</strong>：MongoDB 會自動把 <code>$match</code> / <code>$project</code> 往前推、把 <code>$sort + $limit</code> 合併成 top-K、但不保證所有 case。用 <code>explain(&quot;executionStats&quot;)</code> 看 rewrite 後的 effective pipeline、不要靠原始 pipeline 推斷實際執行順序。</p>
<p><strong>Index 配合</strong>：pipeline 的 <em>第一個 stage</em> 若是 <code>$match</code> 或 <code>$sort</code>、且能對到 index、就走 IXSCAN。中間 stage 都是 in-memory stream、沒 index 概念。所以 <code>$match</code> 永遠該排第一、配合對應 index。</p>
<p><strong>Memory 邊界</strong>：每個 aggregation stage 預設 100MB memory 上限、超過要 <code>allowDiskUse: true</code>（4.2+ 是預設）。Disk spill 啟動後 IO 嚴重拖慢、aggregation 變慢 50-100x。</p>
<p><strong><code>$lookup</code> 在 sharded cluster</strong>：foreign collection 不能 sharded（5.0 前完全不行、5.0+ 有限放寬）；<code>$lookup</code> 本質是 nested loop join、沒 hash join / merge join — 對大 collection 不可用。</p>
<p><strong><code>$facet</code> 平行多 pipeline</strong>：但所有 facet 共享同一個 100MB 限制、複雜 facet 容易撞 memory ceiling。</p>
<p><strong><code>$merge</code> / <code>$out</code></strong>：把結果寫回 collection（pre-computed view / materialized view）— 把 hot analytical query 移出 read path、是治理 anti-pattern 的主要工具。</p>
<p>對應 knowledge card：<a href="/blog/backend/knowledge-cards/hot-partition/" data-link-title="Hot Partition" data-link-desc="說明分散式 KV / OLTP 中、單一 partition 流量遠超其他的容量問題">hot-partition</a>（aggregation 集中讀單 shard 的副作用）、<a href="/blog/backend/knowledge-cards/document-store/" data-link-title="Document Store" data-link-desc="說明以 JSON 文件與彈性 schema 提供資料存取的模式，以及它仍需的治理邊界">document-store</a>、<a href="/blog/backend/knowledge-cards/stale-read/" data-link-title="Stale Read" data-link-desc="讀取到落後於最新寫入版本的舊資料">stale-read</a>（從 secondary 跑 aggregation 的 trade-off）。</p>
<h2 id="操作流程">操作流程</h2>
<p><strong>Step 0：把壞 pipeline 跟好 pipeline 並排</strong>。看一個簡化但典型的優化：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// 壞：lookup 在 match 前、sort 沒 limit、project 在最後
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="nx">db</span><span class="p">.</span><span class="nx">orders</span><span class="p">.</span><span class="nx">aggregate</span><span class="p">([</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="p">{</span> <span class="nx">$lookup</span><span class="o">:</span> <span class="p">{</span> <span class="nx">from</span><span class="o">:</span> <span class="s2">&#34;users&#34;</span><span class="p">,</span> <span class="nx">localField</span><span class="o">:</span> <span class="s2">&#34;userId&#34;</span><span class="p">,</span> <span class="nx">foreignField</span><span class="o">:</span> <span class="s2">&#34;_id&#34;</span><span class="p">,</span> <span class="nx">as</span><span class="o">:</span> <span class="s2">&#34;user&#34;</span> <span class="p">}</span> <span class="p">},</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="p">{</span> <span class="nx">$match</span><span class="o">:</span> <span class="p">{</span> <span class="nx">status</span><span class="o">:</span> <span class="s2">&#34;completed&#34;</span><span class="p">,</span> <span class="s2">&#34;user.region&#34;</span><span class="o">:</span> <span class="s2">&#34;ap-tokyo&#34;</span> <span class="p">}</span> <span class="p">},</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="p">{</span> <span class="nx">$sort</span><span class="o">:</span> <span class="p">{</span> <span class="nx">createdAt</span><span class="o">:</span> <span class="o">-</span><span class="mi">1</span> <span class="p">}</span> <span class="p">},</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="p">{</span> <span class="nx">$project</span><span class="o">:</span> <span class="p">{</span> <span class="nx">_id</span><span class="o">:</span> <span class="mi">1</span><span class="p">,</span> <span class="nx">total</span><span class="o">:</span> <span class="mi">1</span><span class="p">,</span> <span class="nx">createdAt</span><span class="o">:</span> <span class="mi">1</span> <span class="p">}</span> <span class="p">}</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="c1">// 好：可推前的 match 寫前面、sort + limit 配對、project 早寫
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"></span><span class="nx">db</span><span class="p">.</span><span class="nx">orders</span><span class="p">.</span><span class="nx">aggregate</span><span class="p">([</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="p">{</span> <span class="nx">$match</span><span class="o">:</span> <span class="p">{</span> <span class="nx">status</span><span class="o">:</span> <span class="s2">&#34;completed&#34;</span> <span class="p">}</span> <span class="p">},</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">  <span class="p">{</span> <span class="nx">$sort</span><span class="o">:</span> <span class="p">{</span> <span class="nx">createdAt</span><span class="o">:</span> <span class="o">-</span><span class="mi">1</span> <span class="p">}</span> <span class="p">},</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">  <span class="p">{</span> <span class="nx">$limit</span><span class="o">:</span> <span class="mi">100</span> <span class="p">},</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">  <span class="p">{</span> <span class="nx">$lookup</span><span class="o">:</span> <span class="p">{</span> <span class="nx">from</span><span class="o">:</span> <span class="s2">&#34;users&#34;</span><span class="p">,</span> <span class="nx">localField</span><span class="o">:</span> <span class="s2">&#34;userId&#34;</span><span class="p">,</span> <span class="nx">foreignField</span><span class="o">:</span> <span class="s2">&#34;_id&#34;</span><span class="p">,</span> <span class="nx">as</span><span class="o">:</span> <span class="s2">&#34;user&#34;</span> <span class="p">}</span> <span class="p">},</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">  <span class="p">{</span> <span class="nx">$match</span><span class="o">:</span> <span class="p">{</span> <span class="s2">&#34;user.region&#34;</span><span class="o">:</span> <span class="s2">&#34;ap-tokyo&#34;</span> <span class="p">}</span> <span class="p">},</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">  <span class="p">{</span> <span class="nx">$project</span><span class="o">:</span> <span class="p">{</span> <span class="nx">_id</span><span class="o">:</span> <span class="mi">1</span><span class="p">,</span> <span class="nx">total</span><span class="o">:</span> <span class="mi">1</span><span class="p">,</span> <span class="nx">createdAt</span><span class="o">:</span> <span class="mi">1</span><span class="p">,</span> <span class="s2">&#34;user.name&#34;</span><span class="o">:</span> <span class="mi">1</span> <span class="p">}</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="p">])</span></span></span></code></pre></div><p>差別：壞 pipeline 對整個 orders 做 lookup、然後才過濾；好 pipeline 先過濾 + top-100、只對 100 筆做 lookup、再過濾 lookup 結果。實際 collection 大時兩者差 50-100x。</p>
<p><strong>Step 1：拿 explain plan</strong>。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">db</span><span class="p">.</span><span class="nx">coll</span><span class="p">.</span><span class="nx">explain</span><span class="p">(</span><span class="s2">&#34;executionStats&#34;</span><span class="p">).</span><span class="nx">aggregate</span><span class="p">([...])</span></span></span></code></pre></div><p>看 <code>stages[]</code> 顯示 rewrite 後的 effective pipeline、<code>executionTimeMillis</code>、<code>totalDocsExamined / totalDocsReturned</code> 比值、是否 <code>usedDisk</code>。</p>
<p><strong>Step 2：把 <code>$match</code> 推到最前</strong>。越早過濾、後續 stage 處理量越小。Optimizer 通常自己會推、但 <code>$lookup</code> 之後的 <code>$match</code> 不會自動推到 <code>$lookup</code> 之前 — 因為 lookup 出的欄位才能被那個 match 用、邏輯依賴。寫 query 時就把能推前的 <code>$match</code> 寫前面。</p>
<p><strong>Step 3：對 <code>$match</code> 欄位建 compound index</strong>。確保 <code>executionStages</code> 顯示 <code>IXSCAN</code> 而不是 <code>COLLSCAN</code>。Compound index 順序敏感 — <code>{ status: 1, createdAt: -1 }</code> 對 <code>{ status: ..., createdAt: $gte: ... }</code> 高效、對 <code>{ createdAt: $gte: ... }</code> 走不到 index。</p>
<p><strong>Step 4：<code>$sort + $limit</code> 寫在一起</strong>。Optimizer 才會推 top-K（不需要 full sort、只需要 heap）。單 <code>$sort</code> 不限 limit 會做 full sort、容易撞 memory。</p>
<p><strong>Step 5：<code>$project</code> 早寫</strong>。把不需要的欄位早期 drop、減少後續 stage 處理 doc size。對大 document 特別有效。</p>
<p><strong>Step 6：把 hot analytical pipeline 寫成 materialized view</strong>。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nx">db</span><span class="p">.</span><span class="nx">orders</span><span class="p">.</span><span class="nx">aggregate</span><span class="p">([</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="p">{</span> <span class="nx">$match</span><span class="o">:</span> <span class="p">{</span> <span class="nx">createdAt</span><span class="o">:</span> <span class="p">{</span> <span class="nx">$gte</span><span class="o">:</span> <span class="nx">ISODate</span><span class="p">(</span><span class="s2">&#34;2026-05-01&#34;</span><span class="p">)</span> <span class="p">}</span> <span class="p">}</span> <span class="p">},</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="p">{</span> <span class="nx">$group</span><span class="o">:</span> <span class="p">{</span> <span class="nx">_id</span><span class="o">:</span> <span class="s2">&#34;$customerId&#34;</span><span class="p">,</span> <span class="nx">total</span><span class="o">:</span> <span class="p">{</span> <span class="nx">$sum</span><span class="o">:</span> <span class="s2">&#34;$amount&#34;</span> <span class="p">}</span> <span class="p">}</span> <span class="p">},</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="p">{</span> <span class="nx">$merge</span><span class="o">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">      <span class="nx">into</span><span class="o">:</span> <span class="s2">&#34;monthly_customer_summary&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">      <span class="nx">on</span><span class="o">:</span> <span class="s2">&#34;_id&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">      <span class="nx">whenMatched</span><span class="o">:</span> <span class="s2">&#34;merge&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">      <span class="nx">whenNotMatched</span><span class="o">:</span> <span class="s2">&#34;insert&#34;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="p">}}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">])</span></span></span></code></pre></div><p>定時更新（cron / 5 分鐘一次）、application 讀 materialized view 而不是即時跑 aggregation。</p>
<p><strong>Step 7：sharded cluster 處理</strong>。避免在 hot path 用 cross-shard <code>$lookup</code> / <code>$group</code>、或把這類 query 路由到 analytical replica（用 tag set + read preference）、見 <a href="../replica-set-read-preference/">replica set read preference</a>。</p>
<p>驗證點：</p>
<ul>
<li><code>executionTimeMillis</code> 在預期 budget 內</li>
<li><code>totalDocsExamined / totalDocsReturned</code> 比值接近 1（過濾效率高）</li>
<li>無 <code>usedDisk: true</code></li>
<li>無 stage 看到 <code>inMemory &gt; 50MB</code></li>
</ul>
<p>Rollback boundary：pipeline 改寫是 application code 變更、可以灰度；materialized view（<code>$merge</code>）需備份 target collection 才能還原。</p>
<h3 id="典型-tuning-過程200ms--8s--250ms">典型 tuning 過程（200ms → 8s → 250ms）</h3>
<p>一個常見的 production pipeline 演化路徑：</p>
<ol>
<li><strong>上線時 200ms</strong>：collection 100K doc、<code>$match</code> 過濾 95%、<code>$lookup</code> 只跑 5K 次、in-memory <code>$sort</code> 處理 5K row 在 100MB 內</li>
<li><strong>半年後 8s</strong>：collection 長到 2M doc、<code>$match</code> 仍過濾 95% 但變 100K row、<code>$lookup</code> 跑 100K 次（5K → 100K 是 20x）、<code>$sort</code> 在 in-memory 撞 100MB 開始 disk spill、IO 100x 退化</li>
<li><strong>加 compound index 沒用</strong>：index 是給 <code>$match</code> 用的、但 <code>$match</code> 之後的 stage（<code>$lookup</code> / <code>$sort</code>）走的是 in-memory pipeline、index 救不了</li>
<li><strong>修法到 250ms</strong>：(a) <code>$sort + $limit</code> 配對讓 optimizer 走 top-K、避免 full sort (b) 改 schema embed 把 <code>$lookup</code> 拿掉（見 <a href="../schema-design-pattern/">schema design pattern</a>）(c) hot pipeline 寫成 <code>$merge</code> materialized view、application 讀 view 不跑 aggregation</li>
</ol>
<p>關鍵教訓：aggregation 慢的原因不在 query 本身、在 <em>資料形狀演進</em>。Index 是 hot path 的第一個槓桿、但只對 <code>$match</code> / <code>$sort</code> 第一 stage 有效；後續 stage 要靠 stage 順序、materialized view、schema denormalize 來救。</p>
<h2 id="失敗模式">失敗模式</h2>
<p><strong><code>$lookup</code> 在 hot path</strong>：list page 每行去另一 collection 查、p99 隨 page size 線性增。應在 schema design 階段 denormalize、把 read-together 資料 embed 回 aggregate root（見 <a href="../schema-design-pattern/">schema design pattern</a>）。</p>
<p><strong><code>$sort</code> 不帶 limit + 沒 index</strong>：全表 in-memory sort、撞 100MB 限制 → OOM 或 disk spill。<code>allowDiskUse: true</code> 解 OOM 但 IO 100x 退化。修法是建對應 index 走 IXSCAN sort、或限 limit 走 top-K。</p>
<p><strong>Sharded cluster cross-shard aggregation</strong>：<code>$group</code> 階段所有 partial result 跑到 mongos 合併、mongos memory + CPU 爆。修法是 group key 包含 shard key prefix（讓 group 在 shard 內完成）、或路由到 analytical replica 跑。</p>
<p><strong>Stage 順序錯</strong>：<code>$lookup</code> 放在 <code>$match</code> 前、等於對全表都做 lookup 再過濾、每個 input doc 都觸發 lookup。<code>$match</code> 永遠該排第一。</p>
<p><strong>Aggregation 把 working set 擠走</strong>：OLTP 的 hot page 被 aggregation 的 cold scan 擠出 cache、整體 query latency 一起退化。修法是 analytical workload 跟 OLTP read 隔離（read preference tag）、或搬走 analytical（見下面 anti-recommendation）。</p>
<p><strong><code>$facet</code> 滿載</strong>：四個 facet 各跑大 pipeline、共享 100MB 限制立刻爆。修法是拆成獨立 query、不要硬塞 facet。</p>
<p>Anti-recommendation：</p>
<ul>
<li><strong>報表 / BI / analytics workload 跑 MongoDB primary 是反模式</strong>：應該 (a) 設定 analytical secondary + read preference tag (b) 用 <code>$merge</code> 寫到 reporting collection (c) 進階用 BI Connector / data lake / 把 analytical workload 整批搬到 <a href="https://clickhouse.com">ClickHouse</a> / BigQuery</li>
<li><strong>「report dashboard 跑爆 primary」典型 anti-pattern</strong>：BI 工具直連 MongoDB primary 跑長 pipeline、cache eviction 把 OLTP working set 擠走、p99 latency 在報表時段集體升。沒拿到具體 incident 數字、不在本文編造、改寫成「常見 anti-pattern」並推到治理路徑</li>
<li><strong>Aggregation 不能解 read scaling</strong>：aggregation 是 OLTP 的補位、不是 read scaling 的主路。Read scaling 在大規模 OLTP 走 cache + freshness token（見 <a href="../connection-management-and-cache-layer/">connection management and cache layer</a>）、不是把 aggregation 跑爆 secondary</li>
</ul>
<h2 id="容量與觀測">容量與觀測</h2>
<p>關鍵 metric：</p>
<ul>
<li>Aggregation operation time 分布</li>
<li>Disk spill 次數</li>
<li><code>opcounters.command</code> 中 aggregate 比例</li>
<li>Cache eviction rate 在 aggregation 高峰時的變化</li>
</ul>
<p>Mongo command：</p>
<ul>
<li><code>db.currentOp({ &quot;command.aggregate&quot;: { $exists: true } })</code>：當前 aggregation 在跑</li>
<li><code>db.serverStatus().metrics.aggStageCounters</code>：stage 級別 counter</li>
<li><code>explain(&quot;executionStats&quot;)</code>：單 query 詳細分析</li>
</ul>
<p>Profiler：<code>db.setProfilingLevel(1, {slowms: 200})</code>、看 <code>usedDisk</code> flag 跟 <code>numYield</code>。</p>
<p>回到 <a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">4.20 observability evidence</a>：aggregation slow log + cache hit ratio + disk spill rate 是「analytical 壓力」的 evidence 三件套。</p>
<p>回到 <a href="/blog/backend/09-performance-capacity/bottleneck-localization/" data-link-title="9.5 瓶頸定位流程" data-link-desc="從 app 到 DB / cache / broker / 第三方 quota 的逐層瓶頸定位">9.5 bottleneck localization</a>：用 explain executionStats 把 pipeline stage 對到瓶頸（IXSCAN 還是 COLLSCAN、in-memory 還是 disk spill、shard-local 還是 mongos merge）。</p>
<h2 id="邊界與整合">邊界與整合</h2>
<p>Sibling deep articles：</p>
<ul>
<li><a href="../schema-design-pattern/">schema design pattern</a> — embedded 設計可消除大部分 <code>$lookup</code></li>
<li><a href="../shard-key-selection/">shard key selection</a> — 決定 aggregation 是 shard-local 還是 cross-shard</li>
<li><a href="../replica-set-read-preference/">replica set read preference</a> — aggregation 跑 secondary 的 stale read trade-off</li>
<li><a href="../connection-management-and-cache-layer/">connection management and cache layer</a> — report dashboard 跑爆 primary 時的 cache + read scaling 主路</li>
</ul>
<p>Migration playbook：analytical workload 大到不能繼續混在 MongoDB → split 出 <a href="/blog/backend/01-database/vendors/cosmosdb/" data-link-title="Azure Cosmos DB" data-link-desc="全球分散式 multi-model DB、5 個 consistency levels、Microsoft 自家 dogfood 證據">→ Cosmos DB MongoDB API + Synapse</a> 或 <a href="/blog/backend/01-database/vendors/dynamodb/" data-link-title="DynamoDB" data-link-desc="AWS managed key-value、partition-based scaling、9000 萬 RPS sustained 實戰證據">→ DynamoDB + Athena/Glue</a>（access pattern 重設計）。</p>
<p>跟 1.x 互引：<a href="/blog/backend/01-database/kv-document-capacity-planning/" data-link-title="1.10 KV / Document DB 容量規劃" data-link-desc="DynamoDB / Cosmos DB / Bigtable / MongoDB 等 KV / Document DB 的容量設計、partition key 取捨、capacity mode 選擇">1.10 KV / Document DB 容量規劃</a> 把 aggregation 列為 read-shape 的成本維度；<a href="/blog/backend/01-database/high-concurrency-access/" data-link-title="1.1 高併發下的 SQL 讀寫邊界" data-link-desc="說明高併發服務如何共用資料庫 client、控制 transaction、管理 connection pool、避免資料庫成為瓶頸">1.1 高併發資料存取</a> 處理「OLTP + analytical 同 cluster」的反模式。</p>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/mongodb/" data-link-title="MongoDB" data-link-desc="Document database 代表、Atlas managed、跨雲可用、許多大規模平台從 MongoDB 起家">MongoDB vendor overview</a> — 本文是該頁尾「aggregation pipeline optimization」backlog 的深度展開</li>
<li><a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章方法論</a></li>
<li>官方：<a href="https://www.mongodb.com/docs/manual/aggregation/">Aggregation Pipeline</a>、<a href="https://www.mongodb.com/docs/manual/core/aggregation-pipeline-optimization/">Optimize Pipelines</a>、<a href="https://www.mongodb.com/docs/manual/reference/operator/aggregation/merge/">$merge</a></li>
</ul>
]]></content:encoded></item><item><title>PostgreSQL Logical Replication + Debezium CDC：replication slot × failure × recovery 對照</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/logical-replication-debezium/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/logical-replication-debezium/</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 提到 logical decoding / Debezium CDC、本文聚焦 &lt;em>replication slot 生命週期 + 5 個 production failure mode 跟 recovery&lt;/em> 的對照。&lt;/p>&lt;/blockquote>
&lt;h2 id="replication-slot--failure--recovery-對照">Replication slot × Failure × Recovery 對照&lt;/h2>
&lt;p>Logical replication 跟 Debezium CDC 的 production 議題集中在 &lt;em>replication slot&lt;/em> — 它是 PostgreSQL 內保證 WAL 不被回收的 anchor point；slot 設不對、整個 CDC pipeline 失效。各 failure mode 對 slot 的影響跟 recovery 路徑：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Failure mode&lt;/th>
 &lt;th>對 slot 影響&lt;/th>
 &lt;th>Primary 端徵兆&lt;/th>
 &lt;th>Recovery 路徑&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Consumer 卡住 / lag&lt;/td>
 &lt;td>slot LSN 不前進、WAL 留著&lt;/td>
 &lt;td>&lt;code>pg_wal&lt;/code> 目錄持續長大、disk 撐爆&lt;/td>
 &lt;td>修 consumer / 加 throttle / 必要時 drop slot&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Consumer crash 無 restart&lt;/td>
 &lt;td>slot 留在 active state&lt;/td>
 &lt;td>跟 lag 同、不會自動清&lt;/td>
 &lt;td>手動 &lt;code>SELECT pg_drop_replication_slot('name')&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Schema change（ADD COLUMN）&lt;/td>
 &lt;td>多數 plugin 自動處理、無感&lt;/td>
 &lt;td>通常無感&lt;/td>
 &lt;td>-&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Schema change（DROP / RENAME COLUMN）&lt;/td>
 &lt;td>多數 plugin 直接斷&lt;/td>
 &lt;td>Consumer log 報錯、slot active 卻不前進&lt;/td>
 &lt;td>重建 publication / 重 init load&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Initial COPY&lt;/td>
 &lt;td>slot 建立時跑 snapshot、long-running tx&lt;/td>
 &lt;td>大表 COPY 期間鎖跟 WAL 都受影響&lt;/td>
 &lt;td>用 &lt;code>CREATE_REPLICATION_SLOT ... NOEXPORT_SNAPSHOT&lt;/code> 分階段&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Promotion (failover)&lt;/td>
 &lt;td>physical slot 跟 logical slot 處理不同&lt;/td>
 &lt;td>logical slot 在 PG 16- 不跨 failover&lt;/td>
 &lt;td>PG 16+ logical slot 持久化、或 consumer 重 init load&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Replay storm（offset 重置）&lt;/td>
 &lt;td>slot 不變、consumer 重讀&lt;/td>
 &lt;td>Kafka 端流量爆、application 看 duplicate&lt;/td>
 &lt;td>Idempotent consumer 設計、或 transactional outbox&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>每個 failure mode 對應的詳細配置 + recovery 步驟、下面分段展開。&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 提到 logical decoding / Debezium CDC、本文聚焦 <em>replication slot 生命週期 + 5 個 production failure mode 跟 recovery</em> 的對照。</p></blockquote>
<h2 id="replication-slot--failure--recovery-對照">Replication slot × Failure × Recovery 對照</h2>
<p>Logical replication 跟 Debezium CDC 的 production 議題集中在 <em>replication slot</em> — 它是 PostgreSQL 內保證 WAL 不被回收的 anchor point；slot 設不對、整個 CDC pipeline 失效。各 failure mode 對 slot 的影響跟 recovery 路徑：</p>
<table>
  <thead>
      <tr>
          <th>Failure mode</th>
          <th>對 slot 影響</th>
          <th>Primary 端徵兆</th>
          <th>Recovery 路徑</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Consumer 卡住 / lag</td>
          <td>slot LSN 不前進、WAL 留著</td>
          <td><code>pg_wal</code> 目錄持續長大、disk 撐爆</td>
          <td>修 consumer / 加 throttle / 必要時 drop slot</td>
      </tr>
      <tr>
          <td>Consumer crash 無 restart</td>
          <td>slot 留在 active state</td>
          <td>跟 lag 同、不會自動清</td>
          <td>手動 <code>SELECT pg_drop_replication_slot('name')</code></td>
      </tr>
      <tr>
          <td>Schema change（ADD COLUMN）</td>
          <td>多數 plugin 自動處理、無感</td>
          <td>通常無感</td>
          <td>-</td>
      </tr>
      <tr>
          <td>Schema change（DROP / RENAME COLUMN）</td>
          <td>多數 plugin 直接斷</td>
          <td>Consumer log 報錯、slot active 卻不前進</td>
          <td>重建 publication / 重 init load</td>
      </tr>
      <tr>
          <td>Initial COPY</td>
          <td>slot 建立時跑 snapshot、long-running tx</td>
          <td>大表 COPY 期間鎖跟 WAL 都受影響</td>
          <td>用 <code>CREATE_REPLICATION_SLOT ... NOEXPORT_SNAPSHOT</code> 分階段</td>
      </tr>
      <tr>
          <td>Promotion (failover)</td>
          <td>physical slot 跟 logical slot 處理不同</td>
          <td>logical slot 在 PG 16- 不跨 failover</td>
          <td>PG 16+ logical slot 持久化、或 consumer 重 init load</td>
      </tr>
      <tr>
          <td>Replay storm（offset 重置）</td>
          <td>slot 不變、consumer 重讀</td>
          <td>Kafka 端流量爆、application 看 duplicate</td>
          <td>Idempotent consumer 設計、或 transactional outbox</td>
      </tr>
  </tbody>
</table>
<p>每個 failure mode 對應的詳細配置 + recovery 步驟、下面分段展開。</p>
<h2 id="logical-replication-基礎publication--subscription--slot">Logical replication 基礎：publication + subscription + slot</h2>





<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">-- Primary：建 publication
</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="n">PUBLICATION</span><span class="w"> </span><span class="n">app_changes</span><span class="w"> </span><span class="k">FOR</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">orders</span><span class="p">,</span><span class="w"> </span><span class="n">events</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></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="c1">-- Subscriber：建 subscription（自動建 replication slot）
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="k">CREATE</span><span class="w"> </span><span class="n">SUBSCRIPTION</span><span class="w"> </span><span class="n">app_sub</span><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w">  </span><span class="k">CONNECTION</span><span class="w"> </span><span class="s1">&#39;host=primary user=replicator dbname=app&#39;</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">PUBLICATION</span><span class="w"> </span><span class="n">app_changes</span><span class="w">
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="w">  </span><span class="k">WITH</span><span class="w"> </span><span class="p">(</span><span class="n">slot_name</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;app_sub_slot&#39;</span><span class="p">,</span><span class="w"> </span><span class="n">copy_data</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">true</span><span class="p">);</span></span></span></code></pre></div><p>關鍵物件：</p>
<ul>
<li><strong>publication</strong>（primary 端）：宣告 <em>哪些表 + 哪些操作（INSERT/UPDATE/DELETE/TRUNCATE）</em> 對外暴露</li>
<li><strong>subscription</strong>（subscriber 端、若是 PG-to-PG）：訂閱 + 自動建 slot + 自動 initial COPY</li>
<li><strong>replication slot</strong>：primary 端、保證 <em>consumer 還沒消費的 WAL</em> 不被回收</li>
</ul>
<p><code>copy_data = true</code> 觸發 initial COPY（snapshot）+ 後續 streaming；<code>copy_data = false</code> 只 streaming、適合 already-in-sync 場景。</p>
<h2 id="debezium-cdc用-logical-replication-slot-但繞過-subscription">Debezium CDC：用 logical replication slot 但繞過 subscription</h2>
<p>Debezium 不是 PostgreSQL subscriber、是 <em>直接讀 replication slot</em> 的外部 consumer：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-properties" data-lang="properties"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># Debezium PostgreSQL connector</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="na">connector.class</span><span class="o">=</span><span class="s">io.debezium.connector.postgresql.PostgresConnector</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="na">database.hostname</span><span class="o">=</span><span class="s">primary</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="na">database.dbname</span><span class="o">=</span><span class="s">app</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="na">plugin.name</span><span class="o">=</span><span class="s">pgoutput                            # 內建、PG 10+ 推薦</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="na">slot.name</span><span class="o">=</span><span class="s">debezium_app</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="na">publication.name</span><span class="o">=</span><span class="s">app_changes</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="na">publication.autocreate.mode</span><span class="o">=</span><span class="s">filtered            # debezium 自動建 publication</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="na">table.include.list</span><span class="o">=</span><span class="s">public.orders,public.events</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="na">snapshot.mode</span><span class="o">=</span><span class="s">initial                            # 起始 snapshot 後 streaming</span></span></span></code></pre></div><p>差異：</p>
<ul>
<li>Debezium 用 <code>pgoutput</code>（PG 10+ 內建）或 <code>wal2json</code>（外掛 plugin）解 WAL、轉成結構化事件送 Kafka</li>
<li>不像 PG-to-PG subscription、Debezium 沒 subscription object、是 <em>外部 consumer 自管</em> replication slot</li>
<li>Failure mode 上 <em>consumer 端是 Debezium 自己</em>、所以 lag 來源是 Debezium 處理速度 / Kafka 寫入速度</li>
</ul>
<h2 id="production-故障演練">Production 故障演練</h2>
<h3 id="case-1consumer-lagslot-lsn-不前進primary-disk-爆">Case 1：consumer lag、slot LSN 不前進、primary disk 爆</h3>
<p><strong>徵兆</strong>：primary <code>pg_wal</code> 目錄持續長大、<code>df -h</code> 看磁碟 90%+；<code>pg_replication_slots</code> 看 <code>confirmed_flush_lsn</code> 卡在某 LSN、<code>pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn)</code> 數十 GB。</p>
<p><strong>根因</strong>：consumer（Debezium / subscriber）處理慢於 primary 寫入；replication slot <em>保證 WAL 不回收</em>、但 consumer 沒消費 → WAL 堆積。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>監測</strong>：Prometheus alert <code>pg_replication_slot_lag_bytes &gt; 5GB</code> 觸發前 catch</li>
<li><strong>修 consumer</strong>：throttle primary 寫入 OR scale Debezium / subscriber 處理能力</li>
<li><strong>緊急</strong>：<code>SELECT pg_drop_replication_slot('debezium_app')</code> 釋放 WAL — 但 consumer 必須重 init load（資料缺一塊）</li>
<li><strong>架構</strong>：用 <em>max_slot_wal_keep_size</em>（PG 13+）設 slot 能保留 WAL 上限、超出自動 invalidate slot、保護 primary disk</li>
</ol>
<h3 id="case-2consumer-crash-後-slot-變-zombie">Case 2：consumer crash 後 slot 變 zombie</h3>
<p><strong>徵兆</strong>：Debezium pod OOM crash、新 pod 起來時報 <code>slot is active for PID X</code>、無法 attach；primary 端 <code>pg_replication_slots.active = true</code>、<code>active_pid</code> 指向已經死掉的 process。</p>
<p><strong>根因</strong>：PostgreSQL 把 slot 標 active 是基於 <em>當下有 connection</em>；consumer crash 但 connection 沒被 server 端發現（network 沒 RST）、slot 留在 active state。</p>
<p><strong>修法</strong>：</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">-- 手動清 zombie slot
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">pg_terminate_backend</span><span class="p">(</span><span class="n">active_pid</span><span class="p">)</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">pg_replication_slots</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w">  </span><span class="k">WHERE</span><span class="w"> </span><span class="n">slot_name</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;debezium_app&#39;</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="n">active</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></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="c1">-- 或直接 drop（會丟資料、consumer 要重 init）
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">pg_drop_replication_slot</span><span class="p">(</span><span class="s1">&#39;debezium_app&#39;</span><span class="p">);</span></span></span></code></pre></div><p>預防：</p>
<ol>
<li>PostgreSQL <code>tcp_keepalives_idle / interval / count</code> 設較短（300 / 60 / 6）、network drop 較快被發現</li>
<li>Consumer 端用 <em>graceful shutdown</em> + <code>pg_terminate_backend(active_pid)</code> 在 startup 前主動清 stale connection</li>
</ol>
<h3 id="case-3schema-changedrop--rename-column斷流">Case 3：schema change（DROP / RENAME COLUMN）斷流</h3>
<p><strong>徵兆</strong>：Debezium consumer 突然停 produce 訊息、log 報 <code>column XYZ does not exist</code>；primary 端 slot 還 active、但 <code>confirmed_flush_lsn</code> 不前進。</p>
<p><strong>根因</strong>：pgoutput plugin 把 WAL 解成 row event 時、用的 schema 是 <em>當下 catalog</em>；如果中間 DROP COLUMN、之前 WAL 內的 row event 含已不存在欄位、解析失敗。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>預防</strong>：schema change 走 <em>expand-contract pattern</em>
<ul>
<li>Phase 1: ADD COLUMN new_col（不影響 logical replication）</li>
<li>Phase 2: application 雙寫 old + new</li>
<li>Phase 3: 等 consumer catch up old column 訊息</li>
<li>Phase 4: DROP COLUMN old_col（此時無 in-flight WAL 帶 old_col）</li>
</ul>
</li>
<li><strong>緊急</strong>：DROP existing slot、重建 publication 跟 slot、consumer 重 init load</li>
<li><strong>長期</strong>：用 Debezium <em>snapshot.mode=schema_only_recovery</em> 在 schema 變動時不重灌資料、只 reset schema</li>
</ol>
<h3 id="case-4initial-copy-大表鎖太久">Case 4：initial COPY 大表鎖太久</h3>
<p><strong>徵兆</strong>：對 1TB 表跑 <code>CREATE SUBSCRIPTION ... WITH (copy_data=true)</code> 後、application 對該表 query / write 阻塞 30+ 分鐘；application timeout 大量。</p>
<p><strong>根因</strong>：initial COPY 默認跑在 <em>single transaction</em>、整個 snapshot LSN 鎖住、長 transaction 跟 vacuum 衝突；同時對 subscriber 端鎖表寫入。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>分階段 init</strong>：</li>
</ol>





<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">-- Primary：建 publication 不 copy
</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="n">PUBLICATION</span><span class="w"> </span><span class="n">app_changes</span><span class="w"> </span><span class="k">FOR</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">big_table</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></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w"></span><span class="c1">-- Subscriber：建 subscription 不 copy
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span><span class="k">CREATE</span><span class="w"> </span><span class="n">SUBSCRIPTION</span><span class="w"> </span><span class="n">app_sub</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">  </span><span class="k">CONNECTION</span><span class="w"> </span><span class="s1">&#39;...&#39;</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">PUBLICATION</span><span class="w"> </span><span class="n">app_changes</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">  </span><span class="k">WITH</span><span class="w"> </span><span class="p">(</span><span class="n">copy_data</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">false</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></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w"></span><span class="c1">-- 手動跑 partition-by-partition COPY（若是 partition table）
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1">-- 或用 pg_dump / pg_basebackup 拿 snapshot</span></span></span></code></pre></div><ol start="2">
<li><strong>PG 16+ parallel init</strong>：<code>max_sync_workers_per_subscription = 4</code> 平行 COPY 多個表</li>
<li><strong>Debezium replacement</strong>：用 incremental snapshot（Debezium 1.6+）、background trickle copy、不鎖長 transaction</li>
</ol>
<h3 id="case-5replay-storm-後-consumer-offset-reset">Case 5：replay storm 後 consumer offset reset</h3>
<p><strong>徵兆</strong>：Debezium 修 bug / 重 deploy 後、<code>snapshot.mode=initial</code> 觸發整個資料重灌；Kafka topic 流量爆 10x、下游 application 看到大量 duplicate event。</p>
<p><strong>根因</strong>：Debezium offset store（Kafka topic 或 file）被誤刪 / corruption；重啟時不知道從哪 LSN 開始、預設 fall back 到 initial snapshot。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>預防</strong>：Debezium offset store 跟 Kafka cluster <em>backup 一起做</em>、不要單獨依賴 Kafka topic</li>
<li><strong>架構</strong>：consumer side 設計 <em>idempotent</em> — 用 event 自帶的 (source LSN + transaction ID) 當 dedupe key</li>
<li><strong>transactional outbox pattern</strong>：CDC 只 capture outbox 表、application 主動寫 outbox + business data 在同 transaction；duplicate 由 application 自己 dedupe</li>
</ol>
<h2 id="容量規劃">容量規劃</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>估算</th>
          <th>警戒</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Replication slot lag</td>
          <td><code>pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn)</code></td>
          <td>&gt; 1GB lag 訊號 consumer 跟不上</td>
      </tr>
      <tr>
          <td>Primary <code>pg_wal</code> size</td>
          <td>retention × peak WAL rate</td>
          <td>預留 disk 容量 = max_slot_wal_keep_size + 30% buffer</td>
      </tr>
      <tr>
          <td>Debezium throughput</td>
          <td>~5-10K row/s 單 connector、多表平行可拉</td>
          <td>跟 primary write rate 對比</td>
      </tr>
      <tr>
          <td>Initial COPY time</td>
          <td>100GB ~ 10-30 分鐘（看 network + subscriber IO）</td>
          <td>TB 級必須分階段</td>
      </tr>
      <tr>
          <td>Slot 數量</td>
          <td>每 slot 佔 primary 一份 WAL 保留 buffer</td>
          <td>5+ slot 同時跑 disk 壓力倍增</td>
      </tr>
      <tr>
          <td>max_replication_slots</td>
          <td>預設 10、production 跑 CDC + standby 各佔 slot 要拉到 20-50</td>
          <td>達上限會拒新 slot 建立</td>
      </tr>
  </tbody>
</table>
<p>實務 default：</p>
<ul>
<li>Debezium production：1 connector per source schema、不要 1 connector 跨 50 個表</li>
<li>Slot retention：<code>max_slot_wal_keep_size = 100GB</code>、超出 invalidate slot 保護 primary</li>
<li>Monitor cadence：1 分鐘 sample lag + 5 分鐘 alert threshold</li>
</ul>
<h2 id="整合--下一步">整合 / 下一步</h2>
<h3 id="跟-patroni-ha-整合">跟 <a href="/blog/backend/01-database/vendors/postgresql/patroni-ha/" data-link-title="PostgreSQL Patroni HA：從 leader 失聯到 client 重連的 5 段 failover lifecycle" data-link-desc="Patroni 把 PostgreSQL HA 拆成 detection / election / promotion / reconfiguration / recovery 五段 lifecycle、每段都有獨立配置跟 failure mode；DCS quorum &#43; watchdog 防 split-brain、async/sync replication 取捨、5 個 production 踩雷、跟 PgBouncer / HAProxy / cert-manager 整合">Patroni HA</a> 整合</h3>
<p>logical slot 在 PG 16- 不跨 failover、是長期痛點：</p>
<ol>
<li><strong>PG 16-</strong>：failover 後 logical consumer 必須重 init（slot 在新 leader 上不存在）</li>
<li><strong>PG 16+</strong>：<code>failover</code> parameter 讓 logical slot 在 standby 同步、failover 後 consumer 直接接</li>
<li>Patroni 16+ 支援 logical slot persistence 配置、配合用</li>
</ol>
<h3 id="跟-kafka-outbox-pattern">跟 Kafka outbox pattern</h3>
<p>production-grade CDC 不直接 read business table、是 read <em>outbox table</em>：</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">-- Application transaction
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">BEGIN</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="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="p">(...)</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </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">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="n">outbox</span><span class="w"> </span><span class="p">(</span><span class="n">event_type</span><span class="p">,</span><span class="w"> </span><span class="n">payload</span><span class="p">,</span><span class="w"> </span><span class="n">created_at</span><span class="p">)</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="s1">&#39;order_created&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;...&#39;</span><span class="p">,</span><span class="w"> </span><span class="n">now</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">COMMIT</span><span class="p">;</span></span></span></code></pre></div><p>Debezium 只 capture outbox table、event payload 已是 application-shaped JSON、不用解 row event。好處：</p>
<ol>
<li>Schema change 不影響 CDC（outbox table schema 穩定）</li>
<li>跨表 transaction 對應到單 event（outbox 是業務語意層）</li>
<li>Replay 可靠 — outbox 是 append-only、可重讀</li>
</ol>
<h3 id="跟-partitioning-整合">跟 <a href="/blog/backend/01-database/vendors/postgresql/declarative-partitioning/" data-link-title="PostgreSQL declarative partitioning：partition 不是切表、是讓 planner pruning" data-link-desc="Declarative partitioning 的真實價值是 query planner pruning &#43; maintenance scope 縮小、不是「把大表切小」；RANGE / LIST / HASH 取捨、partition key 選法、5 個 production 踩雷（key 選錯不 prune / unique 不 enforce 跨 partition / ATTACH 鎖太久 / partition 數爆 / DETACH 不 reclaim 空間）、跟 autovacuum &#43; index 設計整合">partitioning</a> 整合</h3>
<p>partitioned table 的 logical replication：</p>
<ol>
<li>PG 13+ <code>publish_via_partition_root = true</code> — publication 從 parent 角度看、不是 per-partition</li>
<li>Subscriber 端可 partition 不同 strategy（甚至不 partition）</li>
<li>Schema change 對 partition table 更複雜、走 expand-contract 嚴格</li>
</ol>
<h3 id="下一步議題">下一步議題</h3>
<ul>
<li><strong>Logical replication conflict</strong>：subscriber 端寫衝突的處理（PG 17+ 加 conflict resolution）</li>
<li><strong>bi-directional replication（pg_active）</strong>：多 region active-active、衝突解決設計</li>
<li><strong>Decoder plugin 對比</strong>：pgoutput / wal2json / decoderbufs 效能跟易用性</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li>上游 vendor 頁：<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></li>
<li>上游 chapter：<a href="/blog/backend/01-database/schema-migration-rollout-evidence/" data-link-title="1.7 Schema Migration Rollout 證據（Schema Migration Rollout Evidence）實作示範" data-link-desc="以訂單付款狀態欄位演進示範 schema migration 如何產出 evidence、release gate 與 incident decision log。">Schema Migration Rollout Evidence</a> — schema change × CDC 對應</li>
<li>平行 deep article：<a href="/blog/backend/01-database/vendors/postgresql/patroni-ha/" data-link-title="PostgreSQL Patroni HA：從 leader 失聯到 client 重連的 5 段 failover lifecycle" data-link-desc="Patroni 把 PostgreSQL HA 拆成 detection / election / promotion / reconfiguration / recovery 五段 lifecycle、每段都有獨立配置跟 failure mode；DCS quorum &#43; watchdog 防 split-brain、async/sync replication 取捨、5 個 production 踩雷、跟 PgBouncer / HAProxy / cert-manager 整合">Patroni HA</a> / <a href="/blog/backend/01-database/vendors/postgresql/pitr-wal-archiving/" data-link-title="PostgreSQL PITR &#43; WAL archiving：從 base backup 到 point-in-time recovery 的完整鏈" data-link-desc="Base backup &#43; WAL archive 構成 PITR 的雙軌資料、archive_command &#43; restore_command 配置、用 pgBackRest / WAL-G 替代手寫腳本、5 個 production 踩雷（archive 靜默失敗 / archive lag / 錯誤 target time / base backup 過期未清 / timeline 分歧 recovery 模糊）、跟 Patroni &#43; monitoring 整合">PITR + WAL Archiving</a> / <a href="/blog/backend/01-database/vendors/postgresql/replication-slot-management/" data-link-title="PostgreSQL Replication Slot Management：Physical / Logical / Failover Slot 治理" data-link-desc="PG replication slot 是 *primary 端的 standby 進度紀錄*、防 WAL premature deletion。但 orphan slot 會吃 disk、failover 後 logical slot 不會自動跟新 primary、是 PG 操作的 hidden complexity。本文走 physical / logical slot 差異、slot lifecycle、failover slot synchronization（PG 17&#43; 新特性）、orphan slot 治理、5 production 踩雷（orphan slot disk 爆 / logical slot lag / failover 後 slot 丟 / wal_keep_size 跟 slot 衝突 / connection 同時打 slot 數量限制）">Replication Slot Management</a>（slot lifecycle / orphan / failover sync）/ <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>（streaming + LSN 基礎）</li>
<li>Methodology：<a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章的寫作方法論</a></li>
</ul>
]]></content:encoded></item><item><title>DynamoDB Transaction 與 Conditional Write：跨 item 原子性、optimistic locking 與 idempotency</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/dynamodb/transactions-conditional-writes/</link><pubDate>Tue, 02 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/dynamodb/transactions-conditional-writes/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/dynamodb/" data-link-title="DynamoDB" data-link-desc="AWS managed key-value、partition-based scaling、9000 萬 RPS sustained 實戰證據">DynamoDB&lt;/a> overview 的 implementation-layer deep article。寫作參照 &lt;a href="https://tarrragon.github.io/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">vendor deep article methodology&lt;/a>。&lt;/p>&lt;/blockquote>
&lt;p>對帳跑出一筆異常：用戶錢包餘額扣了 100 元、但對應訂單沒建立。追 log 發現 application 先 &lt;code>PutItem&lt;/code> 扣餘額、再 &lt;code>PutItem&lt;/code> 建訂單、兩步之間 process 被 OOM kill、第二步沒跑完。另一個系統反向情境：秒殺活動庫存剩 1、兩個請求同時讀到「剩 1」、各自 &lt;code>PutItem&lt;/code> 扣成 0、實際賣出 2 件。兩個 production 痛點指向同一件事 — DynamoDB 預設的單筆寫入沒有跨 item 原子性、也沒有「讀到的值寫回時還沒被改」的保證。本文展開 DynamoDB 提供的三層寫保護：跨 item transaction、單 item conditional write、version-based optimistic locking。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>寫一致性前提：先確認 workload 適配 DynamoDB&lt;/strong>：本篇假設 workload 已通過 DynamoDB 適配 4 軸（PK 天然均勻 / control plane vs data plane / consistency 可接受 eventual / access pattern 穩定）— 判讀軸詳見 &lt;a href="../single-table-design-pattern/#dynamodb-%e9%81%a9%e7%94%a8%e5%ba%a6%e5%89%8d%e7%bd%ae%e5%88%a4%e8%ae%804-%e8%bb%b8">single-table-design-pattern 開頭 4 軸前置判讀&lt;/a>。寫一致性是 &lt;em>已選 DynamoDB&lt;/em> 後的操作層議題；若 workload 需要頻繁跨多表多列複雜交易、那是 relational 的主場、應先回頭問 DynamoDB 是否選錯。&lt;/p>&lt;/blockquote>
&lt;h2 id="核心機制三層寫保護">核心機制：三層寫保護&lt;/h2>
&lt;p>DynamoDB 的寫一致性由三種粒度不同的工具組成 — 單 item 寫、conditional write、跨 item transaction，三者解的問題與成本各異，不是單一 ACID 開關：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>工具&lt;/th>
 &lt;th>解的問題&lt;/th>
 &lt;th>原子性範圍&lt;/th>
 &lt;th>成本&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>單 item 寫&lt;/td>
 &lt;td>一筆 item 的 put / update / delete&lt;/td>
 &lt;td>單 item&lt;/td>
 &lt;td>1x WCU&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Conditional write&lt;/td>
 &lt;td>只在條件成立時才寫（防覆蓋、防重複）&lt;/td>
 &lt;td>單 item + 前置條件&lt;/td>
 &lt;td>1x WCU（條件不成立也計費）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>TransactWriteItems&lt;/td>
 &lt;td>多筆 item 一起成功或一起失敗&lt;/td>
 &lt;td>跨 item（同 region / account）&lt;/td>
 &lt;td>2x WCU（prepare + commit 兩階段）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>TransactWriteItems 的工程語意&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>一次 transaction 最多含若干個 action（put / update / delete / condition check）— 上限屬 vendor 規格、實作時 cross-verify AWS doc 當前數字&lt;/li>
&lt;li>全成功或全失敗：任一 action 的 condition 不成立、整個 transaction roll back、拋 &lt;code>TransactionCanceledException&lt;/code> 帶 &lt;code>CancellationReasons&lt;/code>&lt;/li>
&lt;li>不跨 region、不跨 account：transaction 只在單一 region 單一 account 內成立、Global Tables 多 region 寫不享有跨 region transaction（對應 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/dynamodb/global-tables-conflict/" data-link-title="DynamoDB Global Tables：multi-region active-active、LWW conflict 與 cross-device sync 正向用例" data-link-desc="Global Tables 不只是 conflict 痛點、也是 cross-device sync / global read / DR failover 的正向工程方案；本文展開 B2B SaaS vs B2C 業務 driver、LWW conflict resolution、reconciliation pipeline，含 Genesys 99.999% 跨 15 region 跟 Disney&amp;#43; 跨裝置同步的對照">global-tables-conflict&lt;/a>）&lt;/li>
&lt;li>兩階段（prepare + commit）導致 2x capacity 消耗 — 這是 transaction 不能濫用的成本根源&lt;/li>
&lt;/ul>
&lt;blockquote>
&lt;p>&lt;strong>Scope warning&lt;/strong>：「TransactWriteItems 100 action 上限」、「transaction 2x WCU」這些具體數字屬 AWS vendor 規格、會隨版本調整、實作時 cross-verify 官方 doc 當前值。本文不含對應 production case 揭露的 transaction 規模數字。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/dynamodb/" data-link-title="DynamoDB" data-link-desc="AWS managed key-value、partition-based scaling、9000 萬 RPS sustained 實戰證據">DynamoDB</a> overview 的 implementation-layer deep article。寫作參照 <a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">vendor deep article methodology</a>。</p></blockquote>
<p>對帳跑出一筆異常：用戶錢包餘額扣了 100 元、但對應訂單沒建立。追 log 發現 application 先 <code>PutItem</code> 扣餘額、再 <code>PutItem</code> 建訂單、兩步之間 process 被 OOM kill、第二步沒跑完。另一個系統反向情境：秒殺活動庫存剩 1、兩個請求同時讀到「剩 1」、各自 <code>PutItem</code> 扣成 0、實際賣出 2 件。兩個 production 痛點指向同一件事 — DynamoDB 預設的單筆寫入沒有跨 item 原子性、也沒有「讀到的值寫回時還沒被改」的保證。本文展開 DynamoDB 提供的三層寫保護：跨 item transaction、單 item conditional write、version-based optimistic locking。</p>
<blockquote>
<p><strong>寫一致性前提：先確認 workload 適配 DynamoDB</strong>：本篇假設 workload 已通過 DynamoDB 適配 4 軸（PK 天然均勻 / control plane vs data plane / consistency 可接受 eventual / access pattern 穩定）— 判讀軸詳見 <a href="../single-table-design-pattern/#dynamodb-%e9%81%a9%e7%94%a8%e5%ba%a6%e5%89%8d%e7%bd%ae%e5%88%a4%e8%ae%804-%e8%bb%b8">single-table-design-pattern 開頭 4 軸前置判讀</a>。寫一致性是 <em>已選 DynamoDB</em> 後的操作層議題；若 workload 需要頻繁跨多表多列複雜交易、那是 relational 的主場、應先回頭問 DynamoDB 是否選錯。</p></blockquote>
<h2 id="核心機制三層寫保護">核心機制：三層寫保護</h2>
<p>DynamoDB 的寫一致性由三種粒度不同的工具組成 — 單 item 寫、conditional write、跨 item transaction，三者解的問題與成本各異，不是單一 ACID 開關：</p>
<table>
  <thead>
      <tr>
          <th>工具</th>
          <th>解的問題</th>
          <th>原子性範圍</th>
          <th>成本</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>單 item 寫</td>
          <td>一筆 item 的 put / update / delete</td>
          <td>單 item</td>
          <td>1x WCU</td>
      </tr>
      <tr>
          <td>Conditional write</td>
          <td>只在條件成立時才寫（防覆蓋、防重複）</td>
          <td>單 item + 前置條件</td>
          <td>1x WCU（條件不成立也計費）</td>
      </tr>
      <tr>
          <td>TransactWriteItems</td>
          <td>多筆 item 一起成功或一起失敗</td>
          <td>跨 item（同 region / account）</td>
          <td>2x WCU（prepare + commit 兩階段）</td>
      </tr>
  </tbody>
</table>
<p><strong>TransactWriteItems 的工程語意</strong>：</p>
<ul>
<li>一次 transaction 最多含若干個 action（put / update / delete / condition check）— 上限屬 vendor 規格、實作時 cross-verify AWS doc 當前數字</li>
<li>全成功或全失敗：任一 action 的 condition 不成立、整個 transaction roll back、拋 <code>TransactionCanceledException</code> 帶 <code>CancellationReasons</code></li>
<li>不跨 region、不跨 account：transaction 只在單一 region 單一 account 內成立、Global Tables 多 region 寫不享有跨 region transaction（對應 <a href="/blog/backend/01-database/vendors/dynamodb/global-tables-conflict/" data-link-title="DynamoDB Global Tables：multi-region active-active、LWW conflict 與 cross-device sync 正向用例" data-link-desc="Global Tables 不只是 conflict 痛點、也是 cross-device sync / global read / DR failover 的正向工程方案；本文展開 B2B SaaS vs B2C 業務 driver、LWW conflict resolution、reconciliation pipeline，含 Genesys 99.999% 跨 15 region 跟 Disney&#43; 跨裝置同步的對照">global-tables-conflict</a>）</li>
<li>兩階段（prepare + commit）導致 2x capacity 消耗 — 這是 transaction 不能濫用的成本根源</li>
</ul>
<blockquote>
<p><strong>Scope warning</strong>：「TransactWriteItems 100 action 上限」、「transaction 2x WCU」這些具體數字屬 AWS vendor 規格、會隨版本調整、實作時 cross-verify 官方 doc 當前值。本文不含對應 production case 揭露的 transaction 規模數字。</p></blockquote>
<p>對應 knowledge card：<a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a>、<a href="/blog/backend/knowledge-cards/transaction-boundary/" data-link-title="Transaction Boundary" data-link-desc="說明哪些資料變更應在同一個交易中一起成功或一起回復">transaction boundary</a>、<a href="/blog/backend/knowledge-cards/isolation-level/" data-link-title="Isolation Level" data-link-desc="說明資料庫交易隔離級別如何影響並發讀寫結果">isolation level</a>。</p>
<h2 id="conditional-write最便宜的一致性工具">Conditional Write：最便宜的一致性工具</h2>
<p>跨 item transaction 之前、先看單 item conditional write 能不能解。多數「race condition」其實是單 item 問題、不需要 transaction 的 2x 成本。</p>
<p>ConditionExpression 在寫入前檢查條件、條件不成立則拒絕寫入並拋 <code>ConditionalCheckFailedException</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 防重複建立：只有 item 不存在時才寫</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">table</span><span class="o">.</span><span class="n">put_item</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="n">Item</span><span class="o">=</span><span class="p">{</span><span class="s2">&#34;PK&#34;</span><span class="p">:</span> <span class="sa">f</span><span class="s2">&#34;ORDER#</span><span class="si">{</span><span class="n">order_id</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span> <span class="s2">&#34;SK&#34;</span><span class="p">:</span> <span class="s2">&#34;META&#34;</span><span class="p">,</span> <span class="s2">&#34;status&#34;</span><span class="p">:</span> <span class="s2">&#34;created&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="n">ConditionExpression</span><span class="o">=</span><span class="s2">&#34;attribute_not_exists(PK)&#34;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">)</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 防超賣：只有庫存 &gt; 0 時才扣</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">table</span><span class="o">.</span><span class="n">update_item</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="n">Key</span><span class="o">=</span><span class="p">{</span><span class="s2">&#34;PK&#34;</span><span class="p">:</span> <span class="sa">f</span><span class="s2">&#34;SKU#</span><span class="si">{</span><span class="n">sku</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span> <span class="s2">&#34;SK&#34;</span><span class="p">:</span> <span class="s2">&#34;STOCK&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="n">UpdateExpression</span><span class="o">=</span><span class="s2">&#34;SET stock = stock - :one&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="n">ConditionExpression</span><span class="o">=</span><span class="s2">&#34;stock &gt;= :one&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="n">ExpressionAttributeValues</span><span class="o">=</span><span class="p">{</span><span class="s2">&#34;:one&#34;</span><span class="p">:</span> <span class="mi">1</span><span class="p">}</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">)</span></span></span></code></pre></div><p>第二個例子是關鍵：<code>update_item</code> 帶 condition 是 <em>原子的 read-modify-write</em>。DynamoDB 在單 item 上保證「條件檢查 + 寫入」不會被其他寫入插隊。前述「兩個請求同時讀到剩 1」的超賣問題、用單 item conditional update 即可解、不需要 transaction。</p>
<h2 id="optimistic-locking跨讀寫週期的保護">Optimistic Locking：跨讀寫週期的保護</h2>
<p>Conditional write 解單次寫的 race；當 application 需要「讀出來、業務邏輯運算、再寫回」、且運算期間不能被別人改、用 version-based optimistic locking。</p>
<p>機制是在 item 上維護一個 <code>version</code> attribute、寫回時用 condition 確認 version 沒被改過：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">def</span> <span class="nf">update_with_optimistic_lock</span><span class="p">(</span><span class="n">pk</span><span class="p">,</span> <span class="n">new_balance</span><span class="p">,</span> <span class="n">expected_version</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="n">table</span><span class="o">.</span><span class="n">update_item</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="n">Key</span><span class="o">=</span><span class="p">{</span><span class="s2">&#34;PK&#34;</span><span class="p">:</span> <span class="n">pk</span><span class="p">,</span> <span class="s2">&#34;SK&#34;</span><span class="p">:</span> <span class="s2">&#34;WALLET&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="n">UpdateExpression</span><span class="o">=</span><span class="s2">&#34;SET balance = :b, version = version + :one&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="n">ConditionExpression</span><span class="o">=</span><span class="s2">&#34;version = :expected&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="n">ExpressionAttributeValues</span><span class="o">=</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">            <span class="s2">&#34;:b&#34;</span><span class="p">:</span> <span class="n">new_balance</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">            <span class="s2">&#34;:one&#34;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">            <span class="s2">&#34;:expected&#34;</span><span class="p">:</span> <span class="n">expected_version</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="p">},</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="p">)</span></span></span></code></pre></div><p>讀出時拿到 <code>version=5</code>、運算後寫回時 condition 是 <code>version = 5</code>；若期間別人已寫成 <code>version=6</code>、condition 失敗、application 收到 <code>ConditionalCheckFailedException</code>、retry 整個讀-算-寫週期。</p>
<p>optimistic 的代價是衝突時要重試、不是阻塞等待。高衝突 workload（同一 item 大量並發寫）optimistic locking 會 retry 風暴、這時要回頭問資料模型 — 把熱點 item 拆開、或改用單 item atomic counter（<code>ADD</code>）避免 read-modify-write。</p>
<blockquote>
<p><strong>Scope warning</strong>：optimistic locking 是通用並發控制 pattern、DynamoDB 用 ConditionExpression 實作；本段機制描述屬 vendor 規格 + 通用工程知識、非 production case 揭露。</p></blockquote>
<h2 id="idempotencytransaction-的重複提交保護">Idempotency：transaction 的重複提交保護</h2>
<p>分散式系統的寫入會重試（network timeout、client retry）。同一筆 transaction 重送兩次、不能扣兩次款。DynamoDB transaction 提供 <code>ClientRequestToken</code> 做 dedup：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="n">client</span><span class="o">.</span><span class="n">transact_write_items</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="n">ClientRequestToken</span><span class="o">=</span><span class="n">request_id</span><span class="p">,</span>  <span class="c1"># 同 token 在 dedup window 內視為同一次</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="n">TransactItems</span><span class="o">=</span><span class="p">[</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="p">{</span><span class="s2">&#34;Update&#34;</span><span class="p">:</span> <span class="p">{</span>  <span class="c1"># 扣錢包</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">            <span class="s2">&#34;TableName&#34;</span><span class="p">:</span> <span class="s2">&#34;wallet&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">            <span class="s2">&#34;Key&#34;</span><span class="p">:</span> <span class="p">{</span><span class="s2">&#34;PK&#34;</span><span class="p">:</span> <span class="p">{</span><span class="s2">&#34;S&#34;</span><span class="p">:</span> <span class="sa">f</span><span class="s2">&#34;USER#</span><span class="si">{</span><span class="n">uid</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">}},</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">            <span class="s2">&#34;UpdateExpression&#34;</span><span class="p">:</span> <span class="s2">&#34;SET balance = balance - :amt&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">            <span class="s2">&#34;ConditionExpression&#34;</span><span class="p">:</span> <span class="s2">&#34;balance &gt;= :amt&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">            <span class="s2">&#34;ExpressionAttributeValues&#34;</span><span class="p">:</span> <span class="p">{</span><span class="s2">&#34;:amt&#34;</span><span class="p">:</span> <span class="p">{</span><span class="s2">&#34;N&#34;</span><span class="p">:</span> <span class="nb">str</span><span class="p">(</span><span class="n">amount</span><span class="p">)}},</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="p">}},</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="p">{</span><span class="s2">&#34;Put&#34;</span><span class="p">:</span> <span class="p">{</span>  <span class="c1"># 建訂單</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">            <span class="s2">&#34;TableName&#34;</span><span class="p">:</span> <span class="s2">&#34;orders&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">            <span class="s2">&#34;Item&#34;</span><span class="p">:</span> <span class="p">{</span><span class="s2">&#34;PK&#34;</span><span class="p">:</span> <span class="p">{</span><span class="s2">&#34;S&#34;</span><span class="p">:</span> <span class="sa">f</span><span class="s2">&#34;ORDER#</span><span class="si">{</span><span class="n">order_id</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">},</span> <span class="s2">&#34;amount&#34;</span><span class="p">:</span> <span class="p">{</span><span class="s2">&#34;N&#34;</span><span class="p">:</span> <span class="nb">str</span><span class="p">(</span><span class="n">amount</span><span class="p">)}},</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">            <span class="s2">&#34;ConditionExpression&#34;</span><span class="p">:</span> <span class="s2">&#34;attribute_not_exists(PK)&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="p">}},</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="p">],</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="p">)</span></span></span></code></pre></div><p>同一個 <code>ClientRequestToken</code> 在 dedup window 內重送、DynamoDB 視為同一次、不會重複執行。這解掉開場的「扣款成功但訂單沒建」問題：兩個 action 在同一 transaction、要嘛都成、要嘛都不成；client 重試帶同 token、不會重複扣款。</p>
<blockquote>
<p><strong>Scope warning</strong>：「ClientRequestToken dedup window 約 10 分鐘」屬 AWS vendor 規格、實作時 cross-verify 官方 doc；application 層仍應有自己的 idempotency key 設計、不依賴 vendor dedup window 當唯一防線（對應 <a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a> 卡）。</p></blockquote>
<h2 id="操作流程">操作流程</h2>
<p>從一致性需求判讀到工具選擇的 6 步流程。</p>
<h4 id="step-1分類寫入的一致性需求">Step 1：分類寫入的一致性需求</h4>
<p>每個寫入路徑標記它真正需要的保護：</p>
<ul>
<li>單筆獨立寫、無前置條件 → 單 item put / update（最便宜）</li>
<li>單筆寫但要防覆蓋 / 防重複 / 防超賣 → 單 item conditional write</li>
<li>讀-算-寫週期、期間不能被改 → version optimistic locking</li>
<li>多筆 item 必須一起成功或失敗 → TransactWriteItems</li>
</ul>
<h4 id="step-2先用-conditional-write-解單-item-race">Step 2：先用 conditional write 解單 item race</h4>
<p>把「需要 transaction」當成最後選項。多數 race condition 是單 item 問題、conditional update 的 atomic read-modify-write 已足夠、成本 1x 而非 2x。</p>
<h4 id="step-3跨-item-才上-transaction">Step 3：跨 item 才上 transaction</h4>
<p>只有「多筆 item 的修改必須綁在一起」才用 TransactWriteItems。例：扣錢包 + 建訂單 + 寫流水帳三筆綁定。寫進 transaction 的 item 數量越少越好、每多一個 item 多一份 2x 成本。</p>
<h4 id="step-4加-idempotency-token">Step 4：加 idempotency token</h4>
<p>所有會被 client 重試的 transaction 帶 <code>ClientRequestToken</code>；token 用業務層的唯一鍵（order_id / request_id）、不要用隨機值（隨機值每次重試都不同、dedup 失效）。</p>
<h4 id="step-5處理失敗例外">Step 5：處理失敗例外</h4>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kn">from</span> <span class="nn">botocore.exceptions</span> <span class="kn">import</span> <span class="n">ClientError</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="n">client</span><span class="o">.</span><span class="n">transact_write_items</span><span class="p">(</span><span class="o">...</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="k">except</span> <span class="n">ClientError</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="n">code</span> <span class="o">=</span> <span class="n">e</span><span class="o">.</span><span class="n">response</span><span class="p">[</span><span class="s2">&#34;Error&#34;</span><span class="p">][</span><span class="s2">&#34;Code&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">if</span> <span class="n">code</span> <span class="o">==</span> <span class="s2">&#34;TransactionCanceledException&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="n">reasons</span> <span class="o">=</span> <span class="n">e</span><span class="o">.</span><span class="n">response</span><span class="p">[</span><span class="s2">&#34;CancellationReasons&#34;</span><span class="p">]</span>  <span class="c1"># 逐 action 失敗原因</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="c1"># 區分 ConditionalCheckFailed（業務拒絕、不重試）</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="c1"># vs TransactionConflict / ThrottlingError（可重試）</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">elif</span> <span class="n">code</span> <span class="o">==</span> <span class="s2">&#34;ConditionalCheckFailedException&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="k">pass</span>  <span class="c1"># 單 item condition 失敗、業務層決定</span></span></span></code></pre></div><p>關鍵：<code>ConditionalCheckFailed</code> 是 <em>業務拒絕</em>（庫存不足、訂單已存在）、不該不分原因一律重試；<code>TransactionConflict</code> / <code>ThrottlingError</code> 才是可重試的 transient error。混為一談會把「庫存真的不夠」當成 transient 一直重試。</p>
<h4 id="step-6驗證點">Step 6：驗證點</h4>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 驗證 conditional write 真的擋住併發</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># 啟兩個並發 update 扣同一庫存、確認只有一個成功、另一個拋 ConditionalCheckFailed</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">response</span> <span class="o">=</span> <span class="n">table</span><span class="o">.</span><span class="n">update_item</span><span class="p">(</span><span class="o">...</span><span class="p">,</span> <span class="n">ReturnValues</span><span class="o">=</span><span class="s2">&#34;UPDATED_NEW&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="n">response</span><span class="p">[</span><span class="s2">&#34;Attributes&#34;</span><span class="p">])</span>  <span class="c1"># 確認 version / stock 變化符合預期</span></span></span></code></pre></div><p><strong>Rollback boundary</strong>：transaction 本身全成全敗、無 partial state 需要 rollback；但 application 層若在 transaction 外還有副作用（送通知、呼叫外部 API）、那些不在 transaction 保護內、要另行設計補償。</p>
<h2 id="失敗模式">失敗模式</h2>
<p>production 常見的 5 個踩雷：</p>
<h4 id="case-1用-transaction-取代本該單-item-的寫">Case 1：用 transaction 取代本該單 item 的寫</h4>
<p>team 把所有寫入都包進 TransactWriteItems「保險」、cost 翻倍、且 transaction 有 throughput 上限比單寫低。修法：transaction 只用於真正跨 item 綁定的場景；單 item 用 conditional write。</p>
<h4 id="case-2optimistic-lock-在高衝突-item-上-retry-風暴">Case 2：optimistic lock 在高衝突 item 上 retry 風暴</h4>
<p>熱點 item（如全站唯一的計數器）大量並發寫、version condition 不斷失敗、application retry 風暴、latency 爆炸。修法：高衝突計數改用 atomic <code>ADD</code>（單 item 原子累加、不需 read-modify-write）；或把計數 shard 成多個 item 分散寫入。</p>
<h4 id="case-3idempotency-token-用隨機值">Case 3：idempotency token 用隨機值</h4>
<p>這個 case 的失敗代價跟其他踩雷不同層級。Case 1（cost 翻倍）、Case 2（retry 風暴）、Case 5（跨 region 誤解）都可以在發現後調整設定或改資料模型補救；idempotency token 用隨機值導致的重複扣款是 <em>財務不可逆</em> — 每次 client retry 產生新 token、dedup 完全失效、同一筆付款被執行多次、錢已經從用戶帳戶扣走、要靠對帳發現後人工退款，且退款流程本身又是另一條容易出錯的補償路徑。修法：token 綁業務唯一鍵（order_id / payment_id）、同一筆業務操作的所有重試共用同一 token；且不只依賴 DynamoDB 的 dedup window（有時效上限），application 層自己也維護 idempotency 記錄當第二道防線（對應 <a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a> 卡）。涉及金流的寫入，這道防線要在上線前用「同一 token 重送 N 次只執行一次」的測試明確驗證。</p>
<h4 id="case-4把-conditionalcheckfailed-當-transient-error-重試">Case 4：把 ConditionalCheckFailed 當 transient error 重試</h4>
<p>庫存真的為 0、condition 永遠失敗、application 無限重試打爆 capacity。修法：例外分流 — 業務拒絕（ConditionalCheckFailed）回報給呼叫端、transient error（throttle / conflict）才 backoff retry。</p>
<h4 id="case-5以為-transaction-跨-region-有效">Case 5：以為 transaction 跨 region 有效</h4>
<p>Global Tables 多 region 部署、誤以為 TransactWriteItems 在跨 region 也原子。實際 transaction 只在單 region 成立、跨 region 是 last-writer-wins（對應 <a href="/blog/backend/01-database/vendors/dynamodb/global-tables-conflict/" data-link-title="DynamoDB Global Tables：multi-region active-active、LWW conflict 與 cross-device sync 正向用例" data-link-desc="Global Tables 不只是 conflict 痛點、也是 cross-device sync / global read / DR failover 的正向工程方案；本文展開 B2B SaaS vs B2C 業務 driver、LWW conflict resolution、reconciliation pipeline，含 Genesys 99.999% 跨 15 region 跟 Disney&#43; 跨裝置同步的對照">global-tables-conflict</a>）。修法：跨 region 一致性需求不能靠 transaction、要重新設計資料 ownership（單一 region 為 write authority）。</p>
<p><strong>Anti-recommendation</strong>：寫入無併發競爭、或業務本身可接受最終一致（各 message_id 獨立的訊息事件即屬此類）→ 不要為了求保險而加 transaction；transaction 的 2x 成本只在真正需要跨 item 原子性時才值得。</p>
<h2 id="容量與觀測">容量與觀測</h2>
<p>CloudWatch metric：</p>
<ul>
<li><code>TransactionConflict</code>：transaction 因併發衝突取消的次數、持續高代表熱點 item 競爭</li>
<li><code>ConditionalCheckFailedRequests</code>：condition 失敗次數、區分業務拒絕 vs 設計問題</li>
<li><code>ThrottledRequests</code>：transaction 因 capacity 不足被限流、transaction 的 2x 消耗更容易撞上限</li>
</ul>
<p><strong>判讀</strong>：</p>
<ul>
<li><code>TransactionConflict</code> 持續上升 → 資料模型有熱點、考慮拆 item 或改 atomic counter</li>
<li><code>ConditionalCheckFailed</code> 突然飆高 → 可能是業務異常（大量重複請求 / 攻擊）、也可能是 application 邏輯把 version 算錯</li>
<li>transaction 的 capacity 用量按 2x 計、容量規劃要把 transaction 比例算進去</li>
</ul>
<blockquote>
<p><strong>Scope warning</strong>：本文未引用 production case 的 transaction metric 數字；上述 metric 名稱與判讀屬 vendor 規格 + 通用觀測工程。</p></blockquote>
<p>接回 <a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">4.20 Observability Evidence Package</a>、<a href="/blog/backend/01-database/transaction-boundary/" data-link-title="1.3 Transaction 與一致性邊界" data-link-desc="交易邊界、isolation level、retry 策略、distributed transaction（2PC、Saga）與跨 region 強一致取捨">1.3 transaction 與一致性邊界</a>。</p>
<h2 id="邊界與整合">邊界與整合</h2>
<h3 id="跟-relational-transaction-的責任差異">跟 relational transaction 的責任差異</h3>
<p>DynamoDB transaction 跟 relational transaction 不是同一個東西。Relational transaction 支援任意複雜的多表多列交易、長交易、isolation level 調整；DynamoDB transaction 是「一次性提交一組有限 action、全成全敗、無互動式 transaction、無 SELECT FOR UPDATE」。當 application 需要長交易、複雜 join 內的一致性、或多步互動式 transaction、那是 relational 的場景、不該硬塞進 DynamoDB（回頭看 single-table 4 軸前置判讀）。</p>
<h3 id="sibling-與-cross-link">Sibling 與 cross-link</h3>
<ul>
<li><a href="/blog/backend/01-database/vendors/dynamodb/consistency-model-optimization/" data-link-title="DynamoDB Strongly Consistent → Eventually Consistent：same protocol, different contract" data-link-desc="DynamoDB consistency model 從 strongly consistent read 改 eventually consistent read 是 50% cost 優化但風險集中在 application contract — 同 vendor / 同 protocol / 同 table / 不同 read consistency；驗證 [#128](/report/data-topology-as-audit-dimension/) self-aware limitation 提出的 consistency axis 候選；涵蓋 read pattern audit / 5 個 production 踩雷">consistency-model-optimization</a> — 該篇主寫 <em>讀</em> 一致性（eventual vs strong read）、本篇主寫 <em>寫</em> 原子性、兩篇互補</li>
<li><a href="/blog/backend/01-database/vendors/dynamodb/single-table-design-pattern/" data-link-title="DynamoDB Single-Table Design：從適用度前置判讀到 access pattern 反推 PK/SK" data-link-desc="DynamoDB single-table 設計不是「資料表越少越好」，而是 access pattern 反推 PK/SK 跟 GSI；本文先做 DynamoDB 適用度 4 軸前置判讀（PK 天然均勻 / control plane vs data plane / consistency / access pattern 穩定），再展開設計流程、failure modes 與 durable queue 正向用例">single-table-design-pattern</a> — 跨 item transaction 常用於 single-table 內多 entity 綁定寫</li>
<li><a href="/blog/backend/01-database/vendors/dynamodb/global-tables-conflict/" data-link-title="DynamoDB Global Tables：multi-region active-active、LWW conflict 與 cross-device sync 正向用例" data-link-desc="Global Tables 不只是 conflict 痛點、也是 cross-device sync / global read / DR failover 的正向工程方案；本文展開 B2B SaaS vs B2C 業務 driver、LWW conflict resolution、reconciliation pipeline，含 Genesys 99.999% 跨 15 region 跟 Disney&#43; 跨裝置同步的對照">global-tables-conflict</a> — transaction 不跨 region、多 region 寫衝突另有處理</li>
<li><a href="/blog/backend/01-database/vendors/dynamodb/streams-lambda-event-driven/" data-link-title="DynamoDB Streams 與 Lambda 事件驅動：CDC、shard 順序保證、消費模式與失敗處理" data-link-desc="DynamoDB Streams 不是免費的可靠事件流；本文展開 stream record 的四種 view type、shard 對應 partition 的順序保證邊界、Lambda event source mapping vs Kinesis 消費模式、at-least-once 下游冪等需求，以及 batch 失敗時的 bisect / DLQ 處理">streams-lambda-event-driven</a> — transaction 寫入會觸發 stream、下游 event 處理要 idempotent</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</a> / <a href="/blog/backend/01-database/vendors/aurora/" data-link-title="AWS Aurora" data-link-desc="AWS managed PostgreSQL / MySQL、storage / compute 分離、&#43;75% 效能改善的 production 證據">Aurora</a>、relational transaction 是主場</li>
<li>對應 <a href="/blog/backend/01-database/reconciliation-data-repair/" data-link-title="1.9 Reconciliation 與 Data Repair" data-link-desc="資料不一致的分類、偵測模式、修復策略、audit trail、跟 backup / PITR 整合">1.9 Reconciliation 與 Data Repair</a> — 寫一致性失守後的對帳與修復</li>
</ul>
]]></content:encoded></item><item><title>Spanner PostgreSQL dialect：PG-compatible interface vs GoogleSQL、相容子集邊界、何時選 PG dialect</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/spanner/postgresql-dialect/</link><pubDate>Tue, 02 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/spanner/postgresql-dialect/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/spanner/" data-link-title="Google Cloud Spanner" data-link-desc="全球分散式 strong-consistency OLTP、TrueTime API、線性擴展到 10 億 req/sec">Cloud Spanner&lt;/a> overview 的 implementation-layer deep article、寫作參照 &lt;a href="https://tarrragon.github.io/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">vendor deep article methodology&lt;/a>。Overview 已說明 Spanner 在全球 OLTP 譜系的定位、本文聚焦 &lt;em>PostgreSQL dialect&lt;/em> — Spanner 為降低 PostgreSQL 生態遷入門檻提供的 PG-compatible 介面、跟原生 GoogleSQL dialect 的差異與邊界。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="核心定位pg-dialect-是介面層不是換引擎">核心定位：PG dialect 是介面層、不是換引擎&lt;/h2>
&lt;p>Spanner PostgreSQL dialect 的責任是讓 PostgreSQL 生態的語法、型別系統與 wire protocol 能跑在 Spanner 的分散式引擎之上、降低團隊既有 PostgreSQL 知識與工具的遷移成本。它改變的是 &lt;em>query 語言與 client 介面&lt;/em>、不改變底層的 split-based 儲存、Paxos 複製、TrueTime commit 與 external consistency — 這些 Spanner 的分散式語意在兩種 dialect 下完全一致。&lt;/p>
&lt;p>把這條定位放在最前面、是因為最常見的誤解是「選了 PG dialect 就等於用 PostgreSQL」。實際上 PG dialect 是「用 PostgreSQL 的方言跟 Spanner 對話」、不是「在 Spanner 裡裝一個 PostgreSQL」。team 帶著 PostgreSQL 的 &lt;code>psql&lt;/code>、libpq driver、PG 語法進來、但要寫的仍是 Spanner — 一個沒有 single-primary、沒有本地 sequence、partition 由系統管理的分散式 SQL。&lt;/p>
&lt;p>GoogleSQL dialect 是 Spanner 原生方言、語法接近 BigQuery 的 GoogleSQL、攜帶 Spanner-specific 的 &lt;code>INTERLEAVE IN PARENT&lt;/code>、array 型別、&lt;code>PENDING_COMMIT_TIMESTAMP&lt;/code> 等原生概念。兩種 dialect 是 instance / database 建立時就固定的選擇、之後不可變更。&lt;/p>
&lt;h2 id="問題情境postgresql-團隊想遷入-spanner但不想重寫所有-sql">問題情境：PostgreSQL 團隊想遷入 Spanner、但不想重寫所有 SQL&lt;/h2>
&lt;p>PostgreSQL dialect 的存在價值、在「既有 PostgreSQL 應用要拿到 Spanner 的全球強一致與線性擴展、但團隊的 SQL、ORM、tooling、人員技能都綁在 PostgreSQL」這個壓力下浮現。讀者徵兆：團隊評估 Spanner 時發現 GoogleSQL 語法陌生、ORM（如 SQLAlchemy、Hibernate）的 PostgreSQL dialect 已深度整合、DBA 熟悉 &lt;code>psql&lt;/code> 與 PG 工具鏈、不想為了遷移把整套 SQL 知識重學。&lt;/p>
&lt;p>真實壓力場景：一個建在 Cloud SQL for PostgreSQL 上的金融 ledger、撞到 single-primary 寫入上限、需要遷到 Spanner 拿跨 region 強一致;團隊有數萬行 PostgreSQL SQL、用 libpq-based driver、若 target 是 GoogleSQL、application 層改動範圍會大到讓遷移 ROI 不成立。PG dialect 把這個改動範圍縮小到「相容子集邊界內的 SQL 多數可沿用、邊界外的功能需要改寫」。&lt;/p>
&lt;p>Case anchor：本主題在 case 庫覆蓋稀薄。9.C10 是 Google internal dogfood case、未展開 dialect 選擇細節、且不是 customer-facing 參考。本文 dialect 機制、相容子集邊界、wire protocol 行為均以 GCP vendor 規格 + 通用遷移工程展開、case 僅作「為什麼 PostgreSQL 團隊要遷 Spanner」的壓力 anchor。延伸的遷移流程在 sibling &lt;a href="../migrate-from-cloud-sql-pg/">migrate-from-cloud-sql-pg&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/spanner/" data-link-title="Google Cloud Spanner" data-link-desc="全球分散式 strong-consistency OLTP、TrueTime API、線性擴展到 10 億 req/sec">Cloud Spanner</a> overview 的 implementation-layer deep article、寫作參照 <a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">vendor deep article methodology</a>。Overview 已說明 Spanner 在全球 OLTP 譜系的定位、本文聚焦 <em>PostgreSQL dialect</em> — Spanner 為降低 PostgreSQL 生態遷入門檻提供的 PG-compatible 介面、跟原生 GoogleSQL dialect 的差異與邊界。</p></blockquote>
<hr>
<h2 id="核心定位pg-dialect-是介面層不是換引擎">核心定位：PG dialect 是介面層、不是換引擎</h2>
<p>Spanner PostgreSQL dialect 的責任是讓 PostgreSQL 生態的語法、型別系統與 wire protocol 能跑在 Spanner 的分散式引擎之上、降低團隊既有 PostgreSQL 知識與工具的遷移成本。它改變的是 <em>query 語言與 client 介面</em>、不改變底層的 split-based 儲存、Paxos 複製、TrueTime commit 與 external consistency — 這些 Spanner 的分散式語意在兩種 dialect 下完全一致。</p>
<p>把這條定位放在最前面、是因為最常見的誤解是「選了 PG dialect 就等於用 PostgreSQL」。實際上 PG dialect 是「用 PostgreSQL 的方言跟 Spanner 對話」、不是「在 Spanner 裡裝一個 PostgreSQL」。team 帶著 PostgreSQL 的 <code>psql</code>、libpq driver、PG 語法進來、但要寫的仍是 Spanner — 一個沒有 single-primary、沒有本地 sequence、partition 由系統管理的分散式 SQL。</p>
<p>GoogleSQL dialect 是 Spanner 原生方言、語法接近 BigQuery 的 GoogleSQL、攜帶 Spanner-specific 的 <code>INTERLEAVE IN PARENT</code>、array 型別、<code>PENDING_COMMIT_TIMESTAMP</code> 等原生概念。兩種 dialect 是 instance / database 建立時就固定的選擇、之後不可變更。</p>
<h2 id="問題情境postgresql-團隊想遷入-spanner但不想重寫所有-sql">問題情境：PostgreSQL 團隊想遷入 Spanner、但不想重寫所有 SQL</h2>
<p>PostgreSQL dialect 的存在價值、在「既有 PostgreSQL 應用要拿到 Spanner 的全球強一致與線性擴展、但團隊的 SQL、ORM、tooling、人員技能都綁在 PostgreSQL」這個壓力下浮現。讀者徵兆：團隊評估 Spanner 時發現 GoogleSQL 語法陌生、ORM（如 SQLAlchemy、Hibernate）的 PostgreSQL dialect 已深度整合、DBA 熟悉 <code>psql</code> 與 PG 工具鏈、不想為了遷移把整套 SQL 知識重學。</p>
<p>真實壓力場景：一個建在 Cloud SQL for PostgreSQL 上的金融 ledger、撞到 single-primary 寫入上限、需要遷到 Spanner 拿跨 region 強一致;團隊有數萬行 PostgreSQL SQL、用 libpq-based driver、若 target 是 GoogleSQL、application 層改動範圍會大到讓遷移 ROI 不成立。PG dialect 把這個改動範圍縮小到「相容子集邊界內的 SQL 多數可沿用、邊界外的功能需要改寫」。</p>
<p>Case anchor：本主題在 case 庫覆蓋稀薄。9.C10 是 Google internal dogfood case、未展開 dialect 選擇細節、且不是 customer-facing 參考。本文 dialect 機制、相容子集邊界、wire protocol 行為均以 GCP vendor 規格 + 通用遷移工程展開、case 僅作「為什麼 PostgreSQL 團隊要遷 Spanner」的壓力 anchor。延伸的遷移流程在 sibling <a href="../migrate-from-cloud-sql-pg/">migrate-from-cloud-sql-pg</a>。</p>
<h2 id="相容子集邊界哪些-postgresql-功能不在範圍內">相容子集邊界：哪些 PostgreSQL 功能不在範圍內</h2>
<p>PG dialect 提供 PostgreSQL 語法、型別、function 與 wire protocol 的 <em>一個子集</em>、邊界由「Spanner 分散式引擎能不能支援該語意」決定、不是 PostgreSQL 有什麼就有什麼。理解邊界的關鍵是分清三類：相容沿用的、Spanner 用不同方式達成的、根本不存在的。</p>
<h3 id="相容沿用多數標準-sql">相容沿用：多數標準 SQL</h3>
<p>標準 DML（<code>SELECT</code> / <code>INSERT</code> / <code>UPDATE</code> / <code>DELETE</code>）、多數 JOIN、聚合、CTE、常見型別（<code>bigint</code> / <code>text</code> / <code>numeric</code> / <code>timestamptz</code> / <code>bool</code> / <code>jsonb</code>）、prepared statement、parameterized query 在 PG dialect 下沿用 PostgreSQL 語法。libpq-based driver 與 <code>psql</code> 可直接連、wire protocol 相容讓 PostgreSQL client 工具多數可用。</p>
<h3 id="spanner-用不同方式達成sequenceschema-changepk">Spanner 用不同方式達成：sequence、schema change、PK</h3>
<p>PostgreSQL 的 <code>SERIAL</code> / <code>bigserial</code> 在分散式系統下會製造熱點（單調遞增的 PK 集中寫到同一個 split）、Spanner 引導用 UUID 或 bit-reversed sequence 分散寫入。schema change 在 PG dialect 下仍是 Spanner 的 long-running operation、不是 PostgreSQL 的同步 DDL — DDL 語法是 PG 風格、但執行語意是 Spanner 的（見 <a href="../schema-migration-interleaved-tables/">schema-migration-interleaved-tables</a>）。primary key 設計直接決定資料分布、跟 PostgreSQL 把 PK 當邏輯約束的心智不同。</p>
<h3 id="根本不存在postgresql-重度功能">根本不存在：PostgreSQL 重度功能</h3>
<p>部分 PostgreSQL 的進階功能不在 PG dialect 範圍內、團隊若依賴它們、遷移要先找替代路徑。常見的缺口包含：自訂 extension（PostGIS、pgvector 等需另尋路徑）、stored procedure / 觸發器生態、部分 window function 與進階型別、<code>LISTEN</code> / <code>NOTIFY</code>、以及 PostgreSQL 特有的 lock 與 vacuum 心智。這些缺口不是 bug、是「Spanner 不是 PostgreSQL」的直接後果。</p>
<blockquote>
<p><strong>Scope warning</strong>：PG dialect 的具體支援清單（支援哪些型別、function、語法）逐版本擴充、本文列舉的相容子集邊界屬 GCP 規格、實作前必須 cross-verify <a href="https://cloud.google.com/spanner/docs/postgresql-interface">Spanner PostgreSQL dialect 官方文件</a> 的當前支援矩陣、不能依本文清單當最終依據。</p></blockquote>
<h2 id="操作流程建立-pg-dialect-database連線驗證相容性">操作流程：建立 PG dialect database、連線、驗證相容性</h2>
<h3 id="step-1建立-pg-dialect-database">Step 1：建立 PG dialect database</h3>
<p>dialect 在建立 database 時指定、不可事後變更。建立時明確選 PostgreSQL dialect：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">gcloud spanner databases create my-pg-db <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --instance<span class="o">=</span>my-instance <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --database-dialect<span class="o">=</span>POSTGRESQL</span></span></code></pre></div><p>驗證：查 database metadata 確認 dialect 是 POSTGRESQL。這步若選錯、唯一修法是建新 database 重遷、沒有 in-place 轉換 — 這是本文反覆強調的不可逆決策。</p>
<h3 id="step-2用-postgresql-client-連線">Step 2：用 PostgreSQL client 連線</h3>
<p>PG dialect 接受 PostgreSQL wire protocol、可用 <code>psql</code> 或 libpq-based driver 連線（透過 PGAdapter proxy 或支援的 client library）。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 透過 PGAdapter 用 psql 連線</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">psql -h localhost -p <span class="m">5432</span> -d my-pg-db</span></span></code></pre></div><p>驗證：跑一個簡單 <code>SELECT 1</code>、確認 wire protocol 通;再跑一個帶 PG 型別的 query、確認型別映射正確。</p>
<h3 id="step-3相容性-audit--跑既有-sql-測邊界">Step 3：相容性 audit — 跑既有 SQL 測邊界</h3>
<p>把既有 PostgreSQL application 的 SQL 集合在 PG dialect database 上跑一遍、標出哪些直接通過、哪些報不支援。這步是遷移評估的核心 evidence — 它把「相容子集邊界」從文件文字變成「我的 SQL 有多少落在邊界內」的具體數字。</p>
<p>驗證點：統計通過率、把不通過的 SQL 分類（用 different way 達成 vs 根本不支援）、對「根本不支援」的部分評估改寫成本。若改寫成本過高、這是 PG dialect 路徑的 no-go 訊號。</p>
<h3 id="step-4rollback-boundary">Step 4：rollback boundary</h3>
<p>dialect 不可變更、所以 rollback boundary 在「遷移評估階段」、不在「上線後」。決策樹是：相容性 audit 通過率高 + 改寫成本可控 → 選 PG dialect;通過率低 + 大量 Spanner-only 優化需求 → 直接學 GoogleSQL。一旦 database 建好、dialect 就鎖定、rollback 等於重建 database 重遷。</p>
<h2 id="失敗模式把-pg-dialect-當完整-postgresql與-dialect-鎖定">失敗模式：把 PG dialect 當完整 PostgreSQL、與 dialect 鎖定</h2>
<h3 id="把-pg-dialect-當完整-postgresql-用">把 PG dialect 當完整 PostgreSQL 用</h3>
<p>團隊假設「PG dialect = PostgreSQL」、直接把依賴 extension、stored procedure、<code>SERIAL</code> PK 的應用搬過來、上線後發現 extension 不存在、<code>SERIAL</code> 製造熱點、p99 write latency 因 PK 集中而退化。徵兆是特定 PK range 的 split CPU 飆高、其餘 split 閒置。修法是審查 PK 設計改用分散式友善的 key（UUID / bit-reversed sequence）、把 extension 依賴改成 application 層或外部服務。這個失敗的根因是心智模型錯位、不是 bug。</p>
<h3 id="dialect-鎖定後才發現需要另一種-dialect">Dialect 鎖定後才發現需要另一種 dialect</h3>
<p>dialect 是 database 建立時的不可逆選擇、團隊選了 PG dialect、後續發現需要 GoogleSQL 才有的某個原生能力（或反之）、唯一路徑是建新 database 重遷全部資料。這個失敗的代價遠高於一般 config 錯誤 — 它不是改一行設定、是一次完整的資料遷移 + application cutover + 驗證 + rollback 規劃。回退路徑是把它當成一次 Type E migration（見 <a href="../migrate-from-cloud-sql-pg/">migrate-from-cloud-sql-pg</a> 的 paradigm shift 結構）、不能當成 hotfix。預防勝於回退：在 Step 3 的相容性 audit 階段就要把「未來可能需要哪種 dialect 的能力」一起評估、而不是只看當下的 SQL 通過率。</p>
<h3 id="以為換了-pg-dialect-就不用懂-spanner-分散式語意">以為換了 PG dialect 就不用懂 Spanner 分散式語意</h3>
<p>PG dialect 降低語法門檻、但 Spanner 的 split、hot range、interleaved table、commit wait、cross-region quorum 在 PG dialect 下完全一樣。團隊若以為「用 PG 語法就能當 PostgreSQL 維運」、會在 hot partition、跨 region latency、schema change 是 long-running operation 這些 Spanner-specific 議題上踩雷。修法是不論選哪種 dialect、Spanner 的分散式機制都要懂 — dialect 是介面、不是引擎。</p>
<h2 id="容量與觀測dialect-不改變容量模型">容量與觀測：dialect 不改變容量模型</h2>
<p>PG dialect 跟 GoogleSQL 共用同一個 Spanner 引擎、容量模型、metric、sizing 完全一致 — 選 dialect 不影響容量規劃。核心觀測仍是 Spanner instance 的 CPU、split distribution、commit latency、跟原生 GoogleSQL database 沒有差別。</p>
<p>需要額外觀測的是 PG dialect 特有的接入層：若透過 PGAdapter proxy 連線、proxy 本身是一跳、要監控 proxy 的延遲與可用性、避免它成為單點。</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">Spanner CPU utilization        → 跟 dialect 無關、共用引擎指標
</span></span><span class="line"><span class="ln">2</span><span class="cl">split / hot range distribution → PK 設計（含 SERIAL 熱點）直接反映在這
</span></span><span class="line"><span class="ln">3</span><span class="cl">PGAdapter proxy latency        → PG dialect 接入層的額外一跳（若使用）
</span></span><span class="line"><span class="ln">4</span><span class="cl">commit_latencies               → external consistency 的 commit wait、兩 dialect 一致</span></span></code></pre></div><p>容量規劃路由回 <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> — sizing 邏輯跟 dialect 無關。觀測接 <a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">4.20 Observability Evidence Package</a>。</p>
<blockquote>
<p><strong>Scope warning</strong>：PGAdapter 的部署模型（sidecar / standalone proxy）與其延遲特性屬 GCP 規格、cross-verify 官方文件、非 9.C10 case 揭露。</p></blockquote>
<h2 id="邊界與整合何時選-pg-dialect何時選-googlesql">邊界與整合：何時選 PG dialect、何時選 GoogleSQL</h2>
<h3 id="選-pg-dialect-的條件">選 PG dialect 的條件</h3>
<p>既有 PostgreSQL 應用要遷入、SQL / ORM / tooling 深度綁 PostgreSQL、相容性 audit 通過率高、且不需要大量 Spanner-only 原生優化 — 這是 PG dialect 的適用條件。它讓遷移的 application 層改動最小化、保留團隊既有 PostgreSQL 技能。</p>
<h3 id="選-googlesql-的條件">選 GoogleSQL 的條件</h3>
<p>全新專案、團隊願意學 Spanner 原生方言、需要深度用 interleaved table、array 型別、Spanner-specific 優化、或想跟 BigQuery 的 GoogleSQL 生態對齊 — 選 GoogleSQL。它是 Spanner 的一等公民方言、新功能通常先在 GoogleSQL 落地。</p>
<h3 id="何時兩者都不選不該升-spanner">何時兩者都不選（不該升 Spanner）</h3>
<p>若 workload 是單 region、不需要全球強一致、PostgreSQL dialect 的相容性吸引力不該成為升 Spanner 的理由 — Cloud SQL for PostgreSQL 是真正的 PostgreSQL、相容性 100%、成本更低。Anti-recommendation 的判準是：PG dialect 的價值在「已經要遷 Spanner、想降低遷移成本」、不在「因為它像 PostgreSQL 所以選 Spanner」。把 dialect 相容性當升級理由是把次要因素當主要決策。</p>
<h3 id="sibling-deep-articles-路由">Sibling deep articles 路由</h3>
<ul>
<li><a href="../migrate-from-cloud-sql-pg/">migrate-from-cloud-sql-pg</a>：PG dialect 是 Cloud SQL → Spanner 遷移降低改動成本的關鍵、本文的相容子集邊界對應該 playbook 的 diff audit</li>
<li><a href="../schema-migration-interleaved-tables/">schema-migration-interleaved-tables</a>：PG dialect 下 DDL 仍是 Spanner long-running operation、interleaved table 在兩 dialect 都要懂</li>
<li><a href="../consistency-models-comparison/">consistency-models-comparison</a>：兩 dialect 共用 external consistency、dialect 不改變一致性語意</li>
</ul>
<h3 id="跟-knowledge-card-的互引">跟 knowledge card 的互引</h3>
<ul>
<li><a href="/blog/backend/knowledge-cards/distributed-sql/" data-link-title="Distributed SQL" data-link-desc="把 SQL 與交易語意延伸到多節點與多區域的資料庫形態">distributed-sql</a> — PG dialect 是 distributed SQL 上的相容介面、不改變 distributed SQL 的本質</li>
<li><a href="/blog/backend/knowledge-cards/isolation-level/" data-link-title="Isolation Level" data-link-desc="說明資料庫交易隔離級別如何影響並發讀寫結果">isolation-level</a> — 兩 dialect 共用 Spanner 的 external consistency、isolation 語意一致</li>
</ul>
<h3 id="跟其他-vendor-的對照路由">跟其他 vendor 的對照路由</h3>
<ul>
<li><a href="/blog/backend/01-database/vendors/cockroachdb/" data-link-title="CockroachDB" data-link-desc="分散式 SQL、PostgreSQL 相容、跨區強一致、Spanner 的開源 / 跨雲替代">CockroachDB vendor</a>：CockroachDB 走 PostgreSQL wire 相容是其核心策略、跟 Spanner PG dialect 是兩種「PostgreSQL 相容的 distributed SQL」路線、相容程度與邊界不同</li>
</ul>
]]></content:encoded></item><item><title>MongoDB Change Streams + Kafka 整合：resume token、scope 選擇與 connector 治理</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/mongodb/change-streams-kafka/</link><pubDate>Wed, 27 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/mongodb/change-streams-kafka/</guid><description>&lt;p>MongoDB change streams 是 3.6+ 原生 CDC 介面、本質上是 oplog tail 包裝成 cursor API。Application 從 dual-write 模式（自己寫 MongoDB 又寫 Elasticsearch / Redis / data warehouse）換成 change stream → Kafka → downstream sink 後、有了第一版 CDC pipeline、但連續工作幾週後出現「downstream 漏 event」或「duplicate event」；最痛的是 connector restart 後 resume token 過期（oplog 已滾掉）、整個 collection 必須重灌。本文把 change stream 機制、Kafka Connector 配置、resume token 治理、sharded cluster scope 選擇講清楚。&lt;/p>
&lt;p>本文不重複 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/mongodb/" data-link-title="MongoDB" data-link-desc="Document database 代表、Atlas managed、跨雲可用、許多大規模平台從 MongoDB 起家">MongoDB vendor overview&lt;/a> 已寫過的 change streams 簡介 — 而是 production CDC pipeline 部署 + 失敗修復的實作層教學。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>MongoDB 適用度前置判讀&lt;/strong>：進到 CDC pipeline 設計前先確認 workload 在 MongoDB 適用區（document shape 主導 / contract layer 該放哪 / 跨雲 hedging 是否需要）— 詳見 &lt;a href="../schema-design-pattern/#%e5%95%8f%e9%a1%8c%e6%83%85%e5%a2%83document-%e8%87%aa%e7%94%b1%e7%9a%84%e5%be%8c%e5%ba%a7%e5%8a%9b">schema-design-pattern 開頭 3 軸前置判讀&lt;/a>、本篇不重複展開。Change streams 是 &lt;em>已選 MongoDB 後&lt;/em> 的 event-driven 整合議題。&lt;/p>&lt;/blockquote>
&lt;h2 id="問題情境第一版-cdc-pipeline-跑幾週的踩雷">問題情境：第一版 CDC pipeline 跑幾週的踩雷&lt;/h2>
&lt;p>典型觸發場景：application 寫 MongoDB 後還要 dual-write Elasticsearch / Redis / data warehouse、application code 越塞越多 hook、寫入失敗的補償邏輯散落各處。改用 change stream → Kafka → downstream sink 後、有了第一版 CDC pipeline、但連續工作幾週後出現：&lt;/p>
&lt;ul>
&lt;li>Downstream 漏 event 或 duplicate event&lt;/li>
&lt;li>Connector restart 後 resume token 過期（oplog 已滾掉）、整個 collection 必須重灌&lt;/li>
&lt;li>Sharded cluster 上 collection-level change stream 跟 cluster-wide change stream 行為不同、application 連 mongos 跟連 single shard 拿到不同 event&lt;/li>
&lt;/ul>
&lt;p>讀者徵兆：&lt;/p>
&lt;ul>
&lt;li>MongoDB Kafka Connector log &lt;code>ChangeStreamHistoryLost&lt;/code> 或 &lt;code>ResumeTokenChanged&lt;/code>&lt;/li>
&lt;li>Downstream Kafka topic event count vs source collection write count 不平&lt;/li>
&lt;li>Replication oplog 跟 change stream consumer 的 lag 同時升&lt;/li>
&lt;/ul>
&lt;p>Case anchor：CDC pipeline resume token 過期導致全量重灌的具體 incident 細節需未來 case 補完、本文以「常見 failure pattern」+ 容量公式處理、不憑空編造 incident 數字。側面引用 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/spotify-kafka-to-pubsub-migration-gcp/" data-link-title="9.C9 Spotify：從自管 Kafka 遷移到 GCP Pub/Sub 的事件交付系統" data-link-desc="Spotify 把自管 Kafka 事件系統遷移到 Google Cloud Pub/Sub、避免自管 broker 的容量規劃成本">Spotify Kafka → PubSub migration&lt;/a>（pipeline-level migration 經驗對照）。&lt;/p></description><content:encoded><![CDATA[<p>MongoDB change streams 是 3.6+ 原生 CDC 介面、本質上是 oplog tail 包裝成 cursor API。Application 從 dual-write 模式（自己寫 MongoDB 又寫 Elasticsearch / Redis / data warehouse）換成 change stream → Kafka → downstream sink 後、有了第一版 CDC pipeline、但連續工作幾週後出現「downstream 漏 event」或「duplicate event」；最痛的是 connector restart 後 resume token 過期（oplog 已滾掉）、整個 collection 必須重灌。本文把 change stream 機制、Kafka Connector 配置、resume token 治理、sharded cluster scope 選擇講清楚。</p>
<p>本文不重複 <a href="/blog/backend/01-database/vendors/mongodb/" data-link-title="MongoDB" data-link-desc="Document database 代表、Atlas managed、跨雲可用、許多大規模平台從 MongoDB 起家">MongoDB vendor overview</a> 已寫過的 change streams 簡介 — 而是 production CDC pipeline 部署 + 失敗修復的實作層教學。</p>
<blockquote>
<p><strong>MongoDB 適用度前置判讀</strong>：進到 CDC pipeline 設計前先確認 workload 在 MongoDB 適用區（document shape 主導 / contract layer 該放哪 / 跨雲 hedging 是否需要）— 詳見 <a href="../schema-design-pattern/#%e5%95%8f%e9%a1%8c%e6%83%85%e5%a2%83document-%e8%87%aa%e7%94%b1%e7%9a%84%e5%be%8c%e5%ba%a7%e5%8a%9b">schema-design-pattern 開頭 3 軸前置判讀</a>、本篇不重複展開。Change streams 是 <em>已選 MongoDB 後</em> 的 event-driven 整合議題。</p></blockquote>
<h2 id="問題情境第一版-cdc-pipeline-跑幾週的踩雷">問題情境：第一版 CDC pipeline 跑幾週的踩雷</h2>
<p>典型觸發場景：application 寫 MongoDB 後還要 dual-write Elasticsearch / Redis / data warehouse、application code 越塞越多 hook、寫入失敗的補償邏輯散落各處。改用 change stream → Kafka → downstream sink 後、有了第一版 CDC pipeline、但連續工作幾週後出現：</p>
<ul>
<li>Downstream 漏 event 或 duplicate event</li>
<li>Connector restart 後 resume token 過期（oplog 已滾掉）、整個 collection 必須重灌</li>
<li>Sharded cluster 上 collection-level change stream 跟 cluster-wide change stream 行為不同、application 連 mongos 跟連 single shard 拿到不同 event</li>
</ul>
<p>讀者徵兆：</p>
<ul>
<li>MongoDB Kafka Connector log <code>ChangeStreamHistoryLost</code> 或 <code>ResumeTokenChanged</code></li>
<li>Downstream Kafka topic event count vs source collection write count 不平</li>
<li>Replication oplog 跟 change stream consumer 的 lag 同時升</li>
</ul>
<p>Case anchor：CDC pipeline resume token 過期導致全量重灌的具體 incident 細節需未來 case 補完、本文以「常見 failure pattern」+ 容量公式處理、不憑空編造 incident 數字。側面引用 <a href="/blog/backend/09-performance-capacity/cases/spotify-kafka-to-pubsub-migration-gcp/" data-link-title="9.C9 Spotify：從自管 Kafka 遷移到 GCP Pub/Sub 的事件交付系統" data-link-desc="Spotify 把自管 Kafka 事件系統遷移到 Google Cloud Pub/Sub、避免自管 broker 的容量規劃成本">Spotify Kafka → PubSub migration</a>（pipeline-level migration 經驗對照）。</p>
<h2 id="核心機制">核心機制</h2>
<p>Change stream 是 MongoDB 3.6+ 原生 CDC、本質上是 oplog tail 包裝成 cursor API。可以從 collection / database / cluster 三個 scope 開：</p>
<ul>
<li><strong>Collection-level</strong>：監看單一 collection 的變更</li>
<li><strong>Database-level</strong>：監看整個 database 的所有 collection</li>
<li><strong>Cluster-wide</strong>：監看整個 cluster 的所有 database</li>
</ul>
<p>Oplog 是 capped collection、預設 size = disk 5% 或 50GB（取較小）。Resume token 對應 oplog entry 的 timestamp + UUID + documentKey。Token 必須對應仍在 oplog 內的 entry — oplog 滾掉就拿不到 token 對應的位置、<code>ChangeStreamHistoryLost</code>。</p>
<p><strong>Resume token 兩種用法</strong>：</p>
<ul>
<li><code>_id</code>：每個 event 都帶、application 自己存</li>
<li><code>startAfter</code> / <code>resumeAfter</code> parameter：重啟 cursor 時帶上</li>
</ul>
<p><strong><code>fullDocument: &quot;updateLookup&quot;</code></strong>：update event 預設只給 delta、加這個 option 會額外 query 一次 primary 拿完整 doc；高頻 update 下成本顯著（primary 負擔翻倍）。</p>
<p><strong>Pre-image / post-image（6.0+）</strong>：可以拿到 update 前的 doc 狀態、需 collection-level option <code>changeStreamPreAndPostImages: true</code>。</p>
<p><strong>Cluster-wide vs collection-level change stream</strong>：</p>
<ul>
<li>Cluster-wide 必須打 mongos、event ordering 是 global</li>
<li>Collection-level 可直接打單 shard、ordering 只在該 shard 內</li>
<li>Sharded cluster 上 cluster-wide stream 容易把 mongos 變單點瓶頸（所有 shard 的 event 都收斂到 mongos）</li>
</ul>
<p><strong>MongoDB Kafka Connector</strong>（Confluent / MongoDB 官方）：</p>
<ul>
<li>Source connector：把 change stream → Kafka topic</li>
<li>Sink connector：把 Kafka topic → MongoDB</li>
<li>At-least-once 語義、需 application 處理 idempotency</li>
</ul>
<p>對應 knowledge card：<a href="/blog/backend/knowledge-cards/change-data-capture/" data-link-title="Change Data Capture" data-link-desc="說明資料變更如何被捕捉並傳送到其他系統">change-data-capture</a>、<a href="/blog/backend/knowledge-cards/replication-channel/" data-link-title="Replication Channel" data-link-desc="說明多來源複製中，每個來源對應的獨立複製通道如何成為隔離單位">replication-channel</a>、<a href="/blog/backend/knowledge-cards/replication-slot/" data-link-title="Replication Slot" data-link-desc="說明邏輯複製如何用 slot 追蹤消費進度，並對來源端造成保留壓力">replication-slot</a>（MongoDB 沒 slot、概念對照）。</p>
<h2 id="操作流程">操作流程</h2>
<p><strong>Step 1：scope 決策樹</strong>。</p>
<table>
  <thead>
      <tr>
          <th>Scope</th>
          <th>適用條件</th>
          <th>代價</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Collection-level</td>
          <td>單一 collection 的下游 sink、ordering 需求單一</td>
          <td>多 collection 要多 connector</td>
      </tr>
      <tr>
          <td>Database-level</td>
          <td>多 collection 共享 sink、ordering 跨 collection</td>
          <td>filter cost 在 connector 端</td>
      </tr>
      <tr>
          <td>Cluster-wide</td>
          <td>整個 cluster 統一 audit / replay</td>
          <td>mongos 單點瓶頸風險、event 量大</td>
      </tr>
  </tbody>
</table>
<p><strong>Step 2：oplog sizing</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">oplog size &gt;= peak write rate × max acceptable consumer downtime</span></span></code></pre></div><p>典型設 24-72 小時可恢復窗口。例：peak 5K WPS、想容忍 48 小時 connector down、oplog 至少 5K × 86400 × 2 ÷ docs_per_GB ≈ 看實際 doc size 決定。在 Atlas 上 oplog size 可直接調、自管 cluster 改 <code>replSetResizeOplog</code>。</p>
<p><strong>Step 3：Kafka Connector 配置</strong>。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="nt">&#34;connector.class&#34;</span><span class="p">:</span> <span class="s2">&#34;com.mongodb.kafka.connect.MongoSourceConnector&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="nt">&#34;connection.uri&#34;</span><span class="p">:</span> <span class="s2">&#34;mongodb://...&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="nt">&#34;database&#34;</span><span class="p">:</span> <span class="s2">&#34;shop&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="nt">&#34;collection&#34;</span><span class="p">:</span> <span class="s2">&#34;orders&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="nt">&#34;publish.full.document.only&#34;</span><span class="p">:</span> <span class="s2">&#34;true&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="nt">&#34;change.stream.full.document&#34;</span><span class="p">:</span> <span class="s2">&#34;updateLookup&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="nt">&#34;copy.existing&#34;</span><span class="p">:</span> <span class="s2">&#34;true&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="nt">&#34;copy.existing.namespace.regex&#34;</span><span class="p">:</span> <span class="s2">&#34;shop\\.orders&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="nt">&#34;errors.tolerance&#34;</span><span class="p">:</span> <span class="s2">&#34;none&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="nt">&#34;offset.flush.interval.ms&#34;</span><span class="p">:</span> <span class="s2">&#34;10000&#34;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>關鍵欄位：</p>
<ul>
<li><code>change.stream.full.document: &quot;updateLookup&quot;</code>：每 update 額外 query primary 拿完整 doc（成本意識）</li>
<li><code>copy.existing: &quot;true&quot;</code>：connector 啟動時先把現有 collection 全量複製、再切到 change stream — 適合初次部署</li>
<li><code>errors.tolerance: &quot;none&quot;</code>：sink 失敗時 batch 停在 dead-letter queue、不 silently drop</li>
</ul>
<p><strong>Step 4：resume token persistence</strong>。Connector 把 token 寫 Kafka <code>__consumer_offsets</code> 或外部 store；application 自管 change stream 時要寫到 durable store（不是 in-memory）。</p>
<p><strong>Step 5：filter pipeline</strong>。Change stream 支援 aggregation pipeline 把過濾下推到 MongoDB：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">const</span> <span class="nx">pipeline</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="p">{</span> <span class="nx">$match</span><span class="o">:</span> <span class="p">{</span> <span class="s2">&#34;operationType&#34;</span><span class="o">:</span> <span class="p">{</span> <span class="nx">$in</span><span class="o">:</span> <span class="p">[</span><span class="s2">&#34;insert&#34;</span><span class="p">,</span> <span class="s2">&#34;update&#34;</span><span class="p">,</span> <span class="s2">&#34;delete&#34;</span><span class="p">]</span> <span class="p">}</span> <span class="p">}</span> <span class="p">},</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="p">{</span> <span class="nx">$match</span><span class="o">:</span> <span class="p">{</span> <span class="s2">&#34;fullDocument.region&#34;</span><span class="o">:</span> <span class="s2">&#34;ap-tokyo&#34;</span> <span class="p">}</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">]</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="kr">const</span> <span class="nx">changeStream</span> <span class="o">=</span> <span class="nx">db</span><span class="p">.</span><span class="nx">orders</span><span class="p">.</span><span class="nx">watch</span><span class="p">(</span><span class="nx">pipeline</span><span class="p">)</span></span></span></code></pre></div><p>把過濾下推減少 connector 處理量、特別是高頻 collection 上。</p>
<p><strong>Step 6：downstream idempotency</strong>。Sink 收 Kafka event 時用 <code>documentKey._id + clusterTime</code> 做 dedup key — at-least-once 語義意味著 connector restart 後幾分鐘 event 會重發。</p>
<p>驗證點：</p>
<ul>
<li>Source collection write count vs Kafka topic event count 差異 &lt; 0.1%</li>
<li>Resume token age &lt; oplog retention 的 50%（健康狀態）</li>
<li>Connector restart drill 能 5 分鐘內接回</li>
</ul>
<p>Rollback boundary：source connector 是 read-only 對 MongoDB 無傷；sink connector 要備份 target 才能還原；resume token 寫錯 → 從 <code>startAtOperationTime</code> 回退到時間點重跑。</p>
<h2 id="失敗模式">失敗模式</h2>
<p><strong>Resume token 過期（oplog 滾掉）</strong>：connector down 太久、oplog 已超出 retention、<code>ChangeStreamHistoryLost</code> → 必須 <code>copy.existing</code> 全量重灌、期間 downstream 看不到新資料。預防是 oplog sizing 留 buffer + connector lag alarm + token age 監控（age &gt; oplog retention 的 50% 預警）。</p>
<p><strong>updateLookup 在高頻 update 下打爆 primary</strong>：每筆 update event 都觸發一次 primary query、primary 負擔翻倍。修法是改 collection-level pre/post image（6.0+）、由 MongoDB 自己在寫入時記錄、或在 application 補完整 doc 後再寫 Kafka、不用 updateLookup。</p>
<p><strong>Sharded cluster cluster-wide stream 打爆 mongos</strong>：所有 shard 的 event 都收斂到 mongos、mongos 變單點瓶頸。修法是改 collection-level stream 多 connector 並行、每 connector 連 mongos 但只訂單一 collection。</p>
<p><strong>At-least-once 變 duplicate flood</strong>：connector restart 點之後幾分鐘 event 重發、downstream 沒做 idempotency → 重複 side effect（重複發 email、重複扣款）。修法是 sink 端強制 idempotency（dedup key 寫 Redis / DB）、不能假設「我用 at-least-once 但實際不會 duplicate」。</p>
<p><strong>Schema drift 突然 break sink</strong>：MongoDB 寫了新欄位 / 改型別、sink connector 的 JSON schema 不認、batch 停在 dead-letter queue。修法是 schema 變動有 validation gate（見 <a href="../schema-design-pattern/">schema design pattern</a>）、sink schema 設 <code>lenient</code> 模式吃 unknown field、或加 schema registry 統一版本。</p>
<p><strong>Backup / DDL 期間 change stream 異常</strong>：<code>reIndex</code> / <code>compact</code> / <code>dropCollection</code> 觸發特殊 event、connector 沒處理 → consumer 停。修法是 connector 處理特殊 event 邏輯要明確、不認得的 operation type 至少 log warning 而不是 silently stuck。</p>
<p>Anti-recommendation：</p>
<ul>
<li>簡單的 <a href="/blog/backend/knowledge-cards/outbox-pattern/" data-link-title="Outbox Pattern" data-link-desc="說明資料庫狀態變更與事件發布如何透過 outbox 維持一致">outbox pattern</a> + application transactional write 對於低吞吐 / 單 sink 的場景比 change stream + Kafka 簡單；不是所有「需要 event 通知」的場景都要 CDC pipeline</li>
<li>若 downstream 只是同一 region 同團隊的 Elasticsearch index、<code>$merge</code> 寫進中介 collection 或 application 雙寫 + 對賬可能成本更低</li>
<li>Resume token 過期是這條路徑最痛的事故、oplog sizing 是 <em>投資而不是成本</em> — 不要為了省 storage 把 oplog 設太小</li>
</ul>
<h2 id="容量與觀測">容量與觀測</h2>
<p>關鍵 metric：</p>
<ul>
<li><strong>Oplog 健康</strong>：oplog 寫入速率與保留時間</li>
<li><strong>Change stream 健康</strong>：cursor age、resume token 距 oplog 頭尾的距離</li>
<li><strong>Connector 健康</strong>：connector lag（Kafka offset 對比 source write）</li>
<li><strong>下游健康</strong>：event count diff（source write count vs sink apply count）、event time → arrival time lag 分布</li>
</ul>
<p>Mongo command：</p>
<ul>
<li><code>db.getReplicationInfo()</code>：oplog 大小 / 時間範圍</li>
<li><code>db.printReplicationInfo()</code>：oplog 摘要</li>
<li><code>db.currentOp({ &quot;op&quot;: &quot;getmore&quot;, &quot;ns&quot;: &quot;local.oplog.rs&quot; })</code>：看 change stream consumer 連線</li>
</ul>
<p>Connector metric（Kafka Connect JMX）：<code>source-record-poll-rate</code>、<code>source-record-write-rate</code>、<code>offset-commit-success-rate</code>。</p>
<p>回到 <a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">4.20 observability evidence</a>：oplog retention + connector lag + dedup rate 是 CDC pipeline 健康狀態 evidence 三件套。</p>
<p>回到 <a href="/blog/backend/09-performance-capacity/bottleneck-localization/" data-link-title="9.5 瓶頸定位流程" data-link-desc="從 app 到 DB / cache / broker / 第三方 quota 的逐層瓶頸定位">9.5 bottleneck localization</a>：CDC lag 升高時區分 (a) source oplog 寫太快 (b) connector 處理慢 (c) downstream sink 慢。</p>
<h2 id="邊界與整合">邊界與整合</h2>
<p>Sibling deep articles：</p>
<ul>
<li><a href="../shard-key-selection/">shard key selection</a> — cluster-wide vs collection-level change stream 在 sharded cluster 的選擇</li>
<li><a href="../replica-set-read-preference/">replica set read preference</a> — change stream 對 primary load 的影響、能否走 secondary</li>
<li><a href="../schema-design-pattern/">schema design pattern</a> — schema validator 對下游 sink 的契約意義</li>
<li><a href="../connection-management-and-cache-layer/">connection management and cache layer</a> — CDC sink 在 production 跨層架構裡的角色（cache invalidation / federated DB 同步）</li>
</ul>
<p>Migration playbook：</p>
<ul>
<li>MongoDB → 其他 sink 的 bulk migration 走 <a href="/blog/backend/01-database/vendors/mongodb/migrate-to-atlas/" data-link-title="MongoDB → Atlas：Atlas 不是 MongoDB &#43; managed、是另一個 product" data-link-desc="Atlas 號稱「MongoDB managed」但 operational model 完全不同（auto-scaling / VPC peering / IAM-driven access / 內建 backup / billing 模型）；本文採用 Type C operational redesign hybrid 結構、4-phase operational migration &#43; drop-in cutover、5 個 production 踩雷（連線數限制 / IP whitelist / backup retention / IAM token 過期 / billing 暴漲）">→ Atlas Migration Service</a></li>
<li>遷出 MongoDB 時 change stream 是 catch-up 機制（先 bulk export、再 change stream 補增量）</li>
</ul>
<p>跟 1.x 互引：<a href="/blog/backend/01-database/schema-migration-rollout-evidence/" data-link-title="1.7 Schema Migration Rollout 證據（Schema Migration Rollout Evidence）實作示範" data-link-desc="以訂單付款狀態欄位演進示範 schema migration 如何產出 evidence、release gate 與 incident decision log。">1.7 schema migration rollout evidence</a> 處理 schema drift 時 CDC pipeline 的對賬；<a href="/blog/backend/01-database/reconciliation-data-repair/" data-link-title="1.9 Reconciliation 與 Data Repair" data-link-desc="資料不一致的分類、偵測模式、修復策略、audit trail、跟 backup / PITR 整合">1.9 reconciliation data repair</a> 處理 CDC 失準後的對賬流程。</p>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/mongodb/" data-link-title="MongoDB" data-link-desc="Document database 代表、Atlas managed、跨雲可用、許多大規模平台從 MongoDB 起家">MongoDB vendor overview</a> — 本文是該頁尾「change streams + Kafka」backlog 的深度展開</li>
<li><a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章方法論</a></li>
<li>官方：<a href="https://www.mongodb.com/docs/manual/changeStreams/">Change Streams</a>、<a href="https://www.mongodb.com/docs/kafka-connector/current/">MongoDB Kafka Connector</a>、<a href="https://www.mongodb.com/docs/manual/core/replica-set-oplog/">Oplog</a></li>
</ul>
]]></content:encoded></item><item><title>PostgreSQL PITR + WAL archiving：從 base backup 到 point-in-time recovery 的完整鏈</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/pitr-wal-archiving/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/pitr-wal-archiving/</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 已說明 backup / recovery 是 OLTP 必備能力、本文聚焦 &lt;em>PITR（Point-In-Time Recovery）的雙軌資料設計 + production 5 個 failure mode&lt;/em>。&lt;/p>&lt;/blockquote>
&lt;h2 id="問題情境">問題情境&lt;/h2>
&lt;p>Logical bug 在 production 部署、執行 6 小時後才發現 — 某個 batch job 把 50 萬筆 user.email 改成 NULL。此時：&lt;/p>
&lt;ul>
&lt;li>還原最新 daily backup（昨晚）→ 丟掉今天所有正常寫入（訂單、註冊）&lt;/li>
&lt;li>從 standby promote → standby 已同步 bug、跟 primary 同狀態&lt;/li>
&lt;li>從 application log 重建 → 部分操作不可逆（已寄出 email）&lt;/li>
&lt;/ul>
&lt;p>PITR 是這類 &lt;em>logical disaster&lt;/em> 的標準解 — 不還原到 backup 時間點、而是 &lt;em>還原到 bug 發生前一刻&lt;/em>（例：1 分鐘前）。需要 &lt;em>base backup + WAL archive&lt;/em> 雙軌資料：base backup 是 snapshot、WAL archive 是 snapshot 之後的所有寫入；recovery 時 replay WAL 到指定 timestamp / LSN / transaction ID。&lt;/p>
&lt;h2 id="核心概念base-backup--wal-archive-的雙軌設計">核心概念：base backup + WAL archive 的雙軌設計&lt;/h2>





&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">[Base backup t0] + [WAL archive t0 → now]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> ↓ ↓
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> 全量 snapshot incremental log
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> ↓ ↓
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> └────── recover to t_target ──→ [restored cluster at t_target]&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>兩個軌道各自獨立但必須對齊：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Base backup&lt;/strong>：某時刻整個 data dir 的 snapshot。&lt;code>pg_basebackup&lt;/code> / &lt;code>pgBackRest&lt;/code> / &lt;code>WAL-G&lt;/code> 都產這個；通常 &lt;em>每天 / 每週&lt;/em> 跑一次&lt;/li>
&lt;li>&lt;strong>WAL archive&lt;/strong>：base backup 之後每段 WAL 都 push 到外部 storage（S3 / GCS / NFS）。&lt;code>archive_command&lt;/code> 觸發、PostgreSQL 等到 archive 成功才 &lt;em>回收&lt;/em> 那段 WAL&lt;/li>
&lt;/ol>
&lt;p>兩者組合決定 RPO（recovery point objective）：&lt;/p>
&lt;ul>
&lt;li>RPO ≈ WAL archive frequency（streaming 即時、&lt;code>archive_timeout&lt;/code> 預設 1 分鐘）&lt;/li>
&lt;li>RPO 不是 base backup frequency — daily base backup + 每分鐘 archive WAL → RPO 1 分鐘&lt;/li>
&lt;/ul>
&lt;p>RTO（recovery time objective）跟 &lt;em>base backup size + WAL replay 量&lt;/em> 相關：&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 已說明 backup / recovery 是 OLTP 必備能力、本文聚焦 <em>PITR（Point-In-Time Recovery）的雙軌資料設計 + production 5 個 failure mode</em>。</p></blockquote>
<h2 id="問題情境">問題情境</h2>
<p>Logical bug 在 production 部署、執行 6 小時後才發現 — 某個 batch job 把 50 萬筆 user.email 改成 NULL。此時：</p>
<ul>
<li>還原最新 daily backup（昨晚）→ 丟掉今天所有正常寫入（訂單、註冊）</li>
<li>從 standby promote → standby 已同步 bug、跟 primary 同狀態</li>
<li>從 application log 重建 → 部分操作不可逆（已寄出 email）</li>
</ul>
<p>PITR 是這類 <em>logical disaster</em> 的標準解 — 不還原到 backup 時間點、而是 <em>還原到 bug 發生前一刻</em>（例：1 分鐘前）。需要 <em>base backup + WAL archive</em> 雙軌資料：base backup 是 snapshot、WAL archive 是 snapshot 之後的所有寫入；recovery 時 replay WAL 到指定 timestamp / LSN / transaction ID。</p>
<h2 id="核心概念base-backup--wal-archive-的雙軌設計">核心概念：base backup + WAL archive 的雙軌設計</h2>





<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">[Base backup t0]  +  [WAL archive t0 → now]
</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">  全量 snapshot          incremental log
</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">     └────── recover to t_target ──→ [restored cluster at t_target]</span></span></code></pre></div><p>兩個軌道各自獨立但必須對齊：</p>
<ol>
<li><strong>Base backup</strong>：某時刻整個 data dir 的 snapshot。<code>pg_basebackup</code> / <code>pgBackRest</code> / <code>WAL-G</code> 都產這個；通常 <em>每天 / 每週</em> 跑一次</li>
<li><strong>WAL archive</strong>：base backup 之後每段 WAL 都 push 到外部 storage（S3 / GCS / NFS）。<code>archive_command</code> 觸發、PostgreSQL 等到 archive 成功才 <em>回收</em> 那段 WAL</li>
</ol>
<p>兩者組合決定 RPO（recovery point objective）：</p>
<ul>
<li>RPO ≈ WAL archive frequency（streaming 即時、<code>archive_timeout</code> 預設 1 分鐘）</li>
<li>RPO 不是 base backup frequency — daily base backup + 每分鐘 archive WAL → RPO 1 分鐘</li>
</ul>
<p>RTO（recovery time objective）跟 <em>base backup size + WAL replay 量</em> 相關：</p>
<ul>
<li>Restore base backup ~ 1-4 小時（TB 級）</li>
<li>WAL replay 時間 ~ archive 累積量 / replay throughput</li>
</ul>
<h2 id="step-by-step-配置">Step-by-step 配置</h2>
<h3 id="primaryarchive_command-設好">Primary：archive_command 設好</h3>





<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="c1"># postgresql.conf</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="na">wal_level</span> <span class="o">=</span> <span class="s">replica                          # 預設 replica、PITR 需要</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="na">archive_mode</span> <span class="o">=</span> <span class="s">on                            # 啟用 archive</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="na">archive_command</span> <span class="o">=</span> <span class="s">&#39;wal-g wal-push %p&#39;        # 或 pgBackRest / 自寫 script</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="na">archive_timeout</span> <span class="o">=</span> <span class="s">60                         # 60s 無 WAL 時強制切 segment</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="na">max_wal_size</span> <span class="o">=</span> <span class="s">4GB</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="na">checkpoint_timeout</span> <span class="o">=</span> <span class="s">15min</span></span></span></code></pre></div><p><code>archive_command</code> 必須 <em>回 exit code 0 才算成功</em>；非 0 PostgreSQL retry、retry 失敗會在 <code>pg_wal</code> 堆積 WAL 直到 disk 滿。<strong>critical：archive_command 不能寫成 silent-fail</strong>。</p>
<h3 id="用-pgbackrest-取代手寫-script">用 pgBackRest 取代手寫 script</h3>
<p>production 強烈不建議自寫 archive script — pgBackRest / WAL-G / Barman 處理過所有 edge case：</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="c1"># pgbackrest.conf</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="k">[global]</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="na">repo1-type</span><span class="o">=</span><span class="s">s3</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="na">repo1-s3-bucket</span><span class="o">=</span><span class="s">mybucket</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="na">repo1-s3-region</span><span class="o">=</span><span class="s">us-east-1</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="na">repo1-retention-full</span><span class="o">=</span><span class="s">4                       # 留 4 個 full backup</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="na">repo1-retention-diff</span><span class="o">=</span><span class="s">8                       # 留 8 個 differential</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="na">repo1-cipher-type</span><span class="o">=</span><span class="s">aes-256-cbc                # encrypt at rest</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="na">process-max</span><span class="o">=</span><span class="s">8                                # parallel restore</span>
</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 class="k">[main]</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="na">pg1-path</span><span class="o">=</span><span class="s">/var/lib/postgresql/16/main</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 跑 full backup</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">pgbackrest --stanza<span class="o">=</span>main backup --type<span class="o">=</span>full
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># archive_command 用 pgbackrest 內建</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="nv">archive_command</span> <span class="o">=</span> <span class="s1">&#39;pgbackrest --stanza=main archive-push %p&#39;</span></span></span></code></pre></div><p>pgBackRest 處理：parallel push、compression、encryption、checksum、archive replay timing、backup catalog、retention 自動清理。</p>
<h3 id="restorerecovery_target_time">Restore：recovery_target_time</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 1. 從 S3 / repo 拉 base backup</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">pgbackrest --stanza<span class="o">=</span>main --type<span class="o">=</span><span class="nb">time</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --target<span class="o">=</span><span class="s2">&#34;2026-05-18 14:30:00+00&#34;</span> restore
</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"><span class="c1"># 2. PostgreSQL 進 recovery mode、自動 replay WAL 到 target time</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"># (pgBackRest 寫好 recovery.signal + postgresql.auto.conf)</span>
</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 class="c1"># 3. 確認到目標 timestamp 後、promote</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl">pg_ctl promote</span></span></code></pre></div><p>Recovery target 三種：</p>
<ul>
<li><strong><code>recovery_target_time</code></strong>：到某 timestamp</li>
<li><strong><code>recovery_target_xid</code></strong>：到某 transaction ID（log 有 xid 才好定位）</li>
<li><strong><code>recovery_target_lsn</code></strong>：到某 WAL LSN（最精確、但需要事先記下 LSN）</li>
</ul>
<p>production 多用 timestamp、application log 有時間戳容易定位。</p>
<h2 id="故障演練--邊界-case">故障演練 / 邊界 case</h2>
<h3 id="case-1archive_command-靜默失敗">Case 1：archive_command 靜默失敗</h3>
<p><strong>徵兆</strong>：DBA 發現某 PITR test 時、最近 3 天的 WAL 在 S3 上沒有；但 PostgreSQL 沒 alert、<code>pg_wal</code> 也沒堆積（早就被回收？）。</p>
<p><strong>根因</strong>：archive_command 寫成 <code>aws s3 cp %p s3://bucket/... 2&gt;/dev/null</code> — 錯誤訊息被吞、exit code 卻是 0（cp 失敗但 redirect 後 shell wrapper 不傳 fail code）；PostgreSQL 以為成功、繼續 advance WAL pointer、舊 WAL 已回收、archive 上實際沒有。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>絕對不要靜默 exit code</strong>：archive_command 必須 <em>fail loud</em>、exit code 非 0</li>
<li><strong>用 pgBackRest / WAL-G</strong>、不自寫 shell 腳本</li>
<li><strong>monitoring</strong>：對 archive lag 寫 alert</li>
</ol>





<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">SELECT</span><span class="w"> </span><span class="n">pg_last_archived_xact_time</span><span class="p">(),</span><span class="w"> </span><span class="n">now</span><span class="p">()</span><span class="w"> </span><span class="o">-</span><span class="w"> </span><span class="n">pg_last_archived_xact_time</span><span class="p">()</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">lag</span><span class="p">;</span></span></span></code></pre></div><p>alert if lag &gt; 5 minutes</p>
<ol start="4">
<li><strong>定期測試 restore</strong>：每月跑一次 PITR drill、實際從 archive restore + 驗證 timestamp</li>
</ol>
<h3 id="case-2wal-archive-lagprimary-disk-壓力">Case 2：WAL archive lag、primary disk 壓力</h3>
<p><strong>徵兆</strong>：<code>pg_wal</code> 目錄持續長大、<code>df -h</code> 90%+；<code>pg_stat_archiver</code> 顯示 <code>failed_count</code> 累積、<code>last_failed_time</code> 是 30 分鐘前；archive_command 寫不出去（S3 throttle / network 慢）。</p>
<p><strong>根因</strong>：archive_command 寫到 S3、但 S3 rate limit / connection timeout、PostgreSQL retry；WAL 一直在 <code>pg_wal</code> 不能回收、disk 持續長。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>預防</strong>：<code>archive_command</code> 內部 retry + parallel push（pgBackRest 自帶 <code>process-max</code>）</li>
<li><strong>alert</strong>：<code>pg_stat_archiver.failed_count</code> 增長 + primary disk usage &gt; 80%</li>
<li><strong>緊急</strong>：暫時改 archive_command 寫 local NFS / 其他 storage、等 S3 恢復再同步；不要直接 disable archive（會丟資料）</li>
<li><strong>架構</strong>：archive storage 至少跨 region 兩份、單一 storage 故障不影響 archive</li>
</ol>
<h3 id="case-3recovery-跑到-wrong-target-time">Case 3：recovery 跑到 wrong target time</h3>
<p><strong>徵兆</strong>：PITR 還原後資料看起來 <em>缺一塊</em>；DBA 後悔 — target time 設早了 30 分鐘、recovery 已 promote、後續 WAL 在新 timeline 上、回不去。</p>
<p><strong>根因</strong>：recovery 過程不可逆 — 一旦 promote 開新 timeline、舊 WAL 在新 timeline 上不會被 replay；想還原到更晚 timestamp 必須 <em>重新 restore base backup + WAL</em>。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong><code>recovery_target_action = pause</code></strong>（PG 13+）：到 target time 後 <em>暫停</em>、不自動 promote；DBA 手動 query 確認資料對才 promote</li>
</ol>





<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">recovery_target_time</span> <span class="o">=</span> <span class="s">&#39;2026-05-18 14:30:00+00&#39;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="na">recovery_target_action</span> <span class="o">=</span> <span class="s">pause</span></span></span></code></pre></div><ol start="2">
<li><strong>多次 PITR 試錯</strong>：用 <em>獨立 staging cluster</em> restore、驗證 target time 對、再對 production 跑</li>
<li><strong>記錄 target time 來源</strong>：application log / event timestamp 多比對、避免時區錯亂（<code>+00</code> UTC 跟 local time 差）</li>
</ol>
<h3 id="case-4base-backup-過期未清storage-爆">Case 4：base backup 過期未清、storage 爆</h3>
<p><strong>徵兆</strong>：S3 backup bucket size 半年內從 200GB 漲到 5TB；DBA 才發現 retention 沒設、daily base backup 留 180 天。</p>
<p><strong>根因</strong>：archive_command 自寫腳本沒 retention 邏輯、或 pgBackRest 設了 <code>repo1-retention-full=180</code> 漏看；DB 容量本來就成長 + 每日 full backup 累積。</p>
<p><strong>修法</strong>：</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="c1"># pgBackRest retention：4 full + auto-expire archive</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="na">repo1-retention-full</span><span class="o">=</span><span class="s">4                         # 留 4 個 full backup</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="na">repo1-retention-diff</span><span class="o">=</span><span class="s">8                         # 留 8 個 differential</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="na">repo1-retention-archive</span><span class="o">=</span><span class="s">4                      # WAL archive 跟 full 對齊</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="na">repo1-retention-archive-type</span><span class="o">=</span><span class="s">full</span></span></span></code></pre></div><p>storage budgeting：</p>
<ul>
<li>daily full + diff + WAL archive ≈ 1-2x DB size / day</li>
<li>4-week retention → ~30-60x DB size storage</li>
<li>跨 region replication → 2-3x</li>
</ul>
<h3 id="case-5timeline-分歧後-recovery-模糊">Case 5：timeline 分歧後 recovery 模糊</h3>
<p><strong>徵兆</strong>：production 經歷一次 failover（Patroni promote）+ 之後又 PITR 一次；現在要再 PITR 到 failover 前一刻、archive 上有兩個 timeline、recovery target 搞不清要哪個。</p>
<p><strong>根因</strong>：每次 promote 開新 timeline ID（<code>.history</code> 檔）；archive storage 上同 LSN 可能對應不同 timeline；recovery target time 在分歧點附近、ambiguous。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong><code>recovery_target_timeline</code></strong> 明示要 follow 哪個 timeline</li>
</ol>





<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">recovery_target_time</span> <span class="o">=</span> <span class="s">&#39;2026-05-15 10:00:00+00&#39;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="na">recovery_target_timeline</span> <span class="o">=</span> <span class="s">&#39;3&#39;                 # 要 follow timeline 3</span></span></span></code></pre></div><ol start="2">
<li><strong>熟悉 <code>.history</code> 檔</strong>：<code>/wal_archive/000000XX.history</code> 記錄 timeline 切換點、PITR 前先看</li>
<li><strong>預防</strong>：每次 promote 後 <em>立刻</em> 跑新的 base backup、簡化未來 PITR 流程（不用跨 timeline）</li>
</ol>
<h2 id="容量--cost-規劃">容量 / cost 規劃</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>估算</th>
          <th>警戒</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Base backup size</td>
          <td>跟 DB data dir 大小成正比（PostgreSQL 內部 compression 後）</td>
          <td>每 backup ~ 0.5-1x DB size</td>
      </tr>
      <tr>
          <td>WAL archive size</td>
          <td>~5-50GB / day depending on write volume</td>
          <td>1TB DB / write-heavy 可能 100GB+ / day</td>
      </tr>
      <tr>
          <td>Storage retention</td>
          <td>4-12 weeks 典型</td>
          <td>30-60x DB size budget</td>
      </tr>
      <tr>
          <td>Base backup time</td>
          <td>TB 級 1-4 小時</td>
          <td>跑在 maintenance window</td>
      </tr>
      <tr>
          <td>Restore time</td>
          <td>base backup restore + WAL replay</td>
          <td>TB 級 PITR 通常 2-6 小時</td>
      </tr>
      <tr>
          <td>Network bandwidth</td>
          <td>full backup 期間 100-500 Mbps</td>
          <td>跨 region 注意 egress cost</td>
      </tr>
  </tbody>
</table>
<p>實務 default：</p>
<ul>
<li>Daily full backup + 4 weeks retention</li>
<li>WAL archive every 60s（<code>archive_timeout = 60</code>）</li>
<li>跨 region replication（S3 → S3 cross-region）</li>
<li>月度 restore drill 驗證可用</li>
</ul>
<h2 id="整合--下一步">整合 / 下一步</h2>
<h3 id="跟-patroni-ha-整合">跟 <a href="/blog/backend/01-database/vendors/postgresql/patroni-ha/" data-link-title="PostgreSQL Patroni HA：從 leader 失聯到 client 重連的 5 段 failover lifecycle" data-link-desc="Patroni 把 PostgreSQL HA 拆成 detection / election / promotion / reconfiguration / recovery 五段 lifecycle、每段都有獨立配置跟 failure mode；DCS quorum &#43; watchdog 防 split-brain、async/sync replication 取捨、5 個 production 踩雷、跟 PgBouncer / HAProxy / cert-manager 整合">Patroni HA</a> 整合</h3>
<p>Patroni 不管 backup，但 promotion 後 timeline 切換影響 archive：</p>
<ol>
<li>archive_command 用 <code>%t</code>（timeline）+ <code>%f</code>（filename）路徑、避免不同 timeline WAL 覆蓋</li>
<li>Patroni <code>recovery_conf</code> 包含 <code>restore_command</code>、standby clone 從 archive 拉</li>
<li>每次 Patroni failover 後跑 <em>full backup</em>、簡化未來 PITR</li>
</ol>
<h3 id="跟-logical-replication-對位">跟 <a href="/blog/backend/01-database/vendors/postgresql/logical-replication-debezium/" data-link-title="PostgreSQL Logical Replication &#43; Debezium CDC：replication slot × failure × recovery 對照" data-link-desc="PostgreSQL logical replication slot 跟 Debezium CDC 的失效模式對照表：slot lag 撐爆 primary disk / schema change 斷流 / 初始 COPY 鎖表 / zombie slot 不釋放 / replay storm 後 offset reset；publication / subscription / pgoutput 配置、跟 Kafka outbox pattern 整合">logical replication</a> 對位</h3>
<p>PITR 跟 logical replication 服務不同 use case：</p>
<ul>
<li>PITR 是 <em>災難恢復</em>（logical bug / corruption）— 全量還原到某時刻</li>
<li>Logical replication 是 <em>連續 sync</em> — Kafka / 跨 DB 即時複製</li>
</ul>
<p>兩者 <em>都依賴 WAL</em>、但目標不同；同 PostgreSQL 可同時跑、互不衝突。</p>
<h3 id="跟-monitoring--alert">跟 monitoring + alert</h3>
<p>關鍵 metric：</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">-- archive 健康度
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">pg_stat_archiver</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="c1">-- archived_count, failed_count, last_archived_wal, last_archived_time
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="c1">-- WAL 在 pg_wal 等待 archive 量
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><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">FROM</span><span class="w"> </span><span class="n">pg_ls_waldir</span><span class="p">()</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">name</span><span class="w"> </span><span class="o">~</span><span class="w"> </span><span class="s1">&#39;^[0-9A-F]{24}$&#39;</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></span><span class="line"><span class="ln">8</span><span class="cl"><span class="w"></span><span class="c1">-- base backup 上次跑時間
</span></span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="c1">-- (pgBackRest API 或 backup catalog)</span></span></span></code></pre></div><p>Prometheus alert 三條：archive failed_count 增、archive lag &gt; 5min、base backup &gt; 25h 沒跑。</p>
<h3 id="下一步議題">下一步議題</h3>
<ul>
<li><strong>Incremental backup（PG 17+）</strong>：base backup 不全量、只 base + incremental</li>
<li><strong>Block-level differential</strong>：pgBackRest 已支援</li>
<li><strong>Cloud-native 替代</strong>：RDS / Aurora 用 storage-layer snapshot、不走 PITR 鏈</li>
<li><strong><code>pg_dump</code> vs PITR</strong>：pg_dump 是 logical backup（resume to different schema OK）、PITR 是 physical（必須同 version + same arch）</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li>上游 vendor 頁：<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></li>
<li>上游 chapter：<a href="/blog/backend/01-database/database-migration-playbook/" data-link-title="1.6 資料庫轉換實作：雙寫、回填、切流與回滾" data-link-desc="同 DB 內 schema 演進與資料變更的可分段驗證流程、跟 1.12 cross-DB migration 分工">Database Migration Playbook</a> — PITR 是 migration 的失敗回退</li>
<li>平行 deep article：<a href="/blog/backend/01-database/vendors/postgresql/patroni-ha/" data-link-title="PostgreSQL Patroni HA：從 leader 失聯到 client 重連的 5 段 failover lifecycle" data-link-desc="Patroni 把 PostgreSQL HA 拆成 detection / election / promotion / reconfiguration / recovery 五段 lifecycle、每段都有獨立配置跟 failure mode；DCS quorum &#43; watchdog 防 split-brain、async/sync replication 取捨、5 個 production 踩雷、跟 PgBouncer / HAProxy / cert-manager 整合">Patroni HA</a> / <a href="/blog/backend/01-database/vendors/postgresql/logical-replication-debezium/" data-link-title="PostgreSQL Logical Replication &#43; Debezium CDC：replication slot × failure × recovery 對照" data-link-desc="PostgreSQL logical replication slot 跟 Debezium CDC 的失效模式對照表：slot lag 撐爆 primary disk / schema change 斷流 / 初始 COPY 鎖表 / zombie slot 不釋放 / replay storm 後 offset reset；publication / subscription / pgoutput 配置、跟 Kafka outbox pattern 整合">Logical Replication + Debezium</a> / <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></li>
<li>Methodology：<a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章的寫作方法論</a></li>
</ul>
]]></content:encoded></item><item><title>DynamoDB DAX 快取策略：cluster 架構、item/query cache、write-through 與 invalidation 邊界</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/dynamodb/dax-caching-strategy/</link><pubDate>Tue, 02 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/dynamodb/dax-caching-strategy/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/dynamodb/" data-link-title="DynamoDB" data-link-desc="AWS managed key-value、partition-based scaling、9000 萬 RPS sustained 實戰證據">DynamoDB&lt;/a> overview 的 implementation-layer deep article。寫作參照 &lt;a href="https://tarrragon.github.io/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">vendor deep article methodology&lt;/a>。&lt;/p>&lt;/blockquote>
&lt;p>熱門節目首播時段、application 對同一批 metadata item 的讀取 latency p99 從 5ms 尖到 40ms、下游 timeout 連鎖。team 加了 DAX、p99 壓回個位數毫秒。三個月後另一個 service 也「照抄」加 DAX、結果 cost 上升、latency 沒降 — 那個 service 是寫密集、每次讀的 key 都不同、cache hit rate 不到 20%。同一個工具、在一個 workload 壓回 p99 延遲、在另一個只增加成本卻不降延遲。DAX 的價值取決於 read pattern 跟一致性需求是否匹配。本文展開 DAX 的 cluster 架構、兩種快取的不同失效語意、以及 write-through 跟 strongly consistent read 的邊界。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>DAX 觸發條件 SSoT&lt;/strong>：DAX 「該不該存在」的觸發條件（讀峰值持續高 / cache hit rate 可預期 / read:write ratio 高）主寫於 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/dynamodb/gsi-lsi-design/#dax-%e4%bd%9c%e7%82%ba%e8%ae%80%e5%b3%b0%e5%80%bc%e8%a3%9c%e4%bd%8d" data-link-title="DynamoDB GSI 與 LSI 設計：access pattern 補位、projection、consistency 跟 DAX 補位" data-link-desc="GSI / LSI 是 single-table 沒覆蓋的 access pattern 補位、不是萬靈丹；本文涵蓋 projection 三型選擇、sparse index、GSI 自己會 hot partition、DAX 讀峰值補位的觸發條件（含 Capcom 是 derive vs Lemino 是 case fact 的分層）">gsi-lsi-design 的 DAX 段&lt;/a>、含 &lt;code>9.C29 Lemino&lt;/code> case fact 跟 &lt;code>9.C19 Capcom&lt;/code> derive 分層。本文承接「已決定要用 DAX」之後的機制、配置與失效邊界、不重複展開觸發判讀。&lt;/p>&lt;/blockquote>
&lt;h2 id="核心機制dax-cluster-與兩種快取">核心機制：DAX cluster 與兩種快取&lt;/h2>
&lt;p>DAX（DynamoDB Accelerator）是 DynamoDB 前面的 in-memory write-through cache、提供 microsecond 級讀取（DynamoDB 本身是 single-digit ms）。它 API 相容 — application 把 DynamoDB client 換成 DAX client、API call 不變、讀寫自動經過 cache 層。&lt;/p>
&lt;p>&lt;strong>cluster 拓樸&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>一個 DAX cluster 由多個 node 組成、一個 primary（接受寫）+ 多個 read replica&lt;/li>
&lt;li>跨多 AZ 部署、primary 故障時 replica 接手&lt;/li>
&lt;li>application 透過 DAX endpoint 連 cluster、SDK 自動分散讀取到 replica&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>兩種快取、不同生命週期&lt;/strong>：&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/dynamodb/" data-link-title="DynamoDB" data-link-desc="AWS managed key-value、partition-based scaling、9000 萬 RPS sustained 實戰證據">DynamoDB</a> overview 的 implementation-layer deep article。寫作參照 <a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">vendor deep article methodology</a>。</p></blockquote>
<p>熱門節目首播時段、application 對同一批 metadata item 的讀取 latency p99 從 5ms 尖到 40ms、下游 timeout 連鎖。team 加了 DAX、p99 壓回個位數毫秒。三個月後另一個 service 也「照抄」加 DAX、結果 cost 上升、latency 沒降 — 那個 service 是寫密集、每次讀的 key 都不同、cache hit rate 不到 20%。同一個工具、在一個 workload 壓回 p99 延遲、在另一個只增加成本卻不降延遲。DAX 的價值取決於 read pattern 跟一致性需求是否匹配。本文展開 DAX 的 cluster 架構、兩種快取的不同失效語意、以及 write-through 跟 strongly consistent read 的邊界。</p>
<blockquote>
<p><strong>DAX 觸發條件 SSoT</strong>：DAX 「該不該存在」的觸發條件（讀峰值持續高 / cache hit rate 可預期 / read:write ratio 高）主寫於 <a href="/blog/backend/01-database/vendors/dynamodb/gsi-lsi-design/#dax-%e4%bd%9c%e7%82%ba%e8%ae%80%e5%b3%b0%e5%80%bc%e8%a3%9c%e4%bd%8d" data-link-title="DynamoDB GSI 與 LSI 設計：access pattern 補位、projection、consistency 跟 DAX 補位" data-link-desc="GSI / LSI 是 single-table 沒覆蓋的 access pattern 補位、不是萬靈丹；本文涵蓋 projection 三型選擇、sparse index、GSI 自己會 hot partition、DAX 讀峰值補位的觸發條件（含 Capcom 是 derive vs Lemino 是 case fact 的分層）">gsi-lsi-design 的 DAX 段</a>、含 <code>9.C29 Lemino</code> case fact 跟 <code>9.C19 Capcom</code> derive 分層。本文承接「已決定要用 DAX」之後的機制、配置與失效邊界、不重複展開觸發判讀。</p></blockquote>
<h2 id="核心機制dax-cluster-與兩種快取">核心機制：DAX cluster 與兩種快取</h2>
<p>DAX（DynamoDB Accelerator）是 DynamoDB 前面的 in-memory write-through cache、提供 microsecond 級讀取（DynamoDB 本身是 single-digit ms）。它 API 相容 — application 把 DynamoDB client 換成 DAX client、API call 不變、讀寫自動經過 cache 層。</p>
<p><strong>cluster 拓樸</strong>：</p>
<ul>
<li>一個 DAX cluster 由多個 node 組成、一個 primary（接受寫）+ 多個 read replica</li>
<li>跨多 AZ 部署、primary 故障時 replica 接手</li>
<li>application 透過 DAX endpoint 連 cluster、SDK 自動分散讀取到 replica</li>
</ul>
<p><strong>兩種快取、不同生命週期</strong>：</p>
<table>
  <thead>
      <tr>
          <th>快取類型</th>
          <th>內容</th>
          <th>寫入如何影響</th>
          <th>失效方式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Item cache</td>
          <td><code>GetItem</code> / <code>BatchGetItem</code> 的單筆結果</td>
          <td>write-through 寫入時同步更新對應 item</td>
          <td>item TTL + write-through</td>
      </tr>
      <tr>
          <td>Query cache</td>
          <td><code>Query</code> / <code>Scan</code> 的結果集</td>
          <td>單筆 write <em>不會</em> 失效對應 query 結果集</td>
          <td>只靠 query TTL</td>
      </tr>
  </tbody>
</table>
<p>這張表的第二列是 DAX 最常被誤解的點：<strong>query cache 不會因為底層某筆 item 被改而失效</strong>。item cache 走 write-through、寫入時會更新；但 query cache 存的是「整個結果集」、DAX 無法知道某筆新寫入是否該進某個已快取的 query 結果、所以 query cache 只靠 TTL 過期。這代表 query 結果可能 stale 到一個 TTL 週期。</p>
<blockquote>
<p><strong>Scope warning</strong>：「item cache 預設 TTL 5 分鐘」、「query cache 預設 TTL 5 分鐘」這些預設值屬 AWS vendor 規格、可在 cluster 設定調整、實作時 cross-verify 官方 doc。本文不含 production case 揭露的 DAX TTL 配置數字。</p></blockquote>
<p>對應 knowledge card：<a href="/blog/backend/knowledge-cards/cache-invalidation/" data-link-title="Cache Invalidation" data-link-desc="說明快取資料何時更新、刪除或重建，以及失效策略如何影響一致性">cache-invalidation</a>、<a href="/blog/backend/knowledge-cards/write-through-cache/" data-link-title="Write-Through Cache" data-link-desc="說明寫入時同步更新快取與正式來源的策略">write-through-cache</a>、<a href="/blog/backend/knowledge-cards/ttl/" data-link-title="TTL" data-link-desc="說明資料過期時間如何影響快取新鮮度、成本與一致性">ttl</a>、<a href="/blog/backend/knowledge-cards/cache-hit-rate/" data-link-title="Cache Hit Rate" data-link-desc="說明快取命中比例如何衡量加速效果與下游保護">cache-hit-rate</a>。</p>
<h2 id="一致性與-invalidation-邊界">一致性與 invalidation 邊界</h2>
<p>DAX 的一致性語意是它跟「一般 cache-aside」最大的差別、也是踩雷集中區。</p>
<p><strong>write-through 的保證範圍</strong>：</p>
<p>寫入經過 DAX 時、DAX 先寫 DynamoDB、成功後更新自己的 item cache。所以「寫完馬上用 <code>GetItem</code> 讀同一筆」、在 <em>同一個 DAX node</em> 上能讀到新值。但這不是 strong consistency — 多 node cluster 下、寫入只更新 primary 與被路由到的 node、其他 read replica 的 item cache 仍可能 stale 到 TTL。</p>
<p><strong>strongly consistent read 繞過 cache</strong>：</p>
<p>DAX 只服務 eventually consistent read。application 若要求 strongly consistent read（<code>ConsistentRead=True</code>）、DAX 直接 pass through 到 DynamoDB、不經 cache、也享受不到 microsecond latency。這是設計上的取捨 — DAX 換 latency 的代價是放棄 strong consistency。read-your-write 嚴格場景不能靠 DAX。</p>
<p><strong>query cache stale 的真實後果</strong>：</p>
<p>application 用 <code>Query</code> 列「某 user 的 active order」、結果被 query cache 快取；user 新建一筆 order、item cache 更新了該筆 item、但 <em>列表 query 的 cache 沒失效</em>、user 重整頁面在 TTL 內看不到新訂單。修法不是調 DAX、是判斷「這個 query 能不能接受 TTL 內 stale」— 不能接受的、該 query 不要走 DAX（直接打 DynamoDB）、或縮短該類 query 的 TTL。</p>
<blockquote>
<p><strong>Scope warning</strong>：上述一致性語意屬 DAX vendor 規格 + 通用 cache 工程知識、非 production case 揭露；實際 staleness 視 cluster node 數、TTL 配置與讀寫分布而定。</p></blockquote>
<h2 id="操作流程">操作流程</h2>
<p>從 read pattern 評估到上線的 6 步流程。</p>
<h4 id="step-1確認-read-pattern-適配">Step 1：確認 read pattern 適配</h4>
<p>在加 DAX 前、用 CloudWatch 看目標 table 的 read:write ratio 跟 read 的 key 重複度：</p>
<ul>
<li>read:write 高（讀遠多於寫）+ 重複讀同一組 key → 適合</li>
<li>寫密集 / 每次讀不同 key / 大量 strongly consistent read → 不適合（回頭看 <a href="/blog/backend/01-database/vendors/dynamodb/gsi-lsi-design/#dax-%e4%bd%9c%e7%82%ba%e8%ae%80%e5%b3%b0%e5%80%bc%e8%a3%9c%e4%bd%8d" data-link-title="DynamoDB GSI 與 LSI 設計：access pattern 補位、projection、consistency 跟 DAX 補位" data-link-desc="GSI / LSI 是 single-table 沒覆蓋的 access pattern 補位、不是萬靈丹；本文涵蓋 projection 三型選擇、sparse index、GSI 自己會 hot partition、DAX 讀峰值補位的觸發條件（含 Capcom 是 derive vs Lemino 是 case fact 的分層）">gsi-lsi-design DAX 觸發條件</a>）</li>
</ul>
<h4 id="step-2cluster-sizing">Step 2：cluster sizing</h4>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">node 數 = 讀峰值 throughput / 單 node 容量 + 1（容錯餘量）
</span></span><span class="line"><span class="ln">2</span><span class="cl">node class = 依 working set 大小選（cache 要能裝下熱資料）</span></span></code></pre></div><p>跨至少 2 個 AZ、確保 primary 故障有 replica 接手。</p>
<h4 id="step-3application-切換-client">Step 3：application 切換 client</h4>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="kn">import</span> <span class="nn">amazondax</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># 原本：dynamodb = boto3.resource(&#34;dynamodb&#34;)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">dax</span> <span class="o">=</span> <span class="n">amazondax</span><span class="o">.</span><span class="n">AmazonDaxClient</span><span class="o">.</span><span class="n">resource</span><span class="p">(</span><span class="n">endpoint_url</span><span class="o">=</span><span class="s2">&#34;dax://my-cluster.xxx.dax-clusters.region.amazonaws.com&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="n">table</span> <span class="o">=</span> <span class="n">dax</span><span class="o">.</span><span class="n">Table</span><span class="p">(</span><span class="s2">&#34;orders&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># API 不變、讀寫自動經過 DAX</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="n">response</span> <span class="o">=</span> <span class="n">table</span><span class="o">.</span><span class="n">get_item</span><span class="p">(</span><span class="n">Key</span><span class="o">=</span><span class="p">{</span><span class="s2">&#34;PK&#34;</span><span class="p">:</span> <span class="s2">&#34;ORDER#123&#34;</span><span class="p">,</span> <span class="s2">&#34;SK&#34;</span><span class="p">:</span> <span class="s2">&#34;META&#34;</span><span class="p">})</span></span></span></code></pre></div><h4 id="step-4分流-strongly-consistent-read">Step 4：分流 strongly consistent read</h4>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 需要 strong 的讀直接走 DynamoDB、不要走 DAX</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">ddb_table</span><span class="o">.</span><span class="n">get_item</span><span class="p">(</span><span class="n">Key</span><span class="o">=...</span><span class="p">,</span> <span class="n">ConsistentRead</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>   <span class="c1"># 繞過 cache</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"># 可接受 eventual 的讀走 DAX</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="n">dax_table</span><span class="o">.</span><span class="n">get_item</span><span class="p">(</span><span class="n">Key</span><span class="o">=...</span><span class="p">)</span>                          <span class="c1"># 走 cache</span></span></span></code></pre></div><p>application 要明確區分哪些讀路徑能接受 stale、哪些不能；不能接受的不走 DAX。</p>
<h4 id="step-5設定-ttl-與監控-hit-rate">Step 5：設定 TTL 與監控 hit rate</h4>
<p>依資料變動頻率設 item / query cache TTL：變動慢的 metadata 可設長 TTL、變動快的設短或不快取。上線後盯 <code>CacheHitRate</code>。</p>
<h4 id="step-6驗證點">Step 6：驗證點</h4>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 驗證 hit rate 達預期、確認 DAX 真的減少 DynamoDB 讀</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># CloudWatch: DAX CacheHits / (CacheHits + CacheMisses)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"># 同時看 DynamoDB ConsumedReadCapacityUnits 是否下降</span></span></span></code></pre></div><p><strong>Rollback boundary</strong>：DAX 可隨時 detach — application 端把 DAX endpoint 換回 DynamoDB endpoint 即可、無資料遷移；DAX 只是讀路徑加速層、不持有唯一資料。</p>
<h2 id="失敗模式">失敗模式</h2>
<p>production 常見的 5 個踩雷：</p>
<h4 id="case-1把-dax-當預設配置">Case 1：把 DAX 當預設配置</h4>
<p>寫密集 / 低 hit rate workload 加 DAX、invalidation 開銷 + cluster 成本 &gt; cache 收益。修法：先確認 read pattern 適配（Step 1）、DAX 是讀峰值補位不是預設（觸發條件 SSoT 在 gsi-lsi-design）。</p>
<h4 id="case-2以為-query-cache-會即時反映寫入">Case 2：以為 query cache 會即時反映寫入</h4>
<p>寫入後列表 query 在 TTL 內看不到新資料、被當成 bug 長時間誤查。修法：理解 query cache 只靠 TTL 失效（不是 bug 是設計）；強一致列表需求的 query 不走 DAX、或縮短 TTL。</p>
<h4 id="case-3strongly-consistent-read-全走-dax-還抱怨不快">Case 3：strongly consistent read 全走 DAX 還抱怨不快</h4>
<p>application 全程 <code>ConsistentRead=True</code>、DAX 全部 pass through、等於沒裝 DAX 還多付 cluster 錢。修法：分流 — strong read 直接打 DynamoDB、eventual read 才走 DAX。</p>
<h4 id="case-4cluster-單-az--單-node">Case 4：cluster 單 AZ / 單 node</h4>
<p>省成本只開單 node、primary 故障時讀路徑整個失效、回退到 DynamoDB 瞬間流量尖峰。修法：跨 2+ AZ、primary + replica；DAX 故障的 fallback 路徑（直連 DynamoDB）要先測過。這個 Case 的失敗代價跟其他 Case 不對稱 — 其餘 Case 多是成本浪費或延遲沒降、detach DAX 即可回復；單 AZ / 單 node 故障是讀路徑硬中斷、回退瞬間把原本被 cache 吸收的讀峰值全打回 DynamoDB、若 base table 的 RCU 或 on-demand burst 餘量沒預留、會引發 throttling 連鎖。回退路徑要按「DAX 全失效時的讀峰值」預估 DynamoDB 側容量、而非平時被 cache 削減後的讀量。</p>
<h4 id="case-5working-set-超過-cache-容量">Case 5：working set 超過 cache 容量</h4>
<p>熱資料超過 node memory、cache 不斷 evict、hit rate 掉到沒意義。修法：依 working set 選 node class、或縮小快取範圍（只快取真正熱的 access pattern）。</p>
<p><strong>Anti-recommendation</strong>：read:write ratio 低、或 cache hit rate 預期 &lt; 50% 的 workload、不要上 DAX；application 端的 request-level cache 或根本不快取可能更划算。DAX 是 cluster 常駐成本（instance-hour 計）、只在讀峰值持續高才回本。</p>
<h2 id="容量與觀測">容量與觀測</h2>
<p>CloudWatch metric：</p>
<ul>
<li><code>CacheHits</code> / <code>CacheMisses</code> / 算出 <code>CacheHitRate</code> — 核心健康指標</li>
<li><code>ItemCacheHits</code> / <code>QueryCacheHits</code> — 分辨兩種快取各自的命中</li>
<li><code>CPUUtilization</code> / <code>EvictedSize</code> — node 是否過載、cache 是否頻繁 evict</li>
<li>DynamoDB 端 <code>ConsumedReadCapacityUnits</code> — 確認 DAX 真的削減了 base 讀取</li>
</ul>
<p><strong>判讀</strong>：</p>
<ul>
<li><code>CacheHitRate</code> &lt; 70% — 重新評估 DAX 是否該存在、或快取範圍是否該收窄</li>
<li><code>EvictedSize</code> 持續高 — working set 超過 cache 容量、要加大 node class</li>
<li>DynamoDB read capacity 沒因 DAX 下降 — read pattern 不適配、DAX 沒發揮作用</li>
</ul>
<blockquote>
<p><strong>Scope warning</strong>：「70% hit rate 閾值」屬通用工程估算、非 case 揭露；實際閾值依 cost 結構與 latency 目標調整。</p></blockquote>
<p>接回 <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/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">4.20 Observability Evidence Package</a>。</p>
<h2 id="邊界與整合">邊界與整合</h2>
<h3 id="dax-vs-application-side-cache-vs-elasticache">DAX vs application-side cache vs ElastiCache</h3>
<p>DAX 不是唯一的 DynamoDB 讀加速方案。三者責任不同：</p>
<ul>
<li><strong>DAX</strong>：DynamoDB 專屬、API 相容、write-through、零 application cache 邏輯；綁 DynamoDB</li>
<li><strong>application-side cache</strong>（如 in-process LRU）：最低延遲、但每個 instance 各自一份、一致性難管</li>
<li><strong>ElastiCache（Redis / Valkey）</strong>：通用 cache、可跨資料源、但要自己寫 cache-aside 邏輯與 invalidation</li>
</ul>
<p>當快取需求超出單一 DynamoDB table（跨資料源聚合 / 需要 Redis 資料結構如 sorted set leaderboard）、回 <a href="/blog/backend/02-cache-redis/" data-link-title="模組二：快取與 Redis" data-link-desc="整理快取策略、Redis 資料型別與分散式狀態輔助能力">02 快取模組</a> 評估 ElastiCache；DAX 最適配的情境是「純 DynamoDB 讀加速、且不想自行維護 cache 邏輯」。</p>
<h3 id="sibling-與-cross-link">Sibling 與 cross-link</h3>
<ul>
<li><a href="/blog/backend/01-database/vendors/dynamodb/gsi-lsi-design/" data-link-title="DynamoDB GSI 與 LSI 設計：access pattern 補位、projection、consistency 跟 DAX 補位" data-link-desc="GSI / LSI 是 single-table 沒覆蓋的 access pattern 補位、不是萬靈丹；本文涵蓋 projection 三型選擇、sparse index、GSI 自己會 hot partition、DAX 讀峰值補位的觸發條件（含 Capcom 是 derive vs Lemino 是 case fact 的分層）">gsi-lsi-design</a> — DAX 觸發條件 SSoT（讀峰值補位 / Lemino case fact / Capcom derive）在該篇、本篇承接機制層</li>
<li><a href="/blog/backend/01-database/vendors/dynamodb/on-demand-vs-provisioned/" data-link-title="DynamoDB On-Demand vs Provisioned：6 軸決策、auto-scaling 邊界與 cost crossover" data-link-desc="capacity mode 選擇不是單軸 peak/avg ratio；本文展開 6 軸決策（peak/avg / 讀寫比 trend / surge 暫時 vs 永久 baseline / predictable-peak vs flash-sale / DBA 工時釋放 / vendor vs 自管 cost crossover），含 Zomato 50% 成本下降、Zoom 30x permanent surge、Amazon Ads sustained workload 等 case 分軸 anchor">on-demand-vs-provisioned</a> — DAX 削減 base 讀取後、provisioned RCU 規劃要重算</li>
<li><a href="/blog/backend/01-database/vendors/dynamodb/consistency-model-optimization/" data-link-title="DynamoDB Strongly Consistent → Eventually Consistent：same protocol, different contract" data-link-desc="DynamoDB consistency model 從 strongly consistent read 改 eventually consistent read 是 50% cost 優化但風險集中在 application contract — 同 vendor / 同 protocol / 同 table / 不同 read consistency；驗證 [#128](/report/data-topology-as-audit-dimension/) self-aware limitation 提出的 consistency axis 候選；涵蓋 read pattern audit / 5 個 production 踩雷">consistency-model-optimization</a> — strongly consistent read 繞過 DAX、對應 read 一致性軸</li>
<li><a href="/blog/backend/01-database/vendors/dynamodb/partition-key-antipatterns/" data-link-title="DynamoDB Partition Key 反模式與 Write Sharding：composite key 修復跟 mode × partition 交叉判讀" data-link-desc="DynamoDB partition 上限 1000 WCU 是 hot partition 的根因；composite key（event_id &#43; shard suffix）跟 calculated shard（hash % N）兩種修法、mode × partition 在 provisioned / on-demand 不同表現，以及 9.C15 Tixcraft 6750x 擴展的工程細節">partition-key-antipatterns</a> — DAX 不解 hot partition、寫熱點仍打到 DynamoDB</li>
<li>替代路由：跨資料源快取 / Redis 資料結構需求 → <a href="/blog/backend/02-cache-redis/" data-link-title="模組二：快取與 Redis" data-link-desc="整理快取策略、Redis 資料型別與分散式狀態輔助能力">02 快取模組</a> ElastiCache</li>
<li>跟 <a href="/blog/backend/09-performance-capacity/cases/ntt-docomo-lemino-japanese-streaming/" data-link-title="9.C29 NTT DOCOMO Lemino：3 個月達 500 萬 MAU 的串流後端" data-link-desc="Lemino 用 DynamoDB &#43; AWS Media Services 撐 30 channels live &#43; 5M MAU、工程工時下降 90%">Lemino 9.C29</a> 互引：DAX 讀峰值補位的 case fact</li>
</ul>
]]></content:encoded></item><item><title>Spanner Graph (2024)：property graph 能力、跟 relational 表共存、適用場景與邊界</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/spanner/spanner-graph/</link><pubDate>Tue, 02 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/spanner/spanner-graph/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/spanner/" data-link-title="Google Cloud Spanner" data-link-desc="全球分散式 strong-consistency OLTP、TrueTime API、線性擴展到 10 億 req/sec">Cloud Spanner&lt;/a> overview 的 implementation-layer deep article、寫作參照 &lt;a href="https://tarrragon.github.io/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">vendor deep article methodology&lt;/a>。Overview 已說明 Spanner 在全球 OLTP 譜系的定位、本文聚焦 &lt;em>Spanner Graph&lt;/em>（2024 推出）— 建在 relational 引擎上的 property graph 能力、跟 SQL 表共用同一份資料與 transaction。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="核心定位graph-是-relational-表上的視圖不是另一個資料庫">核心定位：graph 是 relational 表上的視圖、不是另一個資料庫&lt;/h2>
&lt;p>Spanner Graph 的責任是讓「實體之間的多跳關係查詢」用 property graph 模型（node、edge、property）表達、底層仍儲存在 Spanner 的 relational table、graph 與 SQL 共用同一份資料、同一個 transaction、同一套 external consistency。它不是在 Spanner 旁邊掛一個獨立的 graph database、是在既有 relational 表之上定義一層 graph 映射、讓同一份資料能同時被 SQL query 與 GQL graph query 存取。&lt;/p>
&lt;p>把這條定位放最前面、是因為 graph database 常被想成「需要單獨的儲存引擎、單獨的資料同步管線」。Spanner Graph 的設計取捨相反：node table 跟 edge table 就是普通的 Spanner table、graph schema 定義它們之間的映射、查詢時引擎在 relational 儲存上執行圖遍歷。這帶來兩個直接後果 — graph 與 transactional 寫入天然強一致（同一份資料、同一個 commit）、不需要把資料從 OLTP 同步到專用 graph DB;但也意味著 graph 效能受 relational 引擎的特性約束、不是專用 graph engine 的記憶體圖結構。&lt;/p>
&lt;h2 id="問題情境關係查詢在-sql-裡變成難以維護的多層-self-join">問題情境：關係查詢在 SQL 裡變成難以維護的多層 self-JOIN&lt;/h2>
&lt;p>Graph 能力的價值、在「資料本質是關係網絡、但被迫用 relational JOIN 表達多跳查詢」的壓力下浮現。讀者徵兆：反詐欺團隊要查「跟某個可疑帳號在 3 跳內共用過裝置 / 地址 / 付款方式的所有帳號」、寫成 SQL 是 3-4 層 self-JOIN、query 既難寫又難優化;推薦團隊要查「買過 A 的人也買過什麼」的多跳關聯;權限團隊要查「使用者透過群組 / 角色繼承鏈能存取哪些資源」的傳遞閉包。這些查詢的共同形狀是「沿著關係邊走 N 跳」、用 JOIN 表達時跳數越多 SQL 越複雜、優化器越難處理。&lt;/p>
&lt;p>真實壓力場景：金融反詐欺系統把交易、帳號、裝置、地址存在 Spanner、需要即時查可疑帳號的關係網絡;這份資料同時要支援交易的強一致寫入。傳統做法是把資料從 OLTP ETL 到專用 graph DB（Neo4j 等）、付出資料同步延遲 + 兩套系統的運維成本 + graph DB 上的資料不是強一致快照。Spanner Graph 讓「強一致的交易資料」與「圖遍歷查詢」在同一個系統、避開同步管線。&lt;/p>
&lt;p>Case anchor：本主題在 case 庫覆蓋稀薄。9.C10 是 Google internal dogfood case、未展開 graph 能力、且不是 customer-facing 參考。本文 graph 物件模型、GQL 語意、relational 共存機制均以 GCP vendor 規格 + 通用 graph 工程展開、case 僅作「全球大規模 OLTP 之上要做關係查詢」的壓力 anchor。Spanner Graph 是 2024 推出的較新能力、所有能力 claim 屬時間敏感、實作前查官方文件。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/spanner/" data-link-title="Google Cloud Spanner" data-link-desc="全球分散式 strong-consistency OLTP、TrueTime API、線性擴展到 10 億 req/sec">Cloud Spanner</a> overview 的 implementation-layer deep article、寫作參照 <a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">vendor deep article methodology</a>。Overview 已說明 Spanner 在全球 OLTP 譜系的定位、本文聚焦 <em>Spanner Graph</em>（2024 推出）— 建在 relational 引擎上的 property graph 能力、跟 SQL 表共用同一份資料與 transaction。</p></blockquote>
<hr>
<h2 id="核心定位graph-是-relational-表上的視圖不是另一個資料庫">核心定位：graph 是 relational 表上的視圖、不是另一個資料庫</h2>
<p>Spanner Graph 的責任是讓「實體之間的多跳關係查詢」用 property graph 模型（node、edge、property）表達、底層仍儲存在 Spanner 的 relational table、graph 與 SQL 共用同一份資料、同一個 transaction、同一套 external consistency。它不是在 Spanner 旁邊掛一個獨立的 graph database、是在既有 relational 表之上定義一層 graph 映射、讓同一份資料能同時被 SQL query 與 GQL graph query 存取。</p>
<p>把這條定位放最前面、是因為 graph database 常被想成「需要單獨的儲存引擎、單獨的資料同步管線」。Spanner Graph 的設計取捨相反：node table 跟 edge table 就是普通的 Spanner table、graph schema 定義它們之間的映射、查詢時引擎在 relational 儲存上執行圖遍歷。這帶來兩個直接後果 — graph 與 transactional 寫入天然強一致（同一份資料、同一個 commit）、不需要把資料從 OLTP 同步到專用 graph DB;但也意味著 graph 效能受 relational 引擎的特性約束、不是專用 graph engine 的記憶體圖結構。</p>
<h2 id="問題情境關係查詢在-sql-裡變成難以維護的多層-self-join">問題情境：關係查詢在 SQL 裡變成難以維護的多層 self-JOIN</h2>
<p>Graph 能力的價值、在「資料本質是關係網絡、但被迫用 relational JOIN 表達多跳查詢」的壓力下浮現。讀者徵兆：反詐欺團隊要查「跟某個可疑帳號在 3 跳內共用過裝置 / 地址 / 付款方式的所有帳號」、寫成 SQL 是 3-4 層 self-JOIN、query 既難寫又難優化;推薦團隊要查「買過 A 的人也買過什麼」的多跳關聯;權限團隊要查「使用者透過群組 / 角色繼承鏈能存取哪些資源」的傳遞閉包。這些查詢的共同形狀是「沿著關係邊走 N 跳」、用 JOIN 表達時跳數越多 SQL 越複雜、優化器越難處理。</p>
<p>真實壓力場景：金融反詐欺系統把交易、帳號、裝置、地址存在 Spanner、需要即時查可疑帳號的關係網絡;這份資料同時要支援交易的強一致寫入。傳統做法是把資料從 OLTP ETL 到專用 graph DB（Neo4j 等）、付出資料同步延遲 + 兩套系統的運維成本 + graph DB 上的資料不是強一致快照。Spanner Graph 讓「強一致的交易資料」與「圖遍歷查詢」在同一個系統、避開同步管線。</p>
<p>Case anchor：本主題在 case 庫覆蓋稀薄。9.C10 是 Google internal dogfood case、未展開 graph 能力、且不是 customer-facing 參考。本文 graph 物件模型、GQL 語意、relational 共存機制均以 GCP vendor 規格 + 通用 graph 工程展開、case 僅作「全球大規模 OLTP 之上要做關係查詢」的壓力 anchor。Spanner Graph 是 2024 推出的較新能力、所有能力 claim 屬時間敏感、實作前查官方文件。</p>
<h2 id="核心機制node-tableedge-tablegraph-schema-映射">核心機制：node table、edge table、graph schema 映射</h2>
<p>Spanner Graph 用 <em>property graph</em> 模型 — node 代表實體（帳號、裝置）、edge 代表關係（共用、轉帳）、兩者都可帶 property。底層每個 node 類型對應一張 relational table、每個 edge 類型對應一張記錄「來源 PK → 目標 PK」的 relational table、graph schema 用 DDL 把這些表宣告成 node / edge。</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">-- 底層仍是普通 relational table
</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">Account</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">id</span><span class="w"> </span><span class="n">INT64</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"> 4</span><span class="cl"><span class="w">  </span><span class="n">risk_score</span><span class="w"> </span><span class="n">FLOAT64</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="p">)</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="p">(</span><span class="n">id</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></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">AccountTransfersAccount</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="n">src_id</span><span class="w"> </span><span class="n">INT64</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"> 9</span><span class="cl"><span class="w">  </span><span class="n">dst_id</span><span class="w"> </span><span class="n">INT64</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">10</span><span class="cl"><span class="w">  </span><span class="n">amount</span><span class="w"> </span><span class="nb">NUMERIC</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 class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</span><span class="w"> </span><span class="p">(</span><span class="n">src_id</span><span class="p">,</span><span class="w"> </span><span class="n">dst_id</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">-- graph schema 把表映射成 node / edge
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1"></span><span class="k">CREATE</span><span class="w"> </span><span class="n">PROPERTY</span><span class="w"> </span><span class="n">GRAPH</span><span class="w"> </span><span class="n">FraudGraph</span><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w">  </span><span class="n">NODE</span><span class="w"> </span><span class="n">TABLES</span><span class="w"> </span><span class="p">(</span><span class="n">Account</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">EDGE</span><span class="w"> </span><span class="n">TABLES</span><span class="w"> </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">AccountTransfersAccount</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">SOURCE</span><span class="w"> </span><span class="k">KEY</span><span class="w"> </span><span class="p">(</span><span class="n">src_id</span><span class="p">)</span><span class="w"> </span><span class="k">REFERENCES</span><span class="w"> </span><span class="n">Account</span><span class="p">(</span><span class="n">id</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">DESTINATION</span><span class="w"> </span><span class="k">KEY</span><span class="w"> </span><span class="p">(</span><span class="n">dst_id</span><span class="p">)</span><span class="w"> </span><span class="k">REFERENCES</span><span class="w"> </span><span class="n">Account</span><span class="p">(</span><span class="n">id</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 class="p">);</span></span></span></code></pre></div><p>關鍵是 edge table 的 PK 設計直接決定圖遍歷效能。edge table 通常用 <code>(src_id, dst_id)</code> 當 PK、讓「從某 node 出發的所有 out-edge」落在相鄰的 key range、遍歷時是一次 range scan 而非散落查詢。這個物理 layout 跟 <a href="../schema-migration-interleaved-tables/">interleaved table</a> 的思路相通 — 把一起查的資料在 storage 上放近。</p>
<h3 id="gql-查詢用-pattern-matching-表達遍歷">GQL 查詢：用 pattern matching 表達遍歷</h3>
<p>graph 查詢用 GQL（ISO graph query language）的 pattern matching 語法、把多跳遍歷寫成 path pattern、比多層 SQL JOIN 直觀。</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">-- 查跟某帳號 1-3 跳內有轉帳關係的高風險帳號
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="n">GRAPH</span><span class="w"> </span><span class="n">FraudGraph</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">MATCH</span><span class="w"> </span><span class="p">(</span><span class="n">a</span><span class="p">:</span><span class="n">Account</span><span class="w"> </span><span class="err">{</span><span class="n">id</span><span class="p">:</span><span class="w"> </span><span class="mi">12345</span><span class="err">}</span><span class="p">)</span><span class="o">-</span><span class="p">[:</span><span class="n">AccountTransfersAccount</span><span class="p">]</span><span class="o">-&gt;</span><span class="err">{</span><span class="mi">1</span><span class="p">,</span><span class="mi">3</span><span class="err">}</span><span class="p">(</span><span class="n">b</span><span class="p">:</span><span class="n">Account</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">WHERE</span><span class="w"> </span><span class="n">b</span><span class="p">.</span><span class="n">risk_score</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="mi">0</span><span class="p">.</span><span class="mi">8</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">RETURN</span><span class="w"> </span><span class="n">b</span><span class="p">.</span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="n">b</span><span class="p">.</span><span class="n">risk_score</span><span class="p">;</span></span></span></code></pre></div><p><code>-&gt;{1,3}</code> 表達 1 到 3 跳的可變長度路徑 — 這在 SQL 裡需要 recursive CTE 或多個 self-JOIN、在 GQL 裡是一個 pattern。引擎把 pattern 編譯成在底層 relational 表上的遍歷計劃。</p>
<blockquote>
<p><strong>Scope warning</strong>：Spanner Graph 是 2024 推出的能力、GQL 語法、支援的 pattern、graph schema DDL 均屬 GCP 規格且逐版本演進。本文語法為示意、實作前必須 cross-verify <a href="https://cloud.google.com/spanner/docs/graph/overview">Spanner Graph 官方文件</a> 的當前語法與支援範圍、不可依本文當最終依據。</p></blockquote>
<h3 id="graph-與-relational-共存的語意">graph 與 relational 共存的語意</h3>
<p>同一份資料能同時被 SQL 與 GQL 查 — 對 Account 表的 SQL UPDATE 立即反映在 graph 查詢、因為它們是同一份 storage。寫入走標準 Spanner transaction、graph 查詢看到的是 external-consistent 的快照。這個共存是 Spanner Graph 跟「ETL 到專用 graph DB」最根本的差異：沒有同步延遲、graph 看到的就是 OLTP 的當前一致狀態。</p>
<h2 id="操作流程定義-graph查詢驗證遍歷效能">操作流程：定義 graph、查詢、驗證遍歷效能</h2>
<h3 id="step-1設計-node--edge-table-與-pk-layout">Step 1：設計 node / edge table 與 PK layout</h3>
<p>先設計底層 relational table、edge table 的 PK 用 <code>(src, dst)</code> 讓 out-edge 連續。這步是 graph 效能的決定性步驟、也是最難回退的步驟（見失敗模式）。驗證：對「最高頻的遍歷方向」確認 edge table PK 讓該方向的 out-edge 落在連續 key range。</p>
<h3 id="step-2建立-property-graph-schema">Step 2：建立 property graph schema</h3>
<p>用 <code>CREATE PROPERTY GRAPH</code> 宣告 node / edge 映射。驗證：查 information schema 確認 graph 已建立、node / edge 映射符合預期、edge 的 source / destination key 正確 reference 到 node 的 PK。</p>
<h3 id="step-3跑代表性-gql-查詢並量遍歷成本">Step 3：跑代表性 GQL 查詢並量遍歷成本</h3>
<p>用真實業務的代表性遍歷（例如反詐欺的 3 跳查詢）跑 GQL、用 query plan 確認遍歷走 range scan 而非 full scan、量 latency 與掃描的 row 數。驗證點：跳數增加時 latency 的成長曲線 — 圖查詢的成本對「每跳的扇出（fan-out）」非常敏感、高扇出的 node（super node、例如被百萬帳號連到的熱門裝置）會讓遍歷成本急遽放大。</p>
<h3 id="step-4rollback-boundary">Step 4：rollback boundary</h3>
<p>graph schema 本身可加可改（在相容範圍內）、<code>DROP PROPERTY GRAPH</code> 不刪底層 relational 資料 — graph 是視圖層、刪 graph schema 不影響 SQL 存取。真正難回退的是底層 edge table 的 PK 設計（見失敗模式）。所以 rollback boundary 分兩層：graph schema 層可逆、底層 table layout 層接近不可逆。</p>
<h2 id="失敗模式edge-table-layout-設計錯誤的高代價">失敗模式：edge table layout 設計錯誤的高代價</h2>
<p>graph 的失敗模式跟前述機制型文章不同 — 它的核心風險是「資料模型的物理設計錯誤、且代價不可逆」、所以這節用更完整的代價與回退敘事處理、不壓成兩句式。</p>
<h3 id="edge-table-pk-方向選錯最高頻遍歷變成-full-scan">Edge table PK 方向選錯、最高頻遍歷變成 full scan</h3>
<p>這是 graph 設計最高代價、最難回退的失敗。edge table 的 PK 決定哪個遍歷方向是連續 range scan、哪個是散落查詢。若團隊把 PK 設成 <code>(dst_id, src_id)</code>、但 99% 的查詢是「從 src 出發找 dst」、那最高頻的遍歷變成對整張 edge table 的 scan、隨資料量線性退化。</p>
<p>代價之所以高、是因為它不在上線時暴露 — 小資料量下 full scan 也快、效能崩塌在資料長到一定規模、流量打到 production 之後才浮現。徵兆是特定遍歷的 latency 隨 edge table 成長而單調惡化、query plan 顯示 full scan 而非 range scan、Spanner CPU 被掃描打滿。</p>
<p>回退路徑的代價是這個失敗的關鍵：edge table 的 PK 不能 in-place 變更、修正需要建一張新的 edge table（正確 PK 方向）、backfill 全部 edge、更新 graph schema 指向新表、驗證遍歷走 range scan、再 drop 舊表。對 100 億 edge 的圖、backfill 是數小時到數天的 long-running operation、期間要管 capacity 升幅、要保證 graph 查詢在切換期間的正確性。這不是 hotfix、是一次完整的 schema migration。所以這個失敗的真正教訓是「在 Step 1 設計階段就把最高頻遍歷方向定死」、而不是「上線後再優化」 — 設計階段花一天想清楚遍歷方向、勝過上線後花一週重建 edge table。</p>
<h3 id="super-node-讓遍歷扇出急遽放大">Super node 讓遍歷扇出急遽放大</h3>
<p>某些 node 的 degree（連出的 edge 數）極高 — 例如一個被百萬帳號共用的熱門 IP、一個被千萬使用者關注的明星帳號。多跳遍歷經過 super node 時、單跳就扇出百萬條 edge、查詢成本急遽放大、可能拖垮整個 instance。徵兆是「多數遍歷快、少數遍歷極慢」、慢的那些都經過已知的高 degree node。修法不是純技術 — 要在業務層決定如何處理 super node：限制遍歷的 degree（只取前 N 條 edge）、把 super node 的關係單獨建模、或在應用層對經過 super node 的查詢設上限。這個失敗的代價在「它讓 tail latency 不可預測」、容量規劃要把 super node 的扇出當成 worst-case。</p>
<h3 id="把-graph-當專用-graph-db-的全功能替代">把 graph 當專用 graph DB 的全功能替代</h3>
<p>團隊把 Spanner Graph 當 Neo4j 用、期待專用 graph DB 的所有演算法（PageRank、community detection、複雜圖分析）與圖原生效能。Spanner Graph 的強項是「跟強一致 OLTP 共存的關係查詢」、不是「重度圖分析引擎」。徵兆是想跑的圖演算法不在支援範圍、或重度分析查詢效能不如專用引擎。<strong>Anti-recommendation（何時不用）</strong>：純圖分析、不需要跟 OLTP transaction 共用資料、需要豐富圖演算法庫的場景、用專用 graph DB 或圖分析框架;Spanner Graph 的定位是「OLTP 資料順便要做關係查詢」、不是「圖是核心工作負載」。</p>
<h2 id="容量與觀測遍歷扇出是核心容量訊號">容量與觀測：遍歷扇出是核心容量訊號</h2>
<p>graph 查詢的容量壓力不在「資料量」、在「遍歷的扇出與跳數」 — 同樣的資料量、低扇出的遍歷便宜、高扇出的急遽放大。核心觀測是 graph query 掃描的 row 數與 query plan 的遍歷形狀。</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">GQL query 掃描的 row / edge 數    → 遍歷扇出的直接指標
</span></span><span class="line"><span class="ln">2</span><span class="cl">query plan: range scan vs full scan → edge table PK layout 是否匹配遍歷方向
</span></span><span class="line"><span class="ln">3</span><span class="cl">Spanner CPU during graph query    → 高扇出遍歷會打滿 CPU
</span></span><span class="line"><span class="ln">4</span><span class="cl">特定遍歷的 p99 latency 隨資料成長  → edge layout 錯誤的早期訊號</span></span></code></pre></div><p>容量規劃要把「最壞情況遍歷」（經過 super node 的高扇出多跳）當 worst-case 算進 sizing、不能只用平均遍歷成本、回 <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/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">4.20 Observability Evidence Package</a> 把「遍歷掃描 row 數」跟「Spanner CPU」配成 evidence pair：掃描 row 數突增且 CPU 飽和、是某個查詢撞到 super node 或 layout 退化。</p>
<blockquote>
<p><strong>Scope warning</strong>：Spanner Graph 的具體效能特性、query plan 工具、graph 相關 metric 屬 2024 後的新能力規格、隨版本演進、cross-verify 官方文件、非 9.C10 case 揭露。</p></blockquote>
<h2 id="邊界與整合何時用-graph何時用純-relational-或專用-graph-db">邊界與整合：何時用 graph、何時用純 relational 或專用 graph DB</h2>
<h3 id="選-spanner-graph-的條件">選 Spanner Graph 的條件</h3>
<p>資料已在 Spanner、本質是關係網絡、需要多跳遍歷查詢、且這份資料同時要支援強一致的 OLTP 寫入 — 這是 Spanner Graph 的適用條件。它的核心價值是「免去 OLTP → graph DB 的同步管線、graph 看到的就是強一致的當前資料」。反詐欺、權限傳遞、即時推薦這類「在交易資料上做關係查詢」的場景最適合。</p>
<h3 id="何時用純-relational">何時用純 relational</h3>
<p>關係查詢的跳數固定且淺（1-2 跳）、用標準 SQL JOIN 已足夠清晰、不值得引入 graph schema 的額外概念。graph 的價值隨跳數與遍歷複雜度上升、淺查詢用 relational 反而簡單。判準是：若查詢用 JOIN 寫起來不痛、就不需要 graph。</p>
<h3 id="何時用專用-graph-db">何時用專用 graph DB</h3>
<p>純圖工作負載、需要豐富圖演算法（PageRank、最短路徑、社群偵測）、不需要跟 OLTP transaction 共用強一致資料 — 用專用 graph DB 或圖分析框架。Spanner Graph 不是要取代專用 graph engine、是要服務「OLTP 順便要關係查詢」的場景。把重度圖分析硬塞 Spanner Graph 是用錯工具。</p>
<h3 id="sibling-deep-articles-路由">Sibling deep articles 路由</h3>
<ul>
<li><a href="../schema-migration-interleaved-tables/">schema-migration-interleaved-tables</a>：edge table 的 PK layout 思路跟 interleaved table 相通、都是「把一起查的資料在 storage 上放近」、且 graph 的 edge layout 錯誤回退跟 schema migration 同代價</li>
<li><a href="../consistency-models-comparison/">consistency-models-comparison</a>：graph 查詢繼承 external consistency、graph 看到的快照跟 OLTP 一致</li>
<li><a href="../bigquery-federation/">bigquery-federation</a>：重度圖分析若超出 graph 即時查詢範圍、可考慮把資料分到分析層</li>
</ul>
<h3 id="跟-knowledge-card-的互引">跟 knowledge card 的互引</h3>
<ul>
<li><a href="/blog/backend/knowledge-cards/distributed-sql/" data-link-title="Distributed SQL" data-link-desc="把 SQL 與交易語意延伸到多節點與多區域的資料庫形態">distributed-sql</a> — Spanner Graph 是 distributed SQL 引擎上的 property graph 層、繼承其分散式語意</li>
</ul>
<h3 id="跟其他-vendor--章節的對照">跟其他 vendor / 章節的對照</h3>
<ul>
<li><a href="/blog/backend/01-database/vendors/dynamodb/" data-link-title="DynamoDB" data-link-desc="AWS managed key-value、partition-based scaling、9000 萬 RPS sustained 實戰證據">DynamoDB vendor</a>：DynamoDB 的 adjacency list 設計是另一種「在 KV 上做關係查詢」的路線、跟 Spanner Graph 的 native graph 是不同取捨</li>
<li><a href="/blog/backend/01-database/global-distributed-oltp/" data-link-title="1.11 全球分散式 OLTP" data-link-desc="Spanner / Aurora DSQL / Cosmos DB multi-region write / CockroachDB / TiDB 的全球一致性取捨">1.11 全球分散式 OLTP</a>：graph 是 Spanner 在 OLTP 之上擴展的查詢能力之一</li>
</ul>
]]></content:encoded></item><item><title>DynamoDB Streams 與 Lambda 事件驅動：CDC、shard 順序保證、消費模式與失敗處理</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/dynamodb/streams-lambda-event-driven/</link><pubDate>Tue, 02 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/dynamodb/streams-lambda-event-driven/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/dynamodb/" data-link-title="DynamoDB" data-link-desc="AWS managed key-value、partition-based scaling、9000 萬 RPS sustained 實戰證據">DynamoDB&lt;/a> overview 的 implementation-layer deep article。寫作參照 &lt;a href="https://tarrragon.github.io/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">vendor deep article methodology&lt;/a>。&lt;/p>&lt;/blockquote>
&lt;p>訂單寫進 DynamoDB 後、搜尋索引要更新、快取要失效、要推一筆通知、要寫一筆 audit。第一版 application 在寫訂單的同一段 code 裡同步做完這四件事、結果單一步驟（推通知的外部 API）變慢、整個寫訂單路徑被拖垮。第二版改成「另一個 service 每 10 秒輪詢 table 撈新資料」、輪詢既貴（全表 scan）又慢（最差 10 秒延遲）。兩個痛點都指向同一個缺口 — 資料變更需要一條可靠、低延遲、不污染寫路徑的下游通道。這正是 DynamoDB Streams 的責任。本文展開 Streams 的 record 結構、順序保證的真實邊界、消費模式選擇與失敗處理。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>事件機制前提：先確認 workload 適配 DynamoDB&lt;/strong>：事件驅動機制是已選 DynamoDB 後的議題；選型本身先過 workload 適配 4 軸 — PK 天然均勻 / control plane vs data plane / consistency 可接受 eventual / access pattern 穩定。判讀軸詳見 &lt;a href="../single-table-design-pattern/#dynamodb-%e9%81%a9%e7%94%a8%e5%ba%a6%e5%89%8d%e7%bd%ae%e5%88%a4%e8%ae%804-%e8%bb%b8">single-table-design-pattern 開頭 4 軸前置判讀&lt;/a>。本文聚焦 &lt;em>已選 DynamoDB&lt;/em> 後、把資料變更導向下游的事件機制。&lt;/p>&lt;/blockquote>
&lt;h2 id="核心機制stream-record-與-view-type">核心機制：Stream record 與 view type&lt;/h2>
&lt;p>DynamoDB Streams 是 table 的 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/change-data-capture/" data-link-title="Change Data Capture" data-link-desc="說明資料變更如何被捕捉並傳送到其他系統">change data capture&lt;/a> 通道 — 把 item 層級的 insert / modify / delete 變成一條時間排序的事件流。開啟後、每筆寫入產生一筆 stream record。&lt;/p>
&lt;p>&lt;strong>view type 決定 record 帶什麼&lt;/strong>：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>StreamViewType&lt;/th>
 &lt;th>record 內容&lt;/th>
 &lt;th>典型用途&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>KEYS_ONLY&lt;/code>&lt;/td>
 &lt;td>只有被改 item 的 key&lt;/td>
 &lt;td>下游自己回查、最省&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>NEW_IMAGE&lt;/code>&lt;/td>
 &lt;td>寫入後的完整新 item&lt;/td>
 &lt;td>同步到搜尋索引 / 快取&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>OLD_IMAGE&lt;/code>&lt;/td>
 &lt;td>寫入前的舊 item&lt;/td>
 &lt;td>audit「改了什麼」、刪除留底&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>NEW_AND_OLD_IMAGES&lt;/code>&lt;/td>
 &lt;td>新舊都帶&lt;/td>
 &lt;td>算 diff、條件性下游處理&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>view type 在開 stream 時定、改要重開 stream。選 &lt;code>NEW_AND_OLD_IMAGES&lt;/code> 最方便但 record 最大（影響 Lambda payload 與成本）；下游只需 key 就回查的、選 &lt;code>KEYS_ONLY&lt;/code>。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>Scope warning&lt;/strong>：「stream record 保留 24 小時」、「Lambda 單次 batch 上限」這些屬 AWS vendor 規格、會隨版本調整、實作時 cross-verify 官方 doc。本文不含 production case 揭露的 stream 配置數字。&lt;/p>&lt;/blockquote>
&lt;p>對應 knowledge card：&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/change-data-capture/" data-link-title="Change Data Capture" data-link-desc="說明資料變更如何被捕捉並傳送到其他系統">change-data-capture&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency&lt;/a>。&lt;/p>
&lt;h2 id="順序保證的真實邊界">順序保證的真實邊界&lt;/h2>
&lt;p>這是 Streams 最常被誤解的點 — 「stream 是有序的」這句話只在特定範圍成立。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/dynamodb/" data-link-title="DynamoDB" data-link-desc="AWS managed key-value、partition-based scaling、9000 萬 RPS sustained 實戰證據">DynamoDB</a> overview 的 implementation-layer deep article。寫作參照 <a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">vendor deep article methodology</a>。</p></blockquote>
<p>訂單寫進 DynamoDB 後、搜尋索引要更新、快取要失效、要推一筆通知、要寫一筆 audit。第一版 application 在寫訂單的同一段 code 裡同步做完這四件事、結果單一步驟（推通知的外部 API）變慢、整個寫訂單路徑被拖垮。第二版改成「另一個 service 每 10 秒輪詢 table 撈新資料」、輪詢既貴（全表 scan）又慢（最差 10 秒延遲）。兩個痛點都指向同一個缺口 — 資料變更需要一條可靠、低延遲、不污染寫路徑的下游通道。這正是 DynamoDB Streams 的責任。本文展開 Streams 的 record 結構、順序保證的真實邊界、消費模式選擇與失敗處理。</p>
<blockquote>
<p><strong>事件機制前提：先確認 workload 適配 DynamoDB</strong>：事件驅動機制是已選 DynamoDB 後的議題；選型本身先過 workload 適配 4 軸 — PK 天然均勻 / control plane vs data plane / consistency 可接受 eventual / access pattern 穩定。判讀軸詳見 <a href="../single-table-design-pattern/#dynamodb-%e9%81%a9%e7%94%a8%e5%ba%a6%e5%89%8d%e7%bd%ae%e5%88%a4%e8%ae%804-%e8%bb%b8">single-table-design-pattern 開頭 4 軸前置判讀</a>。本文聚焦 <em>已選 DynamoDB</em> 後、把資料變更導向下游的事件機制。</p></blockquote>
<h2 id="核心機制stream-record-與-view-type">核心機制：Stream record 與 view type</h2>
<p>DynamoDB Streams 是 table 的 <a href="/blog/backend/knowledge-cards/change-data-capture/" data-link-title="Change Data Capture" data-link-desc="說明資料變更如何被捕捉並傳送到其他系統">change data capture</a> 通道 — 把 item 層級的 insert / modify / delete 變成一條時間排序的事件流。開啟後、每筆寫入產生一筆 stream record。</p>
<p><strong>view type 決定 record 帶什麼</strong>：</p>
<table>
  <thead>
      <tr>
          <th>StreamViewType</th>
          <th>record 內容</th>
          <th>典型用途</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>KEYS_ONLY</code></td>
          <td>只有被改 item 的 key</td>
          <td>下游自己回查、最省</td>
      </tr>
      <tr>
          <td><code>NEW_IMAGE</code></td>
          <td>寫入後的完整新 item</td>
          <td>同步到搜尋索引 / 快取</td>
      </tr>
      <tr>
          <td><code>OLD_IMAGE</code></td>
          <td>寫入前的舊 item</td>
          <td>audit「改了什麼」、刪除留底</td>
      </tr>
      <tr>
          <td><code>NEW_AND_OLD_IMAGES</code></td>
          <td>新舊都帶</td>
          <td>算 diff、條件性下游處理</td>
      </tr>
  </tbody>
</table>
<p>view type 在開 stream 時定、改要重開 stream。選 <code>NEW_AND_OLD_IMAGES</code> 最方便但 record 最大（影響 Lambda payload 與成本）；下游只需 key 就回查的、選 <code>KEYS_ONLY</code>。</p>
<blockquote>
<p><strong>Scope warning</strong>：「stream record 保留 24 小時」、「Lambda 單次 batch 上限」這些屬 AWS vendor 規格、會隨版本調整、實作時 cross-verify 官方 doc。本文不含 production case 揭露的 stream 配置數字。</p></blockquote>
<p>對應 knowledge card：<a href="/blog/backend/knowledge-cards/change-data-capture/" data-link-title="Change Data Capture" data-link-desc="說明資料變更如何被捕捉並傳送到其他系統">change-data-capture</a>、<a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a>。</p>
<h2 id="順序保證的真實邊界">順序保證的真實邊界</h2>
<p>這是 Streams 最常被誤解的點 — 「stream 是有序的」這句話只在特定範圍成立。</p>
<p><strong>保證範圍</strong>：</p>
<ul>
<li>stream 切成多個 shard、每個 shard 對應 table 的一組 partition</li>
<li><strong>同一 partition key 的所有變更、進同一個 shard、在 shard 內嚴格時間排序</strong></li>
<li>跨 shard <em>沒有</em> 全域順序保證</li>
</ul>
<p>這代表：同一筆訂單（同 PK）的 create → update → delete 一定按序到下游；但訂單 A 跟訂單 B（不同 PK、可能不同 shard）的相對順序不保證。下游若依賴「跨實體的全域順序」、會踩雷。</p>
<p><strong>shard split / merge</strong>：</p>
<p>table partition 會隨資料量與流量 split、stream shard 跟著變動。消費端要能處理 shard 生命週期（Lambda event source mapping 自動處理；自己用 SDK 拉的要處理 shard iterator 的 parent-child 關係）。</p>
<p><strong>順序 + 冪等的組合</strong>：</p>
<p>Lambda 消費 stream 是 <em>at-least-once</em> — 同一筆 record 可能被送兩次（retry、shard 重平衡）。下游處理必須冪等：用 record 的 sequence number 或業務鍵去重、不能假設「每筆只處理一次」。每筆訊息帶獨立 message_id 的事件流天然適合 — message_id 當冪等鍵、重送不重複發。</p>
<blockquote>
<p><strong>Scope warning</strong>：上述順序與 at-least-once 語意屬 Streams vendor 規格 + 通用事件處理工程、非 production case 揭露。</p></blockquote>
<h2 id="消費模式lambda-vs-kinesis">消費模式：Lambda vs Kinesis</h2>
<p>兩條主要消費路徑、責任與運維成本不同：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>Lambda event source mapping</th>
          <th>Kinesis Data Streams for DynamoDB</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>模式</td>
          <td>push（DynamoDB 觸發 Lambda）</td>
          <td>pull（消費端自己拉）</td>
      </tr>
      <tr>
          <td>retention</td>
          <td>stream 原生較短</td>
          <td>較長（可重播更久）</td>
      </tr>
      <tr>
          <td>消費者數</td>
          <td>適合單一 / 少量消費者</td>
          <td>適合多消費者 fan-out</td>
      </tr>
      <tr>
          <td>運維</td>
          <td>幾乎零（managed trigger）</td>
          <td>要管 Kinesis consumer / KCL</td>
      </tr>
      <tr>
          <td>重播能力</td>
          <td>受 stream retention 限制</td>
          <td>retention 內可重播</td>
      </tr>
  </tbody>
</table>
<p>多數「寫入後觸發一個下游動作」用 Lambda event source mapping 最簡單。需要長 retention、多消費者 fan-out、或要重播歷史變更的、用 Kinesis Data Streams for DynamoDB。</p>
<p><strong>Lambda event source mapping 的關鍵旋鈕</strong>：</p>
<ul>
<li>batch size：一次給 Lambda 幾筆 record（吞吐 vs 延遲）</li>
<li>batch window：湊滿 batch 或等多久才觸發（低流量時的延遲控制）</li>
<li>parallelization factor：一個 shard 並行幾個 Lambda（提升單 shard 吞吐、但犧牲 shard 內嚴格順序）</li>
</ul>
<blockquote>
<p><strong>Scope warning</strong>：parallelization factor &gt; 1 會在單 shard 內並行處理、放寬順序保證；需要嚴格順序的維持 factor = 1。具體上限屬 vendor 規格。</p></blockquote>
<h2 id="操作流程">操作流程</h2>
<p>從開 stream 到下游上線的 6 步流程。</p>
<h4 id="step-1選-view-type">Step 1：選 view type</h4>
<p>依下游需要什麼決定。同步到搜尋索引要完整新 item → <code>NEW_IMAGE</code>；audit 要看改動 → <code>NEW_AND_OLD_IMAGES</code>；下游自己回查 → <code>KEYS_ONLY</code>。</p>
<h4 id="step-2開-stream">Step 2：開 stream</h4>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">aws dynamodb update-table <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --table-name orders <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --stream-specification <span class="nv">StreamEnabled</span><span class="o">=</span>true,StreamViewType<span class="o">=</span>NEW_AND_OLD_IMAGES</span></span></code></pre></div><h4 id="step-3接-lambda-event-source-mapping">Step 3：接 Lambda event source mapping</h4>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">def</span> <span class="nf">handler</span><span class="p">(</span><span class="n">event</span><span class="p">,</span> <span class="n">context</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">for</span> <span class="n">record</span> <span class="ow">in</span> <span class="n">event</span><span class="p">[</span><span class="s2">&#34;Records&#34;</span><span class="p">]:</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="n">event_name</span> <span class="o">=</span> <span class="n">record</span><span class="p">[</span><span class="s2">&#34;eventName&#34;</span><span class="p">]</span>      <span class="c1"># INSERT / MODIFY / REMOVE</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="k">if</span> <span class="n">event_name</span> <span class="o">==</span> <span class="s2">&#34;REMOVE&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">            <span class="n">old</span> <span class="o">=</span> <span class="n">record</span><span class="p">[</span><span class="s2">&#34;dynamodb&#34;</span><span class="p">][</span><span class="s2">&#34;OldImage&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">            <span class="n">delete_from_search_index</span><span class="p">(</span><span class="n">old</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="k">else</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">            <span class="n">new</span> <span class="o">=</span> <span class="n">record</span><span class="p">[</span><span class="s2">&#34;dynamodb&#34;</span><span class="p">][</span><span class="s2">&#34;NewImage&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">            <span class="n">upsert_to_search_index</span><span class="p">(</span><span class="n">new</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="c1"># 冪等：用 sequence number 或業務鍵去重</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="n">seq</span> <span class="o">=</span> <span class="n">record</span><span class="p">[</span><span class="s2">&#34;dynamodb&#34;</span><span class="p">][</span><span class="s2">&#34;SequenceNumber&#34;</span><span class="p">]</span></span></span></code></pre></div><h4 id="step-4設定-batch-與失敗處理">Step 4：設定 batch 與失敗處理</h4>





<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">BatchSize: 依下游處理能力與延遲目標
</span></span><span class="line"><span class="ln">2</span><span class="cl">MaximumBatchingWindowInSeconds: 低流量湊批、控制延遲
</span></span><span class="line"><span class="ln">3</span><span class="cl">BisectBatchOnFunctionError: true   # 失敗時二分批、隔離壞 record
</span></span><span class="line"><span class="ln">4</span><span class="cl">MaximumRetryAttempts: 有限次       # 避免毒丸 record 無限重試
</span></span><span class="line"><span class="ln">5</span><span class="cl">DestinationConfig.OnFailure: DLQ   # 超過重試送 DLQ</span></span></code></pre></div><h4 id="step-5下游冪等設計">Step 5：下游冪等設計</h4>
<p>下游 upsert 用業務鍵（PK）做 idempotent write、刪除用「刪不存在不報錯」；確保同一 record 處理兩次結果相同。</p>
<h4 id="step-6驗證點">Step 6：驗證點</h4>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 灌一筆寫入、確認下游在預期延遲內收到對應 record</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># CloudWatch: Lambda IteratorAge（消費落後程度）應接近 0</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"># 製造一筆會失敗的 record、確認進 DLQ 而非卡住整個 shard</span></span></span></code></pre></div><p><strong>Rollback boundary</strong>：關 stream 即停止產生新 record；已產生的 record 在 retention 內仍存在。下游邏輯出錯時、修好 Lambda 後可在 retention 內讓未處理 record 重新消費（或從 DLQ 重放）。</p>
<h2 id="失敗模式">失敗模式</h2>
<p>production 常見的 5 個踩雷：</p>
<h4 id="case-1下游非冪等重送導致重複副作用">Case 1：下游非冪等、重送導致重複副作用</h4>
<p>at-least-once 重送、下游每次都發一筆通知、用戶收到重複推播。修法：下游用業務鍵冪等、sequence number 去重；副作用（發通知 / 扣款）必須 idempotent。</p>
<h4 id="case-2依賴跨實體全域順序">Case 2：依賴跨實體全域順序</h4>
<p>下游假設「所有訂單事件按全域時間到達」、實際跨 shard 無此保證、算錯聚合。修法：只依賴「同 PK 內有序」；需要跨實體順序的、在下游用 event timestamp 重排、或重新設計不依賴全域順序。</p>
<h4 id="case-3毒丸-record-卡住整個-shard">Case 3：毒丸 record 卡住整個 shard</h4>
<p>某筆 record 讓 Lambda 永遠拋例外、預設行為是重試整個 batch、shard 卡死、IteratorAge 無限上升。修法：開 <code>BisectBatchOnFunctionError</code> + <code>MaximumRetryAttempts</code> + DLQ、隔離壞 record 讓其餘繼續。</p>
<h4 id="case-4consumer-落後record-過期遺失">Case 4：consumer 落後、record 過期遺失</h4>
<p>下游處理太慢、IteratorAge 超過 stream retention、未處理 record 被清掉。這個 Case 的代價跟前三個不同層級：前三個是「重複副作用 / 算錯聚合 / shard 卡住」、都還在 stream 裡留有 record、修好邏輯後可重新消費或從 DLQ 重放。Case 4 是 record 本身已被 retention 清除、那段時間的資料變更在 stream 這條通道上永久消失、沒有回退路徑。要補回只能反向比對 table 當前狀態跟下游狀態（若下游存得了），或在源頭重跑一次寫入觸發新 record — 兩者都是事故後的人工修復、成本遠高於前三個 Case 的設定旋鈕。</p>
<p>因為不可逆、防線要前置在「逼近 retention 之前」而非「過期之後」：IteratorAge alarm 的閾值設在遠低於 retention 的水位、留出擴容反應時間；吞吐不足時加 parallelization factor 或改 Kinesis（更長 retention、爭取更大的落後緩衝）；下游設計要能水平擴、讓落後可被快速追平。</p>
<h4 id="case-5parallelization-factor-開了還抱怨順序錯">Case 5：parallelization factor 開了還抱怨順序錯</h4>
<p>為提吞吐把 factor 開 &gt; 1、又依賴 shard 內嚴格順序、兩者矛盾。修法：需要嚴格順序維持 factor = 1；要並行吞吐就接受順序放寬、或把順序敏感的處理移到下游用 PK 分組。</p>
<p><strong>Anti-recommendation</strong>：只有單一同步下游、且寫路徑延遲容忍度高 → 直接在 application 寫入後同步處理可能更簡單、不必引入 stream 的運維與冪等複雜度。Streams 的價值在「多下游 / 解耦寫路徑 / 低延遲 CDC」。</p>
<h2 id="容量與觀測">容量與觀測</h2>
<p>CloudWatch metric：</p>
<ul>
<li><code>IteratorAge</code>（Lambda）：消費落後程度、最關鍵指標、持續上升代表下游跟不上</li>
<li>Lambda <code>Errors</code> / <code>Throttles</code>：下游處理失敗 / 被限流</li>
<li>DLQ 訊息數：毒丸 record 累積、需要人工介入</li>
<li>stream <code>ReadProvisionedThroughputExceeded</code>（Kinesis 模式）：消費端讀超限</li>
</ul>
<p><strong>判讀</strong>：</p>
<ul>
<li><code>IteratorAge</code> 接近 retention 上限 → 資料變更即將遺失、緊急擴消費端</li>
<li>DLQ 持續累積 → 有系統性壞 record、查 Lambda 邏輯或上游資料</li>
<li>Errors 尖峰但 IteratorAge 正常 → transient 失敗、retry 有在吸收</li>
</ul>
<blockquote>
<p><strong>Scope warning</strong>：本文未引用 production case 的 stream metric 數字；上述指標與判讀屬 vendor 規格 + 通用事件處理觀測。</p></blockquote>
<p>接回 <a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">4.20 Observability Evidence Package</a>、<a href="/blog/backend/09-performance-capacity/bottleneck-localization/" data-link-title="9.5 瓶頸定位流程" data-link-desc="從 app 到 DB / cache / broker / 第三方 quota 的逐層瓶頸定位">9.5 瓶頸定位流程</a>。</p>
<h2 id="邊界與整合">邊界與整合</h2>
<h3 id="streams-跟-03-訊息佇列的責任切分">Streams 跟 03 訊息佇列的責任切分</h3>
<p>DynamoDB Streams 是 <em>資料庫變更</em> 的 CDC 通道、不是通用訊息佇列。兩者責任不同：</p>
<ul>
<li><strong>Streams</strong>：源頭是 table 寫入、record 由 DynamoDB 自動產生、生命週期綁 table、retention 短</li>
<li><strong>訊息佇列（SQS / SNS / Kafka）</strong>：源頭是 application 主動 publish、用於通用解耦、retention 與語意更彈性</li>
</ul>
<p>典型組合：Streams 捕捉 table 變更 → Lambda 處理 → 需要扇出到多個獨立服務時、再 publish 到 SNS / EventBridge。當事件來源不是「資料庫變更」而是「業務事件」、直接用 <a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">03 訊息佇列模組</a> 的 queue / topic、不要硬塞進 table 再用 stream。</p>
<h3 id="sibling-與-cross-link">Sibling 與 cross-link</h3>
<ul>
<li><a href="/blog/backend/01-database/vendors/dynamodb/transactions-conditional-writes/" data-link-title="DynamoDB Transaction 與 Conditional Write：跨 item 原子性、optimistic locking 與 idempotency" data-link-desc="DynamoDB 的寫原子性不是免費 ACID；本文展開 TransactWriteItems 跨 item 原子性、ConditionExpression 條件寫、version-based optimistic locking、ClientRequestToken idempotency，以及 transaction 2x 成本邊界與何時用單 item conditional write 取代 transaction">transactions-conditional-writes</a> — transaction 寫入也觸發 stream、下游處理要冪等</li>
<li><a href="/blog/backend/01-database/vendors/dynamodb/single-table-design-pattern/" data-link-title="DynamoDB Single-Table Design：從適用度前置判讀到 access pattern 反推 PK/SK" data-link-desc="DynamoDB single-table 設計不是「資料表越少越好」，而是 access pattern 反推 PK/SK 跟 GSI；本文先做 DynamoDB 適用度 4 軸前置判讀（PK 天然均勻 / control plane vs data plane / consistency / access pattern 穩定），再展開設計流程、failure modes 與 durable queue 正向用例">single-table-design-pattern</a> — single-table 下不同 entity 共用 stream、下游用 type 欄位分流</li>
<li><a href="/blog/backend/01-database/vendors/dynamodb/global-tables-conflict/" data-link-title="DynamoDB Global Tables：multi-region active-active、LWW conflict 與 cross-device sync 正向用例" data-link-desc="Global Tables 不只是 conflict 痛點、也是 cross-device sync / global read / DR failover 的正向工程方案；本文展開 B2B SaaS vs B2C 業務 driver、LWW conflict resolution、reconciliation pipeline，含 Genesys 99.999% 跨 15 region 跟 Disney&#43; 跨裝置同步的對照">global-tables-conflict</a> — Global Tables 跨 region 複製本身基於 stream 機制</li>
<li>替代路由：通用業務事件 / 多消費者扇出 / 長 retention → <a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">03 訊息佇列模組</a></li>
<li>搜尋索引同步下游 → OpenSearch / Elasticsearch（DynamoDB 不適合做全文檢索）</li>
<li>跟 <a href="/blog/backend/09-performance-capacity/cases/paypay-mobile-payment-messaging/" data-link-title="9.C26 PayPay：行動支付每日 3 億訊息的 DynamoDB 後端" data-link-desc="日本最大行動支付 PayPay 每日 3 億訊息、用 DynamoDB 處理通知與訊息功能、支撐次秒級反應">PayPay 9.C26</a> 互引：訊息事件 message_id 天然冪等、適合 stream 下游處理</li>
</ul>
]]></content:encoded></item><item><title>Spanner ↔ BigQuery federation：OLTP/OLAP 分工、federated query、Data Boost、何時把分析 workload 分出去</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/spanner/bigquery-federation/</link><pubDate>Tue, 02 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/spanner/bigquery-federation/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/spanner/" data-link-title="Google Cloud Spanner" data-link-desc="全球分散式 strong-consistency OLTP、TrueTime API、線性擴展到 10 億 req/sec">Cloud Spanner&lt;/a> overview 的 implementation-layer deep article、寫作參照 &lt;a href="https://tarrragon.github.io/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">vendor deep article methodology&lt;/a>。Overview 已說明 Spanner 在全球 OLTP 譜系的定位、本文聚焦 &lt;em>Spanner ↔ BigQuery federation&lt;/em> — OLTP 與 OLAP 的責任分工、以及讓分析查詢存取 OLTP 活資料的整合機制。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="核心定位oltp-與-olap-是兩種不同的資料責任">核心定位：OLTP 與 OLAP 是兩種不同的資料責任&lt;/h2>
&lt;p>Spanner ↔ BigQuery federation 的責任是讓「分析查詢」存取「交易資料」、同時把 OLTP 與 OLAP 兩種根本不同的工作負載分開、各自用適合的引擎與運算資源。Spanner 承擔交易責任 — 低延遲、高並發、行級讀寫、強一致;BigQuery 承擔分析責任 — 掃描大量資料、複雜聚合、欄式儲存、吞吐優先。federation 是讓這兩種責任協作的橋、不是讓一個引擎兼做兩件事。&lt;/p>
&lt;p>把這條分工放最前面、是因為最常見的反模式是「在 OLTP 庫上直接跑分析查詢」。一個掃描全表做月度營收聚合的查詢、跑在 Spanner 上會吃掉本該服務交易的 CPU、把 OLTP 的 p99 latency 拖垮。federation 的價值是讓分析查詢「邏輯上看得到 OLTP 資料、物理上不搶 OLTP 資源」。理解這點、才能正確判斷哪些查詢該留在 Spanner、哪些該推到 BigQuery。&lt;/p>
&lt;h2 id="問題情境分析查詢正在拖垮交易系統">問題情境：分析查詢正在拖垮交易系統&lt;/h2>
&lt;p>federation 的價值、在「分析需求與交易需求共用同一個 OLTP 庫、互相干擾」的壓力下浮現。讀者徵兆：BI 團隊的 dashboard 每小時跑全表聚合、每次跑都讓 Spanner CPU spike、交易 p99 跟著抖;資料團隊想做 ad-hoc 分析、卻被告知「不要在 production Spanner 上跑大查詢」;為了避免干擾、團隊每天 batch export 一次到 BigQuery、但分析師抱怨資料延遲一天、看不到當天的活資料。&lt;/p>
&lt;p>真實壓力場景：全球電商把訂單寫進 Spanner、營運團隊要即時看「過去一小時各區域的訂單趨勢」。這個查詢需要近即時的活資料（不能等隔日 batch）、又是掃描大量 row 的聚合（不該跑在 OLTP 上）。兩個需求拉扯：要新鮮就得查 Spanner 活資料、要不干擾交易就得分到分析引擎。federation + Data Boost 正是為了同時滿足這兩端 — 查 Spanner 的活資料、但用獨立運算資源。&lt;/p>
&lt;p>Case anchor：&lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/spanner-planetary-scale-database-gcp/" data-link-title="9.C10 Cloud Spanner：每秒 10 億請求的全球一致性資料庫" data-link-desc="Google Cloud Spanner 內部峰值 10 億 req/sec、跨地區強一致 — 全球分散式 OLTP 容量參考">9.C10 Cloud Spanner planetary scale&lt;/a> 提供「Spanner 定位在 OLTP、analytics workload 交給 BigQuery」的分工 anchor — overview 已指出 Spanner 的不適用場景包含「需要 OLAP 分析能力」、替代是跟 BigQuery 整合。&lt;strong>dogfood 邊界明示&lt;/strong>：9.C10 是 Google 內部 dogfood case、未展開 federation 實作細節;本文 federation 機制、Data Boost 行為均以 GCP vendor 規格 + 通用 OLTP/OLAP 工程展開、case 僅作分工壓力 anchor。&lt;/p>
&lt;h2 id="核心機制external-dataset-federated-query-與-data-boost">核心機制：external dataset federated query 與 Data Boost&lt;/h2>
&lt;p>federation 讓 BigQuery 把 Spanner database 註冊成 &lt;em>external dataset&lt;/em>、之後用標準 BigQuery SQL 直接查 Spanner 的表、查詢在執行時把資料從 Spanner 拉進 BigQuery 的執行引擎。資料不複製、查的是 Spanner 當前狀態 — 這是 federation 跟「定期 export 一份 copy 到 BigQuery」的根本差異:federated query 看到的是活資料、export 看到的是某個時間點的快照。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/spanner/" data-link-title="Google Cloud Spanner" data-link-desc="全球分散式 strong-consistency OLTP、TrueTime API、線性擴展到 10 億 req/sec">Cloud Spanner</a> overview 的 implementation-layer deep article、寫作參照 <a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">vendor deep article methodology</a>。Overview 已說明 Spanner 在全球 OLTP 譜系的定位、本文聚焦 <em>Spanner ↔ BigQuery federation</em> — OLTP 與 OLAP 的責任分工、以及讓分析查詢存取 OLTP 活資料的整合機制。</p></blockquote>
<hr>
<h2 id="核心定位oltp-與-olap-是兩種不同的資料責任">核心定位：OLTP 與 OLAP 是兩種不同的資料責任</h2>
<p>Spanner ↔ BigQuery federation 的責任是讓「分析查詢」存取「交易資料」、同時把 OLTP 與 OLAP 兩種根本不同的工作負載分開、各自用適合的引擎與運算資源。Spanner 承擔交易責任 — 低延遲、高並發、行級讀寫、強一致;BigQuery 承擔分析責任 — 掃描大量資料、複雜聚合、欄式儲存、吞吐優先。federation 是讓這兩種責任協作的橋、不是讓一個引擎兼做兩件事。</p>
<p>把這條分工放最前面、是因為最常見的反模式是「在 OLTP 庫上直接跑分析查詢」。一個掃描全表做月度營收聚合的查詢、跑在 Spanner 上會吃掉本該服務交易的 CPU、把 OLTP 的 p99 latency 拖垮。federation 的價值是讓分析查詢「邏輯上看得到 OLTP 資料、物理上不搶 OLTP 資源」。理解這點、才能正確判斷哪些查詢該留在 Spanner、哪些該推到 BigQuery。</p>
<h2 id="問題情境分析查詢正在拖垮交易系統">問題情境：分析查詢正在拖垮交易系統</h2>
<p>federation 的價值、在「分析需求與交易需求共用同一個 OLTP 庫、互相干擾」的壓力下浮現。讀者徵兆：BI 團隊的 dashboard 每小時跑全表聚合、每次跑都讓 Spanner CPU spike、交易 p99 跟著抖;資料團隊想做 ad-hoc 分析、卻被告知「不要在 production Spanner 上跑大查詢」;為了避免干擾、團隊每天 batch export 一次到 BigQuery、但分析師抱怨資料延遲一天、看不到當天的活資料。</p>
<p>真實壓力場景：全球電商把訂單寫進 Spanner、營運團隊要即時看「過去一小時各區域的訂單趨勢」。這個查詢需要近即時的活資料（不能等隔日 batch）、又是掃描大量 row 的聚合（不該跑在 OLTP 上）。兩個需求拉扯：要新鮮就得查 Spanner 活資料、要不干擾交易就得分到分析引擎。federation + Data Boost 正是為了同時滿足這兩端 — 查 Spanner 的活資料、但用獨立運算資源。</p>
<p>Case anchor：<a href="/blog/backend/09-performance-capacity/cases/spanner-planetary-scale-database-gcp/" data-link-title="9.C10 Cloud Spanner：每秒 10 億請求的全球一致性資料庫" data-link-desc="Google Cloud Spanner 內部峰值 10 億 req/sec、跨地區強一致 — 全球分散式 OLTP 容量參考">9.C10 Cloud Spanner planetary scale</a> 提供「Spanner 定位在 OLTP、analytics workload 交給 BigQuery」的分工 anchor — overview 已指出 Spanner 的不適用場景包含「需要 OLAP 分析能力」、替代是跟 BigQuery 整合。<strong>dogfood 邊界明示</strong>：9.C10 是 Google 內部 dogfood case、未展開 federation 實作細節;本文 federation 機制、Data Boost 行為均以 GCP vendor 規格 + 通用 OLTP/OLAP 工程展開、case 僅作分工壓力 anchor。</p>
<h2 id="核心機制external-dataset-federated-query-與-data-boost">核心機制：external dataset federated query 與 Data Boost</h2>
<p>federation 讓 BigQuery 把 Spanner database 註冊成 <em>external dataset</em>、之後用標準 BigQuery SQL 直接查 Spanner 的表、查詢在執行時把資料從 Spanner 拉進 BigQuery 的執行引擎。資料不複製、查的是 Spanner 當前狀態 — 這是 federation 跟「定期 export 一份 copy 到 BigQuery」的根本差異:federated query 看到的是活資料、export 看到的是某個時間點的快照。</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">-- BigQuery 端：透過 external connection 查 Spanner 活資料
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">region</span><span class="p">,</span><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">AS</span><span class="w"> </span><span class="n">order_count</span><span class="p">,</span><span class="w"> </span><span class="k">SUM</span><span class="p">(</span><span class="n">total</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">revenue</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="n">EXTERNAL_QUERY</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="s1">&#39;my-project.us-central1.spanner-conn&#39;</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="s1">&#39;SELECT region, total FROM orders WHERE created_at &gt; TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 1 HOUR)&#39;</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">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">region</span><span class="p">;</span></span></span></code></pre></div><h3 id="data-boost分析查詢的-workload-隔離">Data Boost：分析查詢的 workload 隔離</h3>
<p>federated query 直接查 Spanner、預設仍消耗 Spanner instance 的運算資源 — 大分析查詢還是會干擾 OLTP。Data Boost 解的就是這層:它讓分析查詢用 <em>獨立的、按需配置的運算資源</em> 讀 Spanner 資料、不消耗服務交易的 instance CPU。Data Boost 讀的是同一份 storage、但用獨立 compute、所以「分析查詢看活資料」與「不干擾 OLTP」可以同時成立。</p>
<p>這是 federation 整套機制的關鍵 — 沒有 Data Boost、federated query 只是把查詢入口換到 BigQuery、底層仍搶 Spanner CPU;有了 Data Boost、workload 隔離才真正成立。Data Boost 適合 batch / ad-hoc 的大型分析讀取、按使用量計費、不需要預先 provision。</p>
<blockquote>
<p><strong>Scope warning</strong>：external dataset / EXTERNAL_QUERY 的語法、Data Boost 的計費模型與資源隔離邊界屬 GCP 規格、逐版本演進。實作前 cross-verify <a href="https://cloud.google.com/bigquery/docs/spanner-federated-queries">BigQuery Spanner federation</a> 與 <a href="https://cloud.google.com/spanner/docs/databoost/databoost-overview">Data Boost 官方文件</a>、不可依本文當最終依據。</p></blockquote>
<h3 id="兩條整合路線federation-vs-change-stream-to-bigquery">兩條整合路線：federation vs change-stream-to-BigQuery</h3>
<p>把 Spanner 資料給 BigQuery 分析有兩條路線、取捨不同：</p>
<table>
  <thead>
      <tr>
          <th>路線</th>
          <th>資料新鮮度</th>
          <th>對 OLTP 影響</th>
          <th>適合場景</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Federated query + Data Boost</td>
          <td>查詢當下的活資料</td>
          <td>Data Boost 隔離、不搶 CPU</td>
          <td>ad-hoc 分析、即時 dashboard、低頻大查詢</td>
      </tr>
      <tr>
          <td>Change stream → BigQuery</td>
          <td>近即時持續同步</td>
          <td>change stream 讀取耗少量 CPU</td>
          <td>高頻分析、需要在 BigQuery 落地的歷史資料</td>
      </tr>
  </tbody>
</table>
<p>federation 是「需要時去查」、change stream 是「持續推一份到 BigQuery 落地」。federation 適合不需要把資料常駐 BigQuery、偶爾查活資料的場景;change stream（見 <a href="../change-streams-cdc/">change-streams-cdc</a>）適合要在 BigQuery 累積歷史、做高頻或需要 BigQuery 原生效能的分析。兩者不互斥 — 即時 ad-hoc 用 federation、長期歷史分析用 change stream 落地。</p>
<h2 id="操作流程建立-connectionfederated-query啟用-data-boost">操作流程：建立 connection、federated query、啟用 Data Boost</h2>
<h3 id="step-1建立-bigquery--spanner-external-connection">Step 1：建立 BigQuery → Spanner external connection</h3>
<p>在 BigQuery 建立指向 Spanner 的 external connection、設定 IAM 讓 BigQuery service account 有讀 Spanner 的權限。驗證：用 <code>EXTERNAL_QUERY</code> 跑一個簡單 <code>SELECT 1</code> 確認 connection 通、權限正確。</p>
<h3 id="step-2跑-federated-query-並確認查的是活資料">Step 2：跑 federated query 並確認查的是活資料</h3>
<p>跑一個帶時間條件的 federated query、在 Spanner 端寫一筆新資料、立即用 federated query 確認讀得到 — 驗證它查的是活資料、不是快照。這步確立 federation 的核心性質。</p>
<h3 id="step-3對大分析查詢啟用-data-boost-並驗證隔離">Step 3：對大分析查詢啟用 Data Boost 並驗證隔離</h3>
<p>對會掃描大量資料的分析查詢啟用 Data Boost、然後在跑分析查詢的同時觀測 Spanner OLTP 的 CPU 與 p99 latency。驗證點：開 Data Boost 後、大分析查詢執行期間 Spanner OLTP CPU 不應 spike、交易 p99 不應退化。這是 Data Boost 隔離是否生效的直接 evidence — 若 OLTP CPU 仍 spike、表示查詢沒走 Data Boost。</p>
<h3 id="step-4rollback-boundary">Step 4：rollback boundary</h3>
<p>federation 是讀取路徑、不改 Spanner 資料、rollback 成本低 — 停掉 federated query 即可、不影響 OLTP。決策的回退在「分析需求是否該用 federation」:若 federated query 即使開 Data Boost 仍無法滿足效能 / 成本、回退路徑是改用 change stream 把資料落地 BigQuery、用 BigQuery 原生效能查。</p>
<h2 id="失敗模式未隔離的查詢拖垮-oltp資料一致性誤解過度依賴-federation">失敗模式：未隔離的查詢拖垮 OLTP、資料一致性誤解、過度依賴 federation</h2>
<h3 id="federated-query-未開-data-boost拖垮-oltp">Federated query 未開 Data Boost、拖垮 OLTP</h3>
<p>團隊用 federated query 跑大分析查詢、但沒啟用 Data Boost、查詢直接吃 Spanner instance CPU、把交易 p99 拖垮。徵兆是「BI 查詢一跑、交易 latency 就抖」、Spanner CPU 在分析查詢期間 spike。修法是對所有大分析查詢啟用 Data Boost、把「federation = workload 隔離」這個假設明確驗證 — federation 本身不保證隔離、Data Boost 才保證。這個失敗的代價是它直接傷害 production 交易、不是只影響分析。</p>
<h3 id="把-federated-query-的快照當成跨系統強一致">把 federated query 的快照當成跨系統強一致</h3>
<p>federated query 讀的是 Spanner 的活資料、但這份分析結果是「查詢執行那一刻」的快照、不是跟某個 OLTP transaction 綁定的一致點。團隊若把 federated 分析結果當成「跟某筆交易嚴格對齊的數字」、會在對帳場景出錯 — 分析查詢跨多張表掃描時、不同表讀到的時間點可能略有差異、不像單一 OLTP transaction 有 external consistency 的全序保證。</p>
<p>這個失敗的代價在它的隱蔽性:多數分析場景對「秒級的時間點差異」不敏感、所以平時看不出問題;但在「分析數字被當成財務對帳依據」的場景、這個鬆散的一致性會讓對帳對不上、且很難 debug — 因為資料「看起來都對」、只是時間點不嚴格對齊。修法是分清分析查詢的一致性需求:近似趨勢分析、federation 的快照足夠;需要跟交易嚴格對齊的對帳、要用 Spanner 的 read-only transaction 配明確 timestamp bound、或在 OLTP 側生成對帳快照、不靠跨表 federated 掃描拼湊。回退路徑是把「需要強一致對帳」的查詢移回 Spanner read-only transaction、不要硬用 federation 省事。</p>
<h3 id="把所有分析都堆在-federation不評估落地-bigquery">把所有分析都堆在 federation、不評估落地 BigQuery</h3>
<p>團隊把所有分析都用 federated query 直查 Spanner、即使是高頻、重複、不需要活資料的查詢。federated query 每次都從 Spanner 拉資料、高頻重複查的成本與延遲都高於「資料已落地 BigQuery、用 BigQuery 原生欄式儲存查」。徵兆是同樣的分析查詢高頻跑、每次都付 federation 的拉取成本。<strong>Anti-recommendation（何時不該用 federation）</strong>:高頻、重複、可容忍近即時延遲的分析、用 change stream 把資料落地 BigQuery 更划算;federation 的適用範圍是低頻、ad-hoc、需要活資料的查詢。把高頻分析硬塞 federation 是用錯整合路線。</p>
<h2 id="容量與觀測oltp-cpu-隔離與-federation-拉取成本">容量與觀測：OLTP CPU 隔離與 federation 拉取成本</h2>
<p>federation 的容量壓力分兩端 — Spanner 側看「分析查詢有沒有被 Data Boost 隔離開」、BigQuery 側看「federated query 的拉取量與成本」。</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">Spanner OLTP CPU during analytics   → Data Boost 隔離是否生效的關鍵指標
</span></span><span class="line"><span class="ln">2</span><span class="cl">Spanner read capacity used by 分析   → 未隔離的 federated query 會吃這部分
</span></span><span class="line"><span class="ln">3</span><span class="cl">BigQuery federated query bytes 處理量 → federation 拉取成本的計費基礎
</span></span><span class="line"><span class="ln">4</span><span class="cl">分析查詢 latency vs OLTP p99 抖動相關性 → 隔離失效會讓兩者正相關</span></span></code></pre></div><p>核心容量判讀是「分析查詢執行期間、OLTP CPU 與 p99 是否穩定」 — 若穩定、Data Boost 隔離生效;若兩者正相關、隔離失效、分析查詢正在消耗本該服務 OLTP 的資源。用 <a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">4.20 Observability Evidence Package</a> 把「分析查詢時段」跟「OLTP p99」配成 evidence pair。容量規劃上、若走 federation + Data Boost、OLTP sizing 不需為分析加碼（Data Boost 用獨立 compute）;若 federated query 未隔離、OLTP sizing 要把分析尖峰算進去、回 <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>。</p>
<blockquote>
<p><strong>Scope warning</strong>：Data Boost 的計費單位、federated query 的 bytes 計費、隔離的資源邊界屬 GCP 規格、隨版本演進、cross-verify 官方文件、非 9.C10 case 揭露的 production 數字。</p></blockquote>
<h2 id="邊界與整合何時把分析-workload-完全分出去">邊界與整合：何時把分析 workload 完全分出去</h2>
<h3 id="何時用-federation--data-boost">何時用 federation + Data Boost</h3>
<p>分析需要 Spanner 的活資料、查詢低頻或 ad-hoc、不想維護資料同步管線 — 這是 federation 的適用條件。Data Boost 讓它不干擾 OLTP、按需計費。即時營運 dashboard、臨時資料探索、不需要常駐 BigQuery 的分析都適合。</p>
<h3 id="何時把分析完全分到-bigquerychange-stream-落地">何時把分析完全分到 BigQuery（change stream 落地）</h3>
<p>分析是高頻、重複、需要 BigQuery 原生欄式效能、或需要在 BigQuery 累積跨年歷史 — 把資料用 change stream 持續同步到 BigQuery 落地、分析直接查 BigQuery、不再回 Spanner。判準是:當分析 workload 穩定且高頻、落地的一次性同步成本會被「不再每次 federated 拉取」攤平。這是「分析 workload 完全分出去」的訊號 — OLTP 與 OLAP 不只查詢入口分開、連儲存都分開。</p>
<h3 id="何時都不需要分析量小">何時都不需要（分析量小）</h3>
<p>若分析需求很小、Spanner 本身的 read capacity 有餘、偶爾在低峰跑個聚合不影響交易 — 不需要引入 federation 的額外設定。Anti-recommendation 的判準是:federation / Data Boost 的價值隨「分析與交易互相干擾的程度」上升;若兩者本來就不打架、保持簡單。</p>
<h3 id="sibling-deep-articles-路由">Sibling deep articles 路由</h3>
<ul>
<li><a href="../change-streams-cdc/">change-streams-cdc</a>：federation 的互補路線、高頻分析用 change stream 把資料落地 BigQuery、跟 federation 的「需要時去查」是兩種整合取捨</li>
<li><a href="../consistency-models-comparison/">consistency-models-comparison</a>：federated query 的快照一致性鬆於 OLTP transaction 的 external consistency、對帳場景的差異對應該文的一致性等級定義</li>
<li><a href="../truetime-api-depth/">truetime-api-depth</a>：需要嚴格時間點的分析要用 read-only transaction 配 timestamp bound、回該文的 staleness 選項</li>
</ul>
<h3 id="跟-knowledge-card-的互引">跟 knowledge card 的互引</h3>
<ul>
<li><a href="/blog/backend/knowledge-cards/federation/" data-link-title="Federation" data-link-desc="跨系統信任與授權交換的聯邦機制">federation</a> — 本文是這張卡在 Spanner ↔ BigQuery 的具體應用</li>
<li><a href="/blog/backend/knowledge-cards/distributed-sql/" data-link-title="Distributed SQL" data-link-desc="把 SQL 與交易語意延伸到多節點與多區域的資料庫形態">distributed-sql</a> — Spanner 作為 OLTP distributed SQL、跟 BigQuery 的 OLAP 分工</li>
</ul>
<h3 id="跟其他章節的對照路由">跟其他章節的對照路由</h3>
<ul>
<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>：OLTP / OLAP 分工後各自的 sizing 不同、Data Boost 讓分析 sizing 跟 OLTP 解耦</li>
<li><a href="/blog/backend/01-database/global-distributed-oltp/" data-link-title="1.11 全球分散式 OLTP" data-link-desc="Spanner / Aurora DSQL / Cosmos DB multi-region write / CockroachDB / TiDB 的全球一致性取捨">1.11 全球分散式 OLTP</a>：Spanner 定位在 OLTP、analytics 分到 BigQuery 是清楚的責任邊界</li>
</ul>
]]></content:encoded></item><item><title>DynamoDB TTL 資料生命週期：自動過期、48 小時刪除延遲、過期仍可讀與 storage 成本</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/dynamodb/ttl-data-lifecycle/</link><pubDate>Tue, 02 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/dynamodb/ttl-data-lifecycle/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/dynamodb/" data-link-title="DynamoDB" data-link-desc="AWS managed key-value、partition-based scaling、9000 萬 RPS sustained 實戰證據">DynamoDB&lt;/a> overview 的 implementation-layer deep article。寫作參照 &lt;a href="https://tarrragon.github.io/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">vendor deep article methodology&lt;/a>。&lt;/p>&lt;/blockquote>
&lt;p>訊息系統的 storage bill 每月穩定上漲、查 table 發現裡面堆了三年份的過期通知、沒人清。team 設了 TTL「自動清理」、結果兩個新問題冒出來：第一、設了 TTL 之後 storage 還是沒馬上降、過了好幾小時才開始掉；第二、有個報表 query 把「已過期但還沒被刪」的 item 也撈進來、算錯數字。兩個痛點揭露 DynamoDB TTL 的真實語意 — 它是 &lt;em>最終會刪除&lt;/em> 的背景機制、不是即時刪除、也不是查詢層的過濾器。本文展開 TTL 的 epoch 語意、刪除延遲特性、過期可讀陷阱與 storage 成本判讀。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>生命週期前提：先確認 workload 適配 DynamoDB&lt;/strong>：資料生命週期管理是 &lt;em>已選 DynamoDB&lt;/em> 之後才浮現的議題 — TTL 解的是「資料存進來之後怎麼自動退場」、而非「資料該不該存進 DynamoDB」。後者由 4 軸前置判讀決定：PK 天然均勻 / control plane vs data plane / consistency 可接受 eventual / access pattern 穩定、判讀軸詳見 &lt;a href="../single-table-design-pattern/#dynamodb-%e9%81%a9%e7%94%a8%e5%ba%a6%e5%89%8d%e7%bd%ae%e5%88%a4%e8%ae%804-%e8%bb%b8">single-table-design-pattern 開頭 4 軸前置判讀&lt;/a>。本文承接該前提、聚焦用 TTL 管理資料生命週期與 storage 成本。&lt;/p>&lt;/blockquote>
&lt;h2 id="核心機制ttl-attribute-與背景刪除">核心機制：TTL attribute 與背景刪除&lt;/h2>
&lt;p>DynamoDB TTL 讓 item 在指定時間後自動被刪除、不消耗寫容量。機制很簡單但語意有三個容易踩的邊界。&lt;/p>
&lt;p>&lt;strong>設定方式&lt;/strong>：在 item 上放一個數值 attribute、值是 &lt;em>Unix epoch 秒數&lt;/em>（不是毫秒、不是 ISO 字串）、並在 table 啟用 TTL 指向該 attribute：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">time&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="n">table&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">put_item&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Item&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="s2">&amp;#34;PK&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;MSG#&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">msg_id&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#34;&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="s2">&amp;#34;SK&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;META&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="s2">&amp;#34;body&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;...&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="s2">&amp;#34;expireAt&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">int&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">time&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">time&lt;/span>&lt;span class="p">())&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="mi">30&lt;/span> &lt;span class="o">*&lt;/span> &lt;span class="mi">86400&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># 30 天後過期、epoch 秒&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;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>三個關鍵語意&lt;/strong>：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>語意&lt;/th>
 &lt;th>內容&lt;/th>
 &lt;th>後果&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>刪除非即時&lt;/td>
 &lt;td>過期後由 AWS 背景程序刪除、通常 48 小時內、不保證準時&lt;/td>
 &lt;td>不能用 TTL 做即時失效邏輯&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>過期仍可讀&lt;/td>
 &lt;td>過期但尚未被刪的 item 仍出現在 GetItem / Query / Scan 結果&lt;/td>
 &lt;td>read 路徑要 application 端 filter&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>刪除免 WCU&lt;/td>
 &lt;td>TTL 刪除不消耗 write capacity&lt;/td>
 &lt;td>大量過期清理不增寫成本&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>第二列是報表算錯的根因：&lt;strong>TTL 不是查詢過濾器&lt;/strong>。過期到實際刪除之間有一段窗口、這期間 item 還在、還會被讀到。需要「過期立刻不可見」的、application 必須在讀取後自己比對 &lt;code>expireAt&lt;/code> 過濾。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>Scope warning&lt;/strong>：「TTL 通常 48 小時內刪除」屬 AWS vendor 規格描述、AWS 不保證準時、實際延遲視 table 大小與背景負載而定、實作時 cross-verify 官方 doc。&lt;code>9.C26 PayPay&lt;/code> case 揭露「TTL 機制可自動清理過期訊息」的 &lt;em>用途&lt;/em>、未揭露刪除延遲的具體數字。&lt;/p>&lt;/blockquote>
&lt;p>對應 knowledge card：&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/ttl/" data-link-title="TTL" data-link-desc="說明資料過期時間如何影響快取新鮮度、成本與一致性">ttl&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/soft-ttl/" data-link-title="Soft TTL" data-link-desc="說明資料進入刷新期後仍可短暫使用以降低 stampede">soft-ttl&lt;/a>。&lt;/p>
&lt;h2 id="刪除延遲與過期可讀兩個必須處理的窗口">刪除延遲與過期可讀：兩個必須處理的窗口&lt;/h2>
&lt;p>TTL 的「最終刪除」特性製造兩個 application 必須意識的窗口。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/dynamodb/" data-link-title="DynamoDB" data-link-desc="AWS managed key-value、partition-based scaling、9000 萬 RPS sustained 實戰證據">DynamoDB</a> overview 的 implementation-layer deep article。寫作參照 <a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">vendor deep article methodology</a>。</p></blockquote>
<p>訊息系統的 storage bill 每月穩定上漲、查 table 發現裡面堆了三年份的過期通知、沒人清。team 設了 TTL「自動清理」、結果兩個新問題冒出來：第一、設了 TTL 之後 storage 還是沒馬上降、過了好幾小時才開始掉；第二、有個報表 query 把「已過期但還沒被刪」的 item 也撈進來、算錯數字。兩個痛點揭露 DynamoDB TTL 的真實語意 — 它是 <em>最終會刪除</em> 的背景機制、不是即時刪除、也不是查詢層的過濾器。本文展開 TTL 的 epoch 語意、刪除延遲特性、過期可讀陷阱與 storage 成本判讀。</p>
<blockquote>
<p><strong>生命週期前提：先確認 workload 適配 DynamoDB</strong>：資料生命週期管理是 <em>已選 DynamoDB</em> 之後才浮現的議題 — TTL 解的是「資料存進來之後怎麼自動退場」、而非「資料該不該存進 DynamoDB」。後者由 4 軸前置判讀決定：PK 天然均勻 / control plane vs data plane / consistency 可接受 eventual / access pattern 穩定、判讀軸詳見 <a href="../single-table-design-pattern/#dynamodb-%e9%81%a9%e7%94%a8%e5%ba%a6%e5%89%8d%e7%bd%ae%e5%88%a4%e8%ae%804-%e8%bb%b8">single-table-design-pattern 開頭 4 軸前置判讀</a>。本文承接該前提、聚焦用 TTL 管理資料生命週期與 storage 成本。</p></blockquote>
<h2 id="核心機制ttl-attribute-與背景刪除">核心機制：TTL attribute 與背景刪除</h2>
<p>DynamoDB TTL 讓 item 在指定時間後自動被刪除、不消耗寫容量。機制很簡單但語意有三個容易踩的邊界。</p>
<p><strong>設定方式</strong>：在 item 上放一個數值 attribute、值是 <em>Unix epoch 秒數</em>（不是毫秒、不是 ISO 字串）、並在 table 啟用 TTL 指向該 attribute：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="kn">import</span> <span class="nn">time</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">table</span><span class="o">.</span><span class="n">put_item</span><span class="p">(</span><span class="n">Item</span><span class="o">=</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="s2">&#34;PK&#34;</span><span class="p">:</span> <span class="sa">f</span><span class="s2">&#34;MSG#</span><span class="si">{</span><span class="n">msg_id</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="s2">&#34;SK&#34;</span><span class="p">:</span> <span class="s2">&#34;META&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="s2">&#34;body&#34;</span><span class="p">:</span> <span class="s2">&#34;...&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="s2">&#34;expireAt&#34;</span><span class="p">:</span> <span class="nb">int</span><span class="p">(</span><span class="n">time</span><span class="o">.</span><span class="n">time</span><span class="p">())</span> <span class="o">+</span> <span class="mi">30</span> <span class="o">*</span> <span class="mi">86400</span><span class="p">,</span>  <span class="c1"># 30 天後過期、epoch 秒</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">})</span></span></span></code></pre></div><p><strong>三個關鍵語意</strong>：</p>
<table>
  <thead>
      <tr>
          <th>語意</th>
          <th>內容</th>
          <th>後果</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>刪除非即時</td>
          <td>過期後由 AWS 背景程序刪除、通常 48 小時內、不保證準時</td>
          <td>不能用 TTL 做即時失效邏輯</td>
      </tr>
      <tr>
          <td>過期仍可讀</td>
          <td>過期但尚未被刪的 item 仍出現在 GetItem / Query / Scan 結果</td>
          <td>read 路徑要 application 端 filter</td>
      </tr>
      <tr>
          <td>刪除免 WCU</td>
          <td>TTL 刪除不消耗 write capacity</td>
          <td>大量過期清理不增寫成本</td>
      </tr>
  </tbody>
</table>
<p>第二列是報表算錯的根因：<strong>TTL 不是查詢過濾器</strong>。過期到實際刪除之間有一段窗口、這期間 item 還在、還會被讀到。需要「過期立刻不可見」的、application 必須在讀取後自己比對 <code>expireAt</code> 過濾。</p>
<blockquote>
<p><strong>Scope warning</strong>：「TTL 通常 48 小時內刪除」屬 AWS vendor 規格描述、AWS 不保證準時、實際延遲視 table 大小與背景負載而定、實作時 cross-verify 官方 doc。<code>9.C26 PayPay</code> case 揭露「TTL 機制可自動清理過期訊息」的 <em>用途</em>、未揭露刪除延遲的具體數字。</p></blockquote>
<p>對應 knowledge card：<a href="/blog/backend/knowledge-cards/ttl/" data-link-title="TTL" data-link-desc="說明資料過期時間如何影響快取新鮮度、成本與一致性">ttl</a>、<a href="/blog/backend/knowledge-cards/soft-ttl/" data-link-title="Soft TTL" data-link-desc="說明資料進入刷新期後仍可短暫使用以降低 stampede">soft-ttl</a>。</p>
<h2 id="刪除延遲與過期可讀兩個必須處理的窗口">刪除延遲與過期可讀：兩個必須處理的窗口</h2>
<p>TTL 的「最終刪除」特性製造兩個 application 必須意識的窗口。</p>
<p><strong>窗口一：過期 → 實際刪除（可讀窗口）</strong>：</p>
<p>item 的 <code>expireAt</code> 已過、但背景程序還沒刪。這段時間 item：</p>
<ul>
<li>仍會被 <code>Query</code> / <code>Scan</code> / <code>GetItem</code> 撈到</li>
<li>仍佔 storage、仍計 storage 費</li>
<li>仍會被 secondary index 索引到</li>
</ul>
<p>application 若依賴「過期就消失」、會在這個窗口讀到 stale 資料。正確做法是 read 後 filter：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="kn">import</span> <span class="nn">time</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">now</span> <span class="o">=</span> <span class="nb">int</span><span class="p">(</span><span class="n">time</span><span class="o">.</span><span class="n">time</span><span class="p">())</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">items</span> <span class="o">=</span> <span class="p">[</span><span class="n">it</span> <span class="k">for</span> <span class="n">it</span> <span class="ow">in</span> <span class="n">response</span><span class="p">[</span><span class="s2">&#34;Items&#34;</span><span class="p">]</span> <span class="k">if</span> <span class="n">it</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;expireAt&#34;</span><span class="p">,</span> <span class="mi">1</span> <span class="o">&lt;&lt;</span> <span class="mi">62</span><span class="p">)</span> <span class="o">&gt;</span> <span class="n">now</span><span class="p">]</span></span></span></code></pre></div><p>或在 query 加 <code>FilterExpression</code> 排除過期 item（注意 filter 在讀取後套用、仍消耗讀容量）。</p>
<p><strong>窗口二：TTL 刪除 → stream record</strong>：</p>
<p>TTL 刪除會在 stream 產生一筆 <code>REMOVE</code> record、且 <code>userIdentity</code> 標記為 DynamoDB 服務本身（principal <code>dynamodb.amazonaws.com</code>）。這讓「過期歸檔」成為可能 — 下游 Lambda 收到 TTL 刪除事件、把 item 寫進冷儲存（S3）再讓它從 hot table 消失：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">def</span> <span class="nf">handler</span><span class="p">(</span><span class="n">event</span><span class="p">,</span> <span class="n">context</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">for</span> <span class="n">record</span> <span class="ow">in</span> <span class="n">event</span><span class="p">[</span><span class="s2">&#34;Records&#34;</span><span class="p">]:</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">        <span class="k">if</span> <span class="n">record</span><span class="p">[</span><span class="s2">&#34;eventName&#34;</span><span class="p">]</span> <span class="o">==</span> <span class="s2">&#34;REMOVE&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">            <span class="n">principal</span> <span class="o">=</span> <span class="n">record</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;userIdentity&#34;</span><span class="p">,</span> <span class="p">{})</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;principalId&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">            <span class="k">if</span> <span class="n">principal</span> <span class="o">==</span> <span class="s2">&#34;dynamodb.amazonaws.com&#34;</span><span class="p">:</span>  <span class="c1"># TTL 刪除、非 application 刪除</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">                <span class="n">archive_to_s3</span><span class="p">(</span><span class="n">record</span><span class="p">[</span><span class="s2">&#34;dynamodb&#34;</span><span class="p">][</span><span class="s2">&#34;OldImage&#34;</span><span class="p">])</span></span></span></code></pre></div><p>區分「TTL 自動刪除」vs「application 主動刪除」靠 <code>userIdentity</code> — 兩者都是 <code>REMOVE</code> record、但只有 TTL 刪除帶服務 principal。對應 <a href="/blog/backend/01-database/vendors/dynamodb/streams-lambda-event-driven/" data-link-title="DynamoDB Streams 與 Lambda 事件驅動：CDC、shard 順序保證、消費模式與失敗處理" data-link-desc="DynamoDB Streams 不是免費的可靠事件流；本文展開 stream record 的四種 view type、shard 對應 partition 的順序保證邊界、Lambda event source mapping vs Kinesis 消費模式、at-least-once 下游冪等需求，以及 batch 失敗時的 bisect / DLQ 處理">streams-lambda-event-driven</a>。</p>
<blockquote>
<p><strong>Scope warning</strong>：stream record 的 <code>userIdentity</code> 標記屬 vendor 規格、欄位細節 cross-verify 官方 doc；本段機制描述非 production case 揭露。</p></blockquote>
<h2 id="操作流程">操作流程</h2>
<p>從生命週期需求到上線的 6 步流程。</p>
<h4 id="step-1判斷資料是否適合-ttl-管理">Step 1：判斷資料是否適合 TTL 管理</h4>
<p>適合 TTL 的資料有「自然過期時間」：session、訊息通知、暫存 token、event log、合規保留期到期的資料。不適合的：需要精確即時刪除的、需要刪除前審批的、永久保存的。</p>
<h4 id="step-2設計-expireat-計算">Step 2：設計 expireAt 計算</h4>
<p>寫入時算好 epoch 秒數的 <code>expireAt</code>；不同資料類型可不同保留期（通知 30 天、session 1 天、audit 依合規要求）。</p>
<h4 id="step-3啟用-table-ttl">Step 3：啟用 table TTL</h4>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">aws dynamodb update-time-to-live <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --table-name messages <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --time-to-live-specification <span class="s2">&#34;Enabled=true, AttributeName=expireAt&#34;</span></span></span></code></pre></div><h4 id="step-4read-路徑加過期過濾">Step 4：read 路徑加過期過濾</h4>
<p>所有面向用戶的讀取、在 application 端比對 <code>expireAt</code>（或加 FilterExpression）；不要假設過期 item 已消失。</p>
<h4 id="step-5可選接-ttl-刪除歸檔">Step 5：（可選）接 TTL 刪除歸檔</h4>
<p>需要保留過期資料的、接 stream Lambda、用 <code>userIdentity</code> 辨識 TTL 刪除、歸檔到 S3。</p>
<h4 id="step-6驗證點">Step 6：驗證點</h4>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 寫一筆短 TTL item、等過期後確認：</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># 1. 過期但未刪窗口內仍可讀到（驗證需要 filter）</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"># 2. 數小時後背景刪除生效、storage 下降</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 3. 若接歸檔、確認 S3 收到對應 OldImage</span></span></span></code></pre></div><p><strong>Rollback boundary</strong>：關閉 TTL 即停止自動刪除、已刪除的 item 不可恢復（除非有歸檔）；啟用 TTL 前先確認 <code>expireAt</code> 計算正確、避免誤設過短把活躍資料刪掉。</p>
<h2 id="失敗模式">失敗模式</h2>
<p>production 常見的 5 個踩雷：</p>
<h4 id="case-1expireat-用毫秒或-iso-字串">Case 1：expireAt 用毫秒或 ISO 字串</h4>
<p>TTL 只認 Unix epoch 秒；填毫秒（多三位數）會讓過期時間落在遙遠未來、item 永不過期；填字串 TTL 直接不生效。修法：統一用 <code>int(time.time()) + seconds</code>、寫測試驗證 attribute 是秒級數值。</p>
<h4 id="case-2以為-ttl-是即時刪除做即時失效邏輯">Case 2：以為 TTL 是即時刪除、做即時失效邏輯</h4>
<p>用 TTL 當「到點立刻不可用」的開關（如優惠券到期）、實際過期後幾小時還能用。修法：即時失效靠 application 邏輯比對時間、TTL 只負責 <em>清理 storage</em>、兩者分開。</p>
<h4 id="case-3報表--對帳撈到過期未刪-item">Case 3：報表 / 對帳撈到過期未刪 item</h4>
<p>聚合 query 沒過濾過期 item、把可讀窗口內的殘留資料算進去。修法：所有讀取路徑一致地過濾 <code>expireAt</code>；對帳查詢明確排除過期。</p>
<h4 id="case-4誤設過短保留期刪掉活躍資料">Case 4：誤設過短保留期刪掉活躍資料</h4>
<p>這個 case 跟前三個的失敗代價層級不同。前面的踩雷多半可回復 — storage 緩漲可回填、過期未刪可在讀取路徑加 filter、index 殘留會隨背景刪除自然消退。誤設過短保留期則是 <em>不可逆</em> 的：<code>expireAt</code> 計算 bug（少乘 86400、用錯時區基準）把保留期算成幾小時、背景程序把仍在使用的活躍資料當成過期 item 刪除、而 TTL 刪除不寫 undo log、刪掉就沒有從 DynamoDB 端救回的途徑、只能靠外部備份（PITR / 另存的 stream archive）回灌、且回灌期間資料缺口已經對線上服務造成影響。</p>
<p>代價的關鍵在於計算錯誤的爆炸半徑：一個錯誤常數會同時套用到所有新寫入 item、刪除是持續發生的背景行為、發現時往往已刪掉大批資料。修法的重心因此放在 <em>上線前驗證</em> 而非事後補救：上線前在 staging 用短週期資料驗證 <code>expireAt</code> 算出的絕對時間點符合預期、TTL 啟用初期把 <code>TimeToLiveDeletedItemCount</code> 跟預估刪除量對照、刪除量明顯偏高就立即停用 TTL 並排查計算、不等 storage 趨勢確認。對保留期敏感的 table 先開 PITR 當不可逆操作的最後防線。</p>
<h4 id="case-5過期-item-仍被-gsi-索引推高-index-成本">Case 5：過期 item 仍被 GSI 索引、推高 index 成本</h4>
<p>過期未刪 item 仍佔 GSI storage；大量過期堆積時 GSI 成本沒因「邏輯過期」下降。修法：理解 GSI 跟著 base item 生命週期、storage 降要等實際刪除；對成本敏感的 sparse index 設計可讓過期 item 不進 GSI（對應 <a href="/blog/backend/01-database/vendors/dynamodb/gsi-lsi-design/" data-link-title="DynamoDB GSI 與 LSI 設計：access pattern 補位、projection、consistency 跟 DAX 補位" data-link-desc="GSI / LSI 是 single-table 沒覆蓋的 access pattern 補位、不是萬靈丹；本文涵蓋 projection 三型選擇、sparse index、GSI 自己會 hot partition、DAX 讀峰值補位的觸發條件（含 Capcom 是 derive vs Lemino 是 case fact 的分層）">gsi-lsi-design sparse index</a>）。</p>
<p><strong>Anti-recommendation</strong>：資料量小、storage 成本可忽略、或刪除需要審批/合規記錄 → 不必用 TTL；手動或排程刪除更可控。TTL 的價值在「大量有自然過期時間的資料、要低成本自動清理」（如 PayPay 式每日上億訊息）。</p>
<h2 id="容量與觀測">容量與觀測</h2>
<p>CloudWatch metric：</p>
<ul>
<li><code>TimeToLiveDeletedItemCount</code>：TTL 背景刪除的 item 數、確認 TTL 真的在運作</li>
<li>table <code>ItemCount</code> / storage size：長期趨勢、確認過期清理讓 storage 趨於穩態</li>
<li>過期未刪比例：自行用 <code>expireAt &lt; now</code> 的 item 數估算可讀窗口殘留量</li>
</ul>
<p><strong>判讀</strong>：</p>
<ul>
<li><code>TimeToLiveDeletedItemCount</code> 為零但有設過期資料 → TTL 沒生效（attribute 名稱錯 / 值格式錯）</li>
<li>storage 持續上漲且 TTL 刪除量遠小於寫入量 → 保留期設太長、或寫入遠超過期速度、要重估保留策略</li>
<li>大量過期未刪堆積 → 背景刪除跟不上寫入、storage 成本被殘留拉高</li>
</ul>
<blockquote>
<p><strong>Scope warning</strong>：<code>9.C26 PayPay</code> 的「3 億/天 × 30 天 = 90 億筆」是 PayPay case 文章（9.C26）的策略段推算、非 PayPay 官方揭露的精確 item 數；引用時當量級壓力 anchor、不當精確數字。</p></blockquote>
<p>接回 <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/01-database/kv-document-capacity-planning/" data-link-title="1.10 KV / Document DB 容量規劃" data-link-desc="DynamoDB / Cosmos DB / Bigtable / MongoDB 等 KV / Document DB 的容量設計、partition key 取捨、capacity mode 選擇">1.10 KV / Document DB 容量規劃</a>。</p>
<h2 id="邊界與整合">邊界與整合</h2>
<h3 id="ttl-vs-cache-ttl-vs-合規保留">TTL vs cache TTL vs 合規保留</h3>
<p>「TTL」這個詞在不同層意義不同、不要混用：</p>
<ul>
<li><strong>DynamoDB TTL</strong>：主資料的生命週期管理、最終刪除、本篇主寫</li>
<li><strong>cache TTL</strong>（如 DAX item / query cache、Redis TTL）：快取副本的新鮮度邊界、過期是「重新回源」不是「刪除主資料」、主寫於 <a href="/blog/backend/02-cache-redis/" data-link-title="模組二：快取與 Redis" data-link-desc="整理快取策略、Redis 資料型別與分散式狀態輔助能力">02 快取模組</a> 與 <a href="/blog/backend/01-database/vendors/dynamodb/dax-caching-strategy/" data-link-title="DynamoDB DAX 快取策略：cluster 架構、item/query cache、write-through 與 invalidation 邊界" data-link-desc="DAX 不是「加上去就變快」的開關；本文展開 DAX cluster 架構、item cache vs query cache 兩種快取、write-through 一致性語意、query cache 只靠 TTL 失效的陷阱，以及 strongly consistent read 繞過 cache 的邊界，含 Lemino 讀峰值補位 case fact 與 gsi-lsi-design 的 SSoT 切分">dax-caching-strategy</a></li>
<li><strong>合規保留期</strong>：法規要求的最短/最長保存、可用 TTL 實作到期清理、但刪除前的稽核記錄要另外保留（對應 <a href="/blog/backend/07-security-data-protection/audit-trail-and-accountability-boundary/" data-link-title="7.7 稽核追蹤與責任邊界" data-link-desc="以問題驅動方式整理高風險操作追蹤、可回查與責任切分">7.7 audit trail</a>）</li>
</ul>
<h3 id="sibling-與-cross-link">Sibling 與 cross-link</h3>
<ul>
<li><a href="/blog/backend/01-database/vendors/dynamodb/streams-lambda-event-driven/" data-link-title="DynamoDB Streams 與 Lambda 事件驅動：CDC、shard 順序保證、消費模式與失敗處理" data-link-desc="DynamoDB Streams 不是免費的可靠事件流；本文展開 stream record 的四種 view type、shard 對應 partition 的順序保證邊界、Lambda event source mapping vs Kinesis 消費模式、at-least-once 下游冪等需求，以及 batch 失敗時的 bisect / DLQ 處理">streams-lambda-event-driven</a> — TTL 刪除觸發 stream REMOVE record、用 userIdentity 辨識、可做過期歸檔</li>
<li><a href="/blog/backend/01-database/vendors/dynamodb/single-table-design-pattern/" data-link-title="DynamoDB Single-Table Design：從適用度前置判讀到 access pattern 反推 PK/SK" data-link-desc="DynamoDB single-table 設計不是「資料表越少越好」，而是 access pattern 反推 PK/SK 跟 GSI；本文先做 DynamoDB 適用度 4 軸前置判讀（PK 天然均勻 / control plane vs data plane / consistency / access pattern 穩定），再展開設計流程、failure modes 與 durable queue 正向用例">single-table-design-pattern</a> — single-table 下不同 entity 用不同 expireAt 保留期</li>
<li><a href="/blog/backend/01-database/vendors/dynamodb/gsi-lsi-design/" data-link-title="DynamoDB GSI 與 LSI 設計：access pattern 補位、projection、consistency 跟 DAX 補位" data-link-desc="GSI / LSI 是 single-table 沒覆蓋的 access pattern 補位、不是萬靈丹；本文涵蓋 projection 三型選擇、sparse index、GSI 自己會 hot partition、DAX 讀峰值補位的觸發條件（含 Capcom 是 derive vs Lemino 是 case fact 的分層）">gsi-lsi-design</a> — 過期未刪 item 仍佔 GSI、sparse index 可讓過期不進 GSI</li>
<li><a href="/blog/backend/01-database/vendors/dynamodb/on-demand-vs-provisioned/" data-link-title="DynamoDB On-Demand vs Provisioned：6 軸決策、auto-scaling 邊界與 cost crossover" data-link-desc="capacity mode 選擇不是單軸 peak/avg ratio；本文展開 6 軸決策（peak/avg / 讀寫比 trend / surge 暫時 vs 永久 baseline / predictable-peak vs flash-sale / DBA 工時釋放 / vendor vs 自管 cost crossover），含 Zomato 50% 成本下降、Zoom 30x permanent surge、Amazon Ads sustained workload 等 case 分軸 anchor">on-demand-vs-provisioned</a> — TTL 刪除免 WCU、不影響寫容量規劃、但 storage 成本要靠 TTL 控制</li>
<li>替代路由：快取副本新鮮度 → <a href="/blog/backend/02-cache-redis/" data-link-title="模組二：快取與 Redis" data-link-desc="整理快取策略、Redis 資料型別與分散式狀態輔助能力">02 快取模組</a>；合規稽核 → <a href="/blog/backend/07-security-data-protection/audit-trail-and-accountability-boundary/" data-link-title="7.7 稽核追蹤與責任邊界" data-link-desc="以問題驅動方式整理高風險操作追蹤、可回查與責任切分">7.7 audit trail</a></li>
<li>跟 <a href="/blog/backend/09-performance-capacity/cases/paypay-mobile-payment-messaging/" data-link-title="9.C26 PayPay：行動支付每日 3 億訊息的 DynamoDB 後端" data-link-desc="日本最大行動支付 PayPay 每日 3 億訊息、用 DynamoDB 處理通知與訊息功能、支撐次秒級反應">PayPay 9.C26</a> 互引：每日上億訊息用 TTL 自動清理避免 storage 爆炸的 case anchor</li>
</ul>
]]></content:encoded></item><item><title>Aurora Cross-AZ Failover：RTO 量測、endpoint routing 與 application reconnect 契約</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/aurora/cross-az-failover-rto/</link><pubDate>Wed, 27 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/aurora/cross-az-failover-rto/</guid><description>&lt;p>Aurora cross-AZ failover 的 RTO 文件數字是「&amp;lt; 30 秒」、但 application 端實測常常看到 60-120 秒 — 這個落差不是 Aurora 慢、是 &lt;em>DNS cache + connection pool + retry policy&lt;/em> 的對齊問題。本文展開 failover lifecycle 三段（detection / promotion / DNS update）、application 端 reconnect 契約、量測真實 RTO 的流程、跟 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/standard-chartered-aurora-banking/" data-link-title="9.C14 Standard Chartered：受監管銀行的 Aurora 4000 TPS 容量提升" data-link-desc="Standard Chartered 銀行遷移到 Aurora 後吞吐量提升 10 倍至 4000 TPS、跨 7 個受監管市場">9.C14 Standard Chartered&lt;/a> 受監管銀行業務為什麼選獨立 cluster 而非 Global Database failover 的合規 driver。&lt;/p>
&lt;p>本文不是 Aurora overview（請看 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/aurora/" data-link-title="AWS Aurora" data-link-desc="AWS managed PostgreSQL / MySQL、storage / compute 分離、&amp;#43;75% 效能改善的 production 證據">Aurora vendor 頁&lt;/a>）— 而是 failover 流程的實作層教學。前置閱讀建議 &lt;a href="../storage-architecture/">Aurora storage architecture&lt;/a>（理解為什麼 Aurora failover 不需要 data catch-up）。&lt;/p>
&lt;h2 id="問題情境">問題情境&lt;/h2>
&lt;p>典型觸發場景：DraftKings / Standard Chartered 等級的金融交易服務、AZ-level outage 期間用戶操作不能斷、RTO 預算 &amp;lt; 60 秒、但 application 端看到的 reconnect 行為跟 AWS 文件不一致。&lt;/p>
&lt;p>讀者常見的具體疑問：&lt;/p>
&lt;ul>
&lt;li>「Failover trigger 後新 connection 還連到舊 primary、為什麼？」&lt;/li>
&lt;li>「Writer endpoint DNS 切換了、application 還沒重連、什麼時候會切？」&lt;/li>
&lt;li>「Failover 期間 in-flight transaction 是全 abort 還是部分 commit？」&lt;/li>
&lt;li>「我手動測 failover RTO 量出 90 秒、AWS 文件講 &amp;lt; 30 秒、誰錯？」&lt;/li>
&lt;/ul>
&lt;p>進一步問題：失敗模式分布在 &lt;em>application 端的 connection state&lt;/em>、不只是 Aurora 端的 promotion 流程。Aurora 端的 promotion 在 storage 共享下確實 &amp;lt; 30 秒（不需要等 data catch-up）、但 application reconnect 受 JVM DNS cache、connection pool validation、retry policy 影響、容易把總體 RTO 拉長到 2-3 倍。&lt;/p>
&lt;p>對 Standard Chartered 這種受監管銀行業務、failover 還有合規維度：受監管市場資料 &lt;em>不能跨境複製&lt;/em>、Global Database 在這種場景違反合規、必須用每市場獨立 cluster 的 cross-AZ failover 吸收 RTO 預算。這個 driver 跟一般工程「跨 region failover 更好」的直覺相反。&lt;/p>
&lt;h2 id="核心機制failover-lifecycle-三段">核心機制：failover lifecycle 三段&lt;/h2>
&lt;p>Aurora cross-AZ failover 的 first-class concept 是 &lt;em>failover lifecycle 三段&lt;/em>：detection → promotion → DNS update。每一段有自己的 SLA 跟可調維度。&lt;/p></description><content:encoded><![CDATA[<p>Aurora cross-AZ failover 的 RTO 文件數字是「&lt; 30 秒」、但 application 端實測常常看到 60-120 秒 — 這個落差不是 Aurora 慢、是 <em>DNS cache + connection pool + retry policy</em> 的對齊問題。本文展開 failover lifecycle 三段（detection / promotion / DNS update）、application 端 reconnect 契約、量測真實 RTO 的流程、跟 <a href="/blog/backend/09-performance-capacity/cases/standard-chartered-aurora-banking/" data-link-title="9.C14 Standard Chartered：受監管銀行的 Aurora 4000 TPS 容量提升" data-link-desc="Standard Chartered 銀行遷移到 Aurora 後吞吐量提升 10 倍至 4000 TPS、跨 7 個受監管市場">9.C14 Standard Chartered</a> 受監管銀行業務為什麼選獨立 cluster 而非 Global Database failover 的合規 driver。</p>
<p>本文不是 Aurora overview（請看 <a href="/blog/backend/01-database/vendors/aurora/" data-link-title="AWS Aurora" data-link-desc="AWS managed PostgreSQL / MySQL、storage / compute 分離、&#43;75% 效能改善的 production 證據">Aurora vendor 頁</a>）— 而是 failover 流程的實作層教學。前置閱讀建議 <a href="../storage-architecture/">Aurora storage architecture</a>（理解為什麼 Aurora failover 不需要 data catch-up）。</p>
<h2 id="問題情境">問題情境</h2>
<p>典型觸發場景：DraftKings / Standard Chartered 等級的金融交易服務、AZ-level outage 期間用戶操作不能斷、RTO 預算 &lt; 60 秒、但 application 端看到的 reconnect 行為跟 AWS 文件不一致。</p>
<p>讀者常見的具體疑問：</p>
<ul>
<li>「Failover trigger 後新 connection 還連到舊 primary、為什麼？」</li>
<li>「Writer endpoint DNS 切換了、application 還沒重連、什麼時候會切？」</li>
<li>「Failover 期間 in-flight transaction 是全 abort 還是部分 commit？」</li>
<li>「我手動測 failover RTO 量出 90 秒、AWS 文件講 &lt; 30 秒、誰錯？」</li>
</ul>
<p>進一步問題：失敗模式分布在 <em>application 端的 connection state</em>、不只是 Aurora 端的 promotion 流程。Aurora 端的 promotion 在 storage 共享下確實 &lt; 30 秒（不需要等 data catch-up）、但 application reconnect 受 JVM DNS cache、connection pool validation、retry policy 影響、容易把總體 RTO 拉長到 2-3 倍。</p>
<p>對 Standard Chartered 這種受監管銀行業務、failover 還有合規維度：受監管市場資料 <em>不能跨境複製</em>、Global Database 在這種場景違反合規、必須用每市場獨立 cluster 的 cross-AZ failover 吸收 RTO 預算。這個 driver 跟一般工程「跨 region failover 更好」的直覺相反。</p>
<h2 id="核心機制failover-lifecycle-三段">核心機制：failover lifecycle 三段</h2>
<p>Aurora cross-AZ failover 的 first-class concept 是 <em>failover lifecycle 三段</em>：detection → promotion → DNS update。每一段有自己的 SLA 跟可調維度。</p>
<p><strong>Detection（10-15 秒）</strong>：</p>
<ul>
<li>AWS 內部 health check 每幾秒檢查 primary writer health</li>
<li>連續失敗到一定閾值才 trigger failover（避免 false positive）</li>
<li>讀者無法直接調 detection 閾值、是 AWS managed</li>
</ul>
<p><strong>Promotion（&lt; 5 秒）</strong>：</p>
<ul>
<li>選 PromotionTier 最低的 read replica 升 primary</li>
<li>Storage 跨 AZ 共享、replica 升 primary <em>不需要 data catch-up</em>（vs 傳統 PostgreSQL streaming replication 要等 WAL apply）</li>
<li>Promotion 本身極快、是 Aurora storage 設計的直接受益</li>
</ul>
<p><strong>DNS update（5-15 秒）</strong>：</p>
<ul>
<li>Cluster endpoint / writer endpoint DNS 切到新 primary</li>
<li>Aurora endpoint DNS TTL 是 5 秒、AWS DNS infrastructure 通常 5-15 秒 propagate 完</li>
<li>但 application 端的 DNS cache 可能 cache 更久 — JVM <code>networkaddress.cache.ttl</code> 預設 -1（cache forever）就會卡在這層</li>
</ul>
<p><strong>Endpoint 類型跟 failover 行為</strong>：</p>
<ul>
<li><strong>Writer endpoint</strong>：跟著 failover 走、DNS 切到新 primary、application 寫操作用這個</li>
<li><strong>Reader endpoint</strong>：load-balance 到所有 replica；failover 期間短暫包含 promoted replica（已升 primary）、reader query 可能打到 primary、引起寫鎖競爭</li>
<li><strong>Custom endpoint</strong>：用戶自定 routing rule、failover 期間行為要驗證、不能假設自動跟隨</li>
</ul>
<p><strong>跟通用 failover 差在哪</strong>：Aurora 不需要 data catch-up phase、failover 主要瓶頸是 DNS propagation + application reconnect、不是 promotion 本身。傳統 PostgreSQL streaming replication failover 要等 replica WAL catch-up（heavy write 期間可能秒級延遲）、Aurora 在 storage 設計下消除這段等待。</p>
<p>對應 knowledge card：<a href="/blog/backend/knowledge-cards/failover/" data-link-title="Failover" data-link-desc="說明主要服務或節點失效時如何切換到備援能力">failover</a>、<a href="/blog/backend/knowledge-cards/rto/" data-link-title="RTO" data-link-desc="說明恢復時間目標如何約束事故回復策略">rto</a>、<a href="/blog/backend/knowledge-cards/rpo/" data-link-title="RPO" data-link-desc="說明恢復點目標如何定義可接受資料損失範圍">rpo</a>。</p>
<h2 id="step-by-step-配置--量測">Step-by-step 配置 / 量測</h2>
<p><strong>Cluster failover 配置</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 確認 cluster 至少有一個跨 AZ replica</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">aws rds describe-db-clusters <span class="se">\
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="se"></span>  --db-cluster-identifier my-cluster <span class="se">\
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="se"></span>  --query <span class="s1">&#39;DBClusters[0].DBClusterMembers&#39;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"># 設定 PromotionTier（0 最優先、15 最不優先）</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">aws rds modify-db-instance <span class="se">\
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="se"></span>  --db-instance-identifier my-replica-az-b <span class="se">\
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="se"></span>  --promotion-tier <span class="m">0</span>
</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 class="c1"># 跨 region replica 預設 tier 15（不優先升、避免 failover 跨 region）</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">aws rds modify-db-instance <span class="se">\
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="se"></span>  --db-instance-identifier my-cross-region-replica <span class="se">\
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="se"></span>  --promotion-tier <span class="m">15</span></span></span></code></pre></div><p><strong>Application 端 JVM 設定</strong>（最常踩雷的點）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-properties" data-lang="properties"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># JVM 系統 property、預設 -1 = cache forever、必改</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="na">networkaddress.cache.ttl</span><span class="o">=</span><span class="s">5</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="na">networkaddress.cache.negative.ttl</span><span class="o">=</span><span class="s">0</span></span></span></code></pre></div><p><strong>Connection pool 設定</strong>（HikariCP 範例）：</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">spring.datasource.hikari</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">maximum-pool-size</span><span class="p">:</span><span class="w"> </span><span class="m">30</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">connection-test-query</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;SELECT 1&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w">  </span><span class="nt">validation-timeout</span><span class="p">:</span><span class="w"> </span><span class="m">5000</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">max-lifetime</span><span class="p">:</span><span class="w"> </span><span class="m">1800000</span><span class="w">      </span><span class="c"># 30 分鐘、強制 recycle connection</span><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w">  </span><span class="nt">keepalive-time</span><span class="p">:</span><span class="w"> </span><span class="m">30000</span><span class="w">      </span><span class="c"># 30 秒檢查 idle connection</span><span class="w">
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="w">  </span><span class="nt">connection-timeout</span><span class="p">:</span><span class="w"> </span><span class="m">30000</span></span></span></code></pre></div><p><strong>Retry policy</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// 簡化範例、實際用 Resilience4j 或 Failsafe</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">RetryPolicy</span><span class="o">&lt;</span><span class="n">Object</span><span class="o">&gt;</span><span class="w"> </span><span class="n">retryPolicy</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">RetryPolicy</span><span class="p">.</span><span class="na">builder</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="p">.</span><span class="na">handle</span><span class="p">(</span><span class="n">SQLTransientConnectionException</span><span class="p">.</span><span class="na">class</span><span class="p">,</span><span class="w"> </span><span class="n">SQLNonTransientConnectionException</span><span class="p">.</span><span class="na">class</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="p">.</span><span class="na">withBackoff</span><span class="p">(</span><span class="n">Duration</span><span class="p">.</span><span class="na">ofSeconds</span><span class="p">(</span><span class="n">1</span><span class="p">),</span><span class="w"> </span><span class="n">Duration</span><span class="p">.</span><span class="na">ofSeconds</span><span class="p">(</span><span class="n">30</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="p">.</span><span class="na">withMaxAttempts</span><span class="p">(</span><span class="n">5</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="na">build</span><span class="p">();</span></span></span></code></pre></div><p><strong>手動觸發 failover 量測 RTO</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 觸發 failover、記錄時間</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nv">START</span><span class="o">=</span><span class="k">$(</span>date +%s%3N<span class="k">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">aws rds failover-db-cluster --db-cluster-identifier my-cluster
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;Failover triggered at </span><span class="nv">$START</span><span class="s2"> ms&#34;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"># 用 application heartbeat 寫入時間戳</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"># application 端跑 every-second insert、failover 後第一個成功 insert 的時間 - START = RTO</span></span></span></code></pre></div><p><strong>驗證點</strong>：</p>
<ul>
<li>CloudWatch <code>FailoverEvent</code> counter &gt; 0（failover 觸發訊號）</li>
<li><code>DatabaseConnections</code> 在 failover 期間 drop &gt; 50%、之後 spike（reconnect 風暴）</li>
<li>Application metric「first successful write after failover trigger」&lt; 30 秒</li>
</ul>
<p><strong>Rollback boundary</strong>：promotion 不可逆 — 原 primary 變 replica、不會自動 fallback。要切回原 AZ 必須再做一次 failover。</p>
<h2 id="故障模式--邊界-case">故障模式 / 邊界 case</h2>
<h3 id="case-1dns-cache-把-rto-從-30-秒拉到-120-秒">Case 1：DNS cache 把 RTO 從 30 秒拉到 120 秒</h3>
<p>徵兆：手動 failover 後、CloudWatch <code>FailoverEvent</code> 1 秒內出現、但 application log 顯示寫操作 120 秒後才恢復。</p>
<p>原因：JVM <code>networkaddress.cache.ttl</code> 預設 <code>-1</code>（cache forever）、application JVM 把 writer endpoint DNS 永久 cache 到舊 primary IP；只有 connection pool eviction 或 application restart 才會重新 resolve。</p>
<p>修：</p>
<ul>
<li>JVM startup 加 <code>-Dnetworkaddress.cache.ttl=5</code></li>
<li>或在 <code>$JAVA_HOME/lib/security/java.security</code> 改 <code>networkaddress.cache.ttl=5</code></li>
<li>Python application 通常沒這問題（DNS resolve per connection）、但要確認 SQLAlchemy 用 <code>pool_pre_ping=True</code></li>
</ul>
<h3 id="case-2connection-pool-cached-connection-全-stale">Case 2：Connection pool cached connection 全 stale</h3>
<p>徵兆：DNS 切換 OK、但 application 寫操作 timeout 10-30 秒後才觸發 reconnect、p99 latency spike。</p>
<p>原因：connection pool 的 cached connection 還指向舊 primary IP、validation 沒開或 timeout 太長、application 拿到 stale connection 才發現 backend gone。</p>
<p>修：</p>
<ul>
<li>HikariCP：<code>connection-test-query: &quot;SELECT 1&quot;</code> + <code>validation-timeout: 5000</code> + <code>keepalive-time: 30000</code></li>
<li>SQLAlchemy：<code>pool_pre_ping=True</code> + <code>pool_recycle=1800</code></li>
<li>failover 演練後驗證 connection pool 在 30 秒內 evict 完所有 stale connection</li>
</ul>
<h3 id="case-3reader-endpoint-failover-期間打到新-primary">Case 3：Reader endpoint failover 期間打到新 primary</h3>
<p>徵兆：failover 期間 application read query 偶發出現 <code>cannot execute SELECT in a read-only transaction</code> 或寫鎖競爭、用戶看到 inconsistent state。</p>
<p>原因：reader endpoint 是 DNS-based load balance 到所有 replica、failover 期間 <em>短暫</em> 包含已升 primary 的 replica（DNS propagation 期間 reader 跟 writer endpoint 都指向同一台）。Read query 打到 primary 後、跟正在寫的 transaction 競爭。</p>
<p>修：</p>
<ul>
<li>Application 端 read 跟 write data source 拆分、不要假設 reader endpoint 永遠 read-only</li>
<li>Failover 期間 application 端做 SQL error type 偵測、<code>read-only transaction</code> 錯誤觸發 retry</li>
<li>用 custom endpoint group 特定 replica、failover 期間 custom endpoint 行為更可控</li>
</ul>
<h3 id="case-4in-flight-transaction-全-abort">Case 4：In-flight transaction 全 abort</h3>
<p>徵兆：failover 期間正在執行的 transaction <em>全部 abort</em>、application 看到 <code>connection reset</code> 或 <code>server closed connection</code>、commit 沒成功。</p>
<p>原因：Aurora failover 不保留 transaction 狀態、所有 in-flight transaction（包括已執行 BEGIN 但還沒 COMMIT 的）全 abort。Application 沒做 idempotent retry 就會丟失 commit。</p>
<p>修：</p>
<ul>
<li>寫操作必須 idempotent（用 idempotency key、application 端做 deduplication）</li>
<li>在 application 層做 transaction-level retry、不在 connection 層 retry</li>
<li>重要寫入做 <em>write-then-verify</em> 模式：commit 後立刻 SELECT 確認、失敗才 retry</li>
</ul>
<h3 id="case-5promotiontier-配置忽略">Case 5：PromotionTier 配置忽略</h3>
<p>徵兆：failover 後 application latency 暴漲、發現升 primary 的是 cross-region replica。</p>
<p>原因：cross-region replica 預設 PromotionTier 是 1（或忘記改）、failover 時優先升、application 跟新 primary 跨 region、latency 從 5ms 變 100ms+。</p>
<p>修：</p>
<ul>
<li>cross-region replica <code>--promotion-tier 15</code>（不優先升）</li>
<li>同 region 跨 AZ replica <code>--promotion-tier 0</code> 或 <code>1</code></li>
<li>Multi-AZ deployment 至少配 2 個 same-region replica、避免 cross-region 被升</li>
</ul>
<h2 id="standard-chartered-為什麼選獨立-cluster-而非-global-database">Standard Chartered 為什麼選獨立 cluster 而非 Global Database</h2>
<p><a href="/blog/backend/09-performance-capacity/cases/standard-chartered-aurora-banking/" data-link-title="9.C14 Standard Chartered：受監管銀行的 Aurora 4000 TPS 容量提升" data-link-desc="Standard Chartered 銀行遷移到 Aurora 後吞吐量提升 10 倍至 4000 TPS、跨 7 個受監管市場">9.C14 Standard Chartered</a> 揭露受監管產業的 failover 設計選擇 — 案例「判讀」段第 1 點：「7 個受監管市場代表 7 個獨立 cluster（資料不能跨境）、容量規劃變成『7 個獨立規劃 × 各自合規門檻』」。</p>
<p><strong>合規 driver</strong>：</p>
<ul>
<li>受監管市場資料 <em>不能跨境複製</em></li>
<li>Aurora Global Database 是跨 region async replication、會把資料推到其他 region</li>
<li>→ Global Database 在這種場景 <em>違反合規</em>、不是 DR 選項</li>
<li>必須用每市場獨立 cluster、各自做 cross-AZ failover、各自吸收 RTO 預算</li>
</ul>
<p><strong>工程含義</strong>：</p>
<ul>
<li>每市場 cross-AZ failover RTO &lt; 30 秒、滿足當地監管 RTO 要求</li>
<li>跨市場 DR 不靠 Global Database、靠應用層的 <em>市場切換</em>（用戶從 A 市場切到 B 市場是業務決策、不是技術 failover）</li>
<li>7 個 cluster 各自獨立、operational surface area × 7（parameter group / backup / IAM / observability fan-out）、但合規要求壓倒運維成本</li>
</ul>
<p><strong>Fleet 拓樸</strong>：合規驅動的 fleet 設計（7 個受監管市場 = 7 個獨立 cluster）詳見 <a href="../read-replica-scaling/">Aurora read replica scaling</a> fleet 治理 SSoT 邊界段。本篇只展開 <em>單 cluster cross-AZ failover</em> 流程、不展開跨 cluster 拓樸決策。</p>
<p><strong>scope warning（必明示、case 自承）</strong>：Standard Chartered case 未公開是 PostgreSQL 還是 MySQL、未公開具體 cost 數字、屬「相關 case study」匿名對照。引用時不能擴寫具體 engine。</p>
<h2 id="容量與觀測">容量與觀測</h2>
<p><strong>核心 metric</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">FailoverEvent           # failover 觸發 counter、&gt; 0 立即通知
</span></span><span class="line"><span class="ln">2</span><span class="cl">DatabaseConnections     # failover 期間 drop、之後 spike
</span></span><span class="line"><span class="ln">3</span><span class="cl">AuroraReplicaLag        # failover 前 replica 是否 caught up</span></span></code></pre></div><p><strong>Application 端 metric</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">first_successful_write_after_failover  # 真實 RTO
</span></span><span class="line"><span class="ln">2</span><span class="cl">connection_pool_error_rate              # stale connection 訊號
</span></span><span class="line"><span class="ln">3</span><span class="cl">db_retry_count                          # retry policy 觸發頻率</span></span></code></pre></div><p><strong>量測 RTO 流程</strong>：</p>
<ol>
<li>跑 application 端 every-second heartbeat insert</li>
<li>手動觸發 failover、記錄 trigger 時間戳</li>
<li>從 heartbeat insert log 找 failover 後第一個成功 insert 的時間戳</li>
<li>差值 = 真實 RTO（包含 detection + promotion + DNS + reconnect）</li>
</ol>
<p><strong>Alert</strong>：</p>
<ul>
<li><code>FailoverEvent &gt; 0</code> 立即通知 on-call</li>
<li><code>DatabaseConnections</code> 5 分鐘內 drop &gt; 50% 警告 stale connection</li>
<li><code>db_retry_count</code> 短期內 spike 警告 reconnect 風暴</li>
</ul>
<p><strong>Failover 演練頻率</strong>：</p>
<ul>
<li>Non-critical workload：每季一次 planned failover drill</li>
<li>受監管產業（Standard Chartered 類）：每月一次、有合規 sign-off 記錄</li>
<li>重大版本升級前必跑一次</li>
</ul>
<p><strong>回路徑</strong>：<a href="/blog/backend/08-incident-response/" data-link-title="模組八：事故處理與復盤" data-link-desc="用 IR 領域詞彙建問題節點、以服務級案例庫累積事故脈絡，先建概念與案例庫再進實作交接">8.x incident response</a> failover playbook、<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> 判斷 reconnect-bound vs query-bound。</p>
<h2 id="邊界與整合--下一步">邊界與整合 / 下一步</h2>
<p><strong>Sibling deep articles</strong>：</p>
<ul>
<li><a href="../storage-architecture/">Aurora storage architecture</a> — 理解為什麼 Aurora failover 不需要 data catch-up（storage 跨 AZ 共享）</li>
<li><a href="../read-replica-scaling/">Aurora read replica scaling</a> — replica 升 primary 流程跟 fleet 治理 SSoT</li>
<li><a href="../global-database-multi-region/">Aurora Global Database</a> — 跨 region failover RTO 不同數量級（2-15 分鐘 vs cross-AZ &lt; 30 秒）</li>
</ul>
<p><strong>Migration playbook</strong>：</p>
<ul>
<li><a href="../migrate-from-self-managed-pg-mysql/">PostgreSQL / MySQL → Aurora</a> — HA redesign 是 operational redesign 主項、從 Patroni / Orchestrator 切到 Aurora cluster endpoint</li>
</ul>
<p><strong>1.x 章節互引</strong>：</p>
<ul>
<li><a href="/blog/backend/01-database/transaction-boundary/" data-link-title="1.3 Transaction 與一致性邊界" data-link-desc="交易邊界、isolation level、retry 策略、distributed transaction（2PC、Saga）與跨 region 強一致取捨">1.3 Transaction Boundary</a> — failover 期間 in-flight transaction abort 對 application 契約的影響</li>
<li><a href="/blog/backend/08-incident-response/" data-link-title="模組八：事故處理與復盤" data-link-desc="用 IR 領域詞彙建問題節點、以服務級案例庫累積事故脈絡，先建概念與案例庫再進實作交接">8.x incident response</a> — failover decision log</li>
</ul>
<p><strong>何時不用本文</strong>：non-critical workload、RTO 預算 &gt; 5 分鐘、Multi-AZ 預設配置足夠時可跳過、看 <a href="/blog/backend/01-database/vendors/aurora/" data-link-title="AWS Aurora" data-link-desc="AWS managed PostgreSQL / MySQL、storage / compute 分離、&#43;75% 效能改善的 production 證據">Aurora vendor overview</a> 即可。</p>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/aurora/" data-link-title="AWS Aurora" data-link-desc="AWS managed PostgreSQL / MySQL、storage / compute 分離、&#43;75% 效能改善的 production 證據">Aurora vendor overview</a> — 服務定位、適用 / 不適用場景</li>
<li><a href="/blog/backend/knowledge-cards/failover/" data-link-title="Failover" data-link-desc="說明主要服務或節點失效時如何切換到備援能力">Failover 卡片</a> — 概念基底</li>
<li><a href="/blog/backend/knowledge-cards/rto/" data-link-title="RTO" data-link-desc="說明恢復時間目標如何約束事故回復策略">RTO 卡片</a> — RTO 量測判讀</li>
<li><a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章方法論</a> — 本文遵循的 6 規格面寫作模板</li>
<li>官方：<a href="https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/Concepts.AuroraHighAvailability.html">Aurora high availability</a></li>
</ul>
]]></content:encoded></item><item><title>CockroachDB Survival Goals：zone 級 vs region 級配置與業務 SLO 倒推流程</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/cockroachdb/survival-goals/</link><pubDate>Wed, 27 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/cockroachdb/survival-goals/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/cockroachdb/" data-link-title="CockroachDB" data-link-desc="分散式 SQL、PostgreSQL 相容、跨區強一致、Spanner 的開源 / 跨雲替代">CockroachDB vendor overview&lt;/a> 的 implementation-layer deep article。Overview 已界定 CockroachDB 的 multi-region 能力、本文聚焦 &lt;em>survival goal 配置怎麼從業務 SLO 倒推、怎麼避開「cross-region = 更快」的動機誤判&lt;/em>。Raft replica 分佈機制屬前置、見 &lt;a href="../hlc-raft-consensus/">HLC + Raft consensus&lt;/a>。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="multi-region-上線前的兩個錯誤期待">Multi-region 上線前的兩個錯誤期待&lt;/h2>
&lt;p>multi-region CockroachDB cluster 上線時、團隊最常踩的兩個錯誤期待：&lt;/p>
&lt;ul>
&lt;li>&lt;em>「default 配置應該就好、上線後再說」&lt;/em>：default 是 &lt;code>SURVIVE ZONE FAILURE&lt;/code>、一旦遇到 region failure 整 cluster 變 read-only、客訴湧入才發現要重新配&lt;/li>
&lt;li>&lt;em>「跨 region 應該會讓全球用戶都更快」&lt;/em>：跨 region quorum 物理上必然 &lt;em>增&lt;/em> 寫入 latency、把 multi-region 動機誤判成 latency 優化會在 production 撞牆&lt;/li>
&lt;/ul>
&lt;p>讀者進來最常問：&lt;/p>
&lt;ul>
&lt;li>&lt;code>SURVIVE ZONE FAILURE&lt;/code> 跟 &lt;code>SURVIVE REGION FAILURE&lt;/code> 差在哪？&lt;/li>
&lt;li>為什麼 region survival 寫入 latency 是 zone survival 的 3 倍？&lt;/li>
&lt;li>Default 配置是什麼、上線前該不該改？&lt;/li>
&lt;/ul>
&lt;p>要回答這三題、必須先把 survival goal 跟業務 SLO 的對應關係講清楚。&lt;/p>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/hard-rock-digital-cockroachdb-sports-betting/" data-link-title="9.C41 Hard Rock Digital：CockroachDB on AWS Outposts、Wire Act 合規 &amp;#43; 跨州單一邏輯 DB" data-link-desc="Hard Rock Digital 用 CockroachDB 跨 AWS Outposts &amp;#43; US-East-1、Wire Act 強制資料留州、單一邏輯 DB 解多州 sportsbook、100 node 32 vCPU 撐 Super Bowl">9.C41 Hard Rock Digital&lt;/a> 提供最 concrete 的 SLO 倒推路徑：sportsbook 中 &lt;em>bet placement 不能 lose&lt;/em> — 玩家下注後系統 crash 沒紀錄、對博彩牌照是合規事故。CockroachDB Raft 3-replica + 跨 AZ + survival goal 配置是把這個業務不可丟事件翻譯成 DB 層保證。&lt;/p>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/netflix-cockroachdb-multi-region-fleet/" data-link-title="9.C40 Netflix：380&amp;#43; CockroachDB cluster 的 multi-active 拓樸艦隊" data-link-desc="Netflix 把 Cassandra 不夠用的 transactional workload 移到 CockroachDB、380&amp;#43; cluster / 60&amp;#43; 跨 region、含 Open Connect、studio cloud drive、gaming control plane">9.C40 Netflix&lt;/a> 則提供反直覺判讀：60+ multi-region cluster 主要動機是 &lt;em>region failure 0 downtime&lt;/em>、不是降 latency。Gaming cluster 48-node 跨 4 region 就是為了「region failover 不停服」、不是讓玩家延遲變低。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/cockroachdb/" data-link-title="CockroachDB" data-link-desc="分散式 SQL、PostgreSQL 相容、跨區強一致、Spanner 的開源 / 跨雲替代">CockroachDB vendor overview</a> 的 implementation-layer deep article。Overview 已界定 CockroachDB 的 multi-region 能力、本文聚焦 <em>survival goal 配置怎麼從業務 SLO 倒推、怎麼避開「cross-region = 更快」的動機誤判</em>。Raft replica 分佈機制屬前置、見 <a href="../hlc-raft-consensus/">HLC + Raft consensus</a>。</p></blockquote>
<hr>
<h2 id="multi-region-上線前的兩個錯誤期待">Multi-region 上線前的兩個錯誤期待</h2>
<p>multi-region CockroachDB cluster 上線時、團隊最常踩的兩個錯誤期待：</p>
<ul>
<li><em>「default 配置應該就好、上線後再說」</em>：default 是 <code>SURVIVE ZONE FAILURE</code>、一旦遇到 region failure 整 cluster 變 read-only、客訴湧入才發現要重新配</li>
<li><em>「跨 region 應該會讓全球用戶都更快」</em>：跨 region quorum 物理上必然 <em>增</em> 寫入 latency、把 multi-region 動機誤判成 latency 優化會在 production 撞牆</li>
</ul>
<p>讀者進來最常問：</p>
<ul>
<li><code>SURVIVE ZONE FAILURE</code> 跟 <code>SURVIVE REGION FAILURE</code> 差在哪？</li>
<li>為什麼 region survival 寫入 latency 是 zone survival 的 3 倍？</li>
<li>Default 配置是什麼、上線前該不該改？</li>
</ul>
<p>要回答這三題、必須先把 survival goal 跟業務 SLO 的對應關係講清楚。</p>
<p><a href="/blog/backend/09-performance-capacity/cases/hard-rock-digital-cockroachdb-sports-betting/" data-link-title="9.C41 Hard Rock Digital：CockroachDB on AWS Outposts、Wire Act 合規 &#43; 跨州單一邏輯 DB" data-link-desc="Hard Rock Digital 用 CockroachDB 跨 AWS Outposts &#43; US-East-1、Wire Act 強制資料留州、單一邏輯 DB 解多州 sportsbook、100 node 32 vCPU 撐 Super Bowl">9.C41 Hard Rock Digital</a> 提供最 concrete 的 SLO 倒推路徑：sportsbook 中 <em>bet placement 不能 lose</em> — 玩家下注後系統 crash 沒紀錄、對博彩牌照是合規事故。CockroachDB Raft 3-replica + 跨 AZ + survival goal 配置是把這個業務不可丟事件翻譯成 DB 層保證。</p>
<p><a href="/blog/backend/09-performance-capacity/cases/netflix-cockroachdb-multi-region-fleet/" data-link-title="9.C40 Netflix：380&#43; CockroachDB cluster 的 multi-active 拓樸艦隊" data-link-desc="Netflix 把 Cassandra 不夠用的 transactional workload 移到 CockroachDB、380&#43; cluster / 60&#43; 跨 region、含 Open Connect、studio cloud drive、gaming control plane">9.C40 Netflix</a> 則提供反直覺判讀：60+ multi-region cluster 主要動機是 <em>region failure 0 downtime</em>、不是降 latency。Gaming cluster 48-node 跨 4 region 就是為了「region failover 不停服」、不是讓玩家延遲變低。</p>
<p>對照 <a href="/blog/backend/09-performance-capacity/cases/standard-chartered-aurora-banking/" data-link-title="9.C14 Standard Chartered：受監管銀行的 Aurora 4000 TPS 容量提升" data-link-desc="Standard Chartered 銀行遷移到 Aurora 後吞吐量提升 10 倍至 4000 TPS、跨 7 個受監管市場">9.C14 Standard Chartered</a> 走另一條路：銀行受監管市場資料 <em>不能跨境</em>、不可用 region survival、必須拆每市場獨立 Aurora cluster + zone survival。這個 anti-recommendation 提醒「survival goal 不是越強越好、合規邊界優先於技術 HA 配置」。</p>
<h2 id="核心機制兩種-survival-goal--replica-placement">核心機制：兩種 survival goal + replica placement</h2>
<h3 id="兩種宣告式配置">兩種宣告式配置</h3>
<p>CockroachDB 把 HA 配置抽象成兩個 database-level（或 table-level）宣告：</p>
<ul>
<li><strong><code>SURVIVE ZONE FAILURE</code></strong>（default）：失去 1 個 AZ 仍能寫入。replica 跨 AZ 分佈、但可能集中在同一個 region 內。對應 RTO ~ 數秒（Raft + <a href="/blog/backend/knowledge-cards/leaseholder/" data-link-title="Leaseholder" data-link-desc="分散式 SQL 每個 range 在任一時間點的 read / write entry point、通常等於 Raft leader、承擔該 range 的 coordination">Leaseholder</a> 自動 failover）、RPO = 0（已 commit 資料不丟）</li>
<li><strong><code>SURVIVE REGION FAILURE</code></strong>：失去 1 個整個 region 仍能寫入。voting replica 強制跨 region、需要至少 3 個 region。對應 RTO ~ 數秒、RPO = 0、但寫入 latency 因跨 region quorum 結構性增加</li>
</ul>
<p>survival goal 是 <em>宣告式</em> 配置 — application 端不用手動指定 <a href="/blog/backend/knowledge-cards/range-sharding/" data-link-title="Range Sharding" data-link-desc="分散式 SQL 把 key space 切成可自動 split / merge 的 range、每個 range 自己的 consensus group、application 透明">Range Sharding</a> 的 replica placement、Raft 根據 survival goal + locality 自動分佈、用 <a href="/blog/backend/knowledge-cards/hybrid-logical-clock/" data-link-title="Hybrid Logical Clock" data-link-desc="用 physical wall clock &#43; monotonic logical counter 給每個事件 timestamp、靠軟體 max-offset 保證跨節點時鐘差不超過上限、超過 panic 保護一致性">Hybrid Logical Clock</a> 串接 commit ordering。對比通用 HA 設計（如 PostgreSQL streaming + Patroni manual failover）、CockroachDB 把這層邏輯壓進系統內。</p>
<h3 id="voting-vs-non-voting-replica">Voting vs non-voting replica</h3>
<p>region survival 模式下、CockroachDB 區分兩種 replica：</p>
<ul>
<li><strong>Voting replica</strong>：參與 Raft majority 決策、commit 必須等 voting majority ack。region survival 下 voting replica 強制跨 region — 這就是 <a href="/blog/backend/knowledge-cards/cross-region-quorum/" data-link-title="Cross-Region Quorum" data-link-desc="multi-region distributed SQL 強制 voting replica 跨 region、commit 等多 region quorum ack、跨洲 RTT 物理硬限">Cross-Region Quorum</a> 拓樸、commit latency 受跨洲 RTT 物理硬限主導</li>
<li><strong>Non-voting replica</strong>：只用來 serve <a href="/blog/backend/knowledge-cards/follower-read/" data-link-title="Follower Read" data-link-desc="分散式 SQL 從 non-voting replica 讀 closed timestamp 之前的資料、不參與 Raft commit、低 latency 但 read-after-write 場景仍可能 stale">Follower Read</a>、不參與 Raft commit。可以放在「不想列入 quorum 但希望本地 read 快」的 region</li>
</ul>
<p>實務影響：region survival 下、跨 3 region 配置最少 3 voting replica（每 region 1 個）、寫入要等其中 2 個 region 的 ack。若想讓第 4 個 region 也能本地 read、可以加 non-voting replica、不影響 commit latency 但增加 storage cost。</p>
<h3 id="配置語法">配置語法</h3>





<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">-- Database-level
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">ALTER</span><span class="w"> </span><span class="k">DATABASE</span><span class="w"> </span><span class="n">mydb</span><span class="w"> </span><span class="n">SURVIVE</span><span class="w"> </span><span class="n">REGION</span><span class="w"> </span><span class="n">FAILURE</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></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="c1">-- Table-level（覆蓋 database 設定）
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="k">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="n">SURVIVE</span><span class="w"> </span><span class="k">ZONE</span><span class="w"> </span><span class="n">FAILURE</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></span><span class="line"><span class="ln">7</span><span class="cl"><span class="w"></span><span class="c1">-- 驗證
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1"></span><span class="k">SHOW</span><span class="w"> </span><span class="n">SURVIVAL</span><span class="w"> </span><span class="n">GOAL</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="k">DATABASE</span><span class="w"> </span><span class="n">mydb</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">SHOW</span><span class="w"> </span><span class="k">ZONE</span><span class="w"> </span><span class="n">CONFIGURATION</span><span class="w"> </span><span class="k">FOR</span><span class="w"> </span><span class="k">DATABASE</span><span class="w"> </span><span class="n">mydb</span><span class="p">;</span></span></span></code></pre></div><p>對應 <a href="/blog/backend/knowledge-cards/quorum/" data-link-title="Quorum" data-link-desc="分散式系統以多數節點同意作為提交或讀取有效性的門檻">quorum 卡</a>、<a href="/blog/backend/knowledge-cards/rto/" data-link-title="RTO" data-link-desc="說明恢復時間目標如何約束事故回復策略">rto 卡</a>、<a href="/blog/backend/knowledge-cards/rpo/" data-link-title="RPO" data-link-desc="說明恢復點目標如何定義可接受資料損失範圍">rpo 卡</a>、<a href="/blog/backend/knowledge-cards/blast-radius/" data-link-title="Blast Radius" data-link-desc="說明事故影響面如何估算與隔離">blast radius 卡</a> 的具體機制實現。</p>
<h3 id="為什麼選-region-survival-是業務動機判讀不是技術-factf48">為什麼選 region survival 是業務動機判讀、不是技術 fact（F4.8）</h3>
<p>Netflix 60+ multi-region cluster 揭露的反直覺結論：<em>主要動機是 region failure 0 downtime、不是降 latency</em>。跨 region quorum 物理上必然增 latency — 跨洲 round trip 物理 ~70-80ms、Raft majority 需要 2 個 region ack、寫入 p99 因此被光速下界限制。</p>
<p>Gaming cluster 48-node 跨 4 region 就是為了「region failover 不停服」、不是讓玩家延遲變低。<strong>Scope warning</strong>：case 沒揭露 Gaming cluster 具體 p99 數字、只揭露「48-node、跨 4 region、region failure 不停服」這個拓樸 fact 跟業務動機釐清。</p>
<p>引用時若提到「region survival 怎麼提升用戶體驗」、要 <em>釐清成 survival、不是 latency 優化</em>。讓讀者誤把跨 region 當成 latency 解法、是這條決策最常見的源頭錯誤。</p>
<h2 id="操作流程從業務-slo-倒推-survival-goal">操作流程：從業務 SLO 倒推 survival goal</h2>
<h3 id="配置前置">配置前置</h3>
<p>region survival 的最小可運行配置：</p>
<ul>
<li>cluster 至少 3 個 region</li>
<li>每 region 至少 3 個節點（保證單一 region 內也能扛 AZ failure）</li>
<li>locality tag 配齊（region + zone）</li>
</ul>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># Region us-east1 的節點</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">cockroach start --locality<span class="o">=</span><span class="nv">region</span><span class="o">=</span>us-east1,zone<span class="o">=</span>us-east1-a ...
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># Region us-west2 的節點</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">cockroach start --locality<span class="o">=</span><span class="nv">region</span><span class="o">=</span>us-west2,zone<span class="o">=</span>us-west2-a ...
</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 class="c1"># Region eu-west1 的節點</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">cockroach start --locality<span class="o">=</span><span class="nv">region</span><span class="o">=</span>eu-west1,zone<span class="o">=</span>eu-west1-a ...</span></span></code></pre></div><h3 id="從業務-slo-倒推9c41-hard-rock-揭露f411">從業務 SLO 倒推（9.C41 Hard Rock 揭露、F4.11）</h3>
<p>Hard Rock Digital sportsbook 揭露的 5 步倒推流程：</p>
<ol>
<li><strong>列業務「不能丟」事件清單</strong>：bet placement、payment、order commit、settlement 等業務事件</li>
<li><strong>對每個事件決定 RPO</strong>：bet placement → RPO = 0（不可丟）、log audit → RPO = 1 分鐘（可接受 short-window 丟失）</li>
<li><strong>對 RPO = 0 事件決定故障域容忍</strong>：Hard Rock 案例 <em>Outpost 或 AZ 失敗不丟</em> 是業務要求、跨 region failure 不是 sportsbook 的硬需求（因為各州各自合規邊界）</li>
<li><strong>故障域容忍翻譯成 survival goal</strong>：
<ul>
<li>Outpost / AZ 失敗 → <code>SURVIVE ZONE FAILURE</code> 即可</li>
<li>region 失敗也不丟 → <code>SURVIVE REGION FAILURE</code></li>
</ul>
</li>
<li><strong>反過來驗 replica 分佈</strong>：survival goal 配置產出的 replica 分佈是否覆蓋業務故障域。Hard Rock CockroachDB Raft 3-replica + 跨 AZ → Outpost 失敗時其他 replica 在、自動 failover、滿足 bet placement RPO = 0</li>
</ol>
<h3 id="跟業務動機釐清的互補">跟業務動機釐清的互補</h3>
<p>Netflix 從技術配置 <em>反推</em>「為什麼選 region survival」（survival 動機、不是 latency）、Hard Rock 從業務不能丟事件 <em>正推</em> 該選哪個 survival goal。兩個方向是同一條路徑：</p>
<ul>
<li>正推（Hard Rock）：業務不能丟 → RPO → 故障域 → survival goal</li>
<li>反推（Netflix）：survival goal 配置 → 揭露的不是「會變快」而是「region failover 不停服」</li>
</ul>
<p>兩個方向互相驗證、避免把跨 region 配置誤解成 latency 工具。</p>
<h3 id="升級流程跟-rollback-邊界">升級流程跟 rollback 邊界</h3>
<p>zone survival → region survival 是 <em>非破壞性</em> 配置變更、Raft 自動 rebalance replica。但要注意：</p>
<ul>
<li>rebalance 期間 cross-region traffic 暴增、p99 短期波動</li>
<li>replication factor 增加 → storage 用量 × 新 RF</li>
<li>升級後 application 寫入 latency 結構性上升、要先在 staging 量過</li>
</ul>
<p>監控 rebalance：</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">-- 看 range 數量變化跟 rebalance queue
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">range_count</span><span class="p">,</span><span class="w"> </span><span class="n">used</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">crdb_internal</span><span class="p">.</span><span class="n">kv_store_status</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></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="c1">-- CockroachDB Console「Rebalance queue size」應該歸零</span></span></span></code></pre></div><p>Rollback：survival goal 可即時降級（region → zone）、replica 自動 rebalance、無不可逆動作。但 application 端如果已經依賴 region failover 0 downtime、降級回 zone survival 後 region failure 會讓 cluster 變 read-only — 配置 rollback 容易、業務 SLO rollback 不容易。</p>
<h2 id="失敗模式5-種典型錯配">失敗模式：5 種典型錯配</h2>
<h3 id="default-zone-survival-期待-region-survival">Default zone survival 期待 region survival</h3>
<p>最常見：上線後一個 region 掛、cluster 變 read-only、客訴。要在 production 前 <em>明確選</em> survival goal、不依賴 default。</p>
<h3 id="region-survival-但只配-2-region">Region survival 但只配 2 region</h3>
<p>Raft majority 需要 3 個獨立 fault domain。2 region 配置實際是 zone survival — 任一 region 失敗剩 1 region 拿不到 majority。要 region survival <em>至少</em> 3 region。</p>
<h3 id="cross-region-cost-暴漲">Cross-region cost 暴漲</h3>
<p>region survival 強制 voting replica 跨 region、每次 write 跨 region traffic × 3。AWS / GCP 的 cross-region data transfer 是高 markup、月費可能 2-3 倍。</p>
<p>production 前必須估：</p>
<ul>
<li>寫 QPS × row size × 3 = cross-region traffic GB/day</li>
<li>對應 cloud provider 定價（AWS 跨 region $0.02/GB、GCP 類似量級）</li>
<li>月度 traffic cost 加總、跟 single-region 配置比</li>
</ul>
<h3 id="locality-跟-survival-goal-衝突">Locality 跟 survival goal 衝突</h3>
<p>業務想把 user data partition by region 留 local（locality 配置）、但 survival goal 要求跨 region replica、結果 replica 仍跑遠端。這是 locality + survival 的互動議題、見 <a href="../locality-aware-schema/">locality-aware schema</a> 詳細展開。</p>
<h3 id="合規邊界-violation">合規邊界 violation</h3>
<p>受監管市場（金融 / 醫療 / 博彩）資料 <em>不能跨境</em>、但 region survival 強制 voting replica 跨 region — 這直接違反合規。對照 <a href="/blog/backend/09-performance-capacity/cases/standard-chartered-aurora-banking/" data-link-title="9.C14 Standard Chartered：受監管銀行的 Aurora 4000 TPS 容量提升" data-link-desc="Standard Chartered 銀行遷移到 Aurora 後吞吐量提升 10 倍至 4000 TPS、跨 7 個受監管市場">9.C14 Standard Chartered</a> 走的是「每市場獨立 Aurora cluster + zone survival」、不是 region survival。</p>
<p>合規邊界判讀：</p>
<ul>
<li>跨境合規 <em>禁止</em> 跨 region replica → 不可用 region survival、走 cluster-per-市場</li>
<li>跨州合規 <em>允許</em> 跨州但要求資料留國內 → 可用 region survival、選同國內的 region</li>
<li>業務邏輯要求跨 boundary（如 Hard Rock 跨州統一帳戶）→ 不可拆獨立 cluster、必須 locality + placement</li>
</ul>
<h2 id="容量與觀測">容量與觀測</h2>
<h3 id="必看-metric">必看 metric</h3>
<ul>
<li><code>Raft replicas per node</code>：replica 分佈均勻度</li>
<li><code>Range count by survival mode</code>：region survival 配置的 range 數量</li>
<li><code>Cross-region write latency p99</code>：跨 region quorum 實測 latency</li>
<li><code>Rebalance queue size</code>：rebalance 是否完成</li>
<li><code>Network traffic by direction</code>：cross-region 流量、cost signal</li>
</ul>
<h3 id="容量公式">容量公式</h3>
<ul>
<li>region survival 最小：region count × 3 nodes</li>
<li>replica factor 預設 3、storage 用量 × replication factor</li>
<li>cross-region traffic = write QPS × row size × (region count - 1)</li>
</ul>
<h3 id="write-latency-預算屬通用工程估算case-未揭露具體-latency-數字">Write latency 預算（屬通用工程估算、case 未揭露具體 latency 數字）</h3>
<p><strong>Scope warning</strong>：以下數字屬通用工程估算（跨 region 物理光速下界推導）、<strong>Netflix / Hard Rock case 都沒揭露 zone / region survival 的 p99 latency 數字</strong>。引用時必須明示來源層次：</p>
<ul>
<li>zone survival single-region 寫入 p99 5-10ms（跨 AZ Raft round trip）</li>
<li>region survival 同洲跨 region p99 30-60ms（跨 region round trip × Raft majority）</li>
<li>region survival 跨洲 p99 100-150ms（跨洲光速下界 ~70-80ms × 2）</li>
</ul>
<p>數字屬「合理的工程估算量級」、不是 case 揭露的 p99。讀者用這些做容量規劃時應該自己 benchmark、不要直接套。</p>
<h3 id="賽季型容量擺盪9c41-hard-rock">賽季型容量擺盪（9.C41 Hard Rock）</h3>
<p>sportsbook 業務年度循環：NFL / NBA 季初季末流量結構性差異 — Hard Rock 100 nodes ↔ 33 nodes 擺盪是 <em>計畫內</em>、不是異常事件。CockroachDB 加減節點靠 range rebalance、不停服。</p>
<p>容量規劃要點：</p>
<ul>
<li>NFL / NBA / 國際賽事曆塞進預測模型、不要當 surprise</li>
<li>scale up 提前 1-2 週執行、留 rebalance 時間</li>
<li>scale down 在淡季低流量時段執行、避免 rebalance 期間 p99 spike</li>
</ul>
<h3 id="回路徑">回路徑</h3>
<ul>
<li><a href="/blog/backend/09-performance-capacity/" data-link-title="模組九：效能工程與容量規劃" data-link-desc="把『目前配置能撐多少、要加多少機器』變成可量化、可驗證、可改進的工程流程">9.6 容量規劃模型</a> survival goal 對 replica count / cost 影響</li>
<li><a href="/blog/backend/09-performance-capacity/" data-link-title="模組九：效能工程與容量規劃" data-link-desc="把『目前配置能撐多少、要加多少機器』變成可量化、可驗證、可改進的工程流程">9.11 高峰事件準備</a> event-driven scaling</li>
<li><a href="/blog/backend/knowledge-cards/latency-budget/" data-link-title="Latency Budget" data-link-desc="把 user-perceived latency 拆到每個 stage 的配額、反推架構選擇">latency budget 卡</a> cross-region 預算</li>
</ul>
<h2 id="邊界與整合">邊界與整合</h2>
<h3 id="sibling-deep-articles">Sibling deep articles</h3>
<ul>
<li><a href="../hlc-raft-consensus/">HLC + Raft consensus</a>：Raft 機制是 survival goal 的基礎</li>
<li><a href="../locality-aware-schema/">locality-aware schema</a>：locality + survival 一起決定 placement</li>
<li><a href="../transaction-retry-pattern/">transaction retry pattern</a>：cross-region latency 加長 retry window</li>
</ul>
<h3 id="跟-aurora-對照">跟 Aurora 對照</h3>
<ul>
<li>Aurora cross-AZ failover：zone-level survival 等價、但只在 single-region 內</li>
<li>Aurora Global Database：跨 region async replication、不是 sync — region failure 仍會丟 last seconds</li>
<li>CockroachDB region survival：sync majority、region failure RPO = 0</li>
</ul>
<p>Aurora 沒有 row-level locality 配置、跨 region 強一致要走 Aurora DSQL（AWS 2024 GA）。</p>
<h3 id="aurora-dsql--spanner-對比">Aurora DSQL / Spanner 對比</h3>
<p>完整三家 distributed SQL 在 multi-region survival 的取捨、見 <a href="../aurora-dsql-spanner-decision-tree/">aurora-dsql-spanner-decision-tree</a>。</p>
<h3 id="1x-章節互引">1.x 章節互引</h3>
<ul>
<li><a href="/blog/backend/01-database/global-distributed-oltp/" data-link-title="1.11 全球分散式 OLTP" data-link-desc="Spanner / Aurora DSQL / Cosmos DB multi-region write / CockroachDB / TiDB 的全球一致性取捨">1.11 全球分散式 OLTP</a> 上游</li>
<li><a href="/blog/backend/01-database/transaction-boundary/" data-link-title="1.3 Transaction 與一致性邊界" data-link-desc="交易邊界、isolation level、retry 策略、distributed transaction（2PC、Saga）與跨 region 強一致取捨">1.3 Transaction Boundary</a> distributed transaction</li>
</ul>
<h3 id="何時不用-region-survival">何時不用 region survival</h3>
<ul>
<li>single-region 已滿足業務 SLO → zone survival 即可</li>
<li>預算敏感、cross-region traffic cost 不划算</li>
<li>合規禁止跨境 → 必須拆每市場獨立 cluster + zone survival</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/cockroachdb/" data-link-title="CockroachDB" data-link-desc="分散式 SQL、PostgreSQL 相容、跨區強一致、Spanner 的開源 / 跨雲替代">CockroachDB vendor overview</a></li>
<li><a href="/blog/backend/09-performance-capacity/cases/hard-rock-digital-cockroachdb-sports-betting/" data-link-title="9.C41 Hard Rock Digital：CockroachDB on AWS Outposts、Wire Act 合規 &#43; 跨州單一邏輯 DB" data-link-desc="Hard Rock Digital 用 CockroachDB 跨 AWS Outposts &#43; US-East-1、Wire Act 強制資料留州、單一邏輯 DB 解多州 sportsbook、100 node 32 vCPU 撐 Super Bowl">9.C41 Hard Rock Digital</a>（bet placement RPO=0 倒推）</li>
<li><a href="/blog/backend/09-performance-capacity/cases/netflix-cockroachdb-multi-region-fleet/" data-link-title="9.C40 Netflix：380&#43; CockroachDB cluster 的 multi-active 拓樸艦隊" data-link-desc="Netflix 把 Cassandra 不夠用的 transactional workload 移到 CockroachDB、380&#43; cluster / 60&#43; 跨 region、含 Open Connect、studio cloud drive、gaming control plane">9.C40 Netflix</a>（Gaming 48-node 跨 4 region survival）</li>
<li><a href="/blog/backend/09-performance-capacity/cases/standard-chartered-aurora-banking/" data-link-title="9.C14 Standard Chartered：受監管銀行的 Aurora 4000 TPS 容量提升" data-link-desc="Standard Chartered 銀行遷移到 Aurora 後吞吐量提升 10 倍至 4000 TPS、跨 7 個受監管市場">9.C14 Standard Chartered</a>（anti-recommendation、為何 <em>不用</em> region survival）</li>
<li><a href="/blog/backend/knowledge-cards/quorum/" data-link-title="Quorum" data-link-desc="分散式系統以多數節點同意作為提交或讀取有效性的門檻">quorum 卡</a> / <a href="/blog/backend/knowledge-cards/rto/" data-link-title="RTO" data-link-desc="說明恢復時間目標如何約束事故回復策略">rto 卡</a> / <a href="/blog/backend/knowledge-cards/rpo/" data-link-title="RPO" data-link-desc="說明恢復點目標如何定義可接受資料損失範圍">rpo 卡</a> / <a href="/blog/backend/knowledge-cards/blast-radius/" data-link-title="Blast Radius" data-link-desc="說明事故影響面如何估算與隔離">blast radius 卡</a></li>
<li>官方：<a href="https://www.cockroachlabs.com/docs/stable/multiregion-survival-goals.html">CockroachDB Multi-Region Survival Goals</a> / <a href="https://www.cockroachlabs.com/docs/stable/multiregion-overview.html">Multi-Region Capabilities Overview</a></li>
</ul>
]]></content:encoded></item><item><title>Cosmos DB RU/s 成本模型 + 容量規劃：RU 思維、payload、index、provisioned vs autoscale vs serverless</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/cosmosdb/ru-cost-model-sizing/</link><pubDate>Wed, 27 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/cosmosdb/ru-cost-model-sizing/</guid><description>&lt;p>Cosmos DB 用單一 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/request-unit/" data-link-title="Request Unit" data-link-desc="Cosmos DB 的容量抽象單位、1 RU = 1KB document strong-consistent read 的 CPU &amp;#43; memory &amp;#43; IOPS 綜合 cost、寫 ~5 RU、複雜 query 數百 RU">Request Unit&lt;/a>（RU）抽象 read / write / query / replace 的成本。這個抽象 &lt;em>簡化&lt;/em> 容量規劃（不用拆 RCU/WCU、不用估 CPU + IOPS）、但也引入 &lt;em>團隊知識遷移&lt;/em> 成本 — 從 MongoDB / PostgreSQL 自管團隊轉過來、工程師要重新學「query 為什麼吃 200 RU」「payload 從 1KB 變 10KB cost 怎麼變」「index 改一個欄位 write RU 漲 30%」這些 RU 思維問題。本文先講 RU 思維的學習曲線、再進操作流程（依負載形狀選容量模式）、再進失敗模式（autoscale reactive 限制等）。&lt;/p>
&lt;p>本文不是 Cosmos DB overview（請看 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/cosmosdb/" data-link-title="Azure Cosmos DB" data-link-desc="全球分散式 multi-model DB、5 個 consistency levels、Microsoft 自家 dogfood 證據">Cosmos DB vendor 頁&lt;/a>）— 而是 &lt;em>RU 成本模型 + sizing&lt;/em> 的深度展開。Case anchor 是 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/asos-cosmos-db-black-friday/" data-link-title="9.C21 ASOS：Cosmos DB 在 Black Friday 撐 1.67 億請求" data-link-desc="ASOS 在 2016 Black Friday 用 Azure Cosmos DB 撐 24 小時 1.67 億請求、3500 req/sec、48ms 平均延遲">9.C21 ASOS&lt;/a>（24h 1.67 億 request、autoscale + RU budgeting）+ &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/minecraft-earth-cosmos-db-global/" data-link-title="9.C11 Minecraft Earth：Azure Cosmos DB 上的全球分散式 AR 遊戲" data-link-desc="Minecraft Earth 用 Cosmos DB 跨地區分散、測試到 100 萬 RU/s 仍維持承諾延遲">9.C11 Minecraft Earth&lt;/a>（測試到 1M RU/s、RU 抽象單位定義）。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>Cosmos DB 適用度前置判讀&lt;/strong>：本篇假設 workload 已通過 Cosmos DB 適用度四層 framing（API model 三型遷移路徑 / RU 思維轉換成本 / multi-model 差異化是否真用上 / 跨雲 hedging vs 單雲 lock-in）— 詳見 &lt;a href="../mongodb-api-vs-sql-api/#%e5%9b%9b%e5%b1%a4-framingvendor-selection-%e7%9a%84%e7%9c%9f%e5%af%a6%e6%b1%ba%e7%ad%96%e8%bb%b8">mongodb-api-vs-sql-api 開頭四層 framing&lt;/a>、本篇不重複展開。RU sizing + 容量模式選擇是 &lt;em>已選 Cosmos DB 後&lt;/em> 的成本決策；若 workload 不適用 Cosmos DB、RU sizing 無法救回 vendor 選錯的成本結構落差。&lt;/p>&lt;/blockquote>
&lt;h2 id="問題情境ru-思維的學習曲線">問題情境：RU 思維的學習曲線&lt;/h2>
&lt;p>典型觸發場景：團隊原本用 MongoDB 自管 / PostgreSQL、把容量規劃成「CPU + IOPS + working set RAM」三軸；遷到 Cosmos DB 後第一個問題是「我們的 query 要設多少 RU/s」 — 文件回答「估每個操作的 RU × 操作頻率」、但工程師沒有 RU 的直覺、不知道「200 RU 是貴還是便宜」。&lt;/p></description><content:encoded><![CDATA[<p>Cosmos DB 用單一 <a href="/blog/backend/knowledge-cards/request-unit/" data-link-title="Request Unit" data-link-desc="Cosmos DB 的容量抽象單位、1 RU = 1KB document strong-consistent read 的 CPU &#43; memory &#43; IOPS 綜合 cost、寫 ~5 RU、複雜 query 數百 RU">Request Unit</a>（RU）抽象 read / write / query / replace 的成本。這個抽象 <em>簡化</em> 容量規劃（不用拆 RCU/WCU、不用估 CPU + IOPS）、但也引入 <em>團隊知識遷移</em> 成本 — 從 MongoDB / PostgreSQL 自管團隊轉過來、工程師要重新學「query 為什麼吃 200 RU」「payload 從 1KB 變 10KB cost 怎麼變」「index 改一個欄位 write RU 漲 30%」這些 RU 思維問題。本文先講 RU 思維的學習曲線、再進操作流程（依負載形狀選容量模式）、再進失敗模式（autoscale reactive 限制等）。</p>
<p>本文不是 Cosmos DB overview（請看 <a href="/blog/backend/01-database/vendors/cosmosdb/" data-link-title="Azure Cosmos DB" data-link-desc="全球分散式 multi-model DB、5 個 consistency levels、Microsoft 自家 dogfood 證據">Cosmos DB vendor 頁</a>）— 而是 <em>RU 成本模型 + sizing</em> 的深度展開。Case anchor 是 <a href="/blog/backend/09-performance-capacity/cases/asos-cosmos-db-black-friday/" data-link-title="9.C21 ASOS：Cosmos DB 在 Black Friday 撐 1.67 億請求" data-link-desc="ASOS 在 2016 Black Friday 用 Azure Cosmos DB 撐 24 小時 1.67 億請求、3500 req/sec、48ms 平均延遲">9.C21 ASOS</a>（24h 1.67 億 request、autoscale + RU budgeting）+ <a href="/blog/backend/09-performance-capacity/cases/minecraft-earth-cosmos-db-global/" data-link-title="9.C11 Minecraft Earth：Azure Cosmos DB 上的全球分散式 AR 遊戲" data-link-desc="Minecraft Earth 用 Cosmos DB 跨地區分散、測試到 100 萬 RU/s 仍維持承諾延遲">9.C11 Minecraft Earth</a>（測試到 1M RU/s、RU 抽象單位定義）。</p>
<blockquote>
<p><strong>Cosmos DB 適用度前置判讀</strong>：本篇假設 workload 已通過 Cosmos DB 適用度四層 framing（API model 三型遷移路徑 / RU 思維轉換成本 / multi-model 差異化是否真用上 / 跨雲 hedging vs 單雲 lock-in）— 詳見 <a href="../mongodb-api-vs-sql-api/#%e5%9b%9b%e5%b1%a4-framingvendor-selection-%e7%9a%84%e7%9c%9f%e5%af%a6%e6%b1%ba%e7%ad%96%e8%bb%b8">mongodb-api-vs-sql-api 開頭四層 framing</a>、本篇不重複展開。RU sizing + 容量模式選擇是 <em>已選 Cosmos DB 後</em> 的成本決策；若 workload 不適用 Cosmos DB、RU sizing 無法救回 vendor 選錯的成本結構落差。</p></blockquote>
<h2 id="問題情境ru-思維的學習曲線">問題情境：RU 思維的學習曲線</h2>
<p>典型觸發場景：團隊原本用 MongoDB 自管 / PostgreSQL、把容量規劃成「CPU + IOPS + working set RAM」三軸；遷到 Cosmos DB 後第一個問題是「我們的 query 要設多少 RU/s」 — 文件回答「估每個操作的 RU × 操作頻率」、但工程師沒有 RU 的直覺、不知道「200 RU 是貴還是便宜」。</p>
<p>讀者徵兆：</p>
<ul>
<li>「為什麼這個 query 吃 200 RU」</li>
<li>「payload 從 1KB 變 10KB、cost 怎麼變」</li>
<li>「Autoscale vs Provisioned 怎麼選」</li>
<li>「Serverless 跟 Provisioned 的 break-even 在哪」</li>
<li>「Index policy 改了一個欄位、write RU 漲 30%」</li>
</ul>
<p>真實壓力：Black Friday 流量 10x、autoscale 跟不上 throttle；dev 環境 24/7 跑、付 provisioned 月費卻只用 1 小時；team 估 RU 估到一半發現「不知道怎麼估」、回去問 PM「我們的 access pattern 是什麼」、PM 給不出答案。</p>
<h3 id="從-cpu--iops-思維轉到-ru-思維">從 CPU + IOPS 思維轉到 RU 思維</h3>
<p>9.C11 Minecraft Earth 案例的平台特性段揭露的 RU 對照：</p>
<ul>
<li>1 RU = 1 KB document 的 strong-consistent read 成本</li>
<li>寫成本約 5 RU</li>
<li>複雜 query 可達數百 RU</li>
</ul>
<p>這個對照看起來簡單、但 <em>容量規劃變成「估每個操作多少 RU × 操作頻率」</em>、跟傳統 RDB「估 CPU / IOPS / working set RAM」是完全不同的思維。具體差異：</p>
<ul>
<li>用 RU 思考、不是用 CPU 思考 — 不需要估「query 跑多久」、要估「query 吃多少 RU」</li>
<li>量單一 query 的 <code>x-ms-request-charge</code> header、不是看 slow query log — 監控位置從 server 端移到 SDK response</li>
<li>拆 query 為 RU budget、不是調 indexing strategy — Cosmos DB index policy 影響 RU、但 <em>改 index 不改 query 速度</em>、改的是 cost</li>
</ul>
<p>跨 vendor 的 capacity 抽象差距（本章合成 frame、跨 vendor case 比對）：</p>
<ul>
<li>MongoDB 用 CPU + IOPS + working set 三軸</li>
<li>DynamoDB 用 WCU / RCU 二軸 + on-demand vs provisioned 模式選擇 + adaptive capacity</li>
<li>Cosmos DB 用 RU 單軸 + 5 consistency level</li>
</ul>
<p><em>思維遷移成本可能高過 vendor 廣告的價格差距</em> — 工程師需要 4-6 週才會建立 RU 直覺、selection 評估時不能只看 monthly bill 就做 ROI 結論。對中型團隊、這個學習曲線可能直接決定遷移成功率。</p>
<p><strong>Scope warning</strong>：9.C11 揭露「100 萬 RU/s 壓測通過」 — <em>壓測通過數字、不是 production 持續跑</em>（case 自己警示）。引用 1M RU/s 時必須帶 scope：壓測 vs 持續、case 明示「實際營運要看 partition key 設計是否均勻」。把壓測數字當 production capacity 推算的後果是 sizing 嚴重低估 hot partition 風險。</p>
<h2 id="ru-的核心機制">RU 的核心機制</h2>
<h3 id="ru-基準">RU 基準</h3>
<p>1 RU = strong-consistent read of 1KB document、用 CPU + memory + IOPS 綜合抽象。每個操作的 RU charge 從 SDK response 的 <code>x-ms-request-charge</code> header 拿、不是事後估算。</p>
<p>操作 RU 對照（rule of thumb、實際以 <code>x-ms-request-charge</code> 為準）：</p>
<ul>
<li>Read 1KB（point read）：1 RU（eventual / session 更便宜、strong / bounded staleness 約 2x）</li>
<li>Write 1KB：5-10 RU（含 index 更新）</li>
<li>Replace 1KB：10-15 RU</li>
<li>Query：跟 query plan + result count + index hit 強相關、可從 5 RU 到 1000+ RU</li>
</ul>
<h3 id="payload-size-的影響">Payload size 的影響</h3>
<p>每多 1 KB payload、write RU 線性增加；read 同 partition 多個 doc 用 query / feed 比多次 point read 更便宜。常見誤區是「拆小 doc 比較便宜」 — 不一定、要看 read pattern：若每次 read 都拿 10 個小 doc、不如合成一個大 doc 一次 read。</p>
<h3 id="index-policy-的影響">Index policy 的影響</h3>
<p>預設 indexing 全欄位（auto-indexing）、降 query cost 但提 write cost；customize index policy（exclude path / include path）可降 write RU 30-50%。判讀時：write-heavy collection 通常該 exclude 不查的欄位、read-heavy collection 通常該 include 常用 query 欄位。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nt">&#34;indexingMode&#34;</span><span class="p">:</span> <span class="s2">&#34;consistent&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nt">&#34;includedPaths&#34;</span><span class="p">:</span> <span class="p">[{</span><span class="nt">&#34;path&#34;</span><span class="p">:</span> <span class="s2">&#34;/userId/?&#34;</span><span class="p">},</span> <span class="p">{</span><span class="nt">&#34;path&#34;</span><span class="p">:</span> <span class="s2">&#34;/orderDate/?&#34;</span><span class="p">}],</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nt">&#34;excludedPaths&#34;</span><span class="p">:</span> <span class="p">[{</span><span class="nt">&#34;path&#34;</span><span class="p">:</span> <span class="s2">&#34;/*&#34;</span><span class="p">}]</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><h3 id="三種容量模式">三種容量模式</h3>
<ul>
<li><strong>Provisioned throughput</strong>：訂死 RU/s、不用也付、適合穩定流量</li>
<li><strong>Autoscale provisioned</strong>：訂 max、實際用多少算多少（10% min ceiling）、適合 unpredictable</li>
<li><strong>Serverless</strong>：完全按 request 計、小流量 / dev / 稀疏負載</li>
</ul>
<p>模式選擇不是「哪個便宜」、是「負載形狀適配哪個」— 下節展開。</p>
<h2 id="操作流程依負載形狀選容量模式">操作流程：依負載形狀選容量模式</h2>
<h3 id="量測單一-query-ru">量測單一 query RU</h3>
<p>SDK response header <code>x-ms-request-charge</code>、或 portal Query Stats。Phase 0 audit 一定要 <em>把 production query corpus 跑一遍量 RU</em>、不是估算 — 估算誤差通常 5-10x。</p>
<h3 id="量測-container-baseline-ru">量測 container baseline RU</h3>
<p><code>az cosmosdb sql container show-throughput</code>、portal Metrics &gt; Normalized RU Consumption。</p>
<h3 id="設定-autoscale">設定 autoscale</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">az cosmosdb sql container update <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --max-throughput <span class="m">40000</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --resource-group myrg --account-name mycosmos <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --database-name mydb --name mycontainer</span></span></code></pre></div><h3 id="依負載形狀對應容量模式">依負載形狀對應容量模式</h3>
<p>不同負載形狀的容量決策完全不同、不能用同一個模板：</p>
<p><strong>持續高峰（24h 整天高）</strong> — Provisioned + <a href="/blog/backend/knowledge-cards/scheduled-scaling/" data-link-title="Scheduled Scaling" data-link-desc="說明按已知時間表預先擴容的 autoscaler 模式">scheduled scaling</a></p>
<ul>
<li>Trigger 訊號：峰值 / 平均 &lt; 2x、預測性高</li>
<li>Case anchor：<a href="/blog/backend/09-performance-capacity/cases/asos-cosmos-db-black-friday/" data-link-title="9.C21 ASOS：Cosmos DB 在 Black Friday 撐 1.67 億請求" data-link-desc="ASOS 在 2016 Black Friday 用 Azure Cosmos DB 撐 24 小時 1.67 億請求、3500 req/sec、48ms 平均延遲">9.C21 ASOS Black Friday</a> — 24h 1.67 億 request、峰值 / 平均 = 1.81、整天高</li>
<li>為什麼選 provisioned：autoscale 的 reactive trigger 在持續高峰時仍會被拖累 p99、provisioned 鎖定 RU 反而平穩</li>
<li>Scheduled scaling 在 event 前 30-60 分鐘 pre-warm、避免事件開始 trigger autoscale</li>
</ul>
<p><strong>隨機 surge（不可預測 timing）</strong> — Autoscale + reactive safety net</p>
<ul>
<li>Trigger 訊號：不規則尖峰、預測訊號弱、流量曲線無規律</li>
<li>為什麼選 autoscale：成本不浪費（10% min ceiling）、reactive 雖然有延遲但比 over-provisioned 划算</li>
<li>Case anchor 屬本章合成 frame、case 庫未直接揭露純「隨機 surge」的 Cosmos DB 案例</li>
</ul>
<p><strong>預測性 surge（外部訊號可預測）</strong> — Pre-provision + scheduled scaling</p>
<ul>
<li>Trigger 訊號：賽事 / 上線 / 季節 peak、有外部訊號可學</li>
<li>Case anchor：<a href="/blog/backend/09-performance-capacity/cases/coinbase-mongodb-document-platform/" data-link-title="9.C36 Coinbase：MongoDB 撐 Ruby 單體 &#43; 1.5M reads/sec identity 服務" data-link-desc="Coinbase 以 MongoDB 為主資料層、自建 mongobetween connection proxy、users 服務在加密貨幣 surge 時撐 1.5M reads/sec">9.C36 Coinbase predictive scaling</a> 模型對 KV / document 同適用 — ML 預測 60 分鐘領先窗、改善的是 <em>trigger 提前</em>、不是擴容本身變快</li>
<li>Coinbase case 是 MongoDB 場景、模型可借鑑、但 Cosmos DB 沒有直接對應 ML 預測整合、需要自建</li>
</ul>
<p><strong>稀疏 / dev / 低流量</strong> — Serverless</p>
<ul>
<li>Trigger 訊號：&lt; 1000 RU/s 預期、長時間閒置（如 dev / test / 內部工具）</li>
<li>Serverless 是建 account 時選、<em>不能事後轉 provisioned</em>、要在 Phase 0 決定</li>
<li>屬本章合成 frame、case 庫未直接揭露 serverless 場景（多數案例都是 production 流量）</li>
</ul>
<p><strong>本章合成 frame 警示</strong>：上表是跨 4 個 case 合成（9.C21 ASOS 提供「持續高峰」明確 anchor、9.C36 Coinbase 提供「預測性 surge」模型）、其他兩格屬 outline knowledge — 引用時必須明示「對照表是本章合成、case 原文沒有此分類」。</p>
<h3 id="切換-provisioned--autoscale">切換 provisioned ↔ autoscale</h3>
<p>portal / CLI 支援、不需停機；但 Serverless 是建 account 時選、<em>不能轉 provisioned</em>。Phase 0 決定 mode 後若要切 serverless ↔ provisioned 等於重建 account + 資料遷移。</p>
<h3 id="驗證點">驗證點</h3>
<ul>
<li>autoscale min ceiling = 10% max；若 traffic 預測 baseline &gt; 25% peak、autoscale 不划算（baseline 已經超過 min ceiling、autoscale 的彈性沒用上）</li>
<li>p99 query RU &lt; provisioned / 100（給 burst 留 100x buffer 是 rule of thumb、實際視 query 分布）</li>
<li>每個 query pattern 的 <code>x-ms-request-charge</code> &lt; SLA budget</li>
</ul>
<h3 id="rollback-boundary">Rollback boundary</h3>
<p>throughput 可即時改、index policy 改完背景 rebuild（rebuild 期間 query 用舊 index、性能可能下降但不中斷）；mode（serverless ↔ provisioned）不可改。</p>
<h2 id="失敗模式">失敗模式</h2>
<h3 id="failure-1用-point-read-取代-query">Failure 1：用 point read 取代 query</h3>
<p>要拿同 partition 100 個 doc、做 100 次 point read（100 RU）vs 一次 query（可能 10-20 RU）— point read 雖然每次便宜、總成本反高。這個 anti-pattern 在 application code 很常見 — 「每次 read 一個 doc 比較簡單」是 application 角度、不是 RU 角度。</p>
<p>修：拉 access pattern audit、把 N+1 read pattern 改 batch query；用 query 拿同 partition 多 doc、用 cross-partition query 拿不同 partition（成本高、但比 N+1 point read 通常還便宜）。</p>
<h3 id="failure-2index-全開不審">Failure 2：Index 全開不審</h3>
<p>所有欄位 auto-index、write 大表時 RU 暴漲；徵兆是 <code>Total RU consumption</code> 寫入路徑佔 80%、read 只佔 20%、但 application 明明 read-heavy。原因是 index 維護成本太高。</p>
<p>修：customize index policy、exclude 不查的欄位（特別是 array / nested object 等高成本欄位）、include 常用 query 路徑。改完背景 rebuild、不中斷服務。</p>
<h3 id="failure-3autoscale-min-沒考慮">Failure 3：Autoscale min 沒考慮</h3>
<p>max 40000、min 4000（10% max ceiling）、實際 baseline 是 500、付 8x baseline 費；應該降 max 或改 serverless。autoscale 的 <em>min ceiling</em> 是常見的隱性成本來源 — 訂太高 max 就被 min 綁住、autoscale 反而比 provisioned 貴。</p>
<p>修：先量 baseline 跟 peak、算 peak / baseline ratio；ratio &gt; 10x 用 autoscale 划算、ratio &lt; 4x 用 provisioned 划算（autoscale min ceiling 吃掉彈性）。</p>
<h3 id="failure-4autoscale-撐不住預測性流量必須-scheduled-scaling-或-pre-provision">Failure 4：Autoscale 撐不住預測性流量、必須 scheduled scaling 或 pre-provision</h3>
<p>autoscale 的 min ceiling = 10% max、實際擴容仍是 <em>reactive</em>（看到 throttle 才往上推）。對預測性流量（季節 peak / 賽事 / 上線日）、autoscale 跟不上、必須 scheduled scaling 或 pre-provision。</p>
<p>9.C21 ASOS Black Friday 是「持續高峰」、整天高 — 用 provisioned + scheduled 比 autoscale 划算（autoscale 仍會被 reactive trigger 拖累 p99）。9.C36 Coinbase 模型雖然是 MongoDB case、可借鑑：cluster 擴容 70 分鐘、reactive 來不及、ML 預測 60 分鐘領先窗、改善的是 <em>trigger 提前</em>、不是擴容本身變快 — Cosmos DB autoscale 的 10% ceiling 同樣是 reactive 限制。</p>
<p>修：預測性 event 前 30-60 分鐘 pre-warm RU/s、事件結束後降回；用 scheduled scaling pipeline（Azure Function trigger + ARM template）自動化。</p>
<h3 id="failure-5provisioned-沒退場">Failure 5：Provisioned 沒退場</h3>
<p>dev / staging container 全開 provisioned、月費 $300+ × N 個 environment；應切 serverless 或共用 shared throughput（多個 container 共享一個 RU pool）。dev 環境的 cost waste 是長尾、月底帳單才發現。</p>
<p>修：dev / staging 改 serverless、production 才 provisioned；或用 <em>shared database throughput</em>、多個 container 共用 400-1000 RU pool。</p>
<h3 id="failure-6跨-partition-query-浪費">Failure 6：跨 partition query 浪費</h3>
<p>query 沒包含 partition key 條件、fan-out 全 partition、RU × partition 數；徵兆是 <code>RetrievedDocumentCount</code> 跟 <code>OutputDocumentCount</code> 比例 &gt; 10（拿了 10x doc 才篩出要的）。</p>
<p>修：query 強制帶 partition key 條件、改 access pattern 讓 query 自然帶 partition key；若必須跨 partition、用 <a href="https://learn.microsoft.com/azure/cosmos-db/change-feed">Change Feed</a> 把投影預先寫到另一個 container 用單一 partition 查。</p>
<h3 id="failure-7沒設-budget-alert">Failure 7：沒設 budget alert</h3>
<p>cost 失控直到月底帳單才發現。Cosmos DB 的成本可以在幾天內飆 10x（hot partition + index 全開 + autoscale max 設太高 互相加乘）、月底才看是災難。</p>
<p>修：Azure Cost Management 設 daily budget alert（超預算 1.5x trigger）、portal Insights &gt; Cost insights 每週 review。</p>
<h3 id="failure-8ttl-自動刪除把-ru-偷走">Failure 8：TTL 自動刪除把 RU 偷走</h3>
<p>Cosmos DB 容器層的 TTL（<a href="https://learn.microsoft.com/azure/cosmos-db/nosql/time-to-live">Time To Live</a>）會在 background 持續掃描過期文件、跑 delete 操作消耗 RU、但不會出現在 application driver 的 RU 統計、容易在 sizing 階段被忽略。屬通用工程議題、case 未直接量化 TTL 對 RU 的佔比。</p>
<p>徵兆：</p>
<ul>
<li>Provisioned RU 估算「query + write」流量明明很穩、實際 <code>NormalizedRUConsumption</code> 卻偏高、找不到對應 application call</li>
<li>高寫入率 container 開啟 TTL 後、<code>Total Request Units</code> 持續高於預期、portal Insights 「Background operations」段非零</li>
<li>TTL 設過短（例：分鐘級）、background delete 跟 application write 競爭同 partition、寫入 latency p99 變高</li>
</ul>
<p>修：</p>
<ul>
<li>估 RU 容量時把 TTL delete 當第三類流量（除了 user read / write 外）、用「過期 doc / 秒 × 平均 doc delete RU」估算</li>
<li>設定 TTL 不要過短、避免 delete 壓力跟 application write 撞 partition</li>
<li>對高 TTL volume 的 container 開啟 <a href="https://learn.microsoft.com/azure/cosmos-db/analytical-store-introduction">analytical store</a>、避免歷史資料保留在 transactional store 持續耗 RU</li>
<li>監控 <code>Background operations</code> 跟 <code>NormalizedRUConsumption</code> 的 ratio、把 TTL 對 RU 的影響可視化</li>
</ul>
<h2 id="容量與觀測">容量與觀測</h2>
<ul>
<li>必看 metric：<code>NormalizedRUConsumption</code>（peak）、<code>TotalRequestUnits</code>（cumulative）、<code>MetadataRequests</code>、<code>UserErrors</code>（for <code>429 throttle</code>）</li>
<li>成本分析：Azure Cost Management 按 container / region tag；portal Insights &gt; Cost insights</li>
<li>容量公式：peak RPS × avg RU per request × peak duration factor = required RU/s</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> 把 RU 當主要 capacity 軸（不只 storage / CPU）</li>
<li>對應 <a href="/blog/backend/09-performance-capacity/saturation-discovery/" data-link-title="9.4 Saturation Discovery" data-link-desc="找出 throughput plateau 與 latency knee 的方法">9.4 Saturation Discovery</a>：把 429 throttle 當 saturation 訊號</li>
<li>Alert：429 rate &gt; 0.1%、RU consumption &gt; 80% provisioned 持續 5 min、daily cost 超預算 1.5x</li>
</ul>
<h3 id="latency-budget-拆解vendor-sla-vs-end-to-end-實測">Latency budget 拆解：vendor SLA vs end-to-end 實測</h3>
<p><a href="/blog/backend/09-performance-capacity/cases/asos-cosmos-db-black-friday/" data-link-title="9.C21 ASOS：Cosmos DB 在 Black Friday 撐 1.67 億請求" data-link-desc="ASOS 在 2016 Black Friday 用 Azure Cosmos DB 撐 24 小時 1.67 億請求、3500 req/sec、48ms 平均延遲">9.C21 ASOS</a> 觀察「48ms 平均響應」段揭露：48ms 包含 <em>網路 + DB + 應用層</em>、DB 本身可能只佔 5-10ms。引用時不能把 vendor 廣告的 5-10ms p99 當「使用者體驗」 — 詳細拆解見 <a href="../partition-key-design/">partition-key-design</a> 的 latency budget 段。</p>
<h3 id="跟其他-vendor-capacity-抽象的對照">跟其他 vendor capacity 抽象的對照</h3>
<table>
  <thead>
      <tr>
          <th>Vendor</th>
          <th>Capacity 抽象</th>
          <th>思維重點</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>MongoDB</td>
          <td>CPU + IOPS + working set RAM</td>
          <td>估資源、調 indexing</td>
      </tr>
      <tr>
          <td>DynamoDB</td>
          <td>WCU / RCU + on-demand vs provisioned + adaptive</td>
          <td>mode 選擇 + PK 均勻度</td>
      </tr>
      <tr>
          <td>Cosmos DB</td>
          <td>RU + 5 consistency level</td>
          <td>RU 預算、每 query 量 charge</td>
      </tr>
      <tr>
          <td>Aurora</td>
          <td>instance class + replica count + storage IOPS</td>
          <td>provisioned</td>
      </tr>
      <tr>
          <td>Spanner</td>
          <td>processing unit（100 pu 起跳）</td>
          <td>node count</td>
      </tr>
      <tr>
          <td>CockroachDB</td>
          <td>range × replication factor × node count</td>
          <td>distributed</td>
      </tr>
  </tbody>
</table>
<p>對照表是本章合成 frame、case 庫沒有單一案例橫跨多 vendor。判讀時要明示「思維遷移成本是 selection 評估的隱性軸、不是只看 monthly bill」。</p>
<h2 id="邊界與整合">邊界與整合</h2>
<ul>
<li>Sibling deep articles：<a href="../partition-key-design/">partition-key-design</a>（partition skew 讓 RU 失效、hot partition 是 sizing 假設失敗的主因）、<a href="../consistency-levels-engineering/">consistency-levels-engineering</a>（Strong / Bounded 對 read RU 2x）、<a href="../multi-region-write-conflict/">multi-region-write-conflict</a>（multi-region RU × region 數）、<a href="../mongodb-api-vs-sql-api/">mongodb-api-vs-sql-api</a>（MongoDB API 翻譯層多 10-20% RU）</li>
<li>跟 1.x 章節：<a href="/blog/backend/01-database/kv-document-capacity-planning/" data-link-title="1.10 KV / Document DB 容量規劃" data-link-desc="DynamoDB / Cosmos DB / Bigtable / MongoDB 等 KV / Document DB 的容量設計、partition key 取捨、capacity mode 選擇">1.10 KV / Document DB 容量規劃</a></li>
<li>跟 9.x 章節：<a href="/blog/backend/09-performance-capacity/saturation-discovery/" data-link-title="9.4 Saturation Discovery" data-link-desc="找出 throughput plateau 與 latency knee 的方法">9.4 Saturation Discovery</a>（429 throttle 當 saturation 訊號）</li>
<li>Knowledge cards：<a href="/blog/backend/knowledge-cards/peak-forecast/" data-link-title="Peak Forecast" data-link-desc="說明預期峰值流量的預測方法 — 容量規劃的第一個輸入">Peak Forecast</a> / <a href="/blog/backend/knowledge-cards/hot-partition/" data-link-title="Hot Partition" data-link-desc="說明分散式 KV / OLTP 中、單一 partition 流量遠超其他的容量問題">Hot Partition</a></li>
<li>Anti-recommendation：流量 &lt; 1000 RU/s 不需 autoscale tuning、用 serverless 或 400 RU/s shared throughput；過度 sizing 比 under-sizing 更常見、特別是 dev / staging</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/cosmosdb/" data-link-title="Azure Cosmos DB" data-link-desc="全球分散式 multi-model DB、5 個 consistency levels、Microsoft 自家 dogfood 證據">Cosmos DB vendor overview</a> — 本文是該頁尾 RU/s cost model backlog 的深度展開</li>
<li><a href="/blog/backend/09-performance-capacity/cases/asos-cosmos-db-black-friday/" data-link-title="9.C21 ASOS：Cosmos DB 在 Black Friday 撐 1.67 億請求" data-link-desc="ASOS 在 2016 Black Friday 用 Azure Cosmos DB 撐 24 小時 1.67 億請求、3500 req/sec、48ms 平均延遲">9.C21 ASOS Black Friday case</a> — 持續高峰 + RU budgeting 主案例</li>
<li><a href="/blog/backend/09-performance-capacity/cases/minecraft-earth-cosmos-db-global/" data-link-title="9.C11 Minecraft Earth：Azure Cosmos DB 上的全球分散式 AR 遊戲" data-link-desc="Minecraft Earth 用 Cosmos DB 跨地區分散、測試到 100 萬 RU/s 仍維持承諾延遲">9.C11 Minecraft Earth case</a> — RU 抽象單位定義 + 1M RU/s 壓測（scope warning：壓測非持續）</li>
<li><a href="/blog/backend/09-performance-capacity/cases/coinbase-mongodb-document-platform/" data-link-title="9.C36 Coinbase：MongoDB 撐 Ruby 單體 &#43; 1.5M reads/sec identity 服務" data-link-desc="Coinbase 以 MongoDB 為主資料層、自建 mongobetween connection proxy、users 服務在加密貨幣 surge 時撐 1.5M reads/sec">9.C36 Coinbase predictive scaling case</a> — 預測性 surge 模型借鑑（跨 vendor）</li>
<li><a href="/blog/backend/knowledge-cards/peak-forecast/" data-link-title="Peak Forecast" data-link-desc="說明預期峰值流量的預測方法 — 容量規劃的第一個輸入">Peak Forecast 卡片</a> / <a href="/blog/backend/knowledge-cards/hot-partition/" data-link-title="Hot Partition" data-link-desc="說明分散式 KV / OLTP 中、單一 partition 流量遠超其他的容量問題">Hot Partition 卡片</a> — 概念基底</li>
<li>官方：<a href="https://learn.microsoft.com/azure/cosmos-db/request-units">Cosmos DB Request Units</a> / <a href="https://learn.microsoft.com/azure/cosmos-db/throughput-serverless">Provisioned throughput vs autoscale vs serverless</a></li>
</ul>
]]></content:encoded></item><item><title>PostgreSQL major version upgrade (14 → 17)：為什麼這篇不套 5 type migration</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/major-version-upgrade/</link><pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/major-version-upgrade/</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。寫作前判讀 &lt;em>不適用&lt;/em> &lt;a href="https://tarrragon.github.io/blog/posts/migration-playbook-%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84stage-0-variant-%E8%A6%8F%E5%8A%83%E6%8A%8A-collapse-%E7%8E%87%E5%BE%9E-60-%E9%99%8D%E5%88%B0-0/" data-link-title="Migration Playbook 方法論的演化紀錄：Stage 0 variant 規劃把 collapse 率從 60% 降到 0%" data-link-desc="跨 vendor migration playbook 需要獨立寫作方法論的依據，以及這套方法論從三輪 batch dogfood 中演化出來的驗證證據。">Migration playbook methodology&lt;/a> 的 5 type — 本文是該 methodology 「何時不該套」段的第 2 項實證（同 vendor major version upgrade）。&lt;/p>&lt;/blockquote>
&lt;h2 id="為什麼這篇不套-5-type-migration">為什麼這篇不套 5 type migration&lt;/h2>
&lt;p>跑 &lt;a href="https://tarrragon.github.io/blog/report/content-structure-by-max-diff-dimension/" data-link-title="Process content 結構由最大差異維度決定、不是 universal phased" data-link-desc="跨 X process content（migration / upgrade / rollout / playbook）的結構由 source / target 之間 *差異維度組合* 決定、不存在 universal phased 模板；6 種 migration / process type 實證（schema 差 / drop-in / operational / multi-tool / paradigm / topology re-layout）跑出 6 種不同結構；寫作前必須做 *6 維 diff dimension audit* 才能決定結構、跳過會套錯模板">diff dimension audit&lt;/a> 對 PostgreSQL 14 → 17：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>維度&lt;/th>
 &lt;th>評估&lt;/th>
 &lt;th>等級&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Schema / API&lt;/td>
 &lt;td>同 PostgreSQL wire protocol、SQL syntax 99%+ 相容&lt;/td>
 &lt;td>Low&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Operational model&lt;/td>
 &lt;td>同 PostgreSQL operational stack、tooling 不變&lt;/td>
 &lt;td>Low&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Abstraction / paradigm&lt;/td>
 &lt;td>同 OLTP RDBMS&lt;/td>
 &lt;td>Low&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Number of components&lt;/td>
 &lt;td>同 1 個&lt;/td>
 &lt;td>Low&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Application change&lt;/td>
 &lt;td>多數 application 不改&lt;/td>
 &lt;td>Low&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>5 維皆 Low — 對映 Type B drop-in。但 &lt;em>實際工作量&lt;/em> 跟 drop-in 完全不同：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Extension 相容性&lt;/strong>：pg14 的 extension 不一定能在 pg17 直接用（API 變動 / ABI break）&lt;/li>
&lt;li>&lt;strong>Breaking change&lt;/strong>：每個 major version 有 release-specific behavior change（pg17 移除 &lt;code>relation&lt;/code>/&lt;code>oid&lt;/code> 隱性 type、pg15 公開 &lt;code>pg_role&lt;/code> 規則變嚴）&lt;/li>
&lt;li>&lt;strong>Storage format&lt;/strong>：major version 之間 &lt;em>data dir 不向後相容&lt;/em>、必須 &lt;code>pg_upgrade&lt;/code> 或 dump-restore&lt;/li>
&lt;li>&lt;strong>Statistics 重建&lt;/strong>：upgrade 後 &lt;code>pg_statistic&lt;/code> 失效、必須跑 &lt;code>ANALYZE&lt;/code>、否則 query plan 退化&lt;/li>
&lt;li>&lt;strong>Replication slot&lt;/strong>：logical replication slot 不跨 major version&lt;/li>
&lt;/ul>
&lt;p>5 type 對映 &lt;em>跨 vendor process&lt;/em>、漏了 &lt;em>同 vendor 內升級&lt;/em> 的 upgrade-specific dimension。本文採用 &lt;em>deep article methodology 的 6-section + 額外 upgrade audit 段&lt;/em> 結構、不是 5 type 的任一個。&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。寫作前判讀 <em>不適用</em> <a href="/blog/posts/migration-playbook-%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84stage-0-variant-%E8%A6%8F%E5%8A%83%E6%8A%8A-collapse-%E7%8E%87%E5%BE%9E-60-%E9%99%8D%E5%88%B0-0/" data-link-title="Migration Playbook 方法論的演化紀錄：Stage 0 variant 規劃把 collapse 率從 60% 降到 0%" data-link-desc="跨 vendor migration playbook 需要獨立寫作方法論的依據，以及這套方法論從三輪 batch dogfood 中演化出來的驗證證據。">Migration playbook methodology</a> 的 5 type — 本文是該 methodology 「何時不該套」段的第 2 項實證（同 vendor major version upgrade）。</p></blockquote>
<h2 id="為什麼這篇不套-5-type-migration">為什麼這篇不套 5 type migration</h2>
<p>跑 <a href="/blog/report/content-structure-by-max-diff-dimension/" data-link-title="Process content 結構由最大差異維度決定、不是 universal phased" data-link-desc="跨 X process content（migration / upgrade / rollout / playbook）的結構由 source / target 之間 *差異維度組合* 決定、不存在 universal phased 模板；6 種 migration / process type 實證（schema 差 / drop-in / operational / multi-tool / paradigm / topology re-layout）跑出 6 種不同結構；寫作前必須做 *6 維 diff dimension audit* 才能決定結構、跳過會套錯模板">diff dimension audit</a> 對 PostgreSQL 14 → 17：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>評估</th>
          <th>等級</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Schema / API</td>
          <td>同 PostgreSQL wire protocol、SQL syntax 99%+ 相容</td>
          <td>Low</td>
      </tr>
      <tr>
          <td>Operational model</td>
          <td>同 PostgreSQL operational stack、tooling 不變</td>
          <td>Low</td>
      </tr>
      <tr>
          <td>Abstraction / paradigm</td>
          <td>同 OLTP RDBMS</td>
          <td>Low</td>
      </tr>
      <tr>
          <td>Number of components</td>
          <td>同 1 個</td>
          <td>Low</td>
      </tr>
      <tr>
          <td>Application change</td>
          <td>多數 application 不改</td>
          <td>Low</td>
      </tr>
  </tbody>
</table>
<p>5 維皆 Low — 對映 Type B drop-in。但 <em>實際工作量</em> 跟 drop-in 完全不同：</p>
<ul>
<li><strong>Extension 相容性</strong>：pg14 的 extension 不一定能在 pg17 直接用（API 變動 / ABI break）</li>
<li><strong>Breaking change</strong>：每個 major version 有 release-specific behavior change（pg17 移除 <code>relation</code>/<code>oid</code> 隱性 type、pg15 公開 <code>pg_role</code> 規則變嚴）</li>
<li><strong>Storage format</strong>：major version 之間 <em>data dir 不向後相容</em>、必須 <code>pg_upgrade</code> 或 dump-restore</li>
<li><strong>Statistics 重建</strong>：upgrade 後 <code>pg_statistic</code> 失效、必須跑 <code>ANALYZE</code>、否則 query plan 退化</li>
<li><strong>Replication slot</strong>：logical replication slot 不跨 major version</li>
</ul>
<p>5 type 對映 <em>跨 vendor process</em>、漏了 <em>同 vendor 內升級</em> 的 upgrade-specific dimension。本文採用 <em>deep article methodology 的 6-section + 額外 upgrade audit 段</em> 結構、不是 5 type 的任一個。</p>
<h2 id="結構-differentiatordeep-article--upgrade-audit">結構 differentiator：deep article + upgrade audit</h2>
<p>跟 single feature deep article（如 <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> / <a href="/blog/backend/01-database/vendors/postgresql/patroni-ha/" data-link-title="PostgreSQL Patroni HA：從 leader 失聯到 client 重連的 5 段 failover lifecycle" data-link-desc="Patroni 把 PostgreSQL HA 拆成 detection / election / promotion / reconfiguration / recovery 五段 lifecycle、每段都有獨立配置跟 failure mode；DCS quorum &#43; watchdog 防 split-brain、async/sync replication 取捨、5 個 production 踩雷、跟 PgBouncer / HAProxy / cert-manager 整合">Patroni HA</a>）對照、本文多一段 <em>upgrade audit</em>；跟 migration playbook 對照、本文 <em>沒 phased translation / parallel run / cutover routing</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">問題情境（為什麼升）
</span></span><span class="line"><span class="ln">2</span><span class="cl">→ Upgrade audit（extension / breaking change / dependency）
</span></span><span class="line"><span class="ln">3</span><span class="cl">→ 升級方法選擇（pg_upgrade / logical / blue-green）
</span></span><span class="line"><span class="ln">4</span><span class="cl">→ Step-by-step 執行
</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">→ Capacity / downtime trade-off
</span></span><span class="line"><span class="ln">7</span><span class="cl">→ 整合 / 下一步</span></span></code></pre></div><p>7 段、220-280 行。比 single feature deep article 多 1 段 audit、比 migration playbook 少 phased translation 章節。</p>
<h2 id="問題情境major-version-不只是-minor-bump">問題情境：major version 不只是 minor bump</h2>
<p>PostgreSQL major version（14 / 15 / 16 / 17）一年一版、每版含 <em>breaking change</em>、不是 minor bump。常見升級驅動：</p>
<ul>
<li><strong>EOL pressure</strong>：PostgreSQL 每版 maintained 5 年、pg14 EOL 2026-11；pg13 EOL 2025-11 已過、production 仍跑 pg13 是 risk</li>
<li><strong>新 feature 需求</strong>：pg15 MERGE / pg16 parallel hash join / pg17 incremental backup</li>
<li><strong>Cloud provider 強制</strong>：Aurora / RDS 對 EOL 版本停 minor patch、planned upgrade 不能拖</li>
</ul>
<p>不升級的代價：security patch 停發、新功能不能用、跟新 client / extension 漸增不相容。</p>
<h2 id="upgrade-audit">Upgrade audit</h2>
<p>升級前的硬閘門 audit、跳過任一個 production 必踩：</p>
<h3 id="audit-1extension-相容性">Audit 1：Extension 相容性</h3>





<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">SELECT</span><span class="w"> </span><span class="n">extname</span><span class="p">,</span><span class="w"> </span><span class="n">extversion</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">pg_extension</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">extname</span><span class="w"> </span><span class="o">!=</span><span class="w"> </span><span class="s1">&#39;plpgsql&#39;</span><span class="p">;</span></span></span></code></pre></div><p>對每個 extension 跑：</p>
<ol>
<li>對應 target version (pg17) 是否有 release？</li>
<li>ABI break？（如 PostGIS major version 對應 PG major version）</li>
<li>是否有 maintainer 持續更新？（TimescaleDB 已不 cover pg17 部分 feature）</li>
</ol>
<p>常見 pg14 → pg17 需要 <em>先升 extension</em> 的：PostGIS / TimescaleDB / pgaudit / pg_partman / pg_repack。</p>
<h3 id="audit-2breaking-change-pull">Audit 2：Breaking change pull</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 查 release note 累積 breaking change（pg14 → pg17 跨 3 個 major）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># pg15: deprecated public schema 預設 write 權限變嚴</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"># pg16: regrole removed implicit casts</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># pg17: removed several deprecated columns from system catalogs</span></span></span></code></pre></div><p>對每個 breaking change：</p>
<ol>
<li>用 SQL grep / static analysis 找 application code 影響範圍</li>
<li>評估修改工作量（通常 50-95% 是 false alarm、5-10% 真實影響）</li>
<li>列出無法立刻修的、規劃 <em>逐 major 升</em> 而不是 <em>一次升 3 major</em></li>
</ol>
<h3 id="audit-3replication--logical-slot">Audit 3：Replication / logical slot</h3>





<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">SELECT</span><span class="w"> </span><span class="n">slot_name</span><span class="p">,</span><span class="w"> </span><span class="n">plugin</span><span class="p">,</span><span class="w"> </span><span class="n">slot_type</span><span class="p">,</span><span class="w"> </span><span class="n">active</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">pg_replication_slots</span><span class="p">;</span></span></span></code></pre></div><p>major version upgrade 後：</p>
<ul>
<li><strong>Physical replication slot</strong>：standby 必須先升級到 <em>相同 major version</em> 才能跟新 primary</li>
<li><strong>Logical replication slot</strong>：<strong>不跨 major version</strong>、必須在 upgrade 前 drop、之後重建（消費者重 init load）</li>
<li>對應 <a href="/blog/backend/01-database/vendors/postgresql/logical-replication-debezium/" data-link-title="PostgreSQL Logical Replication &#43; Debezium CDC：replication slot × failure × recovery 對照" data-link-desc="PostgreSQL logical replication slot 跟 Debezium CDC 的失效模式對照表：slot lag 撐爆 primary disk / schema change 斷流 / 初始 COPY 鎖表 / zombie slot 不釋放 / replay storm 後 offset reset；publication / subscription / pgoutput 配置、跟 Kafka outbox pattern 整合">Debezium CDC</a> consumer 必須重 init</li>
</ul>
<h3 id="audit-4config-參數變更">Audit 4：Config 參數變更</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># diff postgresql.conf default 14 vs 17</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># 重點: shared_preload_libraries / autovacuum_* / wal_level / synchronous_commit</span></span></span></code></pre></div><p>新 major version 預設值常變（pg14 → 17：<code>max_worker_processes</code> 預設變 / <code>unix_socket_directories</code> 行為差異）；自定 config 需逐項 review。</p>
<h3 id="audit-5statistics-重建計畫">Audit 5：Statistics 重建計畫</h3>
<p><code>pg_upgrade</code> 後 <code>pg_statistic</code> 重置、第一次跑 query plan 用空 stats、production 性能會塌；upgrade 計畫必須含：</p>
<ul>
<li><code>ANALYZE</code> 跑全 DB（小 DB ~10 分鐘、大 DB 1-3 小時）</li>
<li>多 stage <code>vacuumdb --analyze-in-stages</code> 先快速跑 baseline、再跑 full</li>
<li>Maintenance window 內預留 statistics 重建時間</li>
</ul>
<h2 id="升級方法選擇">升級方法選擇</h2>
<p>三種主流方法、依 downtime 容忍跟 DB 大小：</p>
<table>
  <thead>
      <tr>
          <th>方法</th>
          <th>Downtime</th>
          <th>風險</th>
          <th>適用</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>pg_upgrade --link</code></td>
          <td>10-30 分鐘</td>
          <td>data dir 跟 OS package 同 host、回退複雜</td>
          <td>&lt; 500GB、可接受 30 分鐘 downtime</td>
      </tr>
      <tr>
          <td>Logical replication</td>
          <td>切換瞬間（&lt; 1 分鐘）</td>
          <td>設定複雜、long-running migration window</td>
          <td>TB 級、低 downtime 需求</td>
      </tr>
      <tr>
          <td>Blue-green deployment</td>
          <td>切換瞬間</td>
          <td>雙倍硬體、cutover 期間需嚴格 traffic shifting</td>
          <td>Cloud-managed（Aurora / RDS 內建）</td>
      </tr>
  </tbody>
</table>
<h3 id="pg_upgrade---link-流程"><code>pg_upgrade --link</code> 流程</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 1. install pg17 binary（不啟動）</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"># 2. stop pg14</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">sudo systemctl stop postgresql@14
</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"><span class="c1"># 3. 跑 pg_upgrade（hard link、不複製資料）</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">sudo -u postgres /usr/lib/postgresql/17/bin/pg_upgrade <span class="se">\
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="se"></span>  --old-bindir<span class="o">=</span>/usr/lib/postgresql/14/bin <span class="se">\
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="se"></span>  --new-bindir<span class="o">=</span>/usr/lib/postgresql/17/bin <span class="se">\
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="se"></span>  --old-datadir<span class="o">=</span>/var/lib/postgresql/14/main <span class="se">\
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="se"></span>  --new-datadir<span class="o">=</span>/var/lib/postgresql/17/main <span class="se">\
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="se"></span>  --link <span class="se">\
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="se"></span>  --jobs<span class="o">=</span><span class="m">8</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1"># 4. 啟動 pg17</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">sudo systemctl start postgresql@17
</span></span><span class="line"><span class="ln">16</span><span class="cl">
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="c1"># 5. 跑 pg_upgrade 產出的 analyze script</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">sudo -u postgres /tmp/analyze_new_cluster.sh</span></span></code></pre></div><p><code>--link</code> 用 hard link、不複製 data dir、適合大 DB；缺點是 <em>回退到 pg14 不可能</em>（data dir 已被新 pg 修改）— 必須有完整 backup + tested restore。</p>
<h2 id="故障演練">故障演練</h2>
<h3 id="case-1extension-相容性沒先-auditupgrade-後啟動失敗">Case 1：Extension 相容性沒先 audit、upgrade 後啟動失敗</h3>
<p><strong>徵兆</strong>：pg_upgrade 跑完、<code>pg_ctl start</code> 失敗、log 顯示 <code>could not load library &quot;timescaledb-2.13.so&quot;</code>。</p>
<p><strong>根因</strong>：TimescaleDB 對應 pg14、pg17 需要 TimescaleDB 2.16+；pg_upgrade 階段沒 check、library path 找不到。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>Pre-upgrade audit</strong>：每個 extension 列出 target version 對應、預先升 extension（在 pg14 上跑、用 <code>ALTER EXTENSION ... UPDATE</code>）</li>
<li><strong>回退</strong>：data dir 用 <code>--link</code> 已不可逆、必須從 backup restore + 重試</li>
<li><strong>預防</strong>：staging 環境完整 dry-run、production upgrade 前已知 path 都驗證過</li>
</ol>
<h3 id="case-2application-用-deprecated-sql跑壞">Case 2：Application 用 deprecated SQL、跑壞</h3>
<p><strong>徵兆</strong>：upgrade 後某些 application query 直接 error <code>ERROR: type &quot;regtype&quot; does not have a cast</code>。</p>
<p><strong>根因</strong>：pg16 移除了某些隱性 cast、application code 用了 implicit cast、現在 explicit cast 才能跑。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>Pre-upgrade</strong>：跑 application test suite 對 pg17 staging、catch 不相容 query</li>
<li><strong>緊急</strong>：staging 找到的 query 在 production 改 application code、deploy 後再 upgrade DB</li>
<li><strong>長期</strong>：application code 用 ORM / query builder、避免 raw SQL 對 PG version-specific behavior 依賴</li>
</ol>
<h3 id="case-3analyze-沒跑production-query-性能崩">Case 3：<code>ANALYZE</code> 沒跑、production query 性能崩</h3>
<p><strong>徵兆</strong>：upgrade 後 5 分鐘、application latency p99 從 50ms 衝到 5000ms；query plan 從 index scan 退化到 seq scan。</p>
<p><strong>根因</strong>：<code>pg_upgrade</code> 重置 <code>pg_statistic</code>、planner 用空 stats 跑 plan、無法估 selectivity、保守選 seq scan。</p>
<p><strong>修法</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># upgrade 完立刻跑 (順序)</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">vacuumdb --all --analyze-in-stages --jobs<span class="o">=</span><span class="m">4</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"># Stage 1: 最少 stats（快、~5 分鐘）</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># Stage 2: 中 stats（~30 分鐘）</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># Stage 3: 完整 stats（1-3 小時）</span></span></span></code></pre></div><p><code>--analyze-in-stages</code> 分 3 階段、第 1 階段就能讓 planner 做大致正確的決策；可在 maintenance window 內接受 stage 3 仍在跑。</p>
<h3 id="case-4logical-replication-slot-漏-dropdebezium-卡死">Case 4：Logical replication slot 漏 drop、Debezium 卡死</h3>
<p><strong>徵兆</strong>：upgrade 完開機後、Debezium connector log 顯示 <code>slot not found</code>、消費停滯；Kafka downstream 訊息斷流。</p>
<p><strong>根因</strong>：logical replication slot 不跨 major version、<code>pg_upgrade</code> 不自動處理 logical slot；upgrade 前沒 drop、新 cluster 上 slot 不存在。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>Pre-upgrade</strong>：列所有 logical replication slot、Debezium 暫停 consumer + drop slot</li>
<li><strong>Upgrade 後重建</strong>：用新 LSN starting position 建 slot、Debezium snapshot.mode=schema_only_recovery 取代 initial（避免重 init load）</li>
<li><strong>架構</strong>：未來考慮用 <em>outbox pattern</em>、CDC 只追 outbox 表、降低 logical slot 重建成本</li>
</ol>
<h3 id="case-5standby-沒同步升replication-斷">Case 5：Standby 沒同步升、replication 斷</h3>
<p><strong>徵兆</strong>：primary 升 pg17 後、standby 仍 pg14、replication 不通；<code>pg_stat_replication</code> 沒 standby connection。</p>
<p><strong>根因</strong>：streaming replication 不跨 major version；standby 必須 <em>先升</em> 或 <em>upgrade 後重 base backup</em>。</p>
<p><strong>修法</strong>：</p>
<p>兩種策略：</p>
<ol>
<li><strong>In-place upgrade standby</strong>：standby 也跑 <code>pg_upgrade</code>、但要先 stop streaming、升完重接（standby 端 archive_command + restore_command 對齊）</li>
<li><strong>Rebuild standby</strong>：upgrade primary 完、standby 跑 <code>pg_basebackup</code> 重建（適合 standby 容量小、network 快）</li>
</ol>
<p>Patroni HA 環境：用 <em>rolling upgrade</em> — 先升 sync standby、failover 過去、再升舊 primary 變新 standby。複雜度高、需要 staging 演練。</p>
<h2 id="capacity--downtime-trade-off">Capacity / downtime trade-off</h2>
<table>
  <thead>
      <tr>
          <th>方法</th>
          <th>Downtime 估算（500GB DB）</th>
          <th>硬體成本</th>
          <th>風險</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>pg_upgrade --link</code></td>
          <td>15-30 分鐘（含 ANALYZE 1st stage）</td>
          <td>同當前</td>
          <td>高（不可逆）</td>
      </tr>
      <tr>
          <td><code>pg_upgrade --clone</code></td>
          <td>1-3 小時</td>
          <td>暫時 2x storage</td>
          <td>中</td>
      </tr>
      <tr>
          <td>Logical replication</td>
          <td>&lt; 1 分鐘 cutover</td>
          <td>暫時 2x compute + storage</td>
          <td>中（複雜）</td>
      </tr>
      <tr>
          <td>Blue-green</td>
          <td>切換瞬間（&lt; 30 秒）</td>
          <td>持續 2x（cutover 後可拆）</td>
          <td>低（cloud managed）</td>
      </tr>
  </tbody>
</table>
<p>實務 default：</p>
<ul>
<li>&lt; 100GB、可接受 30 分鐘 downtime：<code>pg_upgrade --link</code></li>
<li>100GB - 1TB、要求 &lt; 5 分鐘 downtime：logical replication（標準 PostgreSQL）</li>
<li>1TB+ 或 SLA 嚴格：blue-green via Aurora / RDS（cloud managed）</li>
</ul>
<h2 id="整合--下一步">整合 / 下一步</h2>
<h3 id="跟-patroni-ha-整合">跟 <a href="/blog/backend/01-database/vendors/postgresql/patroni-ha/" data-link-title="PostgreSQL Patroni HA：從 leader 失聯到 client 重連的 5 段 failover lifecycle" data-link-desc="Patroni 把 PostgreSQL HA 拆成 detection / election / promotion / reconfiguration / recovery 五段 lifecycle、每段都有獨立配置跟 failure mode；DCS quorum &#43; watchdog 防 split-brain、async/sync replication 取捨、5 個 production 踩雷、跟 PgBouncer / HAProxy / cert-manager 整合">Patroni HA</a> 整合</h3>
<p>HA cluster upgrade 流程：</p>
<ol>
<li>升新 standby（不在 cluster 中、physical / logical replicate 過去）</li>
<li>Promote 新 standby、舊 cluster failover 過去</li>
<li>重建剩餘 standby</li>
</ol>
<p>Patroni 17+ 支援 <a href="/blog/backend/01-database/vendors/postgresql/patroni-ha/" data-link-title="PostgreSQL Patroni HA：從 leader 失聯到 client 重連的 5 段 failover lifecycle" data-link-desc="Patroni 把 PostgreSQL HA 拆成 detection / election / promotion / reconfiguration / recovery 五段 lifecycle、每段都有獨立配置跟 failure mode；DCS quorum &#43; watchdog 防 split-brain、async/sync replication 取捨、5 個 production 踩雷、跟 PgBouncer / HAProxy / cert-manager 整合">logical slot 跨 failover</a> — major version upgrade 期間 logical consumer 影響降低。</p>
<h3 id="跟-monitoring-整合">跟 monitoring 整合</h3>
<p>upgrade 期間特別關注的 metric：</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">-- Pre-upgrade baseline
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">pg_database_size</span><span class="p">(</span><span class="s1">&#39;myapp&#39;</span><span class="p">),</span><span class="w"> </span><span class="k">version</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></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="c1">-- Post-upgrade verification
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">pg_database_size</span><span class="p">(</span><span class="s1">&#39;myapp&#39;</span><span class="p">),</span><span class="w"> </span><span class="k">version</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="k">SELECT</span><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">FROM</span><span class="w"> </span><span class="n">pg_stat_user_tables</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">last_analyze</span><span class="w"> </span><span class="k">IS</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="c1">-- 應該 = 0、若有未 analyze 表、ANALYZE 沒跑完</span></span></span></code></pre></div><p>Prometheus alert 三條：<code>pg_database_size</code> upgrade 後差異 &lt; 1%、<code>pg_stat_replication</code> lag &lt; 10s、<code>pg_query_p99_latency</code> 對 baseline &lt; 1.5x。</p>
<h3 id="下一步議題">下一步議題</h3>
<ul>
<li><strong>Aurora major version upgrade</strong>：blue-green deployment 是 default、流程跟 self-managed 完全不同、見 <a href="/blog/backend/01-database/vendors/postgresql/migrate-to-aurora/" data-link-title="PostgreSQL → Aurora Migration：protocol 相容、operational 重設計" data-link-desc="Aurora 號稱 PostgreSQL-compatible 但 operational model 不同（storage decouple / cluster endpoint / instance class / 自家備份）；遷移流程是混合（protocol drop-in &#43; operational phased）、5 個 production 踩雷（extension 不支援 / replication slot 不直通 / autovacuum 行為差 / IAM 認證強制 / cost model 換算）、跟 Patroni / read replica / DR 對位">PostgreSQL → Aurora migration</a> 對位段</li>
<li><strong>Cross-major version skip upgrade</strong>：pg13 → pg17 跨 4 major、breaking change 累積、建議 <em>逐 major 升</em> 而不是 <em>single hop</em></li>
<li><strong>Extension lifecycle 管理</strong>：自動 audit extension 跟 PG version compatibility、每 quarter 跑 dry-run</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li>上游 vendor 頁：<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></li>
<li>平行 deep article：<a href="/blog/backend/01-database/vendors/postgresql/patroni-ha/" data-link-title="PostgreSQL Patroni HA：從 leader 失聯到 client 重連的 5 段 failover lifecycle" data-link-desc="Patroni 把 PostgreSQL HA 拆成 detection / election / promotion / reconfiguration / recovery 五段 lifecycle、每段都有獨立配置跟 failure mode；DCS quorum &#43; watchdog 防 split-brain、async/sync replication 取捨、5 個 production 踩雷、跟 PgBouncer / HAProxy / cert-manager 整合">Patroni HA</a> / <a href="/blog/backend/01-database/vendors/postgresql/pitr-wal-archiving/" data-link-title="PostgreSQL PITR &#43; WAL archiving：從 base backup 到 point-in-time recovery 的完整鏈" data-link-desc="Base backup &#43; WAL archive 構成 PITR 的雙軌資料、archive_command &#43; restore_command 配置、用 pgBackRest / WAL-G 替代手寫腳本、5 個 production 踩雷（archive 靜默失敗 / archive lag / 錯誤 target time / base backup 過期未清 / timeline 分歧 recovery 模糊）、跟 Patroni &#43; monitoring 整合">PITR + WAL Archiving</a> / <a href="/blog/backend/01-database/vendors/postgresql/logical-replication-debezium/" data-link-title="PostgreSQL Logical Replication &#43; Debezium CDC：replication slot × failure × recovery 對照" data-link-desc="PostgreSQL logical replication slot 跟 Debezium CDC 的失效模式對照表：slot lag 撐爆 primary disk / schema change 斷流 / 初始 COPY 鎖表 / zombie slot 不釋放 / replay storm 後 offset reset；publication / subscription / pgoutput 配置、跟 Kafka outbox pattern 整合">Logical Replication + Debezium</a></li>
<li>對位 migration：<a href="/blog/backend/01-database/vendors/postgresql/migrate-to-aurora/" data-link-title="PostgreSQL → Aurora Migration：protocol 相容、operational 重設計" data-link-desc="Aurora 號稱 PostgreSQL-compatible 但 operational model 不同（storage decouple / cluster endpoint / instance class / 自家備份）；遷移流程是混合（protocol drop-in &#43; operational phased）、5 個 production 踩雷（extension 不支援 / replication slot 不直通 / autovacuum 行為差 / IAM 認證強制 / cost model 換算）、跟 Patroni / read replica / DR 對位">PostgreSQL → Aurora</a></li>
<li>Methodology：<a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章的寫作方法論</a> / <a href="/blog/posts/migration-playbook-%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84stage-0-variant-%E8%A6%8F%E5%8A%83%E6%8A%8A-collapse-%E7%8E%87%E5%BE%9E-60-%E9%99%8D%E5%88%B0-0/" data-link-title="Migration Playbook 方法論的演化紀錄：Stage 0 variant 規劃把 collapse 率從 60% 降到 0%" data-link-desc="跨 vendor migration playbook 需要獨立寫作方法論的依據，以及這套方法論從三輪 batch dogfood 中演化出來的驗證證據。">Migration playbook methodology</a>（本文驗證 <em>漏類</em>）</li>
</ul>
]]></content:encoded></item><item><title>PostgreSQL Partition Redesign：當 monthly partition 越跑越慢</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/partition-redesign/</link><pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/partition-redesign/</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。對應 &lt;a href="https://tarrragon.github.io/blog/report/content-structure-by-max-diff-dimension/" data-link-title="Process content 結構由最大差異維度決定、不是 universal phased" data-link-desc="跨 X process content（migration / upgrade / rollout / playbook）的結構由 source / target 之間 *差異維度組合* 決定、不存在 universal phased 模板；6 種 migration / process type 實證（schema 差 / drop-in / operational / multi-tool / paradigm / topology re-layout）跑出 6 種不同結構；寫作前必須做 *6 維 diff dimension audit* 才能決定結構、跳過會套錯模板">#127 Type F「Topology re-layout」&lt;/a> 第 2 個 dogfood（第 1 個是 &lt;a href="https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/redis/cluster-resharding/" data-link-title="Redis Cluster Re-sharding：source = target，但 topology 重劃的 5 段流程" data-link-desc="Redis cluster re-sharding 是 5 type migration 漏類實證 — source / target 同 cluster、無 schema / paradigm 差、但 16384 slot 重分配是核心；本文涵蓋 4 種 re-sharding driver、slot migration 機制、redis-cli --cluster rebalance / reshard 工具、5 個 production 踩雷（cluster busy / replica lag / client cache stale / cross-slot transaction / monitor gap）">Redis cluster re-sharding&lt;/a>）— 驗證 Type F anatomy 在不同 vendor 上的通用性。&lt;/p>&lt;/blockquote>
&lt;h2 id="為什麼-monthly-partition-越跑越慢">為什麼 monthly partition 越跑越慢&lt;/h2>
&lt;p>上線時 monthly range partition 設計很合理 — 每月一個 partition、12 個月一年、partition_pruning 在 &lt;code>WHERE event_time &amp;gt;= '2026-05-01'&lt;/code> 時跑單 partition、查詢快。但業務跑了 18 個月後：&lt;/p>
&lt;ul>
&lt;li>每月 partition size 從 50GB 漲到 500GB（流量 10x）&lt;/li>
&lt;li>單月查詢 &lt;code>WHERE event_time BETWEEN '2026-05-01' AND '2026-05-15'&lt;/code> 仍掃整月 500GB（partition_pruning 粒度只到 month）&lt;/li>
&lt;li>Vacuum 一個月 partition 需要 6-8 小時、跑不進 maintenance window&lt;/li>
&lt;li>DROP 老 partition 釋放 storage 是 monthly cadence、但 retention policy 要求 daily granularity&lt;/li>
&lt;/ul>
&lt;p>partition 設計需要 &lt;em>redesign&lt;/em>、不是「optimize」 — 從 monthly range partition 改成 daily range partition、partition 數量從 36 個（3 年 retention）變 1095 個。&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。對應 <a href="/blog/report/content-structure-by-max-diff-dimension/" data-link-title="Process content 結構由最大差異維度決定、不是 universal phased" data-link-desc="跨 X process content（migration / upgrade / rollout / playbook）的結構由 source / target 之間 *差異維度組合* 決定、不存在 universal phased 模板；6 種 migration / process type 實證（schema 差 / drop-in / operational / multi-tool / paradigm / topology re-layout）跑出 6 種不同結構；寫作前必須做 *6 維 diff dimension audit* 才能決定結構、跳過會套錯模板">#127 Type F「Topology re-layout」</a> 第 2 個 dogfood（第 1 個是 <a href="/blog/backend/02-cache-redis/vendors/redis/cluster-resharding/" data-link-title="Redis Cluster Re-sharding：source = target，但 topology 重劃的 5 段流程" data-link-desc="Redis cluster re-sharding 是 5 type migration 漏類實證 — source / target 同 cluster、無 schema / paradigm 差、但 16384 slot 重分配是核心；本文涵蓋 4 種 re-sharding driver、slot migration 機制、redis-cli --cluster rebalance / reshard 工具、5 個 production 踩雷（cluster busy / replica lag / client cache stale / cross-slot transaction / monitor gap）">Redis cluster re-sharding</a>）— 驗證 Type F anatomy 在不同 vendor 上的通用性。</p></blockquote>
<h2 id="為什麼-monthly-partition-越跑越慢">為什麼 monthly partition 越跑越慢</h2>
<p>上線時 monthly range partition 設計很合理 — 每月一個 partition、12 個月一年、partition_pruning 在 <code>WHERE event_time &gt;= '2026-05-01'</code> 時跑單 partition、查詢快。但業務跑了 18 個月後：</p>
<ul>
<li>每月 partition size 從 50GB 漲到 500GB（流量 10x）</li>
<li>單月查詢 <code>WHERE event_time BETWEEN '2026-05-01' AND '2026-05-15'</code> 仍掃整月 500GB（partition_pruning 粒度只到 month）</li>
<li>Vacuum 一個月 partition 需要 6-8 小時、跑不進 maintenance window</li>
<li>DROP 老 partition 釋放 storage 是 monthly cadence、但 retention policy 要求 daily granularity</li>
</ul>
<p>partition 設計需要 <em>redesign</em>、不是「optimize」 — 從 monthly range partition 改成 daily range partition、partition 數量從 36 個（3 年 retention）變 1095 個。</p>
<p><a href="/blog/report/content-structure-by-max-diff-dimension/" data-link-title="Process content 結構由最大差異維度決定、不是 universal phased" data-link-desc="跨 X process content（migration / upgrade / rollout / playbook）的結構由 source / target 之間 *差異維度組合* 決定、不存在 universal phased 模板；6 種 migration / process type 實證（schema 差 / drop-in / operational / multi-tool / paradigm / topology re-layout）跑出 6 種不同結構；寫作前必須做 *6 維 diff dimension audit* 才能決定結構、跳過會套錯模板">diff dimension audit</a> 結果：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>評估</th>
          <th>等級</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Schema / API</td>
          <td>同 PostgreSQL、同 table 定義、partition key 不變</td>
          <td>Low</td>
      </tr>
      <tr>
          <td>Operational model</td>
          <td>同 PostgreSQL operational stack</td>
          <td>Low</td>
      </tr>
      <tr>
          <td>Paradigm</td>
          <td>同 OLTP RDBMS</td>
          <td>Low</td>
      </tr>
      <tr>
          <td>Components</td>
          <td>同 1 個 DB</td>
          <td>Low</td>
      </tr>
      <tr>
          <td>Application change</td>
          <td>不改（partition_pruning 透明）</td>
          <td>Low</td>
      </tr>
      <tr>
          <td><strong>Data topology</strong></td>
          <td><strong>Partition strategy 從 monthly → daily</strong></td>
          <td><strong>High</strong></td>
      </tr>
  </tbody>
</table>
<p>6 維皆 Low + topology High = <a href="/blog/report/content-structure-by-max-diff-dimension/" data-link-title="Process content 結構由最大差異維度決定、不是 universal phased" data-link-desc="跨 X process content（migration / upgrade / rollout / playbook）的結構由 source / target 之間 *差異維度組合* 決定、不存在 universal phased 模板；6 種 migration / process type 實證（schema 差 / drop-in / operational / multi-tool / paradigm / topology re-layout）跑出 6 種不同結構；寫作前必須做 *6 維 diff dimension audit* 才能決定結構、跳過會套錯模板">Type F「Topology re-layout」</a>。</p>
<h2 id="pre-layout-analysispartition-不平衡偵測">Pre-layout analysis：partition 不平衡偵測</h2>
<p>執行 redesign 前必須先量化當前 topology：</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">-- 1. 每 partition size + row count
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</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">child</span><span class="p">.</span><span class="n">relname</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">partition_name</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="n">pg_size_pretty</span><span class="p">(</span><span class="n">pg_relation_size</span><span class="p">(</span><span class="n">child</span><span class="p">.</span><span class="n">oid</span><span class="p">))</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="k">size</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">child</span><span class="p">.</span><span class="n">reltuples</span><span class="p">::</span><span class="nb">bigint</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">estimated_rows</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">pg_stat_get_last_vacuum_time</span><span class="p">(</span><span class="n">child</span><span class="p">.</span><span class="n">oid</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">last_vacuum</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">FROM</span><span class="w"> </span><span class="n">pg_inherits</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w"></span><span class="k">JOIN</span><span class="w"> </span><span class="n">pg_class</span><span class="w"> </span><span class="n">parent</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">pg_inherits</span><span class="p">.</span><span class="n">inhparent</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">parent</span><span class="p">.</span><span class="n">oid</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">JOIN</span><span class="w"> </span><span class="n">pg_class</span><span class="w"> </span><span class="n">child</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">pg_inherits</span><span class="p">.</span><span class="n">inhrelid</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">child</span><span class="p">.</span><span class="n">oid</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">WHERE</span><span class="w"> </span><span class="n">parent</span><span class="p">.</span><span class="n">relname</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;events&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w"></span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">pg_relation_size</span><span class="p">(</span><span class="n">child</span><span class="p">.</span><span class="n">oid</span><span class="p">)</span><span class="w"> </span><span class="k">DESC</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">-- 2. partition_pruning 命中率
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1"></span><span class="k">EXPLAIN</span><span class="w"> </span><span class="p">(</span><span class="k">ANALYZE</span><span class="p">,</span><span class="w"> </span><span class="n">BUFFERS</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="k">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><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">16</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="n">event_time</span><span class="w"> </span><span class="k">BETWEEN</span><span class="w"> </span><span class="s1">&#39;2026-05-01&#39;</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="s1">&#39;2026-05-15&#39;</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="c1">-- 期望: 只 scan 1 partition (target: daily) 或 1 partition (current: monthly)
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="c1">-- 觀察: monthly 設計下、即使 query 只跨 15 天、planner 仍 scan 整月 partition (~500GB)
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="w"></span><span class="c1">-- 3. 找 partition imbalance
</span></span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w">
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="w">  </span><span class="n">to_char</span><span class="p">(</span><span class="n">event_time</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;YYYY-MM&#39;</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="k">month</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">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="k">row_count</span><span class="w">
</span></span></span><span class="line"><span class="ln">24</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">25</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="w">
</span></span></span><span class="line"><span class="ln">26</span><span class="cl"><span class="w"></span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="mi">2</span><span class="w"> </span><span class="k">DESC</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="c1">-- 找 hot month / cold month、判斷 redesign 後分佈</span></span></span></code></pre></div><p>Pre-layout 階段的 output：</p>
<ul>
<li><strong>當前 topology 量化</strong>：36 monthly partition、總 size 1.8TB、最大 partition 500GB、最小 50GB</li>
<li><strong>Hot key 分佈</strong>：80% 流量集中最近 3 個月</li>
<li><strong>Redesign 目標</strong>：daily partition、最近 3 個月 hot daily / 3 個月 + 之前 cold weekly / 1 年 + 之前 monthly（sub-partition strategy）</li>
<li><strong>Migration scope</strong>：1095 個 partition 不直接全建、按 retention policy 階段性</li>
</ul>
<h2 id="re-layout-機制attach--detach-線上重劃">Re-layout 機制：ATTACH / DETACH 線上重劃</h2>
<p>PostgreSQL 不支援「直接改 partition strategy」、必須走 <em>新 partition tree + 資料搬遷</em>：</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">-- 1. 建新 daily partition table (parallel to events)
</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">events_daily</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">id</span><span class="w"> </span><span class="nb">bigint</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="n">event_time</span><span class="w"> </span><span class="n">timestamptz</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">payload</span><span class="w"> </span><span class="n">jsonb</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 class="n">PARTITION</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">RANGE</span><span class="w"> </span><span class="p">(</span><span class="n">event_time</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></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w"></span><span class="c1">-- 2. 預建未來 90 天 daily partition
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span><span class="k">SELECT</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">format</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="s1">&#39;CREATE TABLE events_daily_%s PARTITION OF events_daily FOR VALUES FROM (%L) TO (%L)&#39;</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">to_char</span><span class="p">(</span><span class="n">d</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;YYYY_MM_DD&#39;</span><span class="p">),</span><span class="w"> </span><span class="n">d</span><span class="p">,</span><span class="w"> </span><span class="n">d</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="nb">interval</span><span class="w"> </span><span class="s1">&#39;1 day&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">  </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">FROM</span><span class="w"> </span><span class="n">generate_series</span><span class="p">(</span><span class="k">current_date</span><span class="p">,</span><span class="w"> </span><span class="k">current_date</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="nb">interval</span><span class="w"> </span><span class="s1">&#39;90 days&#39;</span><span class="p">,</span><span class="w"> </span><span class="nb">interval</span><span class="w"> </span><span class="s1">&#39;1 day&#39;</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">d</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></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w"></span><span class="c1">-- 3. dual-write phase: application 同寫 events + events_daily
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="c1">-- (用 trigger 或 application-side)
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="c1"></span><span class="k">CREATE</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">FUNCTION</span><span class="w"> </span><span class="n">dual_write_events</span><span class="p">()</span><span class="w"> </span><span class="k">RETURNS</span><span class="w"> </span><span class="k">TRIGGER</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">$$</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">BEGIN</span><span class="w">
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="w">  </span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="n">events_daily</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="k">NEW</span><span class="p">.</span><span class="o">*</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="w">  </span><span class="k">RETURN</span><span class="w"> </span><span class="k">NEW</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><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">23</span><span class="cl"><span class="w"></span><span class="err">$$</span><span class="w"> </span><span class="k">LANGUAGE</span><span class="w"> </span><span class="n">plpgsql</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="w"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">TRIGGER</span><span class="w"> </span><span class="n">events_dual_write</span><span class="w">
</span></span></span><span class="line"><span class="ln">26</span><span class="cl"><span class="w"></span><span class="k">AFTER</span><span class="w"> </span><span class="k">INSERT</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">events</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">FOR</span><span class="w"> </span><span class="k">EACH</span><span class="w"> </span><span class="k">ROW</span><span class="w"> </span><span class="k">EXECUTE</span><span class="w"> </span><span class="k">FUNCTION</span><span class="w"> </span><span class="n">dual_write_events</span><span class="p">();</span><span class="w">
</span></span></span><span class="line"><span class="ln">28</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">29</span><span class="cl"><span class="w"></span><span class="c1">-- 4. backfill historical data per partition
</span></span></span><span class="line"><span class="ln">30</span><span class="cl"><span class="c1"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="n">events_daily</span><span class="w">
</span></span></span><span class="line"><span class="ln">31</span><span class="cl"><span class="w"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><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">32</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="n">event_time</span><span class="w"> </span><span class="o">&gt;=</span><span class="w"> </span><span class="s1">&#39;2026-05-01&#39;</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="n">event_time</span><span class="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="s1">&#39;2026-05-02&#39;</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">33</span><span class="cl"><span class="w"></span><span class="c1">-- ... 每天跑一個 day partition、avoid long transaction
</span></span></span><span class="line"><span class="ln">34</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">35</span><span class="cl"><span class="w"></span><span class="c1">-- 5. cutover: rename swap
</span></span></span><span class="line"><span class="ln">36</span><span class="cl"><span class="c1"></span><span class="k">BEGIN</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">37</span><span class="cl"><span class="w"></span><span class="k">ALTER</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="k">RENAME</span><span class="w"> </span><span class="k">TO</span><span class="w"> </span><span class="n">events_old</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">38</span><span class="cl"><span class="w"></span><span class="k">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">events_daily</span><span class="w"> </span><span class="k">RENAME</span><span class="w"> </span><span class="k">TO</span><span class="w"> </span><span class="n">events</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">39</span><span class="cl"><span class="w"></span><span class="k">DROP</span><span class="w"> </span><span class="k">TRIGGER</span><span class="w"> </span><span class="n">events_dual_write</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">events_old</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">40</span><span class="cl"><span class="w"></span><span class="k">COMMIT</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">41</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">42</span><span class="cl"><span class="w"></span><span class="c1">-- 6. 觀察 1-2 週、DROP events_old</span></span></span></code></pre></div><p>關鍵：rename swap 是 <em>single transaction</em>、cutover 瞬間發生；application connection 不需重連、但 prepared statement cache 可能要刷新。</p>
<h2 id="execution-flow-per-step">Execution flow per-step</h2>
<p>5 段、每段含 rollback boundary：</p>
<table>
  <thead>
      <tr>
          <th>Step</th>
          <th>動作</th>
          <th>Rollback boundary</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1 預建 partition</td>
          <td>建 events_daily + 90 天 partition、不影響 production</td>
          <td>DROP events_daily、無 impact</td>
      </tr>
      <tr>
          <td>2 Dual-write</td>
          <td>加 trigger 同寫兩端、observe diff</td>
          <td>DROP trigger、events_daily 留作 cleanup</td>
      </tr>
      <tr>
          <td>3 Backfill</td>
          <td>逐日 backfill 歷史資料、用 CHECK constraint 確保完整性</td>
          <td>DROP backfilled partition、不影響 source events</td>
      </tr>
      <tr>
          <td>4 Verify</td>
          <td>對 sample query 跑 events vs events_daily、確認 row count 一致</td>
          <td>仍在 dual-write、發現 diff 可暫停 cutover</td>
      </tr>
      <tr>
          <td>5 Cutover</td>
          <td>Rename swap</td>
          <td><strong>不可逆</strong>、回退需 reverse rename + dual-write restart</td>
      </tr>
  </tbody>
</table>
<p>Step 5 是不可逆邊界、應該排在 <em>低流量 maintenance window</em> 跑、且 cutover 前必須有 backup checkpoint。</p>
<h2 id="production-故障演練">Production 故障演練</h2>
<h3 id="case-1backfill-期間-long-transaction-阻塞-vacuum">Case 1：Backfill 期間 long transaction 阻塞 vacuum</h3>
<p><strong>徵兆</strong>：backfill 跑 6 小時的 <code>INSERT INTO events_daily SELECT * FROM events WHERE ...</code>、期間 events 表的 autovacuum 完全不跑、dead tuple 累積、production query 變慢。</p>
<p><strong>根因</strong>：PostgreSQL transaction 期間 <em>xmin horizon 鎖死</em>、vacuum 只能回收「不會被任何 active transaction 看到」的 dead tuple；long backfill = long open transaction、vacuum 失效。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>拆 batch INSERT</strong>：每日 backfill 拆成 small batch（10 萬 row 一個 transaction）、每個 commit 釋放 xmin</li>
<li><strong>用 COPY 不用 INSERT</strong>：<code>COPY events_daily FROM (SELECT * FROM events WHERE ...)</code> 是 PG 對 batch 最快 + 對 vacuum 影響小</li>
<li><strong>Backfill 跑在 standby</strong>：用 logical replication 從 standby 拉資料、不在 primary 跑長 transaction</li>
</ol>
<h3 id="case-2trigger-dual-write-對-application-造成-latency">Case 2：Trigger dual-write 對 application 造成 latency</h3>
<p><strong>徵兆</strong>：加 trigger 後 application 寫入 latency p99 從 5ms 漲到 25-50ms；high-throughput batch job 直接 timeout。</p>
<p><strong>根因</strong>：每筆 INSERT 都觸發 trigger function 跑一次 INSERT 到 events_daily、IO 雙倍、index 也雙倍維護。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>改 application-side dual-write</strong>：application code 顯式寫兩端、用 connection pool batch 攤平 IO</li>
<li><strong>用 logical replication slot</strong>：events → events_daily 用 logical replication 取代 trigger、降 IO 衝擊</li>
<li><strong>dual-write 時間最小化</strong>：trigger 只在 backfill + verify 期間打開、cutover 前關掉</li>
</ol>
<h3 id="case-3partition_pruning-沒命中planner-仍掃所有-partition">Case 3：Partition_pruning 沒命中、planner 仍掃所有 partition</h3>
<p><strong>徵兆</strong>：cutover 完成後、application 端某些 query latency 從 200ms 跳到 5000ms；EXPLAIN 顯示 <code>Append</code> 下面所有 1095 個 partition 都被 scan。</p>
<p><strong>根因</strong>：partition 數量爆到 1000+、planner planning_time 對某些 query 變長（含 prepared statement 沒帶 partition key bound）；或 query 用了 <code>WHERE event_time = some_function(now())</code>、planning-time pruning 不觸發。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong><code>enable_partition_pruning = on</code></strong> 預設、確認沒被 disable</li>
<li><strong>PG 11+ runtime pruning</strong>：prepared statement 用 generic plan、runtime pruning 補位</li>
<li><strong>Sub-partition strategy</strong>：1095 個 daily 太多、改 <em>最近 90 天 daily / 之前 monthly</em> 混合 strategy、減 partition count</li>
<li><strong>Planner statistics</strong>：跑 <code>ANALYZE</code> 重建 statistics、partition 樹太大時 planner 需新 stats</li>
</ol>
<h3 id="case-4constraint-exclusion-失敗跨-partition-unique-不-enforce">Case 4：Constraint exclusion 失敗、跨 partition unique 不 enforce</h3>
<p><strong>徵兆</strong>：cutover 後發現某 user 的 event 在多個 partition 都有、unique constraint <code>(user_id, event_id)</code> 沒 enforce；data audit 抓到 duplicate。</p>
<p><strong>根因</strong>：PostgreSQL partition table 的 <code>UNIQUE</code> constraint <em>必須包含 partition key</em>；本來 monthly partition 下 <code>UNIQUE (user_id, event_id)</code> 加上 <code>event_time</code>（partition key）變 <code>UNIQUE (user_id, event_id, event_time)</code>、實際語意是「同月同 user 同 event_id 唯一」；改 daily 後變「同日同 user 同 event_id 唯一」— unique scope 從月變天、原本月內跨日 dedup 失效。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>Pre-redesign</strong>：明示 unique constraint 的 <em>時間 scope</em>、redesign 後 scope 縮小是否可接受</li>
<li><strong>Application-side dedup</strong>：跨 partition 唯一性走 application 層 lookup（用 Redis SETEX 暫存 key）</li>
<li><strong>退到 non-partitioned dedup 表</strong>：建獨立 user_events_dedup 表、application 寫入前先 lookup</li>
</ol>
<h3 id="case-5drop-老-partition-太頻繁shared_buffers-cache-miss-爆">Case 5：DROP 老 partition 太頻繁、shared_buffers cache miss 爆</h3>
<p><strong>徵兆</strong>：daily partition 上線後、每天凌晨 cron DROP <code>events_2025_05_18</code>（90 天前）；DROP 後 shared_buffers 大量 invalidate、application 端 query latency p99 從 10ms 跳到 100-200ms 持續 30 分鐘。</p>
<p><strong>根因</strong>：PostgreSQL shared_buffers cache 對被 DROP 表的 page 全部 invalidate；DROP 大 partition（10GB+）後 cache hit rate 從 99% 掉到 60%、application 等 disk IO。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>DROP 跑在 off-peak</strong>：凌晨 3-4 點 cron、避開業務高峰</li>
<li><strong>預熱 next partition</strong>：DROP 前用 <code>pg_prewarm</code> 主動 load 熱 partition 進 cache</li>
<li><strong>改 DETACH + DROP TABLE delayed</strong>：DETACH 是 fast、DROP TABLE 排到 weekly batch、降頻率</li>
</ol>
<h2 id="capacity--cost">Capacity / cost</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>Monthly partition (current)</th>
          <th>Daily partition (target)</th>
          <th>Trade-off</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Partition count</td>
          <td>36 (3 年 retention)</td>
          <td>1095 (3 年 retention)</td>
          <td>30x partition count、planner cost 略升</td>
      </tr>
      <tr>
          <td>Single partition size</td>
          <td>50-500GB</td>
          <td>1-20GB</td>
          <td>Daily 更易 vacuum</td>
      </tr>
      <tr>
          <td>DROP old data</td>
          <td>Monthly cadence</td>
          <td>Daily cadence</td>
          <td>更細 retention 控制</td>
      </tr>
      <tr>
          <td>Query latency</td>
          <td>跨 partition 多時 50-200ms</td>
          <td>跨 partition 少時 5-50ms</td>
          <td>Daily 多數 query 更快</td>
      </tr>
      <tr>
          <td>Planning time</td>
          <td>5-10ms</td>
          <td>50-100ms (對 generic plan)</td>
          <td>Planning overhead + 1 order</td>
      </tr>
      <tr>
          <td>Maintenance window</td>
          <td>Vacuum 1 partition 6 小時</td>
          <td>Vacuum 1 partition 5-30 分鐘</td>
          <td>維護視窗更小、可日跑</td>
      </tr>
  </tbody>
</table>
<p><strong>判讀</strong>：daily partition 適合 <em>高流量 + 跨日查詢多 + retention 細的場景</em>；超大 partition (TB 級單日) 仍要 sub-partition 拆。</p>
<h2 id="整合--下一步">整合 / 下一步</h2>
<h3 id="跟-autovacuum-tuning-整合">跟 <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> 整合</h3>
<p>Daily partition 後 autovacuum 行為：</p>
<ul>
<li>每 daily partition 獨立 autovacuum、scale_factor + threshold per-partition tuning</li>
<li><code>autovacuum_max_workers</code> 要從 3 拉到 6-10（partition 數爆）</li>
<li>Cold partition (&gt; 30 天) <code>autovacuum_enabled = false</code>、不浪費 CPU</li>
</ul>
<h3 id="跟-patroni-ha-整合">跟 <a href="/blog/backend/01-database/vendors/postgresql/patroni-ha/" data-link-title="PostgreSQL Patroni HA：從 leader 失聯到 client 重連的 5 段 failover lifecycle" data-link-desc="Patroni 把 PostgreSQL HA 拆成 detection / election / promotion / reconfiguration / recovery 五段 lifecycle、每段都有獨立配置跟 failure mode；DCS quorum &#43; watchdog 防 split-brain、async/sync replication 取捨、5 個 production 踩雷、跟 PgBouncer / HAProxy / cert-manager 整合">Patroni HA</a> 整合</h3>
<p>Failover 期間 partition migration 不能跑、必須在 stable cluster state 執行；Patroni promote 後重新評估 partition health。</p>
<h3 id="跟-logical-replication--debezium-整合">跟 <a href="/blog/backend/01-database/vendors/postgresql/logical-replication-debezium/" data-link-title="PostgreSQL Logical Replication &#43; Debezium CDC：replication slot × failure × recovery 對照" data-link-desc="PostgreSQL logical replication slot 跟 Debezium CDC 的失效模式對照表：slot lag 撐爆 primary disk / schema change 斷流 / 初始 COPY 鎖表 / zombie slot 不釋放 / replay storm 後 offset reset；publication / subscription / pgoutput 配置、跟 Kafka outbox pattern 整合">Logical Replication + Debezium</a> 整合</h3>
<p><code>publish_via_partition_root = true</code> 讓 publication 從 parent 角度看；CDC consumer 不需要對每個 partition 設 subscription。</p>
<h3 id="下一步議題">下一步議題</h3>
<ul>
<li><strong>跨 daily partition 的 archive strategy</strong>：archive 到 S3 cold storage、daily granularity 給更細 retention 控制</li>
<li><strong>pg_partman extension</strong>：自動建 daily partition、不用 cron；但要先確認 Aurora / RDS 支援</li>
<li><strong>Sub-partitioning</strong>：未來流量爆時用「daily by time + list by tenant」雙軸 partition</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li>上游 vendor 頁：<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></li>
<li>平行 deep article：<a href="/blog/backend/01-database/vendors/postgresql/declarative-partitioning/" data-link-title="PostgreSQL declarative partitioning：partition 不是切表、是讓 planner pruning" data-link-desc="Declarative partitioning 的真實價值是 query planner pruning &#43; maintenance scope 縮小、不是「把大表切小」；RANGE / LIST / HASH 取捨、partition key 選法、5 個 production 踩雷（key 選錯不 prune / unique 不 enforce 跨 partition / ATTACH 鎖太久 / partition 數爆 / DETACH 不 reclaim 空間）、跟 autovacuum &#43; index 設計整合">Declarative Partitioning</a>（partition 基礎）/ <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></li>
<li>平行 Type F dogfood：<a href="/blog/backend/02-cache-redis/vendors/redis/cluster-resharding/" data-link-title="Redis Cluster Re-sharding：source = target，但 topology 重劃的 5 段流程" data-link-desc="Redis cluster re-sharding 是 5 type migration 漏類實證 — source / target 同 cluster、無 schema / paradigm 差、但 16384 slot 重分配是核心；本文涵蓋 4 種 re-sharding driver、slot migration 機制、redis-cli --cluster rebalance / reshard 工具、5 個 production 踩雷（cluster busy / replica lag / client cache stale / cross-slot transaction / monitor gap）">Redis Cluster Re-sharding</a>（dogfood #1）/ <a href="/blog/backend/01-database/vendors/mongodb/shard-expansion-multi-dc/" data-link-title="MongoDB Shard Expansion &#43; Multi-DC：Type F「不需要 parallel run」的 multi-region 例外" data-link-desc="MongoDB sharded cluster 加 shard &#43; 跨 DC expansion 是 Type F「topology re-layout」第 3 個 dogfood — 同時改 sharding &#43; replication topology &#43; region distribution；驗證 [#128](/report/data-topology-as-audit-dimension/) self-aware limitation 第 3 點「Type F 不需要 parallel run」claim 的例外（multi-region rollout 必須 parallel run &#43; 切流量）；涵蓋 chunk migration / replica set add member / cross-DC routing">MongoDB Shard + Multi-DC</a>（dogfood #3、F-multi-region sub-type）</li>
<li>Methodology：<a href="/blog/posts/migration-playbook-%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84stage-0-variant-%E8%A6%8F%E5%8A%83%E6%8A%8A-collapse-%E7%8E%87%E5%BE%9E-60-%E9%99%8D%E5%88%B0-0/" data-link-title="Migration Playbook 方法論的演化紀錄：Stage 0 variant 規劃把 collapse 率從 60% 降到 0%" data-link-desc="跨 vendor migration playbook 需要獨立寫作方法論的依據，以及這套方法論從三輪 batch dogfood 中演化出來的驗證證據。">Migration playbook methodology</a> / <a href="/blog/report/content-structure-by-max-diff-dimension/" data-link-title="Process content 結構由最大差異維度決定、不是 universal phased" data-link-desc="跨 X process content（migration / upgrade / rollout / playbook）的結構由 source / target 之間 *差異維度組合* 決定、不存在 universal phased 模板；6 種 migration / process type 實證（schema 差 / drop-in / operational / multi-tool / paradigm / topology re-layout）跑出 6 種不同結構；寫作前必須做 *6 維 diff dimension audit* 才能決定結構、跳過會套錯模板">#127 Process content 結構由最大差異維度決定</a> / <a href="/blog/report/data-topology-as-audit-dimension/" data-link-title="Data topology 是 process content 的第 6 audit 維度" data-link-desc="Process content 的 diff dimension audit 原本 5 維（schema / operational / paradigm / components / application change）漏了 *data topology* — 資料在 cluster / partition / region 之間的分佈拓樸；topology 不在既有 5 維任一個、但決定 re-sharding / partition redesign / multi-region rollout 的結構；本卡擴 audit 到 6 維、新增 Type F「Topology re-layout」結構">#128 Data topology 是第 6 audit 維度</a></li>
</ul>
]]></content:encoded></item><item><title>Aurora Read Replica Scaling：15 replica 上限、lag profile、headroom 預留與 fleet 治理</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/aurora/read-replica-scaling/</link><pubDate>Wed, 27 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/aurora/read-replica-scaling/</guid><description>&lt;p>Aurora 「最多 15 read replica」是文件數字、實際 production 部署常常更早遇到拆 cluster 的決策點 — 不是 15 replica 不夠用、是 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/blast-radius/" data-link-title="Blast Radius" data-link-desc="說明事故影響面如何估算與隔離">blast radius&lt;/a>、業務 sharding、微服務 ownership、合規 boundary 早在 15 replica 之前就推動拆 cluster。本文同時展開兩個議題：(1) 單 cluster 內 read replica 怎麼用、容量怎麼規劃、lag 怎麼管；(2) Aurora fleet 治理的 3 條 driver、什麼條件下拆 cluster vs 加 replica。後者是 Aurora 系列的 &lt;em>fleet 治理 SSoT&lt;/em> — &lt;a href="../storage-architecture/">Aurora storage architecture&lt;/a> / &lt;a href="../cross-az-failover-rto/">Aurora cross-AZ failover RTO&lt;/a> / &lt;a href="../global-database-multi-region/">Aurora Global Database&lt;/a> / &lt;a href="../migrate-from-self-managed-pg-mysql/">Aurora migration playbook&lt;/a> 都 cross-link 到本篇、不重複展開。&lt;/p>
&lt;p>本文不是 Aurora overview（請看 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/aurora/" data-link-title="AWS Aurora" data-link-desc="AWS managed PostgreSQL / MySQL、storage / compute 分離、&amp;#43;75% 效能改善的 production 證據">Aurora vendor 頁&lt;/a>）— 而是 read replica 跟 fleet 拓樸的實作層教學。前置閱讀建議 &lt;a href="../storage-architecture/">Aurora storage architecture&lt;/a>（理解共享 storage 為什麼能養大量 replica）。&lt;/p>
&lt;h2 id="問題情境">問題情境&lt;/h2>
&lt;p>典型觸發場景：FanDuel Super Bowl / DraftKings 比賽日、流量 5-10 倍尖峰、read query（用戶查 balance、投注紀錄、odds）打爆 primary、需要快速擴 read replica 但又怕 lag 把 stale read 推到 user-facing。&lt;/p>
&lt;p>讀者常見的具體疑問：&lt;/p>
&lt;ul>
&lt;li>「加 read replica 後 primary CPU 沒降、為什麼？」&lt;/li>
&lt;li>「Auto-scaling 加 replica 要幾分鐘、來不及接尖峰怎麼辦？」&lt;/li>
&lt;li>「Reader endpoint round-robin 把 query 打到 lag 大的 replica、用戶看到舊 balance」&lt;/li>
&lt;li>「業務跨 200 個 cluster、單個 cluster 才 5-10 個 replica、為什麼不集中？」&lt;/li>
&lt;/ul>
&lt;p>進一步問題：讀寫雙峰錯位是 Aurora 讀寫分流的核心 driver。&lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/draftkings-aurora-financial-ledger/" data-link-title="9.C4 DraftKings：Aurora 撐 100 萬 ops/min 的體育博彩金融帳本" data-link-desc="DraftKings 用 Aurora MySQL 跑體育博彩金融帳本、Super Bowl 流量 &amp;#43;50% 不影響延遲">9.C4 DraftKings&lt;/a> 揭露「write workloads spike up significantly around payout events, but opening the app during the game also activates a lot of balance queries」— 比賽進行時讀爆量、payout event 時寫爆量、兩個峰不在同一時刻。這代表 read replica 容量規劃不是「分散負載」、而是「為讀峰專門配置 capacity」。&lt;/p>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/fanduel-dual-peak-betting-streaming/" data-link-title="9.C28 FanDuel：體育直播 &amp;#43; 投注的雙重峰值" data-link-desc="FanDuel 3.5M MAU、Super Bowl 期間擴容 5-10 倍、用 AWS Local Zones &amp;#43; Wavelength &amp;#43; Outposts 處理 20&amp;#43; 州的雙重峰值">9.C28 FanDuel&lt;/a> 揭露事件型容量分級：平日 baseline → 季後賽 2-3x → 季冠軍賽 4-5x → Super Bowl 5-10x。容量規劃要按事件級別分段、不是一律 10x。&lt;/p></description><content:encoded><![CDATA[<p>Aurora 「最多 15 read replica」是文件數字、實際 production 部署常常更早遇到拆 cluster 的決策點 — 不是 15 replica 不夠用、是 <a href="/blog/backend/knowledge-cards/blast-radius/" data-link-title="Blast Radius" data-link-desc="說明事故影響面如何估算與隔離">blast radius</a>、業務 sharding、微服務 ownership、合規 boundary 早在 15 replica 之前就推動拆 cluster。本文同時展開兩個議題：(1) 單 cluster 內 read replica 怎麼用、容量怎麼規劃、lag 怎麼管；(2) Aurora fleet 治理的 3 條 driver、什麼條件下拆 cluster vs 加 replica。後者是 Aurora 系列的 <em>fleet 治理 SSoT</em> — <a href="../storage-architecture/">Aurora storage architecture</a> / <a href="../cross-az-failover-rto/">Aurora cross-AZ failover RTO</a> / <a href="../global-database-multi-region/">Aurora Global Database</a> / <a href="../migrate-from-self-managed-pg-mysql/">Aurora migration playbook</a> 都 cross-link 到本篇、不重複展開。</p>
<p>本文不是 Aurora overview（請看 <a href="/blog/backend/01-database/vendors/aurora/" data-link-title="AWS Aurora" data-link-desc="AWS managed PostgreSQL / MySQL、storage / compute 分離、&#43;75% 效能改善的 production 證據">Aurora vendor 頁</a>）— 而是 read replica 跟 fleet 拓樸的實作層教學。前置閱讀建議 <a href="../storage-architecture/">Aurora storage architecture</a>（理解共享 storage 為什麼能養大量 replica）。</p>
<h2 id="問題情境">問題情境</h2>
<p>典型觸發場景：FanDuel Super Bowl / DraftKings 比賽日、流量 5-10 倍尖峰、read query（用戶查 balance、投注紀錄、odds）打爆 primary、需要快速擴 read replica 但又怕 lag 把 stale read 推到 user-facing。</p>
<p>讀者常見的具體疑問：</p>
<ul>
<li>「加 read replica 後 primary CPU 沒降、為什麼？」</li>
<li>「Auto-scaling 加 replica 要幾分鐘、來不及接尖峰怎麼辦？」</li>
<li>「Reader endpoint round-robin 把 query 打到 lag 大的 replica、用戶看到舊 balance」</li>
<li>「業務跨 200 個 cluster、單個 cluster 才 5-10 個 replica、為什麼不集中？」</li>
</ul>
<p>進一步問題：讀寫雙峰錯位是 Aurora 讀寫分流的核心 driver。<a href="/blog/backend/09-performance-capacity/cases/draftkings-aurora-financial-ledger/" data-link-title="9.C4 DraftKings：Aurora 撐 100 萬 ops/min 的體育博彩金融帳本" data-link-desc="DraftKings 用 Aurora MySQL 跑體育博彩金融帳本、Super Bowl 流量 &#43;50% 不影響延遲">9.C4 DraftKings</a> 揭露「write workloads spike up significantly around payout events, but opening the app during the game also activates a lot of balance queries」— 比賽進行時讀爆量、payout event 時寫爆量、兩個峰不在同一時刻。這代表 read replica 容量規劃不是「分散負載」、而是「為讀峰專門配置 capacity」。</p>
<p><a href="/blog/backend/09-performance-capacity/cases/fanduel-dual-peak-betting-streaming/" data-link-title="9.C28 FanDuel：體育直播 &#43; 投注的雙重峰值" data-link-desc="FanDuel 3.5M MAU、Super Bowl 期間擴容 5-10 倍、用 AWS Local Zones &#43; Wavelength &#43; Outposts 處理 20&#43; 州的雙重峰值">9.C28 FanDuel</a> 揭露事件型容量分級：平日 baseline → 季後賽 2-3x → 季冠軍賽 4-5x → Super Bowl 5-10x。容量規劃要按事件級別分段、不是一律 10x。</p>
<p>對 <a href="/blog/backend/09-performance-capacity/cases/standard-chartered-aurora-banking/" data-link-title="9.C14 Standard Chartered：受監管銀行的 Aurora 4000 TPS 容量提升" data-link-desc="Standard Chartered 銀行遷移到 Aurora 後吞吐量提升 10 倍至 4000 TPS、跨 7 個受監管市場">9.C14 Standard Chartered</a> 這種受監管金融、不能用單一巨型 cluster — 7 個受監管市場 = 7 個獨立 cluster、合規 boundary 比運維成本優先。</p>
<h2 id="核心機制15-replica-上限共享-storagereader-endpoint">核心機制：15 replica 上限、共享 storage、reader endpoint</h2>
<p>Aurora read replica 的 first-class concept 是 <em>共享 storage + DNS-based reader endpoint</em>。傳統 PostgreSQL streaming replication 靠 primary push WAL 給 replica、replica 自己 apply；Aurora replica 直接從共享 storage 讀已 apply 的 page、不需要 catch-up。</p>
<p><strong>15 replica 上限</strong>：</p>
<ul>
<li>每個 Aurora cluster 最多 15 個 read replica（跨 AZ）</li>
<li>跨 region replica 走 <a href="../global-database-multi-region/">Aurora Global Database</a>（不算這 15 個）</li>
<li>文件上限不是 production 真實上限 — 多數 production 部署在 5-10 replica 之間遇到拆 cluster 訊號</li>
</ul>
<p><strong>共享 storage 對 lag 的影響</strong>：</p>
<ul>
<li>Replica 不靠 logical replication catch-up、直接從共享 storage 讀</li>
<li>Lag 來源是 <em>compute node 的 buffer cache 同步</em>、不是 WAL replay</li>
<li>Typical 10-30ms、heavy write 期間可能 100ms+、但 <em>不會像 PostgreSQL 那樣 unbounded</em></li>
</ul>
<p><strong>DraftKings 揭露的「lag 可預測」frame</strong>（<a href="/blog/backend/09-performance-capacity/cases/draftkings-aurora-financial-ledger/" data-link-title="9.C4 DraftKings：Aurora 撐 100 萬 ops/min 的體育博彩金融帳本" data-link-desc="DraftKings 用 Aurora MySQL 跑體育博彩金融帳本、Super Bowl 流量 &#43;50% 不影響延遲">case「判讀」段第 2 點</a>）：</p>
<p>「30 秒降到 10-30 ms」的工程意義不只是「快」、而是「讓 read-after-write 變得可預測」。30 秒 lag 的世界裡、application 端做 read-after-write 要 cache 用戶最後寫入 30 秒以上、實務上做不到；10-30ms lag 的世界裡、application 可以做「寫操作後 100ms 內走 primary、之後可走 replica」的可規劃策略。</p>
<p><strong>Reader endpoint 行為</strong>：</p>
<ul>
<li>DNS-based round-robin、不感知 replica 健康狀態</li>
<li>Application 想要 lag-aware routing 要自己實作或用 RDS Proxy</li>
<li>Failover 期間短暫包含 promoted replica（已升 primary）、見 <a href="../cross-az-failover-rto/">Aurora cross-AZ failover RTO</a></li>
</ul>
<p><strong>Auto-scaling policy</strong>：</p>
<ul>
<li>CloudWatch metric（CPU / connection）trigger</li>
<li>Replica creation 2-5 分鐘</li>
<li><em>無法用於秒級尖峰</em> — 是 DraftKings「+50% no sweat」誤讀的關鍵點</li>
</ul>
<p><strong>跟通用 read replica 差在哪</strong>：Aurora replica 不用 catch-up WAL、lag 上限可預測；vs PostgreSQL streaming replication lag 是 unbounded（取決於 primary 寫速度）。可預測 lag 是 read-after-write 場景變得可規劃的前提。</p>
<p>對應 knowledge card：<a href="/blog/backend/knowledge-cards/replication-lag/" data-link-title="Replication Lag" data-link-desc="說明資料副本落後正式來源多久，以及它如何影響讀取正確性">replication-lag</a>、<a href="/blog/backend/knowledge-cards/stale-read/" data-link-title="Stale Read" data-link-desc="讀取到落後於最新寫入版本的舊資料">stale-read</a>。</p>
<h2 id="step-by-step-配置--reader-endpoint-設計">Step-by-step 配置 / Reader endpoint 設計</h2>
<p><strong>建 read replica</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">aws rds create-db-instance <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --db-cluster-identifier my-cluster <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --db-instance-identifier my-replica-01 <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --db-instance-class db.r6g.4xlarge <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  --engine aurora-postgresql <span class="se">\
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="se"></span>  --availability-zone us-east-1b <span class="se">\
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="se"></span>  --promotion-tier <span class="m">1</span></span></span></code></pre></div><p><strong>Reader endpoint vs Custom endpoint</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 預設 reader endpoint：所有 replica round-robin</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># 訪問 url: my-cluster.cluster-ro-xxx.us-east-1.rds.amazonaws.com</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># Custom endpoint：group 特定 replica</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">aws rds create-db-cluster-endpoint <span class="se">\
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="se"></span>  --db-cluster-identifier my-cluster <span class="se">\
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="se"></span>  --db-cluster-endpoint-identifier my-cluster-analytics <span class="se">\
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="se"></span>  --endpoint-type READER <span class="se">\
</span></span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="se"></span>  --static-members my-replica-analytics-01 my-replica-analytics-02</span></span></code></pre></div><p>Custom endpoint 適用場景：</p>
<ul>
<li>分析 query 走獨立 endpoint、不影響 OLTP read replica</li>
<li>Read-after-write session 走 primary endpoint、其他 read 走 reader endpoint</li>
<li>不同 SLO 的 read traffic 分流（high-priority vs batch）</li>
</ul>
<p><strong>Auto-scaling policy</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl">aws application-autoscaling register-scalable-target <span class="se">\
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="se"></span>  --service-namespace rds <span class="se">\
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="se"></span>  --resource-id cluster:my-cluster <span class="se">\
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="se"></span>  --scalable-dimension rds:cluster:ReadReplicaCount <span class="se">\
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="se"></span>  --min-capacity <span class="m">2</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="se"></span>  --max-capacity <span class="m">10</span>
</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">aws application-autoscaling put-scaling-policy <span class="se">\
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="se"></span>  --service-namespace rds <span class="se">\
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="se"></span>  --resource-id cluster:my-cluster <span class="se">\
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="se"></span>  --scalable-dimension rds:cluster:ReadReplicaCount <span class="se">\
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="se"></span>  --policy-name my-cluster-cpu-scaling <span class="se">\
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="se"></span>  --policy-type TargetTrackingScaling <span class="se">\
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="se"></span>  --target-tracking-scaling-policy-configuration file://scaling-config.json</span></span></code></pre></div><p><strong>預配 vs auto-scale</strong>：</p>
<ul>
<li>Peak workload 預知（賽事、促銷、季節事件）→ 提前 1 小時預配</li>
<li>Unpredictable burst → auto-scale（接受 2-5 分鐘 lead time）</li>
<li>兩者混合：baseline 預配 + auto-scale 處理 baseline 之上的浮動</li>
</ul>
<p><strong>驗證點</strong>：</p>
<ul>
<li><code>AuroraReplicaLag</code> &lt; 100ms（per replica）</li>
<li>Reader endpoint CPU 分布均勻（不是某 replica 過熱）</li>
<li>Application stale-read error rate &lt; 0.1%</li>
</ul>
<p><strong>Rollback boundary</strong>：移除 replica 即時生效、無 data loss；但 reader endpoint DNS cache 仍可能短暫 routing 到已移除 replica（5-30 秒）。</p>
<h2 id="故障模式--邊界-case">故障模式 / 邊界 case</h2>
<h3 id="case-1加-replica-後-primary-cpu-沒降">Case 1：加 replica 後 primary CPU 沒降</h3>
<p>徵兆：明明加了 3 個 read replica、primary CPU 仍然 90%、reader endpoint CPU 才 10%。</p>
<p>原因：application 沒把 read query routing 到 reader endpoint、所有 query 仍打 primary。Aurora reader endpoint 不會自動分流 — 必須 application 端拆 read / write data source。</p>
<p>修：</p>
<ul>
<li>Application 端 ORM / data source layer 拆 read / write connection pool</li>
<li>寫操作用 writer endpoint、純讀走 reader endpoint</li>
<li>雙峰錯位是這層拆分的 driver（<a href="/blog/backend/09-performance-capacity/cases/draftkings-aurora-financial-ledger/" data-link-title="9.C4 DraftKings：Aurora 撐 100 萬 ops/min 的體育博彩金融帳本" data-link-desc="DraftKings 用 Aurora MySQL 跑體育博彩金融帳本、Super Bowl 流量 &#43;50% 不影響延遲">DraftKings case 揭露</a> 讀寫資源規劃要分開）</li>
</ul>
<h3 id="case-2reader-endpoint-round-robin-推-stale-read">Case 2：Reader endpoint round-robin 推 stale read</h3>
<p>徵兆：read-after-write 場景（用戶下注後立刻查 balance）打到 lagging replica、看到舊 balance、客訴。</p>
<p>原因：reader endpoint DNS-based round-robin、不感知 lag。Application 假設 read 永遠 fresh、但 typical 10-30ms lag 期間用戶操作就會踩到。</p>
<p>修：</p>
<ul>
<li>Sticky session：寫操作後 N 秒內同 session 走 primary（N = lag p99、typical 100ms）</li>
<li>Application 端做「下注後 N 秒走 primary」邏輯（DraftKings「可預測 lag」frame 讓 N 秒可規劃）</li>
<li>或用 RDS Proxy 提供 lag-aware routing（managed alternative）</li>
</ul>
<h3 id="case-3auto-scaling-來不及接秒級尖峰--headroom-預留判讀">Case 3：Auto-scaling 來不及接秒級尖峰 — headroom 預留判讀</h3>
<p>徵兆：賽事開賽 30 秒內流量 +50%、auto-scaling 觸發但 2-5 分鐘後才有新 replica、開賽尖峰已過、用戶在最關鍵時段看到 timeout。</p>
<p>機制限制：replica creation 2-5 分鐘、秒級尖峰過去了 replica 才上線。</p>
<p><strong>DraftKings「Super Bowl +50% no sweat」的工程意義</strong>（<a href="/blog/backend/09-performance-capacity/cases/draftkings-aurora-financial-ledger/" data-link-title="9.C4 DraftKings：Aurora 撐 100 萬 ops/min 的體育博彩金融帳本" data-link-desc="DraftKings 用 Aurora MySQL 跑體育博彩金融帳本、Super Bowl 流量 &#43;50% 不影響延遲">case「判讀」段第 3 點原文</a>）：「這句話的工程意義是 <em>提前做好容量規劃</em>、不是『Aurora 神奇』。寫 workload 預期可能 +50%、整個 system headroom 預留至少 50%、加上 read replica 動態加減、才能讓 50% 增幅變成『不流汗』」。</p>
<p>工程含義：</p>
<ul>
<li>Peak workload 預知（賽事 / 促銷）用 <em>headroom 預留 + <a href="/blog/backend/knowledge-cards/scheduled-scaling/" data-link-title="Scheduled Scaling" data-link-desc="說明按已知時間表預先擴容的 autoscaler 模式">scheduled scaling</a> 提前預配</em>、不靠 auto-scale 接秒級</li>
<li>Auto-scale 是 unpredictable burst 才用（突發新聞、KOL 推廣、未預期事件）</li>
<li>DraftKings 的「不流汗」是 <em>系統設計</em> 結果、不是 Aurora 特殊能力</li>
</ul>
<p>修：</p>
<ul>
<li>賽事日曆建模：賽前 1 小時自動加 replica、賽後 2 小時減</li>
<li>Primary instance class 升級提前一週、不是賽前升（升級期間 failover 風險）</li>
<li>Headroom 預算：read replica 預留 50%、primary CPU baseline &lt; 50%</li>
</ul>
<h3 id="case-415-replica-上限--拆-cluster-訊號">Case 4：15 replica 上限 — 拆 cluster 訊號</h3>
<p>徵兆：read traffic 持續成長、加到 15 replica 仍接近 CPU 瓶頸、想加第 16 個被 API 拒絕。</p>
<p>原因：Aurora 硬上限 15 replica / cluster、超過要拆 cluster。但實務上更常在 5-10 replica 就遇到其他拆 cluster 訊號（blast radius、ownership boundary、業務 sharding）。</p>
<p>修：見下方「邊界與整合：fleet 治理 SSoT」段、按 3 條 driver 判讀拆 cluster vs 加 replica。</p>
<h3 id="case-5heavy-write-期間-replica-lag-spike">Case 5：Heavy write 期間 replica lag spike</h3>
<p>徵兆：bulk insert / DDL 期間 replica lag 從 10-30ms 跳到 100-500ms、application 假設 typical lag 永遠成立、stale read 比例大幅上升。</p>
<p>原因：heavy write 期間 replica buffer cache invalidate 速度跟不上、lag 暫時拉大。Aurora 的「可預測 lag」不等於「lag 永遠 10-30ms」。</p>
<p>修：</p>
<ul>
<li>bulk insert / DDL 期間 application 端切到全 primary 模式（避開 stale read 風險）</li>
<li>重要 DDL 用 <a href="https://github.com/reorg/pg_repack">pg_repack</a> 或 logical migration、避免長時間 table lock</li>
<li>監測 <code>AuroraReplicaLagMaximum</code>、spike 超過 p99 threshold trigger application 端 fallback</li>
</ul>
<h3 id="case-6fanduel-雙-slo-並行--不要壓成單一數字">Case 6：FanDuel 雙 SLO 並行 — 不要壓成單一數字</h3>
<p>徵兆：team 看 FanDuel「5-10x peak」直接套到自家 streaming workload、結果 Aurora 撐不住、發現 FanDuel streaming 根本不走 Aurora。</p>
<p><a href="/blog/backend/09-performance-capacity/cases/fanduel-dual-peak-betting-streaming/" data-link-title="9.C28 FanDuel：體育直播 &#43; 投注的雙重峰值" data-link-desc="FanDuel 3.5M MAU、Super Bowl 期間擴容 5-10 倍、用 AWS Local Zones &#43; Wavelength &#43; Outposts 處理 20&#43; 州的雙重峰值">9.C28 FanDuel</a> case「判讀」段第 1 點原文：「直播跟投注是兩種完全不同 SLO：直播容忍秒級延遲（用 CDN + ABR 串流）、投注必須毫秒級成交。兩個服務必須各自獨立擴容、各自獨立 SLO」。</p>
<p><strong>scope warning（必明示）</strong>：</p>
<ul>
<li>FanDuel 5-10x 是 <em>betting 服務的 Aurora 擴容倍數</em>、不是 streaming</li>
<li>Streaming 走 CDN、不走 Aurora</li>
<li>不能把兩種 SLO 壓縮成「Aurora 撐 5-10x」單一數字</li>
</ul>
<p><strong>case 自承的進一步 scope warning</strong>：「AWS 案例 <em>沒有</em> 提具體 betting transaction TPS、concurrent streams、延遲分布」（case「需要警惕」段）。引用 FanDuel 時不能寫「Aurora 在 betting 路徑撐 X TPS」這類細節 — case 沒提的數字不能擴寫。</p>
<p>修：</p>
<ul>
<li>不同 SLO workload 拆獨立 cluster 或拆 read / write data source</li>
<li>容量規劃看自家 workload TPS、不要套用未公開的 case 數字</li>
</ul>
<h2 id="事件型容量分級表">事件型容量分級表</h2>
<p><a href="/blog/backend/09-performance-capacity/cases/fanduel-dual-peak-betting-streaming/" data-link-title="9.C28 FanDuel：體育直播 &#43; 投注的雙重峰值" data-link-desc="FanDuel 3.5M MAU、Super Bowl 期間擴容 5-10 倍、用 AWS Local Zones &#43; Wavelength &#43; Outposts 處理 20&#43; 州的雙重峰值">9.C28 FanDuel</a> 揭露事件型 scaling 不是一律 10x — <em>事件級別</em> 是容量分級單位：</p>
<table>
  <thead>
      <tr>
          <th>事件級別</th>
          <th>倍數</th>
          <th>來源</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>平日 baseline</td>
          <td>1x</td>
          <td>FanDuel case「判讀」段第 3 點</td>
      </tr>
      <tr>
          <td>季後賽 playoff</td>
          <td>2-3x</td>
          <td>FanDuel case 揭露事件分級</td>
      </tr>
      <tr>
          <td>季冠軍賽 championship</td>
          <td>4-5x</td>
          <td>FanDuel case 揭露事件分級</td>
      </tr>
      <tr>
          <td>Super Bowl</td>
          <td>5-10x</td>
          <td>FanDuel case 揭露事件分級</td>
      </tr>
  </tbody>
</table>
<p><strong>Frame 8 event-driven scaling 5 模式（跨 vendor 共寫）</strong>：本表是 Aurora 端從讀峰視角切入的事件分級、跟 <a href="/blog/backend/01-database/vendors/dynamodb/on-demand-vs-provisioned/" data-link-title="DynamoDB On-Demand vs Provisioned：6 軸決策、auto-scaling 邊界與 cost crossover" data-link-desc="capacity mode 選擇不是單軸 peak/avg ratio；本文展開 6 軸決策（peak/avg / 讀寫比 trend / surge 暫時 vs 永久 baseline / predictable-peak vs flash-sale / DBA 工時釋放 / vendor vs 自管 cost crossover），含 Zomato 50% 成本下降、Zoom 30x permanent surge、Amazon Ads sustained workload 等 case 分軸 anchor">DynamoDB on-demand-vs-provisioned</a> 的 5 模式分類（flash-sale spike / predictable peak / sustained growth / surge baseline permanent shift / B2B sustained + 高可用）共軸。Aurora 端的 FanDuel 季賽 cycle 在 5 模式分類中對應 <em>predictable peak</em> 的時間序列展開 — 事件 tier 已知（賽季 → 季後賽 → 季冠軍賽 → Super Bowl）、按 tier 預配 read replica 數量、本質是「峰值已知 + 重複出現」的 predictable peak 在多 tier 結構下的延伸。</p>
<p><strong>KV 層 vs SQL 層的 mode 決策差異</strong>：DynamoDB 端的 on-demand vs provisioned mode 是 KV vendor 的容量抽象（軸 1 peak/avg ratio / 軸 4 predictable-peak vs flash-sale）、詳見 <a href="/blog/backend/01-database/vendors/dynamodb/on-demand-vs-provisioned/" data-link-title="DynamoDB On-Demand vs Provisioned：6 軸決策、auto-scaling 邊界與 cost crossover" data-link-desc="capacity mode 選擇不是單軸 peak/avg ratio；本文展開 6 軸決策（peak/avg / 讀寫比 trend / surge 暫時 vs 永久 baseline / predictable-peak vs flash-sale / DBA 工時釋放 / vendor vs 自管 cost crossover），含 Zomato 50% 成本下降、Zoom 30x permanent surge、Amazon Ads sustained workload 等 case 分軸 anchor">DynamoDB on-demand-vs-provisioned 6 軸決策</a>、本篇不展開。Aurora 端對應的決策是 <em>read replica 數量 + auto-scaling vs scheduled scaling vs headroom 預留</em>、靠的是 replica fleet size 而非 mode 切換。</p>
<p>兩 vendor 在 Frame 8 各自承擔：</p>
<ul>
<li><strong>DynamoDB on-demand-vs-provisioned</strong>：5 模式分類 SSoT、mode × 事件型分類的合成判讀</li>
<li><strong>Aurora read-replica-scaling（本篇）</strong>：read 峰值的 headroom 預留 + 雙 SLO 並行（FanDuel 分級 + DraftKings 讀寫雙峰錯位）+ fleet 治理</li>
</ul>
<p><strong>case 自帶警示（scope warning 必保留）</strong>：</p>
<ul>
<li>「5-10x」是 <em>峰值倍數</em>、不是 <em>peak 持續時間</em>。Super Bowl 的關鍵 30 分鐘可能 8-10x、其他 3 小時可能 3-5x（case「需要警惕」段）</li>
<li>分級 driver 是「同類事件中的最高倍率」、不是恆定數字 — 引用時要保留事件 tier 對應、不是一律「Super Bowl = 10x」單一閾值</li>
<li>跨業務 transfer 判讀：本表 <em>只代表體育博彩賽季 cycle</em>、不能直接套到 e-commerce flash-sale（後者倍數結構是「秒級數千倍」、跟事件 tier 結構不同）</li>
</ul>
<p><strong>容量規劃做法</strong>：</p>
<ul>
<li>建立 event tier 體系、每 tier 對應不同 pre-scale 倍數跟 lead time（賽前 N 小時預配）</li>
<li>事件型分級的關鍵是「峰值是已知的」、不是「峰值多大」</li>
<li>對應 <a href="/blog/backend/09-performance-capacity/" data-link-title="模組九：效能工程與容量規劃" data-link-desc="把『目前配置能撐多少、要加多少機器』變成可量化、可驗證、可改進的工程流程">9.11 高峰事件準備</a> 的容量分級</li>
</ul>
<h2 id="邊界與整合fleet-治理-ssot--何時拆-cluster-vs-加-replica">邊界與整合：Fleet 治理 SSoT — 何時拆 cluster vs 加 replica</h2>
<p>本段是 Aurora fleet 治理軸 SSoT — <a href="../storage-architecture/">Aurora storage architecture</a> / <a href="../cross-az-failover-rto/">Aurora cross-AZ failover RTO</a> / <a href="../global-database-multi-region/">Aurora Global Database</a> / <a href="../migrate-from-self-managed-pg-mysql/">Aurora migration playbook</a> cross-link 不重複展開。</p>
<p><strong>跨 case 合成 frame</strong>：production scale 不是「單一巨型 cluster」而是 <em>fleet of clusters</em>、但 <em>driver 各異</em>。</p>
<table>
  <thead>
      <tr>
          <th>Driver</th>
          <th>Case anchor</th>
          <th>Fleet 規模</th>
          <th>拆分判讀</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Business sharding</td>
          <td><a href="/blog/backend/09-performance-capacity/cases/draftkings-aurora-financial-ledger/" data-link-title="9.C4 DraftKings：Aurora 撐 100 萬 ops/min 的體育博彩金融帳本" data-link-desc="DraftKings 用 Aurora MySQL 跑體育博彩金融帳本、Super Bowl 流量 &#43;50% 不影響延遲">9.C4 DraftKings</a></td>
          <td>200 cluster</td>
          <td>業務本身可切分（每體育類別 / 每地理 / 每產品線各自 cluster）、blast radius 隔離</td>
      </tr>
      <tr>
          <td>Microservice ownership</td>
          <td><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</a></td>
          <td>多 cluster</td>
          <td>每微服務私有 store、不共用 cluster — 容量規劃分散到 service owner</td>
      </tr>
      <tr>
          <td>合規市場 boundary</td>
          <td><a href="/blog/backend/09-performance-capacity/cases/standard-chartered-aurora-banking/" data-link-title="9.C14 Standard Chartered：受監管銀行的 Aurora 4000 TPS 容量提升" data-link-desc="Standard Chartered 銀行遷移到 Aurora 後吞吐量提升 10 倍至 4000 TPS、跨 7 個受監管市場">9.C14 Standard Chartered</a></td>
          <td>7 cluster</td>
          <td>受監管市場資料 <em>不能跨境複製</em>、每市場獨立 cluster — Global Database 在合規場景反指標</td>
      </tr>
  </tbody>
</table>
<h3 id="driver-1business-shardingdraftkings-200-cluster">Driver 1：Business sharding（DraftKings 200 cluster）</h3>
<p>DraftKings 不用一個巨型 cluster 撐 100 萬 ops/min、而是 <em>按業務切 200 cluster</em>。每體育類別、每地理、每產品線各自 cluster、blast radius 自然隔離。</p>
<p>工程含義：</p>
<ul>
<li>業務本身就有 sharding key（sport type / region / product line）— 拆 cluster 不需要 schema redesign</li>
<li>單 cluster 故障只影響該業務、不影響全平台</li>
<li>容量規劃變成「每 cluster 的容量規劃」、單機極限不重要</li>
</ul>
<p><strong>容易誤判的邊界</strong>：<a href="/blog/backend/09-performance-capacity/cases/draftkings-aurora-financial-ledger/" data-link-title="9.C4 DraftKings：Aurora 撐 100 萬 ops/min 的體育博彩金融帳本" data-link-desc="DraftKings 用 Aurora MySQL 跑體育博彩金融帳本、Super Bowl 流量 &#43;50% 不影響延遲">DraftKings 100 萬 ops/min ≈ 17K ops/sec</a> 是 <em>200 cluster 加總</em>、平均每 cluster 約 80 ops/sec（case「需要警惕」段）— 不是「單一 cluster 撐 100 萬 ops」、案例對照不能擴寫成單 cluster 容量。</p>
<h3 id="driver-2microservice-ownershipnetflix">Driver 2：Microservice ownership（Netflix）</h3>
<p>Netflix 每微服務各自有 private Aurora cluster、不共用 — 跟 monolith「一個大 DB 撐全部」相反。</p>
<p>工程含義：</p>
<ul>
<li>DB 容量規劃變成「每微服務的容量規劃」、複雜度分散到 service owner</li>
<li>跨服務 contention 變成 <em>network 議題</em> 而非 <em>DB lock 議題</em></li>
<li>每多一個微服務就多一個 cluster、operational surface area × N</li>
</ul>
<p><strong>case 自帶 scope 警示</strong>：<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%、串流數十億小時">Netflix 數據層遠不止 Aurora</a> — 還有 Cassandra（playback metadata）、EVCache（cache layer）、Iceberg（data warehouse）。Aurora 主要是「需要 ACID 的 OLTP 工作負載」、不是「all-purpose store」（case「需要警惕」段第 2 點）。讀者引用 Netflix consolidation 時、不能誤推論「Aurora 可以替所有 store」。</p>
<h3 id="driver-3合規市場-boundarystandard-chartered-7-cluster">Driver 3：合規市場 boundary（Standard Chartered 7 cluster）</h3>
<p>Standard Chartered 7 個受監管市場 = 7 個獨立 cluster。<a href="/blog/backend/knowledge-cards/data-residency/" data-link-title="Data Residency" data-link-desc="合規要求資料留在特定地理邊界內、跨境複製違反合規、推動 fleet 拓樸決策">Data Residency</a> 規範資料 <em>不能跨境複製</em>、<a href="../global-database-multi-region/">Aurora Global Database</a> 在這種場景違反合規。</p>
<p>工程含義：</p>
<ul>
<li>容量規劃變成「7 個獨立規劃 × 各自合規門檻」</li>
<li>跨市場 DR 不靠 Global Database、靠應用層市場切換</li>
<li>合規 lead time 是時程主項（見 <a href="../migrate-from-self-managed-pg-mysql/">migration playbook</a> 合規時程段）</li>
</ul>
<p><strong>case 自承 scope 警示</strong>：Standard Chartered case 未公開是 PostgreSQL 還是 MySQL、未公開具體 cost 數字、屬「相關 case study」匿名對照。</p>
<h3 id="何時拆-vs-加-replica-的判讀順序">何時拆 vs 加 replica 的判讀順序</h3>
<p>按以下順序判斷、第一個成立的就是拆 cluster 的訊號：</p>
<ol>
<li><strong>&gt; 15 replica 需求</strong> → 拆 cluster（Aurora 硬上限）</li>
<li><strong>Blast radius 隔離需求</strong> → 拆 cluster（單 cluster 故障影響範圍太大、業務不能接受）</li>
<li><strong>業務本身可切分</strong>（user shard / 產品線 / 地理）→ 拆 cluster（DraftKings 拓樸）</li>
<li><strong>微服務私有 store 拓樸</strong> → 拆 cluster（Netflix 拓樸、跟服務生命週期綁定）</li>
<li><strong>合規禁止跨境複製</strong> → 拆 cluster（Standard Chartered 拓樸、Global Database 反指標）</li>
<li><strong>以上都不成立</strong> → 加 replica（最便宜的容量槓桿）</li>
</ol>
<p><strong>容易誤判的邊界</strong>：</p>
<ul>
<li>Fleet 治理本身有 ops surface area 成本（parameter group / backup / IAM / observability fan-out × N cluster）— 不是免費；driver 不夠強時不該拆</li>
<li>「fleet 看起來大」不是 driver — driver 是業務本身有 boundary、不是運維美觀</li>
<li>拆 cluster 後再合併比拆更難（資料遷移成本高）— driver 不確定時先加 replica</li>
</ul>
<h2 id="容量與觀測">容量與觀測</h2>
<p><strong>核心 metric</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">AuroraReplicaLag           # per replica lag
</span></span><span class="line"><span class="ln">2</span><span class="cl">AuroraReplicaLagMaximum    # cluster max lag
</span></span><span class="line"><span class="ln">3</span><span class="cl">CPUUtilization             # per replica CPU
</span></span><span class="line"><span class="ln">4</span><span class="cl">DatabaseConnections        # per replica connection</span></span></code></pre></div><p><strong>Application 端 metric</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">read_query_latency_p99       # per endpoint (writer vs reader)
</span></span><span class="line"><span class="ln">2</span><span class="cl">stale_read_error_count       # read-after-write 失敗訊號
</span></span><span class="line"><span class="ln">3</span><span class="cl">read_replica_routing_ratio   # writer vs reader 流量比例</span></span></code></pre></div><p><strong>容量上限</strong>：</p>
<ul>
<li>15 replica / cluster（硬上限）</li>
<li>Cross-region replica 走 <a href="../global-database-multi-region/">Aurora Global Database</a>（不算 15）</li>
</ul>
<p><strong>容量公式</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">read replica count = (read QPS / replica throughput) × (1 + lag buffer) × (1 + event tier headroom)
</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">lag buffer        = 30%（典型）
</span></span><span class="line"><span class="ln">4</span><span class="cl">event tier headroom = 0% (平日) / 50% (playoff) / 100% (championship) / 200% (Super Bowl)</span></span></code></pre></div><p><strong>回路徑</strong>：<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> 判斷 read-bound vs write-bound、<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> peak workload 預配 vs auto-scale 決策。</p>
<h2 id="邊界與整合--下一步">邊界與整合 / 下一步</h2>
<p><strong>Sibling deep articles</strong>：</p>
<ul>
<li><a href="../storage-architecture/">Aurora storage architecture</a> — 共享 storage 為什麼能養 15 replica + 雙峰錯位 application 邊界</li>
<li><a href="../cross-az-failover-rto/">Aurora cross-AZ failover RTO</a> — replica 升 primary 流程</li>
<li><a href="../global-database-multi-region/">Aurora Global Database</a> — 跨 region replica 配置 + 合規 anti-pattern</li>
</ul>
<p><strong>Migration playbook</strong>：</p>
<ul>
<li><a href="../migrate-from-self-managed-pg-mysql/">PostgreSQL / MySQL → Aurora</a> — fleet 拓樸是 migration 規劃的維度之一</li>
</ul>
<p><strong>1.x 章節互引</strong>：</p>
<ul>
<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 高併發資料存取</a> — read replica 是 OLTP 擴容的基本槓桿</li>
</ul>
<p><strong>RDS Proxy 整合</strong>：lag-aware routing、connection pool 共享、Lambda 場景；managed alternative。</p>
<p><strong>何時不用本文</strong>：single replica + cross-AZ failover 已滿足、read traffic 不是 bottleneck 時可跳過、看 <a href="/blog/backend/01-database/vendors/aurora/" data-link-title="AWS Aurora" data-link-desc="AWS managed PostgreSQL / MySQL、storage / compute 分離、&#43;75% 效能改善的 production 證據">Aurora vendor overview</a> 即可。</p>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/aurora/" data-link-title="AWS Aurora" data-link-desc="AWS managed PostgreSQL / MySQL、storage / compute 分離、&#43;75% 效能改善的 production 證據">Aurora vendor overview</a> — 服務定位、適用 / 不適用場景</li>
<li><a href="/blog/backend/knowledge-cards/replication-lag/" data-link-title="Replication Lag" data-link-desc="說明資料副本落後正式來源多久，以及它如何影響讀取正確性">Replication Lag 卡片</a> — 概念基底</li>
<li><a href="/blog/backend/knowledge-cards/stale-read/" data-link-title="Stale Read" data-link-desc="讀取到落後於最新寫入版本的舊資料">Stale Read 卡片</a> — read-after-write 容忍度</li>
<li><a href="/blog/backend/09-performance-capacity/cases/draftkings-aurora-financial-ledger/" data-link-title="9.C4 DraftKings：Aurora 撐 100 萬 ops/min 的體育博彩金融帳本" data-link-desc="DraftKings 用 Aurora MySQL 跑體育博彩金融帳本、Super Bowl 流量 &#43;50% 不影響延遲">9.C4 DraftKings</a> — 200 cluster business sharding 跟 headroom 預留</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</a> — 微服務私有 store + Aurora 非 all-purpose store 邊界</li>
<li><a href="/blog/backend/09-performance-capacity/cases/standard-chartered-aurora-banking/" data-link-title="9.C14 Standard Chartered：受監管銀行的 Aurora 4000 TPS 容量提升" data-link-desc="Standard Chartered 銀行遷移到 Aurora 後吞吐量提升 10 倍至 4000 TPS、跨 7 個受監管市場">9.C14 Standard Chartered</a> — 合規驅動 fleet 拓樸</li>
<li><a href="/blog/backend/09-performance-capacity/cases/fanduel-dual-peak-betting-streaming/" data-link-title="9.C28 FanDuel：體育直播 &#43; 投注的雙重峰值" data-link-desc="FanDuel 3.5M MAU、Super Bowl 期間擴容 5-10 倍、用 AWS Local Zones &#43; Wavelength &#43; Outposts 處理 20&#43; 州的雙重峰值">9.C28 FanDuel</a> — 雙 SLO 並行 + 事件型容量分級</li>
<li><a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章方法論</a> — 本文遵循的 6 規格面寫作模板</li>
<li>官方：<a href="https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/Aurora.Replication.html">Aurora replication</a></li>
</ul>
]]></content:encoded></item><item><title>CockroachDB Transaction Retry Pattern：serializable default 與 application contract 重塑</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/cockroachdb/transaction-retry-pattern/</link><pubDate>Wed, 27 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/cockroachdb/transaction-retry-pattern/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/cockroachdb/" data-link-title="CockroachDB" data-link-desc="分散式 SQL、PostgreSQL 相容、跨區強一致、Spanner 的開源 / 跨雲替代">CockroachDB vendor overview&lt;/a> 的 implementation-layer deep article。Overview 已界定 CockroachDB 的 PostgreSQL wire 相容定位、本文聚焦 &lt;em>serializable default 對 application transaction contract 的重塑&lt;/em>。&lt;/p>
&lt;p>&lt;strong>Scope warning（最高、F4 Frame 2）&lt;/strong>：&lt;strong>本篇整篇是跨 case 合成 frame、不是單一 case 揭露&lt;/strong>。3 個 CockroachDB direct case（&lt;a href="https://tarrragon.github.io/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&lt;/a> / &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/netflix-cockroachdb-multi-region-fleet/" data-link-title="9.C40 Netflix：380&amp;#43; CockroachDB cluster 的 multi-active 拓樸艦隊" data-link-desc="Netflix 把 Cassandra 不夠用的 transactional workload 移到 CockroachDB、380&amp;#43; cluster / 60&amp;#43; 跨 region、含 Open Connect、studio cloud drive、gaming control plane">9.C40 Netflix&lt;/a> / &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/hard-rock-digital-cockroachdb-sports-betting/" data-link-title="9.C41 Hard Rock Digital：CockroachDB on AWS Outposts、Wire Act 合規 &amp;#43; 跨州單一邏輯 DB" data-link-desc="Hard Rock Digital 用 CockroachDB 跨 AWS Outposts &amp;#43; US-East-1、Wire Act 強制資料留州、單一邏輯 DB 解多州 sportsbook、100 node 32 vCPU 撐 Super Bowl">9.C41 Hard Rock Digital&lt;/a>）對 application transaction retry contract 重塑的揭露 &lt;em>都偏弱&lt;/em> — DoorDash case 只寫 PostgreSQL wire &lt;em>protocol-level&lt;/em> 相容、SQL 行為（serializable default / retry semantics / partial index）「仍要驗證」、&lt;strong>沒&lt;/strong>直接寫 &lt;code>40001 serialization_failure&lt;/code> / &lt;code>SAVEPOINT cockroach_restart&lt;/code> / hot row contention / retry loop pattern。Netflix / Hard Rock case 完全沒寫 retry pattern。本章 retry pattern 議題從 Cockroach Labs 官方 SQL Layer docs + PG → CockroachDB 通用 contract 重塑視角合成、DoorDash 只作為 trigger context（撞牆訊號 + 觸發遷移）、不是 ground truth case study。讀者引用本章內容到實際系統前、應該 &lt;em>自己跑 application audit&lt;/em> 而不是直接套合成的 pattern。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="問題情境從-pg-read-committed-遷到-cockroachdb-serializable-的-application-衝擊">問題情境：從 PG READ COMMITTED 遷到 CockroachDB SERIALIZABLE 的 application 衝擊&lt;/h2>
&lt;p>團隊從 PostgreSQL（default &lt;code>READ COMMITTED&lt;/code>）遷到 CockroachDB（default &lt;code>SERIALIZABLE&lt;/code>）、上線後 application transaction retry 突然爆增、user-facing latency p99 高 5 倍、error rate 顯著上升。Driver 不會自動 retry — 應用層必須認得 &lt;code>40001 serialization_failure&lt;/code> 並包 retry loop with exponential backoff。沒包就是直接拋例外給用戶。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/cockroachdb/" data-link-title="CockroachDB" data-link-desc="分散式 SQL、PostgreSQL 相容、跨區強一致、Spanner 的開源 / 跨雲替代">CockroachDB vendor overview</a> 的 implementation-layer deep article。Overview 已界定 CockroachDB 的 PostgreSQL wire 相容定位、本文聚焦 <em>serializable default 對 application transaction contract 的重塑</em>。</p>
<p><strong>Scope warning（最高、F4 Frame 2）</strong>：<strong>本篇整篇是跨 case 合成 frame、不是單一 case 揭露</strong>。3 個 CockroachDB direct case（<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</a> / <a href="/blog/backend/09-performance-capacity/cases/netflix-cockroachdb-multi-region-fleet/" data-link-title="9.C40 Netflix：380&#43; CockroachDB cluster 的 multi-active 拓樸艦隊" data-link-desc="Netflix 把 Cassandra 不夠用的 transactional workload 移到 CockroachDB、380&#43; cluster / 60&#43; 跨 region、含 Open Connect、studio cloud drive、gaming control plane">9.C40 Netflix</a> / <a href="/blog/backend/09-performance-capacity/cases/hard-rock-digital-cockroachdb-sports-betting/" data-link-title="9.C41 Hard Rock Digital：CockroachDB on AWS Outposts、Wire Act 合規 &#43; 跨州單一邏輯 DB" data-link-desc="Hard Rock Digital 用 CockroachDB 跨 AWS Outposts &#43; US-East-1、Wire Act 強制資料留州、單一邏輯 DB 解多州 sportsbook、100 node 32 vCPU 撐 Super Bowl">9.C41 Hard Rock Digital</a>）對 application transaction retry contract 重塑的揭露 <em>都偏弱</em> — DoorDash case 只寫 PostgreSQL wire <em>protocol-level</em> 相容、SQL 行為（serializable default / retry semantics / partial index）「仍要驗證」、<strong>沒</strong>直接寫 <code>40001 serialization_failure</code> / <code>SAVEPOINT cockroach_restart</code> / hot row contention / retry loop pattern。Netflix / Hard Rock case 完全沒寫 retry pattern。本章 retry pattern 議題從 Cockroach Labs 官方 SQL Layer docs + PG → CockroachDB 通用 contract 重塑視角合成、DoorDash 只作為 trigger context（撞牆訊號 + 觸發遷移）、不是 ground truth case study。讀者引用本章內容到實際系統前、應該 <em>自己跑 application audit</em> 而不是直接套合成的 pattern。</p></blockquote>
<hr>
<h2 id="問題情境從-pg-read-committed-遷到-cockroachdb-serializable-的-application-衝擊">問題情境：從 PG READ COMMITTED 遷到 CockroachDB SERIALIZABLE 的 application 衝擊</h2>
<p>團隊從 PostgreSQL（default <code>READ COMMITTED</code>）遷到 CockroachDB（default <code>SERIALIZABLE</code>）、上線後 application transaction retry 突然爆增、user-facing latency p99 高 5 倍、error rate 顯著上升。Driver 不會自動 retry — 應用層必須認得 <code>40001 serialization_failure</code> 並包 retry loop with exponential backoff。沒包就是直接拋例外給用戶。</p>
<p>讀者常問：</p>
<ul>
<li>為什麼同樣的 transaction 在 CockroachDB 一直 retry、在 PostgreSQL 從來不會？</li>
<li><code>40001 serialization_failure</code> error 怎麼處理、能不能直接 swallow？</li>
<li>我要把所有 application transaction 都改成 retry loop 包起來嗎？</li>
<li>能不能改 isolation level 回 <code>READ COMMITTED</code>、放棄 serializable 保證？</li>
</ul>
<p>四題的回答都依賴一個前提：CockroachDB 的 application transaction contract 跟 PostgreSQL default 不一樣、必須重塑。</p>
<h3 id="scope-warning-explicit-labeldoordash-case-沒揭露-retry-pattern">Scope warning explicit label：DoorDash case 沒揭露 retry pattern</h3>
<p><strong>DoorDash case 沒直接揭露 serializable retry contract / 40001 / SAVEPOINT pattern / hot row contention</strong>。case 只寫「PostgreSQL wire protocol 相容、實際 SQL 行為（serializable default、retry semantics、partial index）<em>仍要驗證</em>」（DoorDash 觀察段 / 策略段 3、F4.4）。</p>
<p>本章 retry pattern 議題是從 PG → CockroachDB 通用 contract 重塑視角合成、不是 DoorDash case 直接揭露。引用 DoorDash 時應該用：</p>
<ul>
<li><strong>正確口徑</strong>：「DoorDash 揭露 Aurora Postgres 1.636 M QPS 撞牆 → 引出 distributed SQL retry contract 需求、本章 retry pattern 議題是從 PostgreSQL → CockroachDB 通用 contract 重塑視角合成、不是 DoorDash case 直接揭露」</li>
<li><strong>不要寫成</strong>：「DoorDash retry pattern」、「DoorDash 揭露 40001 處理」之類把合成包成 case fact 的語法</li>
</ul>
<h3 id="case-anchortrigger-context不是-ground-truth">Case anchor（trigger context、不是 ground truth）</h3>
<ul>
<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</a>：提供「PG wire 相容、SQL 行為仍要 audit」的 case 警語（F4.4）、作為本章 <em>為什麼 retry contract 要重塑</em> 的觸發訊號。retry pattern 本體走 standard-driven（Cockroach Labs 官方 SQL Layer docs + Transaction Retry docs）</li>
</ul>
<p>Sibling 對照 <a href="/blog/backend/09-performance-capacity/cases/draftkings-aurora-financial-ledger/" data-link-title="9.C4 DraftKings：Aurora 撐 100 萬 ops/min 的體育博彩金融帳本" data-link-desc="DraftKings 用 Aurora MySQL 跑體育博彩金融帳本、Super Bowl 流量 &#43;50% 不影響延遲">9.C4 DraftKings Aurora financial ledger</a> 提供 <em>PostgreSQL READ COMMITTED + Aurora</em> 的另一條路徑 — 用 application-level sharding（200 個獨立 Aurora cluster）避開 retry、而不是處理 retry。<strong>Scope warning</strong>：DraftKings case <em>沒</em> 寫 PostgreSQL READ COMMITTED retry pattern、case 是 Aurora 內 business sharding 路徑。本章引用 DraftKings 為「假想若把 DraftKings 遷 CockroachDB 會撞到 retry contract 重塑」合成對照、不是 case 直接揭露。</p>
<h2 id="核心機制serializable-default-跟-postgresql-的差異">核心機制：serializable default 跟 PostgreSQL 的差異</h2>
<blockquote>
<p><strong>來源分層</strong>：本段機制來源是 Cockroach Labs 官方 SQL Layer docs + Transaction Retry docs（standard-driven）、<em>不是</em> 從 case 抽取。3 個 direct case 都沒揭露這些機制細節。</p></blockquote>
<h3 id="serializable-是-cockroachdb-的-default">Serializable 是 CockroachDB 的 default</h3>
<p>CockroachDB 預設 <code>SERIALIZABLE</code> — 最強 isolation level、保證 transaction 結果等同某個 serial order（即所有 transaction 像逐個按順序執行）。對比：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>PostgreSQL default</th>
          <th>CockroachDB default</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Isolation</td>
          <td>READ COMMITTED</td>
          <td>SERIALIZABLE</td>
      </tr>
      <tr>
          <td>衝突處理</td>
          <td>後 writer 等 lock</td>
          <td>衝突即 abort、丟 40001</td>
      </tr>
      <tr>
          <td>機制</td>
          <td>row lock + MVCC</td>
          <td>timestamp ordering + write intent</td>
      </tr>
      <tr>
          <td>Retry 必要性</td>
          <td>通常不需要</td>
          <td>application 必須有 retry loop</td>
      </tr>
      <tr>
          <td>SSI 對應</td>
          <td>PG SSI（opt-in）</td>
          <td>預設啟用</td>
      </tr>
  </tbody>
</table>
<h3 id="conflict-detectionread--write-set-衝突就-abort">Conflict detection：read / write set 衝突就 abort</h3>
<p>CockroachDB 追蹤每個 transaction 的 read set 跟 write set。當兩個並行 transaction 的 read / write set 衝突、CockroachDB abort 後到的那個、發 <a href="/blog/backend/knowledge-cards/serialization-failure/" data-link-title="Serialization Failure" data-link-desc="SERIALIZABLE isolation 衝突偵測後 abort 的協議、SQL state 40001、application 必須包 retry loop">Serialization Failure</a>（<code>40001 serialization_failure</code>）。</p>
<p>對比 PostgreSQL serializable（SSI）：兩者都是「post-detect」、commit 時偵測 anomaly、不是 pre-lock。差別在 <em>衝突偵測時機</em> 跟 <em>成本</em>：</p>
<ul>
<li>PostgreSQL SSI：用 predicate lock 追蹤 query 條件、commit 時偵測</li>
<li>CockroachDB：用 timestamp ordering + write intent、衝突 <em>當下</em> 就 abort</li>
</ul>
<p>CockroachDB 的成本在「衝突立刻 abort 不等 commit」、好處是「retry window 較短、不會跑完整個 transaction 才發現衝突」。</p>
<h3 id="application-端-retrydriver-不自動處理">Application 端 retry：driver 不自動處理</h3>
<p>關鍵：<strong>CockroachDB driver 不自動 retry</strong>。application 收到 <code>40001 serialization_failure</code> 必須自己決定怎麼處理 — exponential backoff retry、circuit break、或拋給上層。</p>
<p>對比 PostgreSQL：PostgreSQL READ COMMITTED 幾乎不會丟 serialization failure（後 writer 等 lock 不 abort）、SERIALIZABLE 才會、但多數 application 沒走 SERIALIZABLE。CockroachDB <em>預設</em> 就是 SERIALIZABLE、所以 retry loop 是 <em>必要</em>、不是 optional。</p>
<h3 id="savepoint-pattern官方推薦寫法">Savepoint pattern：官方推薦寫法</h3>
<p>Cockroach Labs 官方推薦的 retry pattern 用 <code>SAVEPOINT cockroach_restart</code>：</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">BEGIN</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">SAVEPOINT</span><span class="w"> </span><span class="n">cockroach_restart</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></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w"></span><span class="c1">-- 做正常 transaction 工作
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">balance</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">accounts</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="o">=</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"> 6</span><span class="cl"><span class="w"></span><span class="k">UPDATE</span><span class="w"> </span><span class="n">accounts</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="n">balance</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">balance</span><span class="w"> </span><span class="o">-</span><span class="w"> </span><span class="mi">100</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="o">=</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"> 7</span><span class="cl"><span class="w"></span><span class="k">UPDATE</span><span class="w"> </span><span class="n">accounts</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="n">balance</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">balance</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="mi">100</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">2</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></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w"></span><span class="n">RELEASE</span><span class="w"> </span><span class="n">SAVEPOINT</span><span class="w"> </span><span class="n">cockroach_restart</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">COMMIT</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></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w"></span><span class="c1">-- 如果中途 40001：
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1">-- ROLLBACK TO SAVEPOINT cockroach_restart;
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1">-- 重新跑 transaction body、再 RELEASE + COMMIT</span></span></span></code></pre></div><p><code>cockroach_restart</code> 是特殊保留 savepoint name — CockroachDB 認得這個名字、會把 <code>ROLLBACK TO SAVEPOINT cockroach_restart</code> 視為「重啟整個 transaction」而不是部分 rollback。</p>
<h3 id="read-committed-是-v232-可選降級">READ COMMITTED 是 v23.2+ 可選降級</h3>
<p>CockroachDB v23.2+ 新增 <code>READ COMMITTED</code> isolation level — application 可選擇用 weaker isolation 換少 retry。但這是「降級」、失去 serializable 保證 — 對應的反例段在失敗模式段展開（金融 ledger 走 READ COMMITTED 可能讓 balance 變負）。</p>
<p>對應 <a href="/blog/backend/knowledge-cards/isolation-level/" data-link-title="Isolation Level" data-link-desc="說明資料庫交易隔離級別如何影響並發讀寫結果">isolation level 卡</a> 跟 <a href="/blog/backend/knowledge-cards/transaction-boundary/" data-link-title="Transaction Boundary" data-link-desc="說明哪些資料變更應在同一個交易中一起成功或一起回復">transaction boundary 卡</a>。</p>
<h3 id="doordash-case-對接點trigger-context-only">DoorDash case 對接點（trigger context only）</h3>
<p>DoorDash case 揭露 PG wire <em>protocol-level</em> 相容、明示 SQL 行為（serializable default / retry semantics / partial index）「仍要驗證」（F4.4）。本章機制段就是回答「audit 什麼」的具體展開 — 但 audit checklist 本體屬通用工程知識、case 沒 ground truth。</p>
<p>引用紀律：「DoorDash 揭露 PG wire 相容、SQL 行為仍要 audit、其中 serializable default 跟 retry semantics 是 application contract 重塑的核心議題」— 把 case 揭露的 fact 跟本章合成的 frame 分開講。</p>
<h2 id="操作流程retry-loop-設計">操作流程：retry loop 設計</h2>
<h3 id="retry-loop-偽碼">Retry loop 偽碼</h3>





<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">for</span> <span class="nx">attempt</span> <span class="o">:=</span> <span class="mi">0</span><span class="p">;</span> <span class="nx">attempt</span> <span class="p">&lt;</span> <span class="nx">MAX_RETRIES</span><span class="p">;</span> <span class="nx">attempt</span><span class="o">++</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">tx</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">db</span><span class="p">.</span><span class="nf">Begin</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span> <span class="k">return</span> <span class="nx">err</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">_</span><span class="p">,</span> <span class="nx">err</span> <span class="p">=</span> <span class="nx">tx</span><span class="p">.</span><span class="nf">Exec</span><span class="p">(</span><span class="s">&#34;SAVEPOINT cockroach_restart&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span> <span class="nx">tx</span><span class="p">.</span><span class="nf">Rollback</span><span class="p">();</span> <span class="k">return</span> <span class="nx">err</span> <span class="p">}</span>
</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 class="c1">// ... 跑 transaction body ...</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nx">_</span><span class="p">,</span> <span class="nx">err</span> <span class="p">=</span> <span class="nx">tx</span><span class="p">.</span><span class="nf">Exec</span><span class="p">(</span><span class="s">&#34;RELEASE SAVEPOINT cockroach_restart&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">==</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">err</span> <span class="p">=</span> <span class="nx">tx</span><span class="p">.</span><span class="nf">Commit</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="k">if</span> <span class="nx">err</span> <span class="o">==</span> <span class="kc">nil</span> <span class="p">{</span> <span class="k">return</span> <span class="kc">nil</span> <span class="p">}</span> <span class="c1">// 成功</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="k">if</span> <span class="nf">isSerializationFailure</span><span class="p">(</span><span class="nx">err</span><span class="p">)</span> <span class="p">{</span> <span class="c1">// SQLSTATE == &#34;40001&#34;</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="nx">tx</span><span class="p">.</span><span class="nf">Rollback</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">        <span class="nx">backoff</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Duration</span><span class="p">(</span><span class="nx">math</span><span class="p">.</span><span class="nf">Pow</span><span class="p">(</span><span class="mi">2</span><span class="p">,</span> <span class="nb">float64</span><span class="p">(</span><span class="nx">attempt</span><span class="p">)))</span> <span class="o">*</span> <span class="mi">10</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Millisecond</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">        <span class="nx">time</span><span class="p">.</span><span class="nf">Sleep</span><span class="p">(</span><span class="nx">backoff</span> <span class="o">+</span> <span class="nf">jitter</span><span class="p">())</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="k">continue</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">
</span></span><span class="line"><span class="ln">23</span><span class="cl">    <span class="nx">tx</span><span class="p">.</span><span class="nf">Rollback</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="k">return</span> <span class="nx">err</span> <span class="c1">// 非 retry-able error</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl"><span class="k">return</span> <span class="nx">ErrMaxRetriesExceeded</span></span></span></code></pre></div><p>關鍵點：</p>
<ul>
<li>exponential backoff with jitter（避免 retry storm 同步）</li>
<li>max retry 上限（避免無限 loop、要有 circuit breaker）</li>
<li>只 retry serialization failure、其他 error 直接拋</li>
<li>transaction body 必須是 <em>冪等</em> 的（同樣 input 多次執行結果一致）</li>
</ul>
<h3 id="配置">配置</h3>





<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">-- 改 transaction isolation level（v23.2+ 才支援 READ COMMITTED）
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SET</span><span class="w"> </span><span class="k">TRANSACTION</span><span class="w"> </span><span class="k">ISOLATION</span><span class="w"> </span><span class="k">LEVEL</span><span class="w"> </span><span class="k">READ</span><span class="w"> </span><span class="k">COMMITTED</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></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="c1">-- 看當前 session 預設
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="k">SHOW</span><span class="w"> </span><span class="k">SESSION</span><span class="w"> </span><span class="n">default_transaction_isolation</span><span class="p">;</span></span></span></code></pre></div><h3 id="驗證點">驗證點</h3>





<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">-- 看 transaction retry 統計
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">crdb_internal</span><span class="p">.</span><span class="n">txn_stats</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></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="c1">-- 看哪些 query / table 衝突最多
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">crdb_internal</span><span class="p">.</span><span class="n">cluster_contention_events</span><span class="w"> </span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="k">count</span><span class="w"> </span><span class="k">DESC</span><span class="w"> </span><span class="k">LIMIT</span><span class="w"> </span><span class="mi">10</span><span class="p">;</span></span></span></code></pre></div><h3 id="idempotency-設計transaction-body-必須冪等">Idempotency 設計：transaction body 必須冪等</h3>
<p>retry-safe transaction body 必須冪等 — 同樣 input 多次執行結果一致。這是 <a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a> 在 distributed SQL retry contract 下的具體展開、不是 optional：</p>
<table>
  <thead>
      <tr>
          <th>Transaction body</th>
          <th>是否冪等</th>
          <th>為什麼</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>UPDATE balance SET balance = balance - 100</code></td>
          <td>是</td>
          <td>同樣 input 每次都減 100</td>
      </tr>
      <tr>
          <td><code>UPDATE balance SET balance = 900</code></td>
          <td>是</td>
          <td>設成絕對值、retry 不影響</td>
      </tr>
      <tr>
          <td><code>INSERT INTO logs VALUES (...)</code></td>
          <td>否</td>
          <td>retry 後重複寫、要加 UNIQUE constraint</td>
      </tr>
      <tr>
          <td><code>INSERT ON CONFLICT (id) DO NOTHING</code></td>
          <td>是</td>
          <td>用 ON CONFLICT 處理重複</td>
      </tr>
      <tr>
          <td><code>UPDATE counter SET val = val + 1</code></td>
          <td>否（語意問題）</td>
          <td>retry 後加超過預期次數</td>
      </tr>
  </tbody>
</table>
<p>冪等性是 application 設計議題、不是 CockroachDB 配置可解的 — application contract 重塑的核心成本就在這。</p>
<h3 id="rollback-邊界">Rollback 邊界</h3>
<p>transaction 自身有 <code>SAVEPOINT cockroach_restart</code> 邊界、<code>ROLLBACK TO SAVEPOINT</code> 後可重試整個 transaction body。但：</p>
<ul>
<li>commit 後不可回滾 — 業務狀態還原只能新交易補償</li>
<li>application 端如果在 transaction <em>外</em> cache state、retry 後 state 不一致（見失敗模式段）</li>
</ul>
<h2 id="失敗模式">失敗模式</h2>
<h3 id="retry-stormcontention-嚴重時-cpu-雪崩">Retry storm：contention 嚴重時 CPU 雪崩</h3>
<p>當高頻寫入撞同一 row（例：全局 counter、熱門商品 inventory）、serializable 衝突率可能 100%、application 端 retry loop 不斷重跑、CPU 雪崩。</p>
<p>修法：</p>
<ul>
<li>Max retry 上限 + circuit breaker：超過就放棄、回 5xx 給 client、避免 retry storm 拖垮 cluster</li>
<li>改 schema 避開 hot row（partition by region、shard counter、用 sequence 代替全局 counter）</li>
<li>監控 <code>crdb_internal.cluster_contention_events</code>、針對 top-N table 改設計</li>
</ul>
<h3 id="非冪等-transaction-重試double-count">非冪等 transaction 重試：double-count</h3>
<p>最危險的 production bug：transaction body 不是冪等的、retry 後資料重複寫。ledger double-count、payment 重複扣款、log 重複記錄。</p>
<p>修法：</p>
<ul>
<li>transaction body 寫成 <code>UPDATE balance SET balance = balance - X</code>（相對運算）、不寫 <code>UPDATE balance SET balance = Y</code>（絕對賦值依賴 read 結果）</li>
<li><code>INSERT</code> 加 UNIQUE constraint + <code>ON CONFLICT DO NOTHING</code></li>
<li>用 idempotency key（client 帶 UUID、server 端 dedupe）</li>
</ul>
<h3 id="cross-statement-state-假設">Cross-statement state 假設</h3>
<p>application 在 transaction <em>外</em> cache state（例：開 transaction 前 read 一個值、跑 transaction 期間用 cached 值）— retry 從 SAVEPOINT 重來時、cached state 不會重新讀、retry 後 state 不一致。</p>
<p>修法：</p>
<ul>
<li>把 cached state 改成在 transaction 內 read</li>
<li>retry loop 內 reset 所有 cached state</li>
<li>用 closure / scope 限制 cache 的生命週期到 transaction 內</li>
</ul>
<h3 id="hot-row-contention">Hot row contention</h3>
<p>高頻 update 同一 row（例：全局計數器、熱門商品庫存、世界冠軍直播觀眾數）— serializable 衝突率接近 100%、無論 retry 多少次都繼續衝突。</p>
<p>修法（schema-level、不是 application-level）：</p>
<ul>
<li>用 sequence 或 distributed counter（每節點本地 + 定期 aggregate）</li>
<li>partition by hash key、把單一 row 拆成 N 個 sub-row</li>
<li>改 <em>append-only</em> + 定期 aggregate（事件流 + materialized view）</li>
</ul>
<h3 id="改-read-committed-後忘了驗證業務語意">改 READ COMMITTED 後忘了驗證業務語意</h3>
<p>v23.2+ 可改 <code>READ COMMITTED</code>、少 retry 但失去 serializable 保證。對金融 ledger：READ COMMITTED 可能讓 balance 變負（兩個並行 withdraw 都看到 balance=100、都扣 50、結果 balance=-50）。</p>
<p>修法：</p>
<ul>
<li>金融 / 庫存 / 配額這類 <em>strict consistency</em> 場景必須留 SERIALIZABLE</li>
<li>READ COMMITTED 只用在 <em>容忍 stale read</em> 的場景（搜尋結果 / 分析 dashboard）</li>
<li>改 isolation level 前 <em>跑 application audit</em>、確認業務語意能容忍</li>
</ul>
<h3 id="long-running-transactionretry-機率隨時間線性上升">Long-running transaction：retry 機率隨時間線性上升</h3>
<p>transaction read 開始時間早、commit 時 conflict window 大、retry 機率隨 transaction duration 線性上升。</p>
<p>修法：</p>
<ul>
<li>transaction scope 縮小 — 只包必要 read / write、不要把 RPC call / external API 放 transaction 內</li>
<li>kill long-running query（<code>SHOW SESSIONS</code> + <code>CANCEL QUERY</code>）</li>
<li>把 batch update 拆成多個小 transaction、加 idempotency key</li>
</ul>
<h3 id="distributed-deadlock-跟-retry-互動">Distributed deadlock 跟 retry 互動</h3>
<p>CockroachDB 用 distributed deadlock detection（每個 node 維護 wait-for graph、定期跨 node 交換）跟 PostgreSQL local lock 表的 deadlock detection 不同。一般情況下、被 detector 選為 victim 的 transaction 會直接 abort、application retry loop 應該收到 <code>40001</code> 後重跑。但在三種 corner case 下會跟 retry loop 形成雪崩 pattern：</p>
<ul>
<li>多 transaction 同時撞同一組熱 row、deadlock detector 跨節點時間窗有 lag、多個 victim 同時 abort 後同時 retry、撞回同一個 deadlock window</li>
<li>跨節點的 distributed deadlock 偵測週期（預設 200ms+）放大 application retry latency、application 的 retry backoff 沒對齊偵測週期、形成「detect → abort → 快速 retry → 再 deadlock」迴圈</li>
<li>Application 把 deadlock victim 當 <code>40001</code> 直接 retry、不分流出來看、就難以從 metric 區分「serialization conflict retry」跟「distributed deadlock retry」、調 schema / contention 的策略會用錯方向</li>
</ul>
<p>修法（屬通用工程議題、case 未直接揭露）：</p>
<ul>
<li>Retry backoff 至少對齊 distributed deadlock 偵測週期、避免在偵測窗內快速 retry</li>
<li>加 jitter、不同 session 的 retry 不同步</li>
<li>Application metric 分桶記錄 <code>serialization_conflict_retry</code> vs <code>distributed_deadlock_retry</code>、避免 contention 改善方向判錯</li>
<li>Schema 設計階段避免「跨節點熱 row 環形依賴」（例：兩個服務交叉 update 對方的 counter row）</li>
</ul>
<h3 id="跨-case-合成-scope-warningdraftkings-對照">跨 case 合成 Scope warning：DraftKings 對照</h3>
<p>DraftKings ledger 對照 — <strong>DraftKings case 沒寫 PostgreSQL READ COMMITTED retry pattern</strong>、case 內容是「Aurora 內 business sharding 路徑」、用 200 個獨立 cluster 解 Aurora single-primary 撞牆。本章把 DraftKings 拿來當「假想若遷 CockroachDB 需改 SERIALIZABLE + retry loop」的合成對照、不是 case 揭露的 fact。</p>
<p>實際 DraftKings 走 Aurora + application sharding 而非 CockroachDB、所以「DraftKings retry pattern」這個說法本身就是合成 — 應該寫成「DraftKings 走 Aurora sharding 避開 retry contract 重塑、若改走 CockroachDB 則需處理本章描述的 application 改寫」。</p>
<h2 id="容量與觀測">容量與觀測</h2>
<h3 id="必看-metric">必看 metric</h3>
<ul>
<li><code>Transaction retry rate</code>：per table、per session</li>
<li><code>Serialization failure rate</code>：絕對值 + ratio</li>
<li><code>Transaction duration p99</code>：long-running 是 retry 的根因之一</li>
<li><code>Hot ranges by retry count</code>：top contention 來源</li>
<li>Application metric：retry count per request、retry-induced latency p99、circuit breaker trip count</li>
</ul>
<h3 id="容量公式">容量公式</h3>
<ul>
<li>基底 QPS × (1 + avg retry count) = 實際 transaction load</li>
<li>例：1000 QPS、avg retry = 0.3 → 實際 cluster 處理 1300 transaction/s</li>
</ul>
<p>retry rate 是 <em>容量規劃必納入</em> 的變數 — 沒算 retry 就會 underestimate 真實 load。</p>
<h3 id="tuning">Tuning</h3>
<ul>
<li>reduce transaction scope：transaction 越短、conflict window 越小</li>
<li>kill long-running query：transaction 過長要主動截斷</li>
<li>partition hot rows：schema-level 解 hot contention</li>
<li>改 isolation 到 READ COMMITTED（如果業務語意允許）</li>
</ul>
<h3 id="回路徑">回路徑</h3>
<ul>
<li><a href="/blog/backend/09-performance-capacity/" data-link-title="模組九：效能工程與容量規劃" data-link-desc="把『目前配置能撐多少、要加多少機器』變成可量化、可驗證、可改進的工程流程">9.5 瓶頸定位流程</a> 判斷 retry-bound vs CPU-bound</li>
<li><a href="/blog/backend/09-performance-capacity/" data-link-title="模組九：效能工程與容量規劃" data-link-desc="把『目前配置能撐多少、要加多少機器』變成可量化、可驗證、可改進的工程流程">9.6 容量規劃模型</a> retry rate × baseline QPS</li>
<li><a href="/blog/backend/knowledge-cards/transaction-boundary/" data-link-title="Transaction Boundary" data-link-desc="說明哪些資料變更應在同一個交易中一起成功或一起回復">transaction boundary 卡</a></li>
<li><a href="/blog/backend/knowledge-cards/isolation-level/" data-link-title="Isolation Level" data-link-desc="說明資料庫交易隔離級別如何影響並發讀寫結果">isolation level 卡</a></li>
</ul>
<h2 id="邊界與整合">邊界與整合</h2>
<h3 id="sibling-deep-articles">Sibling deep articles</h3>
<ul>
<li><a href="../hlc-raft-consensus/">HLC + Raft consensus</a>：為什麼 serializable 是 distributed SQL 的合理 default</li>
<li><a href="../locality-aware-schema/">locality-aware schema</a>：partition 降低 hot row contention</li>
<li><a href="../survival-goals/">survival goals</a>：cross-region latency 加長 retry window</li>
</ul>
<h3 id="跟-postgresql-對照">跟 PostgreSQL 對照</h3>
<p>PostgreSQL READ COMMITTED 是 default、application 沒 retry loop 是 acceptable。遷 CockroachDB <em>必須</em> 重塑 application transaction contract — 這是 migration 階段最容易 underestimate 的成本。</p>
<p>對應 PostgreSQL MVCC + SSI 機制細節、見 <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 對比">PostgreSQL MVCC + Lock Model</a>。</p>
<h3 id="migration-playbook">Migration playbook</h3>
<p>PG → CockroachDB 的 application audit 必看 transaction shape：</p>
<ul>
<li>每個 transaction 的 read / write set 預估衝突率</li>
<li>是否冪等（retry-safe）</li>
<li>transaction duration（long-running 是 retry 放大器）</li>
<li>業務語意能否容忍 READ COMMITTED（避開 retry 的 fallback）</li>
</ul>
<h3 id="1x-章節互引">1.x 章節互引</h3>
<ul>
<li><a href="/blog/backend/01-database/transaction-boundary/" data-link-title="1.3 Transaction 與一致性邊界" data-link-desc="交易邊界、isolation level、retry 策略、distributed transaction（2PC、Saga）與跨 region 強一致取捨">1.3 Transaction Boundary</a> 上游 — distributed transaction 邊界</li>
<li><a href="/blog/backend/knowledge-cards/isolation-level/" data-link-title="Isolation Level" data-link-desc="說明資料庫交易隔離級別如何影響並發讀寫結果">isolation level 卡</a></li>
</ul>
<h3 id="何時不用本文">何時不用本文</h3>
<ul>
<li>純 read-only workload、無 contention</li>
<li>已用 PostgreSQL serializable（application contract 相似、遷移衝擊小）</li>
<li>用 CockroachDB v23.2+ READ COMMITTED 且業務允許 stale read</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/cockroachdb/" data-link-title="CockroachDB" data-link-desc="分散式 SQL、PostgreSQL 相容、跨區強一致、Spanner 的開源 / 跨雲替代">CockroachDB vendor overview</a></li>
<li><a href="../hlc-raft-consensus/">HLC + Raft consensus</a></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</a>（trigger context — PG wire 相容警語）</li>
<li><a href="/blog/backend/09-performance-capacity/cases/draftkings-aurora-financial-ledger/" data-link-title="9.C4 DraftKings：Aurora 撐 100 萬 ops/min 的體育博彩金融帳本" data-link-desc="DraftKings 用 Aurora MySQL 跑體育博彩金融帳本、Super Bowl 流量 &#43;50% 不影響延遲">9.C4 DraftKings</a>（合成對照 — Aurora sharding 路徑）</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 對比">PostgreSQL MVCC + Lock Model</a></li>
<li><a href="/blog/backend/knowledge-cards/isolation-level/" data-link-title="Isolation Level" data-link-desc="說明資料庫交易隔離級別如何影響並發讀寫結果">isolation level 卡</a> / <a href="/blog/backend/knowledge-cards/transaction-boundary/" data-link-title="Transaction Boundary" data-link-desc="說明哪些資料變更應在同一個交易中一起成功或一起回復">transaction boundary 卡</a></li>
<li>官方：<a href="https://www.cockroachlabs.com/docs/stable/transactions.html">CockroachDB Transactions</a> / <a href="https://www.cockroachlabs.com/docs/stable/transaction-retry-error-reference.html">Transaction Retry Error Reference</a> / <a href="https://www.cockroachlabs.com/docs/stable/read-committed.html">READ COMMITTED v23.2 announcement</a></li>
</ul>
]]></content:encoded></item><item><title>Cosmos DB Multi-Region Write：active-active、LWW、custom merge、Strong + multi-region 互斥的 AP 取捨</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/cosmosdb/multi-region-write-conflict/</link><pubDate>Wed, 27 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/cosmosdb/multi-region-write-conflict/</guid><description>&lt;p>Cosmos DB 是 &lt;em>AP 系統&lt;/em>（&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/cap/" data-link-title="CAP Theorem" data-link-desc="分散式系統在網路分區時一致性與可用性的取捨框架">CAP&lt;/a> 三選二、放棄跨 region linearizability 換取 multi-region write 可用性）。跨 region 寫同一筆 document 必然有 conflict、Cosmos DB 提供三種 resolution policy 處理：LWW（Last-Writer-Wins）、custom merge stored procedure、conflict feed manual reconciliation。本文先講 AP 取捨的硬約束（為什麼 Strong consistency 跟 multi-region write 互斥）、再進三種 resolution 機制、再進廣告 SLA vs 實測可用性的鏈路拆解（DB 端 SLA 不等於使用者體驗）。&lt;/p>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/cosmosdb/" data-link-title="Azure Cosmos DB" data-link-desc="全球分散式 multi-model DB、5 個 consistency levels、Microsoft 自家 dogfood 證據">Cosmos DB vendor 頁&lt;/a> 的深度展開、也是 &lt;em>Strong + multi-region 互斥&lt;/em> 議題的 SSoT 主寫位置（&lt;a href="../consistency-levels-engineering/">consistency-levels-engineering&lt;/a> cross-link 過來、不展開）。Case anchor 是 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/minecraft-earth-cosmos-db-global/" data-link-title="9.C11 Minecraft Earth：Azure Cosmos DB 上的全球分散式 AR 遊戲" data-link-desc="Minecraft Earth 用 Cosmos DB 跨地區分散、測試到 100 萬 RU/s 仍維持承諾延遲">9.C11 Minecraft Earth&lt;/a>（AR 遊戲跨 region 寫入、5 consistency level + multi-region SLA）+ &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/asos-cosmos-db-black-friday/" data-link-title="9.C21 ASOS：Cosmos DB 在 Black Friday 撐 1.67 億請求" data-link-desc="ASOS 在 2016 Black Friday 用 Azure Cosmos DB 撐 24 小時 1.67 億請求、3500 req/sec、48ms 平均延遲">9.C21 ASOS&lt;/a>（Black Friday 全球零售）+ &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/toyota-connected-mongodb-telematics-iot/" data-link-title="9.C38 Toyota Connected：MongoDB Atlas 撐 900 萬車輛 telematics、月 180 億 transaction" data-link-desc="Toyota Connected 用 MongoDB Atlas 撐 Safety Connect 900 萬車、月 180 億 transaction、緊急訊號 3 秒內到 agent">9.C38 Toyota Connected&lt;/a>（鏈路 SLA 拆解、跨 vendor 適用做 frame anchor）。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>Cosmos DB 適用度前置判讀&lt;/strong>：本篇假設 workload 已通過 Cosmos DB 適用度四層 framing（API model 三型遷移路徑 / RU 思維轉換成本 / multi-model 差異化是否真用上 / 跨雲 hedging vs 單雲 lock-in）— 詳見 &lt;a href="../mongodb-api-vs-sql-api/#%e5%9b%9b%e5%b1%a4-framingvendor-selection-%e7%9a%84%e7%9c%9f%e5%af%a6%e6%b1%ba%e7%ad%96%e8%bb%b8">mongodb-api-vs-sql-api 開頭四層 framing&lt;/a>、本篇不重複展開。Multi-region write + conflict resolution 是 &lt;em>已選 Cosmos DB 後&lt;/em> 的拓樸決策；strong global consistency 必要的 workload 應走 Spanner 或 Cosmos DB Strong（單一 write region）、不是用 LWW 補。&lt;/p></description><content:encoded><![CDATA[<p>Cosmos DB 是 <em>AP 系統</em>（<a href="/blog/backend/knowledge-cards/cap/" data-link-title="CAP Theorem" data-link-desc="分散式系統在網路分區時一致性與可用性的取捨框架">CAP</a> 三選二、放棄跨 region linearizability 換取 multi-region write 可用性）。跨 region 寫同一筆 document 必然有 conflict、Cosmos DB 提供三種 resolution policy 處理：LWW（Last-Writer-Wins）、custom merge stored procedure、conflict feed manual reconciliation。本文先講 AP 取捨的硬約束（為什麼 Strong consistency 跟 multi-region write 互斥）、再進三種 resolution 機制、再進廣告 SLA vs 實測可用性的鏈路拆解（DB 端 SLA 不等於使用者體驗）。</p>
<p>本文是 <a href="/blog/backend/01-database/vendors/cosmosdb/" data-link-title="Azure Cosmos DB" data-link-desc="全球分散式 multi-model DB、5 個 consistency levels、Microsoft 自家 dogfood 證據">Cosmos DB vendor 頁</a> 的深度展開、也是 <em>Strong + multi-region 互斥</em> 議題的 SSoT 主寫位置（<a href="../consistency-levels-engineering/">consistency-levels-engineering</a> cross-link 過來、不展開）。Case anchor 是 <a href="/blog/backend/09-performance-capacity/cases/minecraft-earth-cosmos-db-global/" data-link-title="9.C11 Minecraft Earth：Azure Cosmos DB 上的全球分散式 AR 遊戲" data-link-desc="Minecraft Earth 用 Cosmos DB 跨地區分散、測試到 100 萬 RU/s 仍維持承諾延遲">9.C11 Minecraft Earth</a>（AR 遊戲跨 region 寫入、5 consistency level + multi-region SLA）+ <a href="/blog/backend/09-performance-capacity/cases/asos-cosmos-db-black-friday/" data-link-title="9.C21 ASOS：Cosmos DB 在 Black Friday 撐 1.67 億請求" data-link-desc="ASOS 在 2016 Black Friday 用 Azure Cosmos DB 撐 24 小時 1.67 億請求、3500 req/sec、48ms 平均延遲">9.C21 ASOS</a>（Black Friday 全球零售）+ <a href="/blog/backend/09-performance-capacity/cases/toyota-connected-mongodb-telematics-iot/" data-link-title="9.C38 Toyota Connected：MongoDB Atlas 撐 900 萬車輛 telematics、月 180 億 transaction" data-link-desc="Toyota Connected 用 MongoDB Atlas 撐 Safety Connect 900 萬車、月 180 億 transaction、緊急訊號 3 秒內到 agent">9.C38 Toyota Connected</a>（鏈路 SLA 拆解、跨 vendor 適用做 frame anchor）。</p>
<blockquote>
<p><strong>Cosmos DB 適用度前置判讀</strong>：本篇假設 workload 已通過 Cosmos DB 適用度四層 framing（API model 三型遷移路徑 / RU 思維轉換成本 / multi-model 差異化是否真用上 / 跨雲 hedging vs 單雲 lock-in）— 詳見 <a href="../mongodb-api-vs-sql-api/#%e5%9b%9b%e5%b1%a4-framingvendor-selection-%e7%9a%84%e7%9c%9f%e5%af%a6%e6%b1%ba%e7%ad%96%e8%bb%b8">mongodb-api-vs-sql-api 開頭四層 framing</a>、本篇不重複展開。Multi-region write + conflict resolution 是 <em>已選 Cosmos DB 後</em> 的拓樸決策；strong global consistency 必要的 workload 應走 Spanner 或 Cosmos DB Strong（單一 write region）、不是用 LWW 補。</p></blockquote>
<h2 id="問題情境active-active-的-conflict-是必然代價">問題情境：active-active 的 conflict 是必然代價</h2>
<p>典型觸發場景：產品要 global active-active（每個 region 都能寫、低延遲）、Cosmos DB 是 AP 系統、不像 Spanner 用 quorum 強一致；跨 region 寫同一筆 document 必然有 conflict、團隊不知道「conflict 真的發生時、誰贏 / 怎麼處理 / 業務語義保不保得住」。</p>
<p>讀者徵兆：</p>
<ul>
<li>「multi-region write 開了、user 在 A region 寫『加入購物車』、B region 寫『移除購物車』、最後哪個贏」</li>
<li>「LWW 用 timestamp 決定、client clock skew 不就破壞了嗎」</li>
<li>「conflict feed 是什麼、要不要消費」</li>
<li>「multi-region write 開了之後 consistency level 還能設 Strong 嗎」</li>
<li>「廣告寫 99.999%、為什麼實測只有 99%」</li>
</ul>
<p>真實壓力：購物車跨 region 寫入丟失、遊戲玩家狀態跨 region 衝突回滾、IoT device 跨 region 寫 telemetry 後消失。這些事故的根因不是 bug、是 multi-region write 的 <em>設計取捨</em>、需要在 selection 階段就決定 conflict resolution policy。</p>
<h2 id="核心機制">核心機制</h2>
<h3 id="ap-取捨的硬約束為什麼-strong--multi-region-write-互斥">AP 取捨的硬約束：為什麼 Strong + multi-region write 互斥</h3>
<p>Cosmos DB 是 AP 系統（在 partition 的情況下選 availability 跟 partition tolerance、放棄 cross-region linearizability）。multi-region write 的兩個前置條件：</p>
<ul>
<li>account 開啟 <code>enableMultipleWriteLocations = true</code></li>
<li>consistency level <em>不能設 Strong</em>（multi-region write 跟 Strong 互斥、時間敏感 claim、查 <a href="https://learn.microsoft.com/azure/cosmos-db/consistency-levels">最新文件</a>）</li>
</ul>
<p>為什麼互斥（CAP 三選二的硬約束）：</p>
<ul>
<li><strong>Strong consistency</strong> 在 Cosmos DB 的實作是 quorum-based linearizable read — 確保 read 拿到最新 commit、需要 <em>單一 write region</em> 來保證寫入順序</li>
<li><strong>Multi-region write</strong> 是 active-active、每個 region 都能寫 — 不存在「單一 write region」、寫入是 LWW-based eventual consistency</li>
<li>兩者在技術上 <em>不能同時成立</em> — 不是 Microsoft 工程選擇問題、是 distributed system 的基本限制（跟 Spanner 用 Paxos quorum + TrueTime 不同的設計路徑）</li>
</ul>
<p>對 selection 的意義：產品要「全球都能寫」就接受 eventual consistency；產品要「全球 linearizable」就轉 Spanner / Aurora DSQL、Cosmos DB 不是替代品。把 Cosmos DB Strong 跟 Spanner external consistency 等同視之是 <em>常見的選型誤判</em>。</p>
<p><a href="../consistency-levels-engineering/">consistency-levels-engineering</a> 的 Strong 段只 cross-link 過來、不展開 conflict resolution 細節 — 本篇是 SSoT 主寫位置。</p>
<h3 id="conflict-偵測">Conflict 偵測</h3>
<p>同一 document（partition key + id）在多 region 並發寫入、Cosmos DB 偵測為 conflict。偵測機制基於 LSN（log sequence number）、不是 timestamp — 兩個 region 對同一 document 寫入時、replication 過程比對 LSN 發現分歧、進 resolution。</p>
<h3 id="三種-conflict-resolution-policy">三種 conflict resolution policy</h3>
<h4 id="lwwlast-writer-wins預設">LWW（Last-Writer-Wins、預設）</h4>
<ul>
<li>機制：用 <code>_ts</code>（system timestamp）或自訂 numeric property、value 大的贏</li>
<li>副作用：clock skew 在 ms 級就能讓「先寫的反而贏」、業務邏輯破洞</li>
<li>適合：純覆寫場景（如玩家位置最新值、IoT 最新讀數）— write 順序不影響業務語義</li>
</ul>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="s2">&#34;conflictResolutionPolicy&#34;</span><span class="err">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nt">&#34;mode&#34;</span><span class="p">:</span> <span class="s2">&#34;LastWriterWins&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nt">&#34;conflictResolutionPath&#34;</span><span class="p">:</span> <span class="s2">&#34;/customTimestamp&#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><h4 id="custom-merge-stored-procedure">Custom merge stored procedure</h4>
<ul>
<li>機制：寫一個 JavaScript stored proc、conflict 時 Cosmos DB 呼叫、proc 回傳 merge 結果</li>
<li>適合：要保留業務語義的場景（購物車 merge = union 兩邊 items、計數器 merge = sum、status 機器 merge = 狀態圖規則）</li>
<li>風險：stored proc 在 Cosmos DB JavaScript runtime 跑、有 timeout / RU 限制；複雜 merge 邏輯難 debug</li>
</ul>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="s2">&#34;conflictResolutionPolicy&#34;</span><span class="err">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nt">&#34;mode&#34;</span><span class="p">:</span> <span class="s2">&#34;Custom&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nt">&#34;conflictResolutionProcedure&#34;</span><span class="p">:</span> <span class="s2">&#34;dbs/mydb/colls/mycoll/sprocs/resolveCart&#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><h4 id="conflict-feed-manual-reconciliation">Conflict feed manual reconciliation</h4>
<ul>
<li>機制：Cosmos DB 把 conflict 寫入 conflict feed、不自動解決、app 自行消費並 reconcile</li>
<li>適合：conflict 需要人工 / 業務流程判斷、不能 auto-resolve（如金融交易、合規場景）</li>
<li>風險：feed 不消費就累積、後續分析失準；app 需要實作 reconcile 流程</li>
</ul>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="s2">&#34;conflictResolutionPolicy&#34;</span><span class="err">:</span> <span class="p">{</span> <span class="nt">&#34;mode&#34;</span><span class="p">:</span> <span class="s2">&#34;Custom&#34;</span> <span class="p">}</span></span></span></code></pre></div><p>（沒指 procedure、conflict 全進 feed、app 用 SDK <code>ReadConflictsAsync()</code> / Change Feed Processor pattern 消費）</p>
<h3 id="跟其他-vendor-對比">跟其他 vendor 對比</h3>
<ul>
<li><strong>DynamoDB Global Tables</strong>：也是 LWW、<em>無</em> custom merge、<em>無</em> conflict feed — 行為比 Cosmos DB 簡單但彈性少</li>
<li><strong>Spanner</strong>：用 Paxos quorum、<em>不會有 conflict</em>（CP 系統、可用性換一致性）— 跨 region write 需 quorum、latency 100-200ms</li>
<li><strong>Aurora Global Database</strong>：single-primary（一個 region 寫、其他 region 讀）、不是真 multi-region write、無 conflict</li>
</ul>
<p>對應 knowledge cards：<a href="/blog/backend/knowledge-cards/stale-read/" data-link-title="Stale Read" data-link-desc="讀取到落後於最新寫入版本的舊資料">stale-read</a>、<a href="/blog/backend/knowledge-cards/rpo/" data-link-title="RPO" data-link-desc="說明恢復點目標如何定義可接受資料損失範圍">rpo</a>、<a href="/blog/backend/knowledge-cards/rto/" data-link-title="RTO" data-link-desc="說明恢復時間目標如何約束事故回復策略">rto</a>。</p>
<h2 id="操作流程">操作流程</h2>
<h3 id="開啟-multi-region-write">開啟 multi-region write</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">az cosmosdb update --name mycosmos --resource-group myrg <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --enable-multiple-write-locations <span class="nb">true</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --locations <span class="nv">regionName</span><span class="o">=</span>eastus <span class="nv">failoverPriority</span><span class="o">=</span><span class="m">0</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --locations <span class="nv">regionName</span><span class="o">=</span>westeurope <span class="nv">failoverPriority</span><span class="o">=</span><span class="m">1</span></span></span></code></pre></div><p>開啟後 <em>不能直接關回</em>、要 disable + 改 region 配置 + re-enable、有停機窗口。</p>
<h3 id="設定-lww-policycontainer-層">設定 LWW policy（container 層）</h3>
<p>建 container 時指定、可事後改但 conflict 行為以新 policy 為準（既有 conflict 不會重 resolve）。預設用 <code>_ts</code> 比較；改成 customTimestamp 時要保證 application 寫入時 <em>用單調遞增</em> 的 timestamp source（不能用 client clock）。</p>
<h3 id="設定-custom-merge">設定 custom merge</h3>
<p>建 stored proc：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">function</span> <span class="nx">resolveCart</span><span class="p">(</span><span class="nx">incomingItem</span><span class="p">,</span> <span class="nx">existingItem</span><span class="p">,</span> <span class="nx">isTombstone</span><span class="p">,</span> <span class="nx">conflictingItems</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="c1">// 範例：merge 購物車 items（取 union）
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span>  <span class="kd">var</span> <span class="nx">merged</span> <span class="o">=</span> <span class="nx">existingItem</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nx">merged</span><span class="p">.</span><span class="nx">items</span> <span class="o">=</span> <span class="nx">mergeArrays</span><span class="p">(</span><span class="nx">existingItem</span><span class="p">.</span><span class="nx">items</span><span class="p">,</span> <span class="nx">incomingItem</span><span class="p">.</span><span class="nx">items</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="nx">merged</span><span class="p">.</span><span class="nx">_ts</span> <span class="o">=</span> <span class="nb">Math</span><span class="p">.</span><span class="nx">max</span><span class="p">(</span><span class="nx">existingItem</span><span class="p">.</span><span class="nx">_ts</span><span class="p">,</span> <span class="nx">incomingItem</span><span class="p">.</span><span class="nx">_ts</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="nx">__</span><span class="p">.</span><span class="nx">response</span><span class="p">.</span><span class="nx">setBody</span><span class="p">(</span><span class="nx">merged</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="s2">&#34;conflictResolutionPolicy&#34;</span><span class="err">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nt">&#34;mode&#34;</span><span class="p">:</span> <span class="s2">&#34;Custom&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nt">&#34;conflictResolutionProcedure&#34;</span><span class="p">:</span> <span class="s2">&#34;dbs/mydb/colls/mycoll/sprocs/resolveCart&#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>驗證：proc 內處理 timeout / exception；測 edge case（空 array / null / 並發 3+ region 寫入）。</p>
<h3 id="消費-conflict-feed">消費 conflict feed</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-csharp" data-lang="csharp"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// .NET SDK</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="kt">var</span> <span class="n">iterator</span> <span class="p">=</span> <span class="n">container</span><span class="p">.</span><span class="n">GetItemQueryIterator</span><span class="p">&lt;</span><span class="n">ConflictProperties</span><span class="p">&gt;(</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="s">&#34;SELECT * FROM c&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="k">while</span> <span class="p">(</span><span class="n">iterator</span><span class="p">.</span><span class="n">HasMoreResults</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="kt">var</span> <span class="n">response</span> <span class="p">=</span> <span class="k">await</span> <span class="n">iterator</span><span class="p">.</span><span class="n">ReadNextAsync</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="k">foreach</span> <span class="p">(</span><span class="kt">var</span> <span class="n">conflict</span> <span class="k">in</span> <span class="n">response</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">        <span class="k">await</span> <span class="n">ProcessConflict</span><span class="p">(</span><span class="n">conflict</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>用 Change Feed Processor pattern 把 conflict feed 當 stream 消費、寫到 reconcile queue、由業務流程處理。</p>
<h3 id="驗證點">驗證點</h3>
<ul>
<li>跨 region 並發寫測試（synthetic load）、觀察 conflict count / resolution result</li>
<li>Custom merge stored proc 跑過 edge case（exception / null / 並發 3+）</li>
<li>Conflict feed 不積壓（lag &lt; 5 min）</li>
<li>Region 故障時 application 仍能寫（active-active 設計、不需 manual failover）</li>
</ul>
<h2 id="失敗模式">失敗模式</h2>
<h3 id="failure-1全用-lww--用-server-timestamp">Failure 1：全用 LWW + 用 server timestamp</h3>
<p>clock skew 在 ms 級可能讓「先寫的反而贏」、業務邏輯破洞。常見徵兆：使用者反映「我明明先按確認、後來改的反而是舊的」、debug 才發現是跨 region clock skew。</p>
<p>修：</p>
<ul>
<li>用 <code>customTimestamp</code> 從 application 端 monotonic source 取（如 Snowflake ID、HLC、Lamport clock）</li>
<li>或改用 custom merge stored proc、用業務邏輯而非 timestamp 決勝</li>
<li>或拆 collection、把 conflict 高的 collection 用 stored proc、低的用 LWW</li>
</ul>
<h3 id="failure-2業務語義不適合-lww">Failure 2：業務語義不適合 LWW</h3>
<p>購物車（要 union）、計數器（要 sum）、status 機器（要狀態圖）全用 LWW = <em>資料丟失</em>。LWW 的設計假設是「最新 write 就是正確答案」、但很多業務語義不是覆寫關係。</p>
<p>修：盤點 collection 的業務語義、選對應 resolution policy：</p>
<ul>
<li>覆寫關係 → LWW</li>
<li>累積關係 → custom merge stored proc（union / sum / set 合併）</li>
<li>狀態機 → custom merge stored proc（按狀態圖規則 resolve）</li>
<li>需要人工裁決 → conflict feed</li>
</ul>
<h3 id="failure-3custom-merge-stored-proc-沒測-edge-case">Failure 3：Custom merge stored proc 沒測 edge case</h3>
<p>proc throw exception 時 Cosmos DB 行為：conflict 留 feed、不會自動 retry。團隊以為 proc 跑了就沒事、實際 conflict 累積在 feed、後續分析失準。</p>
<p>修：proc 內部 try-catch、log exception、確保 <em>任何輸入都能 return 一個合理結果</em>（即使是 fallback 到 LWW）；定期掃 conflict feed 檢查積壓。</p>
<h3 id="failure-4不消費-conflict-feed">Failure 4：不消費 conflict feed</h3>
<p>選 manual mode 後忘記實作 feed consumer、conflict 累積、後續分析失準。常見徵兆：feed lag metric alert、或業務反映「資料對不上」、最後發現 conflict feed 裡躺著一堆未處理的 conflict。</p>
<p>修：選 conflict feed mode 前先實作 consumer pipeline（Azure Function trigger on Change Feed / 自建 worker）；設 alert：feed lag &gt; 5 min 通知。</p>
<h3 id="failure-5期待-multi-region-write-還有-strong-consistency">Failure 5：期待 multi-region write 還有 Strong consistency</h3>
<p>兩者互斥、開啟 multi-region write 後 Strong 自動 downgrade（或拒絕設定、時間敏感、查最新文件）。團隊以為「multi-region + Strong = 全球 linearizable」、底層是設計 incompatibility。</p>
<p>修：在 selection 階段就決定「要 active-active write 還是要 Strong」 — 兩者只能擇一。要全球 linearizable 轉 Spanner / Aurora DSQL、要 active-active 就接受 eventual / session / bounded staleness。</p>
<h3 id="failure-6跨-region-寫入後立即同-session-read-看不到">Failure 6：跨 region 寫入後立即同 session read 看不到</h3>
<p>session token 沒跨 region 傳遞、看似 inconsistency 其實是 session 沒對齊。典型 anti-pattern：service A 在 region 1 寫、用 region 1 session token；service B 在 region 2 讀、沒拿到 A 的 token、看不到 A 的寫。</p>
<p>修：session token 隨 request 傳遞（通常進 HTTP header）；或改 account 層 Bounded staleness（提供跨 session 的 K/T bound）；見 <a href="../consistency-levels-engineering/">consistency-levels-engineering</a> 的 session token 管理段。</p>
<h3 id="failure-7region-故障時的-failover-邏輯誤判">Failure 7：Region 故障時的 failover 邏輯誤判</h3>
<p>multi-region write 已是 active-active、<em>不需要 manual failover</em> — 一個 region 掛、其他 region 自動承接寫入。但若用了 <code>failoverPriority</code> 配置、failover 邏輯仍要審 — priority 是 <em>當 multi-region read 切到哪個 region 為 primary</em>、不是 active-active 的 routing。</p>
<p>修：multi-region write 場景不用依賴 failoverPriority、用 Traffic Manager / Front Door 做 region routing；application 端 SDK 配置 <code>PreferredLocations</code> 讓 SDK 自己選 nearest region。</p>
<h2 id="容量與觀測">容量與觀測</h2>
<ul>
<li>必看 metric：<code>ConflictCount</code>、<code>ReplicationLatency</code> per region pair、conflict feed lag</li>
<li>Conflict rate 監控：正常 &lt; 0.01%、突增代表 hot key 或 region 同步異常</li>
<li>Cost 影響：multi-region write 開啟後、寫入成本 × region 數（每個 region 都 replicate）— 3 region active-active = 3x write <a href="/blog/backend/knowledge-cards/request-unit/" data-link-title="Request Unit" data-link-desc="Cosmos DB 的容量抽象單位、1 RU = 1KB document strong-consistent read 的 CPU &#43; memory &#43; IOPS 綜合 cost、寫 ~5 RU、複雜 query 數百 RU">Request Unit</a> cost</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>：multi-region write multiplier 進 sizing</li>
<li>對應 <a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">4.20 Observability Evidence Package</a>：conflict rate 當 reliability evidence</li>
<li>Alert：conflict rate &gt; 0.1%、conflict feed lag &gt; 5 min、cross-region replication lag &gt; SLA</li>
</ul>
<h3 id="廣告-sla-vs-實測可用性鏈路拆解本章合成-frame">廣告 SLA vs 實測可用性鏈路拆解（本章合成 frame）</h3>
<p>9.C11 Minecraft Earth 平台揭露的 Cosmos DB SLA：</p>
<ul>
<li>single-region 99.99%</li>
<li>multi-region 99.999%</li>
</ul>
<p>這是 <em>DB 端 SLA</em>、不是 <em>端到端系統 SLA</em>。真實 production 系統的可用性是鏈路乘積：</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">實測可用性 = DB SLA × 網路 SLA × 應用層 SLA × 客戶端可達性</span></span></code></pre></div><p><a href="/blog/backend/09-performance-capacity/cases/toyota-connected-mongodb-telematics-iot/" data-link-title="9.C38 Toyota Connected：MongoDB Atlas 撐 900 萬車輛 telematics、月 180 億 transaction" data-link-desc="Toyota Connected 用 MongoDB Atlas 撐 Safety Connect 900 萬車、月 180 億 transaction、緊急訊號 3 秒內到 agent">9.C38 Toyota Connected</a> 揭露「99.99% target vs 99% 實測」段的觀察：兩個 9 的差距 <em>不是</em> MongoDB / Atlas 自身問題、是 end-to-end 鏈路（車輛無線網路 / cellular tower / cloud network / event bus / microservice / DB cluster 任一環節掉都會打掉可用性）。Cosmos DB multi-region write 同模型：</p>
<ul>
<li>多 region active-active 可解 <em>DB 端可用性</em>、但網路 / 應用層任一掉、實測仍 &lt; 99.99%</li>
<li>廣告 99.999% 是 multi-region availability zone 級、<em>不是</em> 「使用者 request 成功率」</li>
</ul>
<p>引用時必須明示：Cosmos DB multi-region 廣告 99.999% 是 DB 端、要算實測可用性必須補網路 / 應用層 SLA 乘積、Toyota case 的「99% 實測」揭露的就是這個鏈路問題、跨 vendor 都適用。</p>
<p>跟 conflict resolution 的關係：多 region 高可用性 <em>買來</em> 的代價是 conflict、conflict rate 是 reliability 的暗稅 — 廣告 SLA 不計 conflict 處理成本。production 設計要把「conflict resolution 的工程成本」加進 multi-region write 的 ROI 評估。</p>
<h2 id="邊界與整合">邊界與整合</h2>
<ul>
<li>Sibling deep articles：<a href="../consistency-levels-engineering/">consistency-levels-engineering</a>（multi-region write 跟 Strong 互斥的 cross-link 來源）、<a href="../partition-key-design/">partition-key-design</a>（hot partition 會放大 conflict）、<a href="../ru-cost-model-sizing/">ru-cost-model-sizing</a>（multi-region cost × region 數）</li>
<li>跟 <a href="/blog/backend/01-database/vendors/spanner/" data-link-title="Google Cloud Spanner" data-link-desc="全球分散式 strong-consistency OLTP、TrueTime API、線性擴展到 10 億 req/sec">Spanner vendor</a> 對比：CP vs AP、無 conflict vs LWW / custom</li>
<li>跟 DynamoDB Global Tables 對比：兩者都 LWW、Cosmos DB 多 custom merge + conflict feed</li>
<li>跟 1.x 章節：<a href="/blog/backend/01-database/global-distributed-oltp/" data-link-title="1.11 全球分散式 OLTP" data-link-desc="Spanner / Aurora DSQL / Cosmos DB multi-region write / CockroachDB / TiDB 的全球一致性取捨">1.11 全球分散式 OLTP</a> 把 multi-region write 模式並陳</li>
<li>Knowledge cards：<a href="/blog/backend/knowledge-cards/stale-read/" data-link-title="Stale Read" data-link-desc="讀取到落後於最新寫入版本的舊資料">stale-read</a> / <a href="/blog/backend/knowledge-cards/rpo/" data-link-title="RPO" data-link-desc="說明恢復點目標如何定義可接受資料損失範圍">rpo</a> / <a href="/blog/backend/knowledge-cards/rto/" data-link-title="RTO" data-link-desc="說明恢復時間目標如何約束事故回復策略">rto</a></li>
<li>Anti-recommendation：single-region write + cross-region read replica 在大多數情況更便宜、更易推理；只有 <em>write residency</em> 是產品契約（合規 / latency / 業務需求）時才升 multi-region write</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/cosmosdb/" data-link-title="Azure Cosmos DB" data-link-desc="全球分散式 multi-model DB、5 個 consistency levels、Microsoft 自家 dogfood 證據">Cosmos DB vendor overview</a> — 本文是該頁尾 multi-region write + conflict resolution backlog 的深度展開</li>
<li><a href="/blog/backend/09-performance-capacity/cases/minecraft-earth-cosmos-db-global/" data-link-title="9.C11 Minecraft Earth：Azure Cosmos DB 上的全球分散式 AR 遊戲" data-link-desc="Minecraft Earth 用 Cosmos DB 跨地區分散、測試到 100 萬 RU/s 仍維持承諾延遲">9.C11 Minecraft Earth case</a> — multi-region 99.999% / single-region 99.99% SLA 來源</li>
<li><a href="/blog/backend/09-performance-capacity/cases/asos-cosmos-db-black-friday/" data-link-title="9.C21 ASOS：Cosmos DB 在 Black Friday 撐 1.67 億請求" data-link-desc="ASOS 在 2016 Black Friday 用 Azure Cosmos DB 撐 24 小時 1.67 億請求、3500 req/sec、48ms 平均延遲">9.C21 ASOS case</a> — 全球零售 multi-region 補充</li>
<li><a href="/blog/backend/09-performance-capacity/cases/toyota-connected-mongodb-telematics-iot/" data-link-title="9.C38 Toyota Connected：MongoDB Atlas 撐 900 萬車輛 telematics、月 180 億 transaction" data-link-desc="Toyota Connected 用 MongoDB Atlas 撐 Safety Connect 900 萬車、月 180 億 transaction、緊急訊號 3 秒內到 agent">9.C38 Toyota Connected case</a> — 鏈路 SLA 拆解 frame anchor（跨 vendor 適用）</li>
<li><a href="../consistency-levels-engineering/">consistency-levels-engineering</a> — Strong + multi-region 互斥的 cross-link 目的地</li>
<li><a href="/blog/backend/knowledge-cards/stale-read/" data-link-title="Stale Read" data-link-desc="讀取到落後於最新寫入版本的舊資料">Stale Read 卡片</a> / <a href="/blog/backend/knowledge-cards/rpo/" data-link-title="RPO" data-link-desc="說明恢復點目標如何定義可接受資料損失範圍">RPO 卡片</a> / <a href="/blog/backend/knowledge-cards/rto/" data-link-title="RTO" data-link-desc="說明恢復時間目標如何約束事故回復策略">RTO 卡片</a> — 概念基底</li>
<li>官方：<a href="https://learn.microsoft.com/azure/cosmos-db/conflict-resolution-policies">Cosmos DB conflict resolution</a> / <a href="https://learn.microsoft.com/azure/cosmos-db/how-to-multi-master">Multi-region writes</a></li>
</ul>
]]></content:encoded></item><item><title>Aurora Global Database：跨 region async replication、&lt; 1 秒 lag 與合規 anti-recommendation</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/aurora/global-database-multi-region/</link><pubDate>Wed, 27 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/aurora/global-database-multi-region/</guid><description>&lt;p>Aurora Global Database 是 &lt;em>跨 region async replication&lt;/em>、&amp;lt; 1 秒 typical lag、最多 5 個 secondary region — 看起來是 multi-region OLTP 的標準解、但 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/standard-chartered-aurora-banking/" data-link-title="9.C14 Standard Chartered：受監管銀行的 Aurora 4000 TPS 容量提升" data-link-desc="Standard Chartered 銀行遷移到 Aurora 後吞吐量提升 10 倍至 4000 TPS、跨 7 個受監管市場">9.C14 Standard Chartered&lt;/a> 揭露一個受監管產業的 anti-recommendation：合規禁止跨境複製場景下、Global Database &lt;em>違反合規&lt;/em>、要改用每市場獨立 cluster + 應用層市場切換。本文展開 Global Database 適用條件、跟 cross-AZ failover 的 RTO 數量級差、合規邊界、跟 Aurora DSQL / Spanner / CockroachDB 的決策樹。&lt;/p>
&lt;p>本文不是 Aurora overview（請看 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/aurora/" data-link-title="AWS Aurora" data-link-desc="AWS managed PostgreSQL / MySQL、storage / compute 分離、&amp;#43;75% 效能改善的 production 證據">Aurora vendor 頁&lt;/a>）— 而是 Global Database 的實作層教學。前置閱讀建議 &lt;a href="../storage-architecture/">Aurora storage architecture&lt;/a>（理解 storage-level replication）、&lt;a href="../cross-az-failover-rto/">Aurora cross-AZ failover RTO&lt;/a>（對照單 region failover）。&lt;/p>
&lt;h2 id="問題情境">問題情境&lt;/h2>
&lt;p>典型觸發場景：global SaaS / 跨地理金融服務、需要 region-level DR（us-east-1 整 region 失效時 &amp;lt; 5 分鐘恢復寫入）、或跨地理 read（歐洲用戶查美國 primary 延遲 100ms+ 不可接受）、但又不到「multi-region active-active write」需求。&lt;/p>
&lt;p>讀者常見的具體疑問：&lt;/p>
&lt;ul>
&lt;li>「Global Database 是 sync 還是 async？lag 多少？」&lt;/li>
&lt;li>「Secondary region 可以寫嗎？」&lt;/li>
&lt;li>「Region failover 流程跟 cross-AZ 一樣嗎？」&lt;/li>
&lt;li>「跟 Aurora DSQL / Spanner / CockroachDB 怎麼選？」&lt;/li>
&lt;li>「合規場景一定要用 Global Database 嗎？」&lt;/li>
&lt;/ul>
&lt;p>進一步問題：Global Database 對一般 SaaS 是合理的 DR + 跨地理 read 工具、但對 &lt;em>受監管產業&lt;/em> 是反指標。&lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/standard-chartered-aurora-banking/" data-link-title="9.C14 Standard Chartered：受監管銀行的 Aurora 4000 TPS 容量提升" data-link-desc="Standard Chartered 銀行遷移到 Aurora 後吞吐量提升 10 倍至 4000 TPS、跨 7 個受監管市場">9.C14 Standard Chartered&lt;/a> 7 個受監管市場、各自獨立 Aurora cluster、不用 Global Database — 不是技術不夠、是合規要求「資料不能跨境複製」。讀者規劃 multi-region 架構時、合規維度要在技術維度之前判斷。&lt;/p>
&lt;h2 id="核心機制跨-region-async-storage-replication">核心機制：跨 region async storage replication&lt;/h2>
&lt;p>Aurora Global Database 的 first-class concept 是 &lt;em>跨 region storage-level async replication&lt;/em>。跟 logical replication / streaming replication 不同、Global Database 在 storage layer 複製、lag 上限相對穩定。&lt;/p></description><content:encoded><![CDATA[<p>Aurora Global Database 是 <em>跨 region async replication</em>、&lt; 1 秒 typical lag、最多 5 個 secondary region — 看起來是 multi-region OLTP 的標準解、但 <a href="/blog/backend/09-performance-capacity/cases/standard-chartered-aurora-banking/" data-link-title="9.C14 Standard Chartered：受監管銀行的 Aurora 4000 TPS 容量提升" data-link-desc="Standard Chartered 銀行遷移到 Aurora 後吞吐量提升 10 倍至 4000 TPS、跨 7 個受監管市場">9.C14 Standard Chartered</a> 揭露一個受監管產業的 anti-recommendation：合規禁止跨境複製場景下、Global Database <em>違反合規</em>、要改用每市場獨立 cluster + 應用層市場切換。本文展開 Global Database 適用條件、跟 cross-AZ failover 的 RTO 數量級差、合規邊界、跟 Aurora DSQL / Spanner / CockroachDB 的決策樹。</p>
<p>本文不是 Aurora overview（請看 <a href="/blog/backend/01-database/vendors/aurora/" data-link-title="AWS Aurora" data-link-desc="AWS managed PostgreSQL / MySQL、storage / compute 分離、&#43;75% 效能改善的 production 證據">Aurora vendor 頁</a>）— 而是 Global Database 的實作層教學。前置閱讀建議 <a href="../storage-architecture/">Aurora storage architecture</a>（理解 storage-level replication）、<a href="../cross-az-failover-rto/">Aurora cross-AZ failover RTO</a>（對照單 region failover）。</p>
<h2 id="問題情境">問題情境</h2>
<p>典型觸發場景：global SaaS / 跨地理金融服務、需要 region-level DR（us-east-1 整 region 失效時 &lt; 5 分鐘恢復寫入）、或跨地理 read（歐洲用戶查美國 primary 延遲 100ms+ 不可接受）、但又不到「multi-region active-active write」需求。</p>
<p>讀者常見的具體疑問：</p>
<ul>
<li>「Global Database 是 sync 還是 async？lag 多少？」</li>
<li>「Secondary region 可以寫嗎？」</li>
<li>「Region failover 流程跟 cross-AZ 一樣嗎？」</li>
<li>「跟 Aurora DSQL / Spanner / CockroachDB 怎麼選？」</li>
<li>「合規場景一定要用 Global Database 嗎？」</li>
</ul>
<p>進一步問題：Global Database 對一般 SaaS 是合理的 DR + 跨地理 read 工具、但對 <em>受監管產業</em> 是反指標。<a href="/blog/backend/09-performance-capacity/cases/standard-chartered-aurora-banking/" data-link-title="9.C14 Standard Chartered：受監管銀行的 Aurora 4000 TPS 容量提升" data-link-desc="Standard Chartered 銀行遷移到 Aurora 後吞吐量提升 10 倍至 4000 TPS、跨 7 個受監管市場">9.C14 Standard Chartered</a> 7 個受監管市場、各自獨立 Aurora cluster、不用 Global Database — 不是技術不夠、是合規要求「資料不能跨境複製」。讀者規劃 multi-region 架構時、合規維度要在技術維度之前判斷。</p>
<h2 id="核心機制跨-region-async-storage-replication">核心機制：跨 region async storage replication</h2>
<p>Aurora Global Database 的 first-class concept 是 <em>跨 region storage-level async replication</em>。跟 logical replication / streaming replication 不同、Global Database 在 storage layer 複製、lag 上限相對穩定。</p>
<p><strong>Architecture</strong>：</p>
<ul>
<li>Primary region：1 個 writer cluster + N read replica</li>
<li>Secondary region：最多 5 個 secondary region、每 region N 個 reader-only cluster（最多 16 個 reader 含 1 個 headless）</li>
<li>Storage replication：primary region 寫 storage 後 <em>async</em> push 到 secondary region storage、不等 ack</li>
</ul>
<p><strong>Write path</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
</span></span><span class="line"><span class="ln">2</span><span class="cl">    ↓ writer endpoint (primary region only)
</span></span><span class="line"><span class="ln">3</span><span class="cl">Primary region compute
</span></span><span class="line"><span class="ln">4</span><span class="cl">    ↓ redo log
</span></span><span class="line"><span class="ln">5</span><span class="cl">Primary region storage (4-of-6 quorum)
</span></span><span class="line"><span class="ln">6</span><span class="cl">    ↓ async replication (typical &lt; 1 秒)
</span></span><span class="line"><span class="ln">7</span><span class="cl">Secondary region storage</span></span></code></pre></div><p><strong>Read path</strong>：</p>
<ul>
<li>Secondary region 直接從 local storage 讀、不需要跨 region 拉</li>
<li>Read latency 是 secondary region local latency、不是跨 region</li>
</ul>
<p><strong>DR 切換 RTO 跟 cross-AZ 對比</strong>：</p>
<table>
  <thead>
      <tr>
          <th>場景</th>
          <th>RTO</th>
          <th>機制</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Cross-AZ failover</td>
          <td>&lt; 30 秒</td>
          <td>storage 跨 AZ 共享、replica 升 primary 即可</td>
      </tr>
      <tr>
          <td>Planned failover</td>
          <td>&lt; 2 分鐘</td>
          <td>managed graceful failover、無資料丟失</td>
      </tr>
      <tr>
          <td>Unplanned failover</td>
          <td>5-15 分鐘</td>
          <td>整 region 失效、手動 promote secondary</td>
      </tr>
  </tbody>
</table>
<p>數量級不同 — cross-AZ 是 <em>seconds</em>、cross-region planned 是 <em>minutes</em>、unplanned 是 <em>tens of minutes</em>。</p>
<p><strong>對應 knowledge card</strong>：<a href="/blog/backend/knowledge-cards/stale-read/" data-link-title="Stale Read" data-link-desc="讀取到落後於最新寫入版本的舊資料">stale-read</a>、<a href="/blog/backend/knowledge-cards/rpo/" data-link-title="RPO" data-link-desc="說明恢復點目標如何定義可接受資料損失範圍">rpo</a>、<a href="/blog/backend/knowledge-cards/rto/" data-link-title="RTO" data-link-desc="說明恢復時間目標如何約束事故回復策略">rto</a>。</p>
<p><strong>跟通用 cross-region replication 差在哪</strong>：Aurora 在 storage layer 複製、lag 上限更穩定；vs PostgreSQL logical replication lag 受寫速度影響大、heavy write 期間可能秒級到分鐘級。</p>
<h2 id="step-by-step-配置">Step-by-step 配置</h2>
<p><strong>建 global cluster</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># Step 1：在 primary region 建 global cluster</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">aws rds create-global-cluster <span class="se">\
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="se"></span>  --global-cluster-identifier myglobal <span class="se">\
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="se"></span>  --source-db-cluster-identifier arn:aws:rds:us-east-1:123:cluster:primary-cluster <span class="se">\
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="se"></span>  --region us-east-1
</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 class="c1"># Step 2：在 secondary region 加 reader cluster</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">aws rds create-db-cluster <span class="se">\
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="se"></span>  --db-cluster-identifier secondary-cluster <span class="se">\
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="se"></span>  --global-cluster-identifier myglobal <span class="se">\
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="se"></span>  --engine aurora-postgresql <span class="se">\
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="se"></span>  --source-region us-east-1 <span class="se">\
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="se"></span>  --region eu-west-1
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="c1"># Step 3：在 secondary region 建 db instance</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">aws rds create-db-instance <span class="se">\
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="se"></span>  --db-cluster-identifier secondary-cluster <span class="se">\
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="se"></span>  --db-instance-identifier secondary-reader-01 <span class="se">\
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="se"></span>  --db-instance-class db.r6g.4xlarge <span class="se">\
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="se"></span>  --engine aurora-postgresql <span class="se">\
</span></span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="se"></span>  --region eu-west-1</span></span></code></pre></div><p><strong>Application routing</strong>：</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="c"># 寫永遠去 primary region writer endpoint</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">primary</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="nt">url</span><span class="p">:</span><span class="w"> </span><span class="l">jdbc:postgresql://primary-cluster.cluster-xxx.us-east-1.rds.amazonaws.com/mydb</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="c"># read 可走 secondary region reader endpoint（靠近用戶的 region）</span><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w"></span><span class="nt">secondary-eu</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="nt">url</span><span class="p">:</span><span class="w"> </span><span class="l">jdbc:postgresql://secondary-cluster.cluster-ro-xxx.eu-west-1.rds.amazonaws.com/mydb</span></span></span></code></pre></div><p><strong>DR 切換（planned failover）</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">aws rds failover-global-cluster <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --global-cluster-identifier myglobal <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --target-db-cluster-identifier arn:aws:rds:eu-west-1:123:cluster:secondary-cluster</span></span></code></pre></div><p>切換後 application 端要 <em>reconfigure connection string</em> — DNS 不自動切跨 region（vs cross-AZ failover writer endpoint 自動跟）。</p>
<p><strong>Application reconfiguration 模式</strong>：</p>
<ul>
<li>Connection string 用 service discovery（Consul / Route53 health check）動態解析</li>
<li>或在 application config 加入 region-aware logic、failover 後切換 active region</li>
<li>不能假設 application 自動 reconnect 到新 primary region</li>
</ul>
<p><strong>驗證點</strong>：</p>
<ul>
<li><code>AuroraGlobalDBReplicationLag</code> &lt; 1 秒</li>
<li>Planned failover RTO 量測（手動 trigger + heartbeat timestamp diff）</li>
<li>Application 跨 region read 路徑 latency 符合預期</li>
</ul>
<p><strong>Rollback boundary</strong>：promote secondary 後原 primary 變 secondary、不會自動 fallback；rollback 要再做一次 failover。</p>
<h2 id="故障模式--邊界-case">故障模式 / 邊界 case</h2>
<h3 id="case-1期待-multi-region-active-active-write">Case 1：期待 multi-region active-active write</h3>
<p>徵兆：team 在 secondary region application 直連 secondary cluster 寫資料、收到 <code>cannot execute INSERT in a read-only transaction</code> 錯誤。</p>
<p>原因：Global Database secondary 是 <em>reader-only</em>、寫只能去 primary region。要 active-active write 必須改用其他服務（Aurora DSQL / Spanner / CockroachDB）。</p>
<p>修：</p>
<ul>
<li>Application 設計時明確區分 read region vs write region</li>
<li>寫操作永遠路由到 primary region、容忍跨 region write latency</li>
<li>真的需要 active-active write 才考慮 Aurora DSQL（2024-12 preview / 2025-05 GA）</li>
</ul>
<h3 id="case-2dns-不跨-region-自動切">Case 2：DNS 不跨 region 自動切</h3>
<p>徵兆：手動 failover trigger 後、application 端 connection string 仍指向舊 primary region、寫操作全失敗。</p>
<p>原因：cross-AZ failover writer endpoint DNS 自動跟、cross-region 不會 — Global Database 切換要 application 端管 region-specific connection string。</p>
<p>修：</p>
<ul>
<li>Application 用 service discovery（Route53 / Consul / etcd）解析 active primary region</li>
<li>部署 region-aware DNS（Route53 latency-based routing + health check）</li>
<li>Failover 演練要包含 application reconfiguration step、不只是 DB layer</li>
</ul>
<h3 id="case-3跨-region-read-假設-strong-consistency">Case 3：跨 region read 假設 strong consistency</h3>
<p>徵兆：用戶在 primary region 寫資料、隨即在 secondary region read、看到舊資料、客訴 inconsistency。</p>
<p>原因：Global Database 是 async replication、&lt; 1 秒 lag 不是 zero、read-after-write 場景仍會看到 stale data。</p>
<p>修：</p>
<ul>
<li>用戶寫操作後短期內 read 走 primary region（read-after-write window）</li>
<li>接受最終一致性、application 端做 versioning / timestamp 比對</li>
<li>強一致性需求改 Aurora DSQL / Spanner</li>
</ul>
<h3 id="case-4lag-spike-during-bulk-operation">Case 4：Lag spike during bulk operation</h3>
<p>徵兆：DDL 或 bulk insert 期間 cross-region lag 從 &lt; 1 秒跳到秒級到分鐘級、secondary region read 大量 stale。</p>
<p>原因：Global Database 「&lt; 1 秒」是 typical、heavy write 期間 lag 拉大。Storage-level replication 比 logical 穩定、但 <em>不是 zero variance</em>。</p>
<p>修：</p>
<ul>
<li>DDL 跟 bulk insert 在低峰期跑、避開跨 region read traffic</li>
<li>監測 <code>AuroraGlobalDBReplicationLag</code>、spike 超過閾值 trigger application 端 fallback（read 切回 primary region）</li>
<li>重要 DDL 用 <a href="https://github.com/reorg/pg_repack">pg_repack</a> 避免長時間 lag</li>
</ul>
<h3 id="case-5合規邊界誤用-global-database--standard-chartered-anti-pattern">Case 5：合規邊界誤用 Global Database — Standard Chartered anti-pattern</h3>
<p>徵兆：team 以為 Global Database 是受監管金融的標準 DR 解、配置完才發現監管機構不接受跨境資料複製、被迫拆掉 Global Database 重建獨立 cluster。</p>
<p><a href="/blog/backend/09-performance-capacity/cases/standard-chartered-aurora-banking/" data-link-title="9.C14 Standard Chartered：受監管銀行的 Aurora 4000 TPS 容量提升" data-link-desc="Standard Chartered 銀行遷移到 Aurora 後吞吐量提升 10 倍至 4000 TPS、跨 7 個受監管市場">9.C14 Standard Chartered case</a> 「判讀」段第 1 點原文：「7 個受監管市場代表 7 個獨立 cluster（資料不能跨境）、容量規劃變成『7 個獨立規劃 × 各自合規門檻』」。</p>
<p>原因：受監管市場資料 <em>不能跨境複製</em>（<a href="/blog/backend/knowledge-cards/data-residency/" data-link-title="Data Residency" data-link-desc="合規要求資料留在特定地理邊界內、跨境複製違反合規、推動 fleet 拓樸決策">Data Residency</a> 硬約束）、Global Database 本質上就是跨 region storage replication、配置了就違反合規。Standard Chartered 的選擇是 <em>每市場獨立 cluster</em>、跨市場 DR 走應用層市場切換、不靠 Global Database。</p>
<p>修：</p>
<ul>
<li>規劃 multi-region 前先確認合規要求（資料駐留、跨境複製禁令、稽核要求）</li>
<li>合規禁止跨境複製場景：每市場獨立 cluster + cross-AZ failover 吸收 RTO（見 <a href="../cross-az-failover-rto/">cross-az-failover-rto</a>）</li>
<li>跨市場 DR 設計成 <em>市場切換</em>（用戶從 A 市場切到 B 市場）、不是 <em>資料切換</em></li>
<li>Fleet 拓樸（多市場 → 多 cluster）詳見 <a href="../read-replica-scaling/">Aurora read replica scaling</a> fleet 治理 SSoT</li>
</ul>
<p><strong>scope warning（必明示）</strong>：Standard Chartered case 未公開是 PostgreSQL 還是 MySQL、未公開具體 cost 數字、屬「相關 case study」匿名對照。引用時不能擴寫具體 engine。</p>
<h3 id="case-6cost-trap--cross-region-data-transfer">Case 6：Cost trap — cross-region data transfer</h3>
<p>徵兆：開了 Global Database 後月帳變高 50%、發現 cross-region data transfer 是主要費用、不是 instance。</p>
<p>原因：Aurora 跨 region replication 走 AWS 內部網路、但 <em>cross-region data transfer 仍計費</em>。Heavy write workload 月費可能 doubled。</p>
<p>修：</p>
<ul>
<li>用 <code>AuroraGlobalDBReplicatedWriteIO</code> × per-region transfer rate 估月費</li>
<li>Write-heavy workload 評估 Global Database ROI（保險、低費用版本是用 cross-region snapshot 做冷備）</li>
<li>Cost 跟 RTO 一起看 — 如果接受 hours RTO、cross-region snapshot 更便宜</li>
</ul>
<h3 id="case-7fanduel-雙峰-case-對照避免-over-extrapolate">Case 7：FanDuel 雙峰 case 對照（避免 over-extrapolate）</h3>
<p>如果 team 引用 <a href="/blog/backend/09-performance-capacity/cases/fanduel-dual-peak-betting-streaming/" data-link-title="9.C28 FanDuel：體育直播 &#43; 投注的雙重峰值" data-link-desc="FanDuel 3.5M MAU、Super Bowl 期間擴容 5-10 倍、用 AWS Local Zones &#43; Wavelength &#43; Outposts 處理 20&#43; 州的雙重峰值">9.C28 FanDuel</a> 規劃 multi-region 部署、要明示 scope warning。</p>
<p><strong>case「判讀」段第 1 點原文</strong>：「直播跟投注是兩種完全不同 SLO：直播容忍秒級延遲（用 CDN + ABR 串流）、投注必須毫秒級成交。兩個服務必須各自獨立擴容、各自獨立 SLO」。</p>
<p><strong>scope warning（必明示）</strong>：</p>
<ul>
<li>FanDuel 5-10x 是 <em>betting 服務的 Aurora 擴容倍數</em>、不是 streaming（streaming 走 CDN、不走 Aurora）</li>
<li>不能壓成「Aurora 撐 5-10x」單一數字</li>
<li>案例自承：betting transaction TPS 跟 concurrent streams 未公開、不能 over-extrapolate</li>
</ul>
<p>引用 FanDuel 規劃自家 multi-region betting workload 時、看 <em>策略</em>（事件型分級 + 雙 SLO 拆分 + 多層 edge）、不套用 <em>具體數字</em>。</p>
<h2 id="跟-aurora-dsql--spanner--cockroachdb-的決策樹">跟 Aurora DSQL / Spanner / CockroachDB 的決策樹</h2>
<p>Global Database 是 <em>async + reader-only secondary</em>、不是 multi-region active-active。當 active-active write 是核心需求時、要看 distributed SQL 方案。</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>Aurora Global Database</th>
          <th>Aurora DSQL</th>
          <th>Spanner</th>
          <th>CockroachDB</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Replication</td>
          <td>Async storage-level</td>
          <td>Sync distributed</td>
          <td>Sync TrueTime</td>
          <td>Sync Raft consensus</td>
      </tr>
      <tr>
          <td>Secondary</td>
          <td>Reader-only</td>
          <td>Active-active</td>
          <td>Active-active</td>
          <td>Active-active</td>
      </tr>
      <tr>
          <td>Lag</td>
          <td>&lt; 1 秒 typical</td>
          <td>None (sync)</td>
          <td>None (sync)</td>
          <td>None (sync)</td>
      </tr>
      <tr>
          <td>Write</td>
          <td>Primary region only</td>
          <td>Multi-region</td>
          <td>Multi-region</td>
          <td>Multi-region</td>
      </tr>
      <tr>
          <td>Strong consistency cross-region</td>
          <td>No</td>
          <td>Yes</td>
          <td>Yes</td>
          <td>Yes</td>
      </tr>
      <tr>
          <td>適用</td>
          <td>DR + 跨地理 read</td>
          <td>Multi-region OLTP</td>
          <td>Global scale OLTP</td>
          <td>Cross-cloud OLTP</td>
      </tr>
      <tr>
          <td>邊界</td>
          <td>active-active 不支援、合規 反指標</td>
          <td>AWS-only、新服務</td>
          <td>GCP-only、學習曲線</td>
          <td>跨雲、operational 複雜</td>
      </tr>
  </tbody>
</table>
<p><strong>何時選 Global Database</strong>：</p>
<ul>
<li>DR + 跨地理 read 是主要需求</li>
<li>寫流量集中在一個 region（單 region write 撐得住）</li>
<li>合規允許跨境複製（一般 SaaS、非受監管）</li>
<li>從 single-region Aurora 升級、不想換 engine</li>
</ul>
<p><strong>何時改 Aurora DSQL / Spanner / CockroachDB</strong>：</p>
<ul>
<li>Multi-region active-active write</li>
<li>跨 region strong consistency 是業務需求</li>
<li>跨雲 / on-prem 需求（CockroachDB）</li>
</ul>
<p><strong>何時不用 Global Database</strong>：</p>
<ul>
<li>合規禁止跨境複製（Standard Chartered case）→ 每市場獨立 cluster</li>
<li>Single-region 已滿足 DR / read 需求</li>
<li>跨 region cost 不划算（write-heavy workload）</li>
</ul>
<h2 id="容量與觀測">容量與觀測</h2>
<p><strong>核心 metric</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">AuroraGlobalDBReplicationLag       # secondary lag、&lt; 1 秒 typical
</span></span><span class="line"><span class="ln">2</span><span class="cl">AuroraGlobalDBReplicatedWriteIO    # cross-region data transfer 量
</span></span><span class="line"><span class="ln">3</span><span class="cl">AuroraGlobalDBProgressLag          # storage replication progress</span></span></code></pre></div><p><strong>容量上限</strong>：</p>
<ul>
<li>1 primary region + 5 secondary region</li>
<li>每 secondary region 16 個 reader 含 1 個 headless（可升 writer）</li>
</ul>
<p><strong>Cost signal</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">月費 ≈ AuroraGlobalDBReplicatedWriteIO × per-region transfer rate
</span></span><span class="line"><span class="ln">2</span><span class="cl">     + secondary region instance + storage
</span></span><span class="line"><span class="ln">3</span><span class="cl">     + cross-region snapshot (optional)</span></span></code></pre></div><p>Write 量大的 workload 月費可能 doubled（primary region + secondary region 都計費）、要在規劃時估準。</p>
<p><strong>驗證 DR</strong>：</p>
<ul>
<li>Planned failover drill 每季一次、量測 RTO / RPO</li>
<li>受監管產業：每月一次、有合規 sign-off 記錄</li>
<li>重大版本升級前必跑一次</li>
</ul>
<p><strong>回路徑</strong>：<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> cross-region cost、<a href="/blog/backend/08-incident-response/" data-link-title="模組八：事故處理與復盤" data-link-desc="用 IR 領域詞彙建問題節點、以服務級案例庫累積事故脈絡，先建概念與案例庫再進實作交接">8.x DR playbook</a> region-level failover decision。</p>
<h2 id="邊界與整合--下一步">邊界與整合 / 下一步</h2>
<p><strong>Sibling deep articles</strong>：</p>
<ul>
<li><a href="../storage-architecture/">Aurora storage architecture</a> — cross-region replication 是 storage-level 延伸</li>
<li><a href="../cross-az-failover-rto/">Aurora cross-AZ failover RTO</a> — cross-AZ 跟 cross-region failover RTO 數量級對比</li>
<li><a href="../read-replica-scaling/">Aurora read replica scaling</a> — fleet 治理 SSoT、合規驅動 fleet 拓樸的展開</li>
</ul>
<p><strong>Migration playbook</strong>：</p>
<ul>
<li><a href="../migrate-from-self-managed-pg-mysql/">PostgreSQL / MySQL → Aurora</a> — 從 PostgreSQL streaming replication 跨 region 升級的差異</li>
</ul>
<p><strong>1.x 章節互引</strong>：</p>
<ul>
<li><a href="/blog/backend/01-database/global-distributed-oltp/" data-link-title="1.11 全球分散式 OLTP" data-link-desc="Spanner / Aurora DSQL / Cosmos DB multi-region write / CockroachDB / TiDB 的全球一致性取捨">1.11 全球分散式 OLTP</a> — Global Database vs distributed SQL 對比</li>
</ul>
<p><strong>何時不用本文</strong>：single-region OLTP、無跨 region DR / read 需求時可跳過、看 <a href="/blog/backend/01-database/vendors/aurora/" data-link-title="AWS Aurora" data-link-desc="AWS managed PostgreSQL / MySQL、storage / compute 分離、&#43;75% 效能改善的 production 證據">Aurora vendor overview</a> 即可。</p>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/aurora/" data-link-title="AWS Aurora" data-link-desc="AWS managed PostgreSQL / MySQL、storage / compute 分離、&#43;75% 效能改善的 production 證據">Aurora vendor overview</a> — 服務定位、適用 / 不適用場景</li>
<li><a href="/blog/backend/knowledge-cards/stale-read/" data-link-title="Stale Read" data-link-desc="讀取到落後於最新寫入版本的舊資料">Stale Read 卡片</a> — read-after-write 容忍度</li>
<li><a href="/blog/backend/knowledge-cards/rpo/" data-link-title="RPO" data-link-desc="說明恢復點目標如何定義可接受資料損失範圍">RPO 卡片</a> — DR RPO 判讀</li>
<li><a href="/blog/backend/09-performance-capacity/cases/standard-chartered-aurora-banking/" data-link-title="9.C14 Standard Chartered：受監管銀行的 Aurora 4000 TPS 容量提升" data-link-desc="Standard Chartered 銀行遷移到 Aurora 後吞吐量提升 10 倍至 4000 TPS、跨 7 個受監管市場">9.C14 Standard Chartered</a> — 合規驅動的 Global Database anti-pattern</li>
<li><a href="/blog/backend/09-performance-capacity/cases/fanduel-dual-peak-betting-streaming/" data-link-title="9.C28 FanDuel：體育直播 &#43; 投注的雙重峰值" data-link-desc="FanDuel 3.5M MAU、Super Bowl 期間擴容 5-10 倍、用 AWS Local Zones &#43; Wavelength &#43; Outposts 處理 20&#43; 州的雙重峰值">9.C28 FanDuel</a> — 雙 SLO 並行的 multi-region 策略對照</li>
<li><a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章方法論</a> — 本文遵循的 6 規格面寫作模板</li>
<li>官方：<a href="https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/aurora-global-database.html">Aurora Global Database</a></li>
</ul>
]]></content:encoded></item><item><title>CockroachDB Locality-Aware Schema：跨州合規 + 邏輯一個 cluster 的 region placement 策略</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/cockroachdb/locality-aware-schema/</link><pubDate>Wed, 27 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/cockroachdb/locality-aware-schema/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/cockroachdb/" data-link-title="CockroachDB" data-link-desc="分散式 SQL、PostgreSQL 相容、跨區強一致、Spanner 的開源 / 跨雲替代">CockroachDB vendor overview&lt;/a> 的 implementation-layer deep article。Overview 已界定 CockroachDB 的 multi-region 能力、本文聚焦 &lt;em>locality 配置怎麼解合規地理邊界 + 跨 boundary 業務邏輯需求&lt;/em> — 用 Hard Rock Digital 跨 8 州單一邏輯 cluster 作為 concrete framing。Replica placement 機制屬前置、見 &lt;a href="../hlc-raft-consensus/">HLC + Raft consensus&lt;/a>、survival goal 互動見 &lt;a href="../survival-goals/">survival goals&lt;/a>。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="問題情境hard-rock-的跨州-sportsbook-拓樸創新">問題情境：Hard Rock 的跨州 sportsbook 拓樸創新&lt;/h2>
&lt;p>美國 sportsbook 受 &lt;em>Wire Act&lt;/em> 規範、betting data 必須在下注州內處理 → 每個營運州都要有州內運算資源。傳統路徑是「每州一個獨立 silo、each silo 一個獨立 DB cluster」、合規上沒問題、但撞牆於三個業務需求：&lt;/p>
&lt;ul>
&lt;li>&lt;em>跨州統一帳戶&lt;/em>：玩家在 NJ 跟 FL 兩州都有帳戶、登入要看到統一 portfolio&lt;/li>
&lt;li>&lt;em>跨州 reporting&lt;/em>：總公司 BI / 財務 reporting 要橫跨所有州、不能 query N 個 cluster 後再合&lt;/li>
&lt;li>&lt;em>跨州欺詐偵測&lt;/em>：同一張身分證在不同州 IP 同時下注 → 風控引擎要看 &lt;em>cross-state aggregated&lt;/em> 資料&lt;/li>
&lt;/ul>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/hard-rock-digital-cockroachdb-sports-betting/" data-link-title="9.C41 Hard Rock Digital：CockroachDB on AWS Outposts、Wire Act 合規 &amp;#43; 跨州單一邏輯 DB" data-link-desc="Hard Rock Digital 用 CockroachDB 跨 AWS Outposts &amp;#43; US-East-1、Wire Act 強制資料留州、單一邏輯 DB 解多州 sportsbook、100 node 32 vCPU 撐 Super Bowl">9.C41 Hard Rock Digital&lt;/a> 跨 8 州（AZ / IN / TN / FL / OH / IL / NJ / VA）用 AWS Outposts 把運算放進州內、但邏輯上仍是 &lt;em>一個&lt;/em> CockroachDB cluster — region placement 配置決定哪些 range 釘在哪個 Outpost / AWS region。case 觀察段直接揭露「跨所有 region 一個 logical database」這個拓樸 fact。&lt;/p>
&lt;p>讀者常問：&lt;/p>
&lt;ul>
&lt;li>合規逼我每州一 cluster、但跨州帳戶 / 風控 / 欺詐偵測撞牆怎麼辦？&lt;/li>
&lt;li>&lt;code>REGIONAL BY ROW&lt;/code> 跟 &lt;code>REGIONAL BY TABLE&lt;/code> 怎麼選、&lt;code>GLOBAL&lt;/code> 又在什麼場景？&lt;/li>
&lt;li>&lt;code>GLOBAL&lt;/code> table 為什麼讀快但寫慢、預設為什麼不全部用？&lt;/li>
&lt;li>AWS Outposts 是 latency 工具還是合規工具？&lt;/li>
&lt;/ul>
&lt;p>對照 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/netflix-cockroachdb-multi-region-fleet/" data-link-title="9.C40 Netflix：380&amp;#43; CockroachDB cluster 的 multi-active 拓樸艦隊" data-link-desc="Netflix 把 Cassandra 不夠用的 transactional workload 移到 CockroachDB、380&amp;#43; cluster / 60&amp;#43; 跨 region、含 Open Connect、studio cloud drive、gaming control plane">9.C40 Netflix&lt;/a>：60+ multi-region cluster、最大 Gaming cluster 48-node 跨 4 region、locality 配置直接影響 cluster 規模治理。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/cockroachdb/" data-link-title="CockroachDB" data-link-desc="分散式 SQL、PostgreSQL 相容、跨區強一致、Spanner 的開源 / 跨雲替代">CockroachDB vendor overview</a> 的 implementation-layer deep article。Overview 已界定 CockroachDB 的 multi-region 能力、本文聚焦 <em>locality 配置怎麼解合規地理邊界 + 跨 boundary 業務邏輯需求</em> — 用 Hard Rock Digital 跨 8 州單一邏輯 cluster 作為 concrete framing。Replica placement 機制屬前置、見 <a href="../hlc-raft-consensus/">HLC + Raft consensus</a>、survival goal 互動見 <a href="../survival-goals/">survival goals</a>。</p></blockquote>
<hr>
<h2 id="問題情境hard-rock-的跨州-sportsbook-拓樸創新">問題情境：Hard Rock 的跨州 sportsbook 拓樸創新</h2>
<p>美國 sportsbook 受 <em>Wire Act</em> 規範、betting data 必須在下注州內處理 → 每個營運州都要有州內運算資源。傳統路徑是「每州一個獨立 silo、each silo 一個獨立 DB cluster」、合規上沒問題、但撞牆於三個業務需求：</p>
<ul>
<li><em>跨州統一帳戶</em>：玩家在 NJ 跟 FL 兩州都有帳戶、登入要看到統一 portfolio</li>
<li><em>跨州 reporting</em>：總公司 BI / 財務 reporting 要橫跨所有州、不能 query N 個 cluster 後再合</li>
<li><em>跨州欺詐偵測</em>：同一張身分證在不同州 IP 同時下注 → 風控引擎要看 <em>cross-state aggregated</em> 資料</li>
</ul>
<p><a href="/blog/backend/09-performance-capacity/cases/hard-rock-digital-cockroachdb-sports-betting/" data-link-title="9.C41 Hard Rock Digital：CockroachDB on AWS Outposts、Wire Act 合規 &#43; 跨州單一邏輯 DB" data-link-desc="Hard Rock Digital 用 CockroachDB 跨 AWS Outposts &#43; US-East-1、Wire Act 強制資料留州、單一邏輯 DB 解多州 sportsbook、100 node 32 vCPU 撐 Super Bowl">9.C41 Hard Rock Digital</a> 跨 8 州（AZ / IN / TN / FL / OH / IL / NJ / VA）用 AWS Outposts 把運算放進州內、但邏輯上仍是 <em>一個</em> CockroachDB cluster — region placement 配置決定哪些 range 釘在哪個 Outpost / AWS region。case 觀察段直接揭露「跨所有 region 一個 logical database」這個拓樸 fact。</p>
<p>讀者常問：</p>
<ul>
<li>合規逼我每州一 cluster、但跨州帳戶 / 風控 / 欺詐偵測撞牆怎麼辦？</li>
<li><code>REGIONAL BY ROW</code> 跟 <code>REGIONAL BY TABLE</code> 怎麼選、<code>GLOBAL</code> 又在什麼場景？</li>
<li><code>GLOBAL</code> table 為什麼讀快但寫慢、預設為什麼不全部用？</li>
<li>AWS Outposts 是 latency 工具還是合規工具？</li>
</ul>
<p>對照 <a href="/blog/backend/09-performance-capacity/cases/netflix-cockroachdb-multi-region-fleet/" data-link-title="9.C40 Netflix：380&#43; CockroachDB cluster 的 multi-active 拓樸艦隊" data-link-desc="Netflix 把 Cassandra 不夠用的 transactional workload 移到 CockroachDB、380&#43; cluster / 60&#43; 跨 region、含 Open Connect、studio cloud drive、gaming control plane">9.C40 Netflix</a>：60+ multi-region cluster、最大 Gaming cluster 48-node 跨 4 region、locality 配置直接影響 cluster 規模治理。</p>
<p>對照 <a href="/blog/backend/09-performance-capacity/cases/standard-chartered-aurora-banking/" data-link-title="9.C14 Standard Chartered：受監管銀行的 Aurora 4000 TPS 容量提升" data-link-desc="Standard Chartered 銀行遷移到 Aurora 後吞吐量提升 10 倍至 4000 TPS、跨 7 個受監管市場">9.C14 Standard Chartered</a> Aurora 7 cluster fleet：銀行業跨國合規邊界、走的是「每市場獨立 Aurora cluster」路徑 — 跟 Hard Rock 邏輯一個 cluster 的拓樸完全不同。兩條路徑沒有對錯、trigger 條件不同（合規顆粒 × 跨 boundary 業務邏輯需求）。</p>
<h2 id="核心機制三種-table-locality--row-level-region-標記">核心機制：三種 table locality + row-level region 標記</h2>
<h3 id="三種-locality-模式">三種 locality 模式</h3>
<p>CockroachDB 用 <a href="/blog/backend/knowledge-cards/range-sharding/" data-link-title="Range Sharding" data-link-desc="分散式 SQL 把 key space 切成可自動 split / merge 的 range、每個 range 自己的 consensus group、application 透明">Range Sharding</a> 把 multi-region table 抽象成三種 locality、配合 <a href="/blog/backend/knowledge-cards/data-residency/" data-link-title="Data Residency" data-link-desc="合規要求資料留在特定地理邊界內、跨境複製違反合規、推動 fleet 拓樸決策">Data Residency</a> 合規邊界決定 row 落在哪個 region：</p>
<table>
  <thead>
      <tr>
          <th>Locality</th>
          <th>Read 行為</th>
          <th>Write 行為</th>
          <th>適用場景</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>REGIONAL BY TABLE</code></td>
          <td>本 region 快、其他 region 走 follower read</td>
          <td>本 region 快、其他 region 慢</td>
          <td>整 table 服務單一 region（如：us-orders）</td>
      </tr>
      <tr>
          <td><code>REGIONAL BY ROW</code></td>
          <td>該 row 所在 region 快、其他 follower</td>
          <td>該 row 所在 region 快、其他慢</td>
          <td>用戶資料跟地理綁定（玩家 / 訂單 / 帳戶）</td>
      </tr>
      <tr>
          <td><code>GLOBAL</code></td>
          <td>每 region local（快）</td>
          <td>跨 region quorum（慢）</td>
          <td>reference data（國碼、貨幣、規則表）</td>
      </tr>
  </tbody>
</table>
<h3 id="regional-by-row每-row-帶-crdb_region-隱含欄位">REGIONAL BY ROW：每 row 帶 <code>crdb_region</code> 隱含欄位</h3>
<p><code>REGIONAL BY ROW</code> 是 Hard Rock 場景的主要選擇。每 row 自動帶一個 <code>crdb_region</code> 隱含欄位、根據這個欄位把 row 對應的 range 釘在指定 region：</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">ALTER</span><span class="w"> </span><span class="k">DATABASE</span><span class="w"> </span><span class="n">sportsbook</span><span class="w"> </span><span class="k">PRIMARY</span><span class="w"> </span><span class="n">REGION</span><span class="w"> </span><span class="s2">&#34;us-east1-az&#34;</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="k">ALTER</span><span class="w"> </span><span class="k">DATABASE</span><span class="w"> </span><span class="n">sportsbook</span><span class="w"> </span><span class="k">ADD</span><span class="w"> </span><span class="n">REGION</span><span class="w"> </span><span class="s2">&#34;us-east1-nj&#34;</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="k">ALTER</span><span class="w"> </span><span class="k">DATABASE</span><span class="w"> </span><span class="n">sportsbook</span><span class="w"> </span><span class="k">ADD</span><span class="w"> </span><span class="n">REGION</span><span class="w"> </span><span class="s2">&#34;us-east1-fl&#34;</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></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="k">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">bets</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="n">LOCALITY</span><span class="w"> </span><span class="n">REGIONAL</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="k">ROW</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></span><span class="line"><span class="ln">7</span><span class="cl"><span class="w"></span><span class="c1">-- 寫入時指定 row 屬哪個 region
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="n">bets</span><span class="w"> </span><span class="p">(</span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="n">user_id</span><span class="p">,</span><span class="w"> </span><span class="n">amount</span><span class="p">,</span><span class="w"> </span><span class="n">crdb_region</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">VALUES</span><span class="w"> </span><span class="p">(...,</span><span class="w"> </span><span class="p">...,</span><span class="w"> </span><span class="p">...,</span><span class="w"> </span><span class="s1">&#39;us-east1-nj&#39;</span><span class="p">);</span></span></span></code></pre></div><p>CockroachDB planner 自動感知 <code>crdb_region</code>、把 read / write 路由到 row 所在 region 的 leaseholder。application 不用手動配 shard key、不用 application 端路由邏輯 — 這是 distributed SQL 的「宣告式 locality」優勢。</p>
<h3 id="global每-region-local-read跨-region-sync-write">GLOBAL：每 region local read、跨 region sync write</h3>
<p><code>GLOBAL</code> table 適合 <em>reference data</em> — 變更少、read 頻繁、需要全球 local read latency：</p>
<ul>
<li>read：每 region 都有 leaseholder、本地 read p99 跟 single-region 一樣</li>
<li>write：跨 region quorum、p99 100ms+</li>
</ul>
<p>實務上 <code>GLOBAL</code> 只放國家代碼、貨幣表、規則 lookup 等 <em>變更頻率低</em> 的 reference data。把 high-write workload 設成 <code>GLOBAL</code> 是典型錯配（見失敗模式段）。</p>
<h3 id="follower-readnon-voting-replica-提供本地-read">Follower read：non-voting replica 提供本地 read</h3>
<p>CockroachDB 區分 voting 跟 non-voting replica：</p>
<ul>
<li>voting replica 參與 Raft majority、決定 commit</li>
<li>non-voting replica 不參與 commit、只 serve <a href="/blog/backend/knowledge-cards/follower-read/" data-link-title="Follower Read" data-link-desc="分散式 SQL 從 non-voting replica 讀 closed timestamp 之前的資料、不參與 Raft commit、低 latency 但 read-after-write 場景仍可能 stale">Follower Read</a></li>
</ul>
<p><code>REGIONAL BY ROW</code> + <code>SURVIVE REGION FAILURE</code> 配合時：row 所在 region 是 voting + <a href="/blog/backend/knowledge-cards/leaseholder/" data-link-title="Leaseholder" data-link-desc="分散式 SQL 每個 range 在任一時間點的 read / write entry point、通常等於 Raft leader、承擔該 range 的 coordination">Leaseholder</a>、其他 region 有 voting replica（survival 需要）+ non-voting replica（本地 follower read）。</p>
<p>Follower read 讀到的是 <em>closed timestamp</em> 之前的資料 — strong consistency 場景不能用（read-after-write 會 stale）、但 dashboard / reporting / 風控分析等 <em>容忍 stale</em> 場景大幅降低 cross-region latency。</p>
<h3 id="配置語法跟驗證">配置語法跟驗證</h3>





<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">-- 設 database 的 region
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">ALTER</span><span class="w"> </span><span class="k">DATABASE</span><span class="w"> </span><span class="n">mydb</span><span class="w"> </span><span class="k">PRIMARY</span><span class="w"> </span><span class="n">REGION</span><span class="w"> </span><span class="s2">&#34;us-east1&#34;</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="k">ALTER</span><span class="w"> </span><span class="k">DATABASE</span><span class="w"> </span><span class="n">mydb</span><span class="w"> </span><span class="k">ADD</span><span class="w"> </span><span class="n">REGION</span><span class="w"> </span><span class="s2">&#34;europe-west1&#34;</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></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w"></span><span class="c1">-- 設 table locality
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"></span><span class="k">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">users</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="n">LOCALITY</span><span class="w"> </span><span class="n">REGIONAL</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="k">ROW</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">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">country_codes</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="n">LOCALITY</span><span class="w"> </span><span class="k">GLOBAL</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="k">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">orders_us</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="n">LOCALITY</span><span class="w"> </span><span class="n">REGIONAL</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="k">IN</span><span class="w"> </span><span class="s2">&#34;us-east1&#34;</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></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w"></span><span class="c1">-- 驗證
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></span><span class="k">SHOW</span><span class="w"> </span><span class="n">LOCALITY</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">users</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="k">SHOW</span><span class="w"> </span><span class="n">RANGES</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">users</span><span class="p">;</span><span class="w">  </span><span class="c1">-- 看 replica 分佈
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1"></span><span class="k">EXPLAIN</span><span class="w"> </span><span class="k">ANALYZE</span><span class="w"> </span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">users</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">1</span><span class="p">;</span><span class="w">  </span><span class="c1">-- 看 query plan 是否 local</span></span></span></code></pre></div><p>對應 <a href="/blog/backend/knowledge-cards/stale-read/" data-link-title="Stale Read" data-link-desc="讀取到落後於最新寫入版本的舊資料">stale read 卡</a>、<a href="/blog/backend/knowledge-cards/table-partitioning/" data-link-title="Table Partitioning" data-link-desc="說明單一資料庫內如何把大表拆成多個分區，並由查詢規劃器只掃相關片段">table partitioning 卡</a> 的具體機制實現。</p>
<h2 id="操作流程從合規-boundary-到-schema-配置">操作流程：從合規 boundary 到 schema 配置</h2>
<h3 id="配置-multi-region-database">配置 multi-region database</h3>
<p>第一步是把所有 region 加入 database：</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">-- 假設 cluster 已跨 8 個州（透過 AWS Outposts 在每州內）
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">ALTER</span><span class="w"> </span><span class="k">DATABASE</span><span class="w"> </span><span class="n">sportsbook</span><span class="w"> </span><span class="k">PRIMARY</span><span class="w"> </span><span class="n">REGION</span><span class="w"> </span><span class="s2">&#34;us-east1-virginia&#34;</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="k">ALTER</span><span class="w"> </span><span class="k">DATABASE</span><span class="w"> </span><span class="n">sportsbook</span><span class="w"> </span><span class="k">ADD</span><span class="w"> </span><span class="n">REGION</span><span class="w"> </span><span class="s2">&#34;us-east1-nj&#34;</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">ALTER</span><span class="w"> </span><span class="k">DATABASE</span><span class="w"> </span><span class="n">sportsbook</span><span class="w"> </span><span class="k">ADD</span><span class="w"> </span><span class="n">REGION</span><span class="w"> </span><span class="s2">&#34;us-east1-fl&#34;</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">ALTER</span><span class="w"> </span><span class="k">DATABASE</span><span class="w"> </span><span class="n">sportsbook</span><span class="w"> </span><span class="k">ADD</span><span class="w"> </span><span class="n">REGION</span><span class="w"> </span><span class="s2">&#34;us-east1-az&#34;</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="c1">-- ...其他州</span></span></span></code></pre></div><p>每個「region」對應一個 Outpost / AWS region 的 locality tag、CockroachDB Raft 根據 locality 自動分佈 replica。</p>
<h3 id="table-level-locality-配置">Table-level locality 配置</h3>
<p>bet placement / settlement table 走 <code>REGIONAL BY ROW</code>（資料跟玩家所在州綁定）：</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">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">bets</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="n">LOCALITY</span><span class="w"> </span><span class="n">REGIONAL</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="k">ROW</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="k">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">settlements</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="n">LOCALITY</span><span class="w"> </span><span class="n">REGIONAL</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="k">ROW</span><span class="p">;</span></span></span></code></pre></div><p>account / user profile 跨州統一帳戶 — 玩家可能在多州下注、但 <em>主檔</em> 留 single region：</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">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">accounts</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="n">LOCALITY</span><span class="w"> </span><span class="n">REGIONAL</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="k">IN</span><span class="w"> </span><span class="s2">&#34;us-east1-virginia&#34;</span><span class="p">;</span></span></span></code></pre></div><p>reference data（運動類別、賽事 metadata）— 全球變更少、每州都要快速 read：</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">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">sports_metadata</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="n">LOCALITY</span><span class="w"> </span><span class="k">GLOBAL</span><span class="p">;</span></span></span></code></pre></div><h3 id="application-端寫入">Application 端寫入</h3>





<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">-- 顯式指定 row 所在 region（推薦、明確）
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="n">bets</span><span class="w"> </span><span class="p">(</span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="n">user_id</span><span class="p">,</span><span class="w"> </span><span class="k">state</span><span class="p">,</span><span class="w"> </span><span class="n">amount</span><span class="p">,</span><span class="w"> </span><span class="n">crdb_region</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="k">VALUES</span><span class="w"> </span><span class="p">(...,</span><span class="w"> </span><span class="p">...,</span><span class="w"> </span><span class="s1">&#39;NJ&#39;</span><span class="p">,</span><span class="w"> </span><span class="mi">100</span><span class="p">.</span><span class="mi">00</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;us-east1-nj&#39;</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></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="c1">-- 或用 gateway_region() default（依 application 連到的 region）
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="n">bets</span><span class="w"> </span><span class="p">(</span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="n">user_id</span><span class="p">,</span><span class="w"> </span><span class="k">state</span><span class="p">,</span><span class="w"> </span><span class="n">amount</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">VALUES</span><span class="w"> </span><span class="p">(...,</span><span class="w"> </span><span class="p">...,</span><span class="w"> </span><span class="s1">&#39;NJ&#39;</span><span class="p">,</span><span class="w"> </span><span class="mi">100</span><span class="p">.</span><span class="mi">00</span><span class="p">);</span><span class="w">  </span><span class="c1">-- crdb_region 自動填 gateway 端</span></span></span></code></pre></div><p><code>gateway_region()</code> 是便利但有風險的 default — 如果 application server 在 us-east1-fl 但 user 在 NJ 下注、row 會被放到 FL 而不是 NJ、違反 Wire Act 合規。Hard Rock 場景下顯式指定 <code>crdb_region</code> 是更安全的做法。</p>
<h3 id="rollback-邊界">Rollback 邊界</h3>
<p>locality 變更即時生效、Raft 自動 rebalance — 無不可逆動作。但 rebalance 期間 cross-region traffic 暴增、p99 短期 spike。production 環境改 locality 應該選低流量時段、並監控 rebalance queue。</p>
<h2 id="失敗模式">失敗模式</h2>
<h3 id="拆獨立-cluster-解合規但破壞業務邏輯反模式hard-rock-對比-standard-charteredf410">「拆獨立 cluster 解合規但破壞業務邏輯」反模式（Hard Rock 對比 Standard Chartered、F4.10）</h3>
<p>直覺路徑是「合規要求資料留某地理邊界 → 每邊界開一個獨立 cluster」、合規上沒問題。但獨立 cluster 之間：</p>
<ul>
<li>玩家統一帳戶撞牆 — 每 cluster 各自有 user table、跨 cluster query 麻煩</li>
<li>跨州 reporting 要 N 個 cluster + ETL pipeline</li>
<li>欺詐偵測要 <em>cross-state aggregated view</em> — 獨立 cluster 拼不出</li>
</ul>
<p>Hard Rock 選擇 <em>邏輯一個 cluster + 物理跨州 Outpost placement</em> — 合規 boundary 用 region placement 表達、不是 cluster fragmentation。對比 Standard Chartered：</p>
<ul>
<li><strong>Standard Chartered Aurora 7 cluster fleet</strong>：銀行業跨國合規邊界、<em>跨 cluster 業務邏輯需求弱</em>（每市場用戶獨立、跨境統一帳戶不是核心 driver）→ 用 fleet 拓樸吸收合規可行</li>
<li><strong>Hard Rock Wire Act 跨州</strong>：跨州統一帳戶 + 跨州 reporting + 欺詐偵測是 <em>核心業務需求</em> → 必須邏輯一個 cluster、用 locality + placement 吸收合規</li>
</ul>
<p>兩條路徑沒有對錯、trigger 條件不同。判讀軸線：</p>
<ul>
<li>合規顆粒（跨國 vs 跨州 vs 跨 AZ）</li>
<li>跨 boundary 業務邏輯需求強度（強 → CockroachDB locality / 弱 → 拆獨立 cluster 可行）</li>
<li>團隊運維能力（CockroachDB 邏輯一個 cluster vs Aurora 多 cluster fleet 的人月成本）</li>
</ul>
<h3 id="outposts-是-latency-工具動機誤判f413case-反直覺判讀">「Outposts 是 latency 工具」動機誤判（F4.13、case 反直覺判讀）</h3>
<p>AWS Outposts 主要為「資料留某地理邊界」存在、latency 改善是 <em>副作用</em>。Hard Rock 策略段 2 明確警告：「決策時先看合規驅動力、latency 改善列為 bonus」。</p>
<p>若把 Outposts 當跨州 latency 改善工具、會在沒合規驅動的場景過度投資 — Outposts 硬體成本 + 維運複雜度遠高於純 AWS region 部署。實務判讀：</p>
<ul>
<li>有合規驅動（Wire Act / GDPR / 各州博彩牌照）→ Outposts 是合理投資</li>
<li>純 latency 優化 → 用 AWS Local Zones、用 CDN、用 edge cache、不要碰 Outposts</li>
<li>兩者並存 → Outposts 投資按 <em>合規</em> 計算、latency 改善是 ROI 加分項</li>
</ul>
<h3 id="global-table-write-太慢"><code>GLOBAL</code> table write 太慢</h3>
<p><code>GLOBAL</code> table 每次 write 跨 region quorum、p99 100ms+。用在 high-write workload 是典型錯配 — 該用在 reference data（國家代碼、貨幣表、規則 lookup）。</p>
<p>判讀：</p>
<ul>
<li>write QPS &lt; 10 + read QPS 跨 region 高 → <code>GLOBAL</code> 合理</li>
<li>write QPS &gt; 100 → 不要用 <code>GLOBAL</code>、改 <code>REGIONAL BY ROW</code> + 接受 cross-region read 偶爾走 follower</li>
</ul>
<h3 id="regional-by-row-但-row-沒設-crdb_region"><code>REGIONAL BY ROW</code> 但 row 沒設 <code>crdb_region</code></h3>
<p>application 寫入時忘了設 <code>crdb_region</code>、default 走 <code>gateway_region()</code> — application server 所在 region 變成 row 的 region。常見後果：</p>
<ul>
<li>application server 集中部署 → 所有 row 跑同一 region、locality 失效</li>
<li>application server 跟 user 不同 region → 合規 violation（Wire Act 場景）</li>
</ul>
<p>修法：顯式指定 <code>crdb_region</code>、把 user 的合規區域當業務欄位明確管理。</p>
<h3 id="cross-region-join-跑爆-latency">Cross-region join 跑爆 latency</h3>
<p>兩個 <code>REGIONAL BY ROW</code> table join、planner 要跨 region 拉資料、p99 暴漲。</p>
<p>修法：</p>
<ul>
<li>兩個 table partition by <em>同樣</em> 的 key（如：user_id）、保證 join 對應 row 在同 region</li>
<li>不能保證 co-location 時、考慮用 follower read 接受 stale 資料</li>
<li>query 重寫成多步：先在各 region 算 local 結果、application 端 merge</li>
</ul>
<h3 id="follower-read-假設-strong-consistency">Follower read 假設 strong consistency</h3>
<p>non-voting replica 是 <em>closed timestamp</em> 之前的資料、read-after-write 場景仍會 stale。</p>
<p>修法：</p>
<ul>
<li>read-after-write critical（如：剛下注立刻顯示「下注成功」）→ 不能走 follower、要走 leaseholder</li>
<li>dashboard / 分析 / reporting 容忍 stale → follower read 安全、大幅降 latency</li>
</ul>
<h3 id="data-residency-違規">Data residency 違規</h3>
<p>受監管州 / 國資料應留 boundary 內、但 application 從別 region 寫入 row、沒設 <code>crdb_region</code>、資料跑出 boundary、合規 violation（Wire Act / GDPR / 各州博彩牌照都有類似條款）。</p>
<p>修法（schema-level + application-level 雙保險）：</p>
<ul>
<li>schema：<code>REGIONAL BY ROW</code> + <code>crdb_region</code> 是 NOT NULL + CHECK constraint 限制可選值</li>
<li>application：寫入前明確驗證 <code>crdb_region</code> 對應 user 所在合規區</li>
<li>監控：定期跑 <code>SELECT crdb_region, count(*) FROM bets GROUP BY crdb_region</code> 確認分佈符合預期</li>
</ul>
<h3 id="hard-rock-場景的組合配置9c41">Hard Rock 場景的組合配置（9.C41）</h3>
<p>bet placement / settlement / account management 都需要跨州資料存取 + 州內合規 placement。Hard Rock 案例揭露的具體組合：</p>
<ul>
<li><code>REGIONAL BY ROW</code> + <code>crdb_region</code> 標州別 + region placement pin Outpost</li>
<li>account 跨州統一 → <code>REGIONAL BY TABLE</code> IN primary region、其他州走 follower read</li>
<li>sports metadata → <code>GLOBAL</code>、reference data 全州 local read</li>
</ul>
<p>這是滿足 Wire Act + 跨州業務邏輯的組合、不是唯一解、但揭露了 schema 設計的 <em>判讀軸</em> — 不是「locality 越強越好」、是「locality 對應業務 + 合規邊界」。</p>
<h2 id="容量與觀測">容量與觀測</h2>
<h3 id="必看-metric">必看 metric</h3>
<ul>
<li><code>Range locality distribution</code>：range 分佈跟 locality 配置是否一致</li>
<li><code>Cross-region query count</code>：cross-region query 數量、locality 失效訊號</li>
<li><code>Follower read rate</code>：follower read 命中率、降 latency 效果</li>
<li><code>Leaseholder distribution by region</code>：leaseholder 在 region 間是否均勻</li>
</ul>
<h3 id="容量公式">容量公式</h3>
<ul>
<li>cross-region traffic = <code>GLOBAL</code> table write QPS × region count</li>
<li><code>REGIONAL BY ROW</code> 跨 region read = follower read rate × QPS</li>
<li>storage 用量 = base storage × replication factor × (voting + non-voting replica count)</li>
</ul>
<h3 id="容量上限">容量上限</h3>
<ul>
<li>region count：建議 ≤ 5（多 region 增加 quorum latency + 維運複雜度）</li>
<li><code>GLOBAL</code> table 數量：建議只放 reference data、總 row 數 &lt; 10 萬</li>
<li>single range 寫 throughput ~1000 QPS（通用估算、見 <a href="../hlc-raft-consensus/">HLC + Raft consensus</a>）</li>
</ul>
<h3 id="回路徑">回路徑</h3>
<ul>
<li><a href="/blog/backend/09-performance-capacity/" data-link-title="模組九：效能工程與容量規劃" data-link-desc="把『目前配置能撐多少、要加多少機器』變成可量化、可驗證、可改進的工程流程">9.5 瓶頸定位流程</a> 判斷 cross-region-bound vs CPU-bound</li>
<li><a href="/blog/backend/01-database/global-distributed-oltp/" data-link-title="1.11 全球分散式 OLTP" data-link-desc="Spanner / Aurora DSQL / Cosmos DB multi-region write / CockroachDB / TiDB 的全球一致性取捨">1.11 全球分散式 OLTP</a> 上游合規 / latency 取捨</li>
</ul>
<h2 id="邊界與整合">邊界與整合</h2>
<h3 id="sibling-deep-articles">Sibling deep articles</h3>
<ul>
<li><a href="../survival-goals/">survival goals</a>：locality + survival goal 一起決定 replica placement</li>
<li><a href="../transaction-retry-pattern/">transaction retry pattern</a>：partition 降低 hot row contention 的 schema 路徑</li>
<li><a href="../hlc-raft-consensus/">HLC + Raft consensus</a>：leaseholder 跟 locality 的關係</li>
</ul>
<h3 id="跟-aurora-global-database-對照">跟 Aurora Global Database 對照</h3>
<p>Aurora 不支援 row-level locality — 跨 region 只能 cluster-per-region + async replication。CockroachDB 在一個 cluster 內可以 fine-grained locality、application 不需要管 cross-cluster 路由。Aurora Global Database 適合 <em>async DR</em> 場景、不適合 <em>跨 region 強一致 + row-level locality</em> 需求。</p>
<h3 id="跟-spanner-interleaved-tables-對照">跟 Spanner interleaved tables 對照</h3>
<p>Spanner 的 <a href="/blog/backend/knowledge-cards/interleaved-table/" data-link-title="Interleaved Table" data-link-desc="Spanner 把 parent / child table row 物理交錯儲存、parent &#43; child JOIN 不跨 split">Interleaved Table</a> 跟 CockroachDB 的 <code>REGIONAL BY ROW</code> 概念類似（parent-child row co-location）、語法不同。Spanner 在 GCP region 內 placement、無 Outposts 等效 — Hard Rock 場景下 Spanner 不能直接套用。</p>
<h3 id="aurora-dsql--spanner-對比">Aurora DSQL / Spanner 對比</h3>
<p>完整三家 distributed SQL 在 locality / multi-region placement 的取捨、見 <a href="../aurora-dsql-spanner-decision-tree/">aurora-dsql-spanner-decision-tree</a>。</p>
<h3 id="1x-章節互引">1.x 章節互引</h3>
<ul>
<li><a href="/blog/backend/01-database/global-distributed-oltp/" data-link-title="1.11 全球分散式 OLTP" data-link-desc="Spanner / Aurora DSQL / Cosmos DB multi-region write / CockroachDB / TiDB 的全球一致性取捨">1.11 全球分散式 OLTP</a> 上游</li>
<li><a href="/blog/backend/knowledge-cards/stale-read/" data-link-title="Stale Read" data-link-desc="讀取到落後於最新寫入版本的舊資料">stale read 卡</a></li>
<li><a href="/blog/backend/knowledge-cards/table-partitioning/" data-link-title="Table Partitioning" data-link-desc="說明單一資料庫內如何把大表拆成多個分區，並由查詢規劃器只掃相關片段">table partitioning 卡</a></li>
</ul>
<h3 id="何時不用本文">何時不用本文</h3>
<ul>
<li>single-region 部署、無 data residency 需求 → 用 default locality 即可</li>
<li>合規邊界 <em>禁止</em> 跨境 replica（如 Standard Chartered 模式）→ 拆 cluster-per-市場、不走本文 locality 路徑</li>
<li>純 latency 優化、無合規驅動 → 用 CDN / cache / Local Zones、不必動 schema</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/cockroachdb/" data-link-title="CockroachDB" data-link-desc="分散式 SQL、PostgreSQL 相容、跨區強一致、Spanner 的開源 / 跨雲替代">CockroachDB vendor overview</a></li>
<li><a href="/blog/backend/09-performance-capacity/cases/hard-rock-digital-cockroachdb-sports-betting/" data-link-title="9.C41 Hard Rock Digital：CockroachDB on AWS Outposts、Wire Act 合規 &#43; 跨州單一邏輯 DB" data-link-desc="Hard Rock Digital 用 CockroachDB 跨 AWS Outposts &#43; US-East-1、Wire Act 強制資料留州、單一邏輯 DB 解多州 sportsbook、100 node 32 vCPU 撐 Super Bowl">9.C41 Hard Rock Digital</a>（concrete framing — 跨 8 州 + Outposts + 邏輯一個 cluster）</li>
<li><a href="/blog/backend/09-performance-capacity/cases/netflix-cockroachdb-multi-region-fleet/" data-link-title="9.C40 Netflix：380&#43; CockroachDB cluster 的 multi-active 拓樸艦隊" data-link-desc="Netflix 把 Cassandra 不夠用的 transactional workload 移到 CockroachDB、380&#43; cluster / 60&#43; 跨 region、含 Open Connect、studio cloud drive、gaming control plane">9.C40 Netflix</a>（多 region locality 規模治理）</li>
<li><a href="/blog/backend/09-performance-capacity/cases/standard-chartered-aurora-banking/" data-link-title="9.C14 Standard Chartered：受監管銀行的 Aurora 4000 TPS 容量提升" data-link-desc="Standard Chartered 銀行遷移到 Aurora 後吞吐量提升 10 倍至 4000 TPS、跨 7 個受監管市場">9.C14 Standard Chartered</a>（fleet 拓樸對照、不同合規邊界）</li>
<li><a href="/blog/backend/knowledge-cards/stale-read/" data-link-title="Stale Read" data-link-desc="讀取到落後於最新寫入版本的舊資料">stale read 卡</a> / <a href="/blog/backend/knowledge-cards/table-partitioning/" data-link-title="Table Partitioning" data-link-desc="說明單一資料庫內如何把大表拆成多個分區，並由查詢規劃器只掃相關片段">table partitioning 卡</a></li>
<li>官方：<a href="https://www.cockroachlabs.com/docs/stable/multiregion-overview.html">CockroachDB Multi-Region Capabilities</a> / <a href="https://www.cockroachlabs.com/docs/stable/table-localities.html">Table Localities</a> / <a href="https://www.cockroachlabs.com/docs/stable/follower-reads.html">Follower Reads</a></li>
</ul>
]]></content:encoded></item><item><title>Cosmos DB Partition Key Design：synthetic / composite / hierarchical + 不可逆性硬約束</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/cosmosdb/partition-key-design/</link><pubDate>Wed, 27 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/cosmosdb/partition-key-design/</guid><description>&lt;p>Cosmos DB 的 &lt;em>logical partition 上限是 10,000 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/request-unit/" data-link-title="Request Unit" data-link-desc="Cosmos DB 的容量抽象單位、1 RU = 1KB document strong-consistent read 的 CPU &amp;#43; memory &amp;#43; IOPS 綜合 cost、寫 ~5 RU、複雜 query 數百 RU">Request Unit&lt;/a>/s + 20 GB storage&lt;/em>、partition key 一旦上 production &lt;em>改不了&lt;/em>（要 export → recreate container → import）。partition key 選錯的後果是 Black Friday / 上線日 / VIP 用戶把流量壓在少數 partition、p99 latency 從 50ms 飆到 5s、整體 container 還有 70% RU 剩餘卻全 throttle。Cosmos DB partition key 設計是 &lt;em>selection 階段就要決定的硬約束&lt;/em>、不是「先選錯再改」可承擔的風險 — 這個不可逆性跟 MongoDB（&lt;code>reshardCollection&lt;/code> 線上完成）跟 DynamoDB（建新 table backfill）形成關鍵對比。&lt;/p>
&lt;p>本文不是 Cosmos DB overview（請看 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/cosmosdb/" data-link-title="Azure Cosmos DB" data-link-desc="全球分散式 multi-model DB、5 個 consistency levels、Microsoft 自家 dogfood 證據">Cosmos DB vendor 頁&lt;/a>）— 而是 partition key 設計 + 故障演練的深度展開。Case anchor 是 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/minecraft-earth-cosmos-db-global/" data-link-title="9.C11 Minecraft Earth：Azure Cosmos DB 上的全球分散式 AR 遊戲" data-link-desc="Minecraft Earth 用 Cosmos DB 跨地區分散、測試到 100 萬 RU/s 仍維持承諾延遲">9.C11 Minecraft Earth&lt;/a>（synthetic partition key 強制分散、AR 遊戲玩家位置）+ &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/asos-cosmos-db-black-friday/" data-link-title="9.C21 ASOS：Cosmos DB 在 Black Friday 撐 1.67 億請求" data-link-desc="ASOS 在 2016 Black Friday 用 Azure Cosmos DB 撐 24 小時 1.67 億請求、3500 req/sec、48ms 平均延遲">9.C21 ASOS&lt;/a>（Black Friday 流量分散 + latency budget 拆解）。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>Cosmos DB 適用度前置判讀&lt;/strong>：本篇假設 workload 已通過 Cosmos DB 適用度四層 framing（API model 三型遷移路徑 / RU 思維轉換成本 / multi-model 差異化是否真用上 / 跨雲 hedging vs 單雲 lock-in）— 詳見 &lt;a href="../mongodb-api-vs-sql-api/#%e5%9b%9b%e5%b1%a4-framingvendor-selection-%e7%9a%84%e7%9c%9f%e5%af%a6%e6%b1%ba%e7%ad%96%e8%bb%b8">mongodb-api-vs-sql-api 開頭四層 framing&lt;/a>、本篇不重複展開。Partition key 設計是 &lt;em>已選 Cosmos DB 後&lt;/em> 的硬約束議題；若 workload 不適用 Cosmos DB、partition key 設計無法救回 vendor 選錯的不可逆性風險。&lt;/p></description><content:encoded><![CDATA[<p>Cosmos DB 的 <em>logical partition 上限是 10,000 <a href="/blog/backend/knowledge-cards/request-unit/" data-link-title="Request Unit" data-link-desc="Cosmos DB 的容量抽象單位、1 RU = 1KB document strong-consistent read 的 CPU &#43; memory &#43; IOPS 綜合 cost、寫 ~5 RU、複雜 query 數百 RU">Request Unit</a>/s + 20 GB storage</em>、partition key 一旦上 production <em>改不了</em>（要 export → recreate container → import）。partition key 選錯的後果是 Black Friday / 上線日 / VIP 用戶把流量壓在少數 partition、p99 latency 從 50ms 飆到 5s、整體 container 還有 70% RU 剩餘卻全 throttle。Cosmos DB partition key 設計是 <em>selection 階段就要決定的硬約束</em>、不是「先選錯再改」可承擔的風險 — 這個不可逆性跟 MongoDB（<code>reshardCollection</code> 線上完成）跟 DynamoDB（建新 table backfill）形成關鍵對比。</p>
<p>本文不是 Cosmos DB overview（請看 <a href="/blog/backend/01-database/vendors/cosmosdb/" data-link-title="Azure Cosmos DB" data-link-desc="全球分散式 multi-model DB、5 個 consistency levels、Microsoft 自家 dogfood 證據">Cosmos DB vendor 頁</a>）— 而是 partition key 設計 + 故障演練的深度展開。Case anchor 是 <a href="/blog/backend/09-performance-capacity/cases/minecraft-earth-cosmos-db-global/" data-link-title="9.C11 Minecraft Earth：Azure Cosmos DB 上的全球分散式 AR 遊戲" data-link-desc="Minecraft Earth 用 Cosmos DB 跨地區分散、測試到 100 萬 RU/s 仍維持承諾延遲">9.C11 Minecraft Earth</a>（synthetic partition key 強制分散、AR 遊戲玩家位置）+ <a href="/blog/backend/09-performance-capacity/cases/asos-cosmos-db-black-friday/" data-link-title="9.C21 ASOS：Cosmos DB 在 Black Friday 撐 1.67 億請求" data-link-desc="ASOS 在 2016 Black Friday 用 Azure Cosmos DB 撐 24 小時 1.67 億請求、3500 req/sec、48ms 平均延遲">9.C21 ASOS</a>（Black Friday 流量分散 + latency budget 拆解）。</p>
<blockquote>
<p><strong>Cosmos DB 適用度前置判讀</strong>：本篇假設 workload 已通過 Cosmos DB 適用度四層 framing（API model 三型遷移路徑 / RU 思維轉換成本 / multi-model 差異化是否真用上 / 跨雲 hedging vs 單雲 lock-in）— 詳見 <a href="../mongodb-api-vs-sql-api/#%e5%9b%9b%e5%b1%a4-framingvendor-selection-%e7%9a%84%e7%9c%9f%e5%af%a6%e6%b1%ba%e7%ad%96%e8%bb%b8">mongodb-api-vs-sql-api 開頭四層 framing</a>、本篇不重複展開。Partition key 設計是 <em>已選 Cosmos DB 後</em> 的硬約束議題；若 workload 不適用 Cosmos DB、partition key 設計無法救回 vendor 選錯的不可逆性風險。</p></blockquote>
<h2 id="問題情境">問題情境</h2>
<p>典型觸發場景：團隊用 user_id 當 partition key 上 production、平常正常、Black Friday 或 VIP 大客戶上線當天 — application 收到 <code>429 TooManyRequests</code>、p99 從 50ms 飆到 5s；查 portal Metrics 發現 <em>整體 RU 使用率才 30%</em> 但少數 partition 100% 滿、其他 partition 閒置。Cosmos DB 設了 10000 RU/s、實際只能用 2000 就 throttle。</p>
<p>讀者徵兆：</p>
<ul>
<li>「Cosmos DB throughput 我設了 10000 RU、但寫入只有 2000 就 throttle」</li>
<li>「user_id 當 partition key 結果 VIP 用戶全卡在一個 partition」</li>
<li>「Hierarchical partition key 是 2023 後才有的、跟 composite 差在哪」</li>
<li>「partition key 選錯能改嗎」</li>
</ul>
<p>真實壓力：</p>
<ul>
<li>遊戲玩家位置（同伺服器集中同 partition、Minecraft Earth 場景）</li>
<li>IoT 裝置遙測（單一裝置高頻寫入、device_id 不均）</li>
<li>SaaS 多租戶（大客戶 vs 小客戶不均、tenant_id 直接當 partition key 會 hot）</li>
<li>零售商品 catalog（熱門 SKU vs 冷門 SKU 不均）</li>
</ul>
<p>partition key 選錯的隱性成本：要改就是 <em>export → recreate container with new partition key → import</em>、無 in-place migration、production 等於停機窗口 + 全量資料搬移。selection 階段就要決定、不能 phase 後補。</p>
<h2 id="核心機制">核心機制</h2>
<h3 id="partition-模型">Partition 模型</h3>
<p>每個 container 有 N 個 <em>physical partition</em>、每個 physical 上有多個 <em>logical partition</em>。同 partition key value 的所有 document 落到同一個 logical partition。Cosmos DB 動態調整 physical partition 數量（透明 split）、但 logical partition 的歸屬 <em>永遠不變</em>（同 PK value 永遠在同 logical）。</p>
<p>9.C11 Minecraft Earth 案例的平台特性段揭露「partition 動態分裂：透明」 — physical partition 的 split 對 application 透明、不需要 application 重連 / 重新 hash。但這個透明 <em>只解 physical partition 容量</em> 問題、<em>不解 logical partition 熱點</em> — logical partition 由 PK value 決定、application 必須自己均勻散佈 value。</p>
<h3 id="logical-partition-上限">Logical partition 上限</h3>
<p>10,000 RU/s + 20 GB storage、達 limit 後即使 container 還有總 RU、單一 partition key 一樣 throttle。這是 <em>硬上限</em>、不是 soft limit、不能調高。</p>
<p>20 GB storage 限制在小用戶通常碰不到、但對「以 tenant_id 為 PK 的大客戶」、storage 也可能先到上限（單一大客戶 50GB 資料、塞不進一個 logical partition）。</p>
<h3 id="partition-key-設計三種模式">Partition key 設計三種模式</h3>
<h4 id="synthetic人工合成-key">Synthetic（人工合成 key）</h4>
<p>機制：用 <code>{userId}_{random_0_to_99}</code> 把單一 user 的寫入散到 100 個 logical partition。application 端 hash userId + random suffix、寫入時組合成 partition key。</p>
<p>副作用：read 需 fan-out 100 個 partition、單一 query RU 暴漲 100x。適合 <em>write-heavy + 不需精準 read</em> 場景（如 IoT telemetry、log）。</p>
<p>9.C11 Minecraft Earth 用 synthetic partition key 強制分散 — AR 遊戲玩家位置寫入頻繁、partition 分散讓單一玩家不會打爆一個 partition。但 case 沒揭露具體 schema、synthetic 細節屬 outline knowledge 推論。</p>
<h4 id="composite多欄位合成">Composite（多欄位合成）</h4>
<p>機制：用 <code>{tenantId}_{deviceId}</code> 兩個欄位合成（<a href="/blog/backend/knowledge-cards/composite-partition-key/" data-link-title="Composite Partition Key" data-link-desc="多欄位合成 partition key 把單一 logical hot key 拆成多個物理 shard、寫入分散讀取 fan-out">Composite Partition Key</a> 通用樣式）、避免單一 high-cardinality 欄位 hot。適合 <em>多租戶 SaaS</em>、單一 tenant 內又有多個 device、避免大 tenant 把所有寫入集中。</p>
<p>副作用：read 必須帶兩個欄位、否則 cross-partition query；query API 設計要強制帶 tenant + device。</p>
<h4 id="hierarchical2023-原生支援">Hierarchical（2023+ 原生支援）</h4>
<p>機制：原生支援多層 key（最多 3 層、如 <code>tenantId / deviceId / sessionId</code>）、不用手動合成；query 可指定前綴做 partition scope query（如「拿 tenant X 的所有 device」單一 partition scope）。</p>
<p>適合：多層業務 hierarchy 場景（tenant → user → session、organization → team → project）。比 composite 優勢是 <em>支援 prefix query</em>、composite key 只能完整匹配。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">az cosmosdb sql container create <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --partition-key-paths <span class="s2">&#34;/tenantId&#34;</span> <span class="s2">&#34;/deviceId&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --partition-key-kind <span class="s2">&#34;MultiHash&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  ...</span></span></code></pre></div><p>設計順序要從 <em>低 cardinality</em> 到 <em>高 cardinality</em>（tenant 少、device 多、session 最多）— 反序會讓 prefix query 無意義。</p>
<h3 id="跟其他-vendor-的可逆性對照本章合成-frame">跟其他 vendor 的可逆性對照（本章合成 frame）</h3>
<blockquote>
<p><strong>跨 vendor 可逆性對照 SSoT</strong>：MongoDB / DynamoDB / Cosmos DB 三家 partition key 可逆性不在同一光譜（Cosmos DB 屬不可改、不可逆性最高）、跨 vendor 對照 SSoT 主寫位置在 <a href="/blog/backend/01-database/vendors/db3-vendor-selection/#%e4%b8%89-vendor-%e5%b0%8d%e6%af%94-10-%e8%bb%b8" data-link-title="DB3 Vendor Selection：document / KV / multi-model 三方選型 &#43; workload shape 前置判讀" data-link-desc="MongoDB / DynamoDB / Cosmos DB 三家 NoSQL 選型 entry point：workload shape × access pattern × consistency 三軸前置判讀、migration path 三型、federated DB 視角、三 vendor 對比 10 軸">DB3 entry — 三 vendor 對比 10 軸</a> + 對應的<a href="/blog/backend/01-database/vendors/db3-vendor-selection/#%e8%bb%b8%e7%9a%84%e5%bb%b6%e4%bc%b8%e5%ad%90%e6%ae%b5" data-link-title="DB3 Vendor Selection：document / KV / multi-model 三方選型 &#43; workload shape 前置判讀" data-link-desc="MongoDB / DynamoDB / Cosmos DB 三家 NoSQL 選型 entry point：workload shape × access pattern × consistency 三軸前置判讀、migration path 三型、federated DB 視角、三 vendor 對比 10 軸">軸的延伸子段</a>。本段聚焦 Cosmos DB 不可改特性對 selection 階段 access pattern audit 嚴格度的影響、不重複展開三 vendor 全光譜比較。</p></blockquote>
<p>partition / shard key 的可逆性在 vendor 間差異懸殊：</p>
<table>
  <thead>
      <tr>
          <th>Vendor</th>
          <th>可逆性</th>
          <th>機制</th>
          <th>工程成本</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>MongoDB</td>
          <td>可改（4.4+ <code>reshardCollection</code>）</td>
          <td>線上完成、cluster 內搬移</td>
          <td>高、但 in-place</td>
      </tr>
      <tr>
          <td>DynamoDB</td>
          <td>可改</td>
          <td>建新 table、backfill + dual-write 切換</td>
          <td>中、要 backfill</td>
      </tr>
      <tr>
          <td>Cosmos DB</td>
          <td><em>不可改</em></td>
          <td>必須 export → recreate container → import</td>
          <td>最高、需停機窗口</td>
      </tr>
  </tbody>
</table>
<p><strong>對照表是本章合成 frame、9.C11 Minecraft Earth 沒直接揭露此對比、是從 outline knowledge 跟 MongoDB shard-key-selection 對照得出</strong>。引用時必須明示：Cosmos DB partition key 不可改是 <em>設計選型的硬約束</em>、不是「先選錯再改」可承擔的風險 — 這個約束直接決定 selection 階段的 partition key audit 嚴格度該多高。</p>
<p>對 selection 的意義：若團隊對 access pattern 不確定、不能用「先上 Cosmos DB 再說、不行再改」的心態、要先用 MongoDB / DynamoDB 試 access pattern、確定後再評估 Cosmos DB。</p>
<h3 id="跟-dynamodb-partition-key-對比">跟 DynamoDB partition key 對比</h3>
<ul>
<li><strong>DynamoDB</strong>：partition key + optional sort key、無 hierarchical key、adaptive capacity 自動補 hot partition（部分減緩、不完全解決）</li>
<li><strong>Cosmos DB</strong>：hierarchical key 是 <em>原生功能</em>、不靠 adaptive；單 logical partition 限制嚴格、必須前期設計</li>
</ul>
<p>Cosmos DB 的 <em>硬上限 + 不可逆性</em> 跟 DynamoDB 的 <em>adaptive + 可遷移</em> 是兩種設計哲學 — selection 時要評估團隊能不能負擔前期 design effort。</p>
<p>對應 knowledge cards：<a href="/blog/backend/knowledge-cards/hot-partition/" data-link-title="Hot Partition" data-link-desc="說明分散式 KV / OLTP 中、單一 partition 流量遠超其他的容量問題">hot-partition</a> / <a href="/blog/backend/knowledge-cards/database-sharding/" data-link-title="Database Sharding" data-link-desc="說明資料庫如何依 shard key 分散資料、路由請求與承擔跨 shard 查詢成本">database-sharding</a>。</p>
<h2 id="操作流程">操作流程</h2>
<h3 id="設定-partition-key">設定 partition key</h3>
<p>建 container 時指定、<em>無法事後修改</em>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">az cosmosdb sql container create <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --account-name mycosmos --database-name mydb <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --name mycontainer --resource-group myrg <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --partition-key-path <span class="s2">&#34;/userId&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  --partition-key-version <span class="m">2</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="se"></span>  --throughput <span class="m">10000</span></span></span></code></pre></div><h3 id="hierarchical-key-設定c-sdk-範例">Hierarchical key 設定（C# SDK 範例）</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-csharp" data-lang="csharp"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kt">var</span> <span class="n">properties</span> <span class="p">=</span> <span class="k">new</span> <span class="n">ContainerProperties</span><span class="p">(</span><span class="s">&#34;mycontainer&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">new</span><span class="p">[]</span> <span class="p">{</span> <span class="s">&#34;/tenantId&#34;</span><span class="p">,</span> <span class="s">&#34;/deviceId&#34;</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">properties</span><span class="p">.</span><span class="n">PartitionKeyDefinitionVersion</span> <span class="p">=</span> <span class="n">PartitionKeyDefinitionVersion</span><span class="p">.</span><span class="n">V2</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="kt">var</span> <span class="n">container</span> <span class="p">=</span> <span class="k">await</span> <span class="n">database</span><span class="p">.</span><span class="n">CreateContainerAsync</span><span class="p">(</span><span class="n">properties</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1">// 寫入時帶完整 hierarchical key</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="kt">var</span> <span class="n">pk</span> <span class="p">=</span> <span class="k">new</span> <span class="n">PartitionKeyBuilder</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="p">.</span><span class="n">Add</span><span class="p">(</span><span class="s">&#34;tenant-123&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">.</span><span class="n">Add</span><span class="p">(</span><span class="s">&#34;device-456&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="p">.</span><span class="n">Build</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="k">await</span> <span class="n">container</span><span class="p">.</span><span class="n">CreateItemAsync</span><span class="p">(</span><span class="n">item</span><span class="p">,</span> <span class="n">pk</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1">// Prefix query：拿 tenant-123 的所有 device</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="kt">var</span> <span class="n">prefixPk</span> <span class="p">=</span> <span class="k">new</span> <span class="n">PartitionKeyBuilder</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="p">.</span><span class="n">Add</span><span class="p">(</span><span class="s">&#34;tenant-123&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="p">.</span><span class="n">Build</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="kt">var</span> <span class="n">iterator</span> <span class="p">=</span> <span class="n">container</span><span class="p">.</span><span class="n">GetItemQueryIterator</span><span class="p">&lt;</span><span class="n">Item</span><span class="p">&gt;(</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="s">&#34;SELECT * FROM c&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="n">requestOptions</span><span class="p">:</span> <span class="k">new</span> <span class="n">QueryRequestOptions</span> <span class="p">{</span> <span class="n">PartitionKey</span> <span class="p">=</span> <span class="n">prefixPk</span> <span class="p">});</span></span></span></code></pre></div><h3 id="synthetic-key-寫入">Synthetic key 寫入</h3>
<p>application 端 hash + random suffix、寫入時組合成 partition key：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kn">import</span> <span class="nn">hashlib</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kn">import</span> <span class="nn">random</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="k">def</span> <span class="nf">get_partition_key</span><span class="p">(</span><span class="n">user_id</span><span class="p">,</span> <span class="n">fanout</span><span class="o">=</span><span class="mi">100</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="n">suffix</span> <span class="o">=</span> <span class="n">random</span><span class="o">.</span><span class="n">randint</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="n">fanout</span> <span class="o">-</span> <span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">return</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">user_id</span><span class="si">}</span><span class="s2">_</span><span class="si">{</span><span class="n">suffix</span><span class="si">}</span><span class="s2">&#34;</span>
</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 class="c1"># Read 時 fan-out 所有可能 suffix</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="k">def</span> <span class="nf">read_user_data</span><span class="p">(</span><span class="n">user_id</span><span class="p">,</span> <span class="n">fanout</span><span class="o">=</span><span class="mi">100</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="n">results</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">for</span> <span class="n">suffix</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">fanout</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="n">pk</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">user_id</span><span class="si">}</span><span class="s2">_</span><span class="si">{</span><span class="n">suffix</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="n">results</span><span class="o">.</span><span class="n">extend</span><span class="p">(</span><span class="n">query_partition</span><span class="p">(</span><span class="n">pk</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">return</span> <span class="n">results</span></span></span></code></pre></div><p>注意 fanout 的 trade-off：fanout = 100 等於 read 成本 × 100；要在 <em>write 分散</em> 跟 <em>read 效率</em> 間平衡、通常 fanout 10-100 之間。</p>
<h3 id="查-partition-分布">查 partition 分布</h3>
<p>portal Metrics &gt; Storage by partition key、看分布是否均勻；或用 <code>SELECT * FROM c WHERE c.partitionKey = &quot;specific-value&quot;</code> query + diagnostic log 看 RU 分布。</p>
<h3 id="驗證點">驗證點</h3>
<ul>
<li>每個 logical partition 的 RU 消耗 &lt; 80% limit（給 burst 留 20% buffer）</li>
<li>單一 partition 的 storage &lt; 16 GB（給成長預留 4 GB buffer）</li>
<li>p99 latency 在 hot partition 不退化</li>
<li>跨 partition query 比例 &lt; 5%（多數 query 帶 partition key 條件）</li>
</ul>
<h3 id="rollback-boundary">Rollback boundary</h3>
<p>partition key 選錯只能 export → recreate container with new partition key → import；無 in-place migration、生產系統等於停機窗口 + dual-write cutover 流程。對應 <a href="/blog/backend/01-database/large-scale-db-migration/" data-link-title="1.12 大規模 DB 遷移實戰" data-link-desc="跨 DB 遷移的 dual-write、[shadow read](/backend/knowledge-cards/shadow-read/)、cutover、rollback 流程 — 從實戰案例提煉的工程做法">1.12 大規模 DB 遷移實戰</a> 的遷移模型。</p>
<h2 id="失敗模式">失敗模式</h2>
<h3 id="failure-1user_id-直接當-partition-key">Failure 1：user_id 直接當 partition key</h3>
<p>高活躍用戶（VIP / bot / 大客戶）超過 10,000 RU/s、全 container 被 throttle；徵兆是 <code>429 TooManyRequests</code> 集中在少數 partition、整體 RU 利用率才 30%。</p>
<p>修：</p>
<ul>
<li>短期：把 hot user 拉到獨立 container（合規上有時要這樣做、把 VIP / 企業客戶獨立治理）</li>
<li>長期：換 synthetic key（user_id + random suffix）或 composite key（tenant + user）</li>
<li>selection 階段 audit：access pattern 是否會有「少數 user 主導流量」現象（B2B SaaS、VIP 用戶都有）</li>
</ul>
<h3 id="failure-2時間當-partition-key">Failure 2：時間當 partition key</h3>
<p><code>/createdDate</code> 或 <code>/yyyyMM</code>、新資料全寫入最新 partition、舊 partition 冷掉浪費 — write hot + read 不均。徵兆：最新月份 partition throttle、其他月份 partition 閒置。</p>
<p>修：時間 + 業務維度組合（如 <code>/yyyyMM-userId</code>、<code>/userId-yyyy</code>）、避免純時間維度。time-series workload 該考慮 Azure Time Series Insights 或 Cosmos DB time-series 專屬模式。</p>
<h3 id="failure-3synthetic-key-沒考慮-read-路徑">Failure 3：Synthetic key 沒考慮 read 路徑</h3>
<p>寫入散開但 read 必須 fan-out 100 partition、單一 query RU 暴漲 100x。徵兆：read 成本遠高於估算、<code>RetrievedDocumentCount</code> 跟 <code>OutputDocumentCount</code> 比例 &gt; 50。</p>
<p>修：</p>
<ul>
<li>用 Change Feed 把投影預先寫到 read-optimized container（partition key 用 user_id）、read 走投影</li>
<li>或調 fanout（10 而非 100）、平衡 write 分散跟 read 成本</li>
<li>或重新評估「真的需要 synthetic key 嗎」 — 多數場景用 composite 就夠</li>
</ul>
<h3 id="failure-4hierarchical-key-設計順序顛倒">Failure 4：Hierarchical key 設計順序顛倒</h3>
<p>把 high-cardinality 放第一層、prefix query 變得無意義。如 <code>/userId/tenantId</code> 而非 <code>/tenantId/userId</code> — 想拿「tenant X 的所有 user」變成 cross-partition query、完全失去 hierarchical 優勢。</p>
<p>修：設計順序從 <em>低 cardinality</em> 到 <em>高 cardinality</em>、跟業務 query pattern 對齊。建 container 前畫 access pattern 表、列每個 query 的 hierarchy 順序、再決定 partition key path。</p>
<h3 id="failure-5不監控-partition-分布">Failure 5：不監控 partition 分布</h3>
<p>partition skew 累積幾個月、直到事故才發現。production 上線初期 access pattern 還不明顯、半年後 VIP 客戶開始用、partition 失衡 — 來不及改 partition key、只能在 throttle 中應急。</p>
<p>修：上線第一天就設 alert：</p>
<ul>
<li>單 partition RU 利用 &gt; 80% 持續 5 min</li>
<li>單 partition storage &gt; 16 GB</li>
<li>429 error rate 突增</li>
</ul>
<p>每週看 portal Insights &gt; Top contributors &gt; Partition key range、early detect skew。</p>
<h3 id="failure-6container-之間-partition-設計不一致">Failure 6：Container 之間 partition 設計不一致</h3>
<p>跨 container query 需要 fan-out、cross-partition query 成本爆炸。常見 anti-pattern：訂單 container 用 user_id、商品 container 用 product_id、join 訂單 + 商品時兩邊都 cross-partition。</p>
<p>修：跨 container 的 access pattern 在 selection 階段就要設計、不能各 container 各自決定 partition key。或者用 Change Feed 把跨 container 資料合成 single container 的 materialized view。</p>
<h2 id="容量與觀測">容量與觀測</h2>
<ul>
<li>必看 metric：<code>PhysicalPartitionThroughputInfo</code>、<code>NormalizedRUConsumption</code> per partition、<code>StorageDistributionPerPartition</code></li>
<li>Hot partition 偵測：portal Insights &gt; Top contributors &gt; Partition key range</li>
<li>容量估算公式：peak RU per partition × partition 數 + 預留 buffer（一般 30%）= total RU/s</li>
<li>回 <a href="/blog/backend/09-performance-capacity/saturation-discovery/" data-link-title="9.4 Saturation Discovery" data-link-desc="找出 throughput plateau 與 latency knee 的方法">9.4 Saturation Discovery</a>：把 partition skew 當 saturation signal</li>
<li>Alert：單 partition RU 利用 &gt; 80% 持續 5 min；429 error rate 突增</li>
</ul>
<h3 id="latency-budget-拆解vendor-sla-vs-end-to-end-實測">Latency budget 拆解：vendor SLA vs end-to-end 實測</h3>
<p>9.C21 ASOS 觀察「48ms 平均響應 = 全球分散下 Cosmos DB 的代表性數字」段揭露：48ms 包含 <em>網路 + DB + 應用層</em>、DB 本身可能只佔 5-10ms、其他是網路與應用層。引用時不能把 vendor 廣告的 5-10ms p99 當「使用者體驗」、要明示「48ms 是 9.C21 ASOS 案例的 end-to-end 觀察、Cosmos DB 自身可能只佔 5-10ms（case 揭露的拆解推論、不是 case fact）」。</p>
<p>操作上要把 end-to-end latency 拆 budget：</p>
<ul>
<li><strong>DB 端 latency</strong>（vendor SLA、p99 &lt; 10ms 地區內讀、9.C11 揭露）</li>
<li><strong>跨 region replication latency</strong>（multi-region read 從就近 region 拿、不會跨洲、但 cross-region write 不同、見 <a href="../multi-region-write-conflict/">multi-region-write-conflict</a>）</li>
<li><strong>應用層 latency</strong>（serialize / business logic / HTTP overhead）</li>
<li><strong>客戶端網路 latency</strong>（mobile / 跨洲）</li>
</ul>
<p>跟 partition skew 的關係：partition 失衡時即使 vendor 端 SLA 達標、實測 p99 仍會被 hot partition 拉高 — 單一 partition 的 RU consumption 飽和 → 429 retry → 應用層 latency 暴漲 → end-to-end 從 48ms 變 500ms。partition 設計直接影響 end-to-end SLA 鏈路。</p>
<h2 id="邊界與整合">邊界與整合</h2>
<ul>
<li>Sibling deep articles：<a href="../ru-cost-model-sizing/">ru-cost-model-sizing</a>（partition skew 直接影響 RU sizing）、<a href="../consistency-levels-engineering/">consistency-levels-engineering</a>（partition 失衡時即使設 Strong 也看到 throttle）、<a href="../multi-region-write-conflict/">multi-region-write-conflict</a>（partition key 影響 conflict 分布）、<a href="../mongodb-api-vs-sql-api/">mongodb-api-vs-sql-api</a>（MongoDB shard key → Cosmos DB partition key 翻譯）</li>
<li>跟 <a href="/blog/backend/01-database/vendors/dynamodb/" data-link-title="DynamoDB" data-link-desc="AWS managed key-value、partition-based scaling、9000 萬 RPS sustained 實戰證據">DynamoDB vendor</a> 對比：partition key + adaptive capacity vs 不可逆 + hierarchical</li>
<li>跟 <a href="/blog/backend/01-database/vendors/mongodb/" data-link-title="MongoDB" data-link-desc="Document database 代表、Atlas managed、跨雲可用、許多大規模平台從 MongoDB 起家">MongoDB vendor</a> 對比：<code>reshardCollection</code> 可逆 vs 不可逆</li>
<li>跟 1.x 章節：<a href="/blog/backend/01-database/kv-document-capacity-planning/" data-link-title="1.10 KV / Document DB 容量規劃" data-link-desc="DynamoDB / Cosmos DB / Bigtable / MongoDB 等 KV / Document DB 的容量設計、partition key 取捨、capacity mode 選擇">1.10 KV / Document DB 容量規劃</a> / <a href="/blog/backend/01-database/large-scale-db-migration/" data-link-title="1.12 大規模 DB 遷移實戰" data-link-desc="跨 DB 遷移的 dual-write、[shadow read](/backend/knowledge-cards/shadow-read/)、cutover、rollback 流程 — 從實戰案例提煉的工程做法">1.12 大規模 DB 遷移實戰</a></li>
<li>Knowledge cards：<a href="/blog/backend/knowledge-cards/hot-partition/" data-link-title="Hot Partition" data-link-desc="說明分散式 KV / OLTP 中、單一 partition 流量遠超其他的容量問題">Hot Partition</a> / <a href="/blog/backend/knowledge-cards/database-sharding/" data-link-title="Database Sharding" data-link-desc="說明資料庫如何依 shard key 分散資料、路由請求與承擔跨 shard 查詢成本">Database Sharding</a></li>
<li>Anti-recommendation：小流量（&lt; 1000 RU/s 預期）不必過度設計 synthetic key、Cosmos DB autoscale + 簡單 partition key 即可；過度 design 比 under-design 更常見的成本浪費</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/cosmosdb/" data-link-title="Azure Cosmos DB" data-link-desc="全球分散式 multi-model DB、5 個 consistency levels、Microsoft 自家 dogfood 證據">Cosmos DB vendor overview</a> — 本文是該頁尾 partition key design backlog 的深度展開</li>
<li><a href="/blog/backend/09-performance-capacity/cases/minecraft-earth-cosmos-db-global/" data-link-title="9.C11 Minecraft Earth：Azure Cosmos DB 上的全球分散式 AR 遊戲" data-link-desc="Minecraft Earth 用 Cosmos DB 跨地區分散、測試到 100 萬 RU/s 仍維持承諾延遲">9.C11 Minecraft Earth case</a> — synthetic partition key 主案例</li>
<li><a href="/blog/backend/09-performance-capacity/cases/asos-cosmos-db-black-friday/" data-link-title="9.C21 ASOS：Cosmos DB 在 Black Friday 撐 1.67 億請求" data-link-desc="ASOS 在 2016 Black Friday 用 Azure Cosmos DB 撐 24 小時 1.67 億請求、3500 req/sec、48ms 平均延遲">9.C21 ASOS case</a> — latency budget 拆解 + 全球零售流量分散</li>
<li><a href="/blog/backend/knowledge-cards/hot-partition/" data-link-title="Hot Partition" data-link-desc="說明分散式 KV / OLTP 中、單一 partition 流量遠超其他的容量問題">Hot Partition 卡片</a> / <a href="/blog/backend/knowledge-cards/database-sharding/" data-link-title="Database Sharding" data-link-desc="說明資料庫如何依 shard key 分散資料、路由請求與承擔跨 shard 查詢成本">Database Sharding 卡片</a> — 概念基底</li>
<li>官方：<a href="https://learn.microsoft.com/azure/cosmos-db/partitioning-overview">Cosmos DB partitioning</a> / <a href="https://learn.microsoft.com/azure/cosmos-db/hierarchical-partition-keys">Hierarchical partition keys</a></li>
</ul>
]]></content:encoded></item><item><title>CockroachDB Multi-region Table 配置：三種 table locality 的選擇與 latency / 一致性取捨</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/cockroachdb/multi-region-table-config/</link><pubDate>Tue, 02 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/cockroachdb/multi-region-table-config/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/cockroachdb/" data-link-title="CockroachDB" data-link-desc="分散式 SQL、PostgreSQL 相容、跨區強一致、Spanner 的開源 / 跨雲替代">CockroachDB vendor overview&lt;/a> 的 implementation-layer deep article。寫作參照 &lt;a href="https://tarrragon.github.io/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">vendor deep article methodology&lt;/a>。本文聚焦 &lt;em>三種 table locality 怎麼選、選錯的 latency / 一致性後果與重配代價&lt;/em>。Schema 怎麼配合 locality 設計（合規 boundary、跨州業務邏輯、Outposts 拓樸）主寫於 &lt;a href="../locality-aware-schema/">locality-aware schema&lt;/a>、survival goal 的存活機制主寫於 &lt;a href="../survival-goals/">survival goals&lt;/a>、本文兩者都 cross-link、不重複展開。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="問題情境multi-region-cluster-起來了每張-table-該設哪種-locality">問題情境：multi-region cluster 起來了、每張 table 該設哪種 locality&lt;/h2>
&lt;p>團隊把 CockroachDB 跨 region 拉起來、&lt;code>ALTER DATABASE ... ADD REGION&lt;/code> 也跑完了，接下來面對的是逐張 table 的 locality 決策。這個決策的成本結構很不對稱：設對了，read / write 走本地 leaseholder、latency 貼著單區水準；設錯了，每次寫入或讀取都吃一趟跨 region round trip，p99 從個位數毫秒跳到上百毫秒。&lt;/p>
&lt;p>multi-region table locality 是 &lt;em>把「資料的地理歸屬」跟「讀寫路徑」綁在一起&lt;/em> 的宣告。CockroachDB 提供三種 locality，對應三種「資料屬於誰、誰要快」的業務形狀：&lt;/p>
&lt;ul>
&lt;li>&lt;code>REGIONAL BY TABLE&lt;/code>：整張 table 歸屬單一 region，該 region 讀寫快、其他 region 慢。&lt;/li>
&lt;li>&lt;code>REGIONAL BY ROW&lt;/code>：每一 row 各自歸屬一個 region，row 所在 region 讀寫快。&lt;/li>
&lt;li>&lt;code>GLOBAL&lt;/code>：資料屬於所有 region，每個 region 本地讀都快，但寫入要跨 region 達成共識。&lt;/li>
&lt;/ul>
&lt;p>讀者進來最常卡的三題：&lt;/p>
&lt;ul>
&lt;li>三種 locality 對應什麼業務形狀、判讀軸是什麼？&lt;/li>
&lt;li>&lt;code>GLOBAL&lt;/code> 既然每區讀都快，為什麼不全部設 &lt;code>GLOBAL&lt;/code>？&lt;/li>
&lt;li>上線後發現 locality 設錯，重配的代價有多高、能不能無痛改？&lt;/li>
&lt;/ul>
&lt;p>這三題都是 &lt;em>把業務的資料歸屬與讀寫熱點，翻譯成副本拓樸&lt;/em> 的設計決策，語法層面反而簡單。&lt;/p>
&lt;p>問題情境最常見的 trigger：&lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/netflix-cockroachdb-multi-region-fleet/" data-link-title="9.C40 Netflix：380&amp;#43; CockroachDB cluster 的 multi-active 拓樸艦隊" data-link-desc="Netflix 把 Cassandra 不夠用的 transactional workload 移到 CockroachDB、380&amp;#43; cluster / 60&amp;#43; 跨 region、含 Open Connect、studio cloud drive、gaming control plane">9.C40 Netflix&lt;/a> 的 60+ multi-region cluster、最大 Gaming cluster 48-node 跨 4 region。case 揭露一個反直覺判讀 — multi-region 的主要動機是 &lt;em>region failure 0 downtime&lt;/em>、不是降 latency；跨 region quorum 物理上會 &lt;em>增&lt;/em> 寫入 latency。這條判讀直接決定 table locality 怎麼設：當 multi-region 的目的是 survival 而非 latency，把高寫入 table 設成 &lt;code>GLOBAL&lt;/code>（跨區同步寫）就是把成本花在錯的地方。&lt;/p>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/hard-rock-digital-cockroachdb-sports-betting/" data-link-title="9.C41 Hard Rock Digital：CockroachDB on AWS Outposts、Wire Act 合規 &amp;#43; 跨州單一邏輯 DB" data-link-desc="Hard Rock Digital 用 CockroachDB 跨 AWS Outposts &amp;#43; US-East-1、Wire Act 強制資料留州、單一邏輯 DB 解多州 sportsbook、100 node 32 vCPU 撐 Super Bowl">9.C41 Hard Rock Digital&lt;/a> 則提供 row-level 歸屬的 concrete framing：跨 8 州 sportsbook、bet 資料按下注州歸屬、邏輯上仍是一個 cluster。case 觀察段揭露「跨所有 region 一個 logical database」這個拓樸 fact — 也就是 row-level locality 撐起了「合規分州 placement + 單一邏輯 DB」的組合。Hard Rock 的合規驅動與 schema 設計細節在 &lt;a href="../locality-aware-schema/">locality-aware schema&lt;/a> 展開，本文只取「row-level 歸屬」這個 locality 選擇本身。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/cockroachdb/" data-link-title="CockroachDB" data-link-desc="分散式 SQL、PostgreSQL 相容、跨區強一致、Spanner 的開源 / 跨雲替代">CockroachDB vendor overview</a> 的 implementation-layer deep article。寫作參照 <a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">vendor deep article methodology</a>。本文聚焦 <em>三種 table locality 怎麼選、選錯的 latency / 一致性後果與重配代價</em>。Schema 怎麼配合 locality 設計（合規 boundary、跨州業務邏輯、Outposts 拓樸）主寫於 <a href="../locality-aware-schema/">locality-aware schema</a>、survival goal 的存活機制主寫於 <a href="../survival-goals/">survival goals</a>、本文兩者都 cross-link、不重複展開。</p></blockquote>
<hr>
<h2 id="問題情境multi-region-cluster-起來了每張-table-該設哪種-locality">問題情境：multi-region cluster 起來了、每張 table 該設哪種 locality</h2>
<p>團隊把 CockroachDB 跨 region 拉起來、<code>ALTER DATABASE ... ADD REGION</code> 也跑完了，接下來面對的是逐張 table 的 locality 決策。這個決策的成本結構很不對稱：設對了，read / write 走本地 leaseholder、latency 貼著單區水準；設錯了，每次寫入或讀取都吃一趟跨 region round trip，p99 從個位數毫秒跳到上百毫秒。</p>
<p>multi-region table locality 是 <em>把「資料的地理歸屬」跟「讀寫路徑」綁在一起</em> 的宣告。CockroachDB 提供三種 locality，對應三種「資料屬於誰、誰要快」的業務形狀：</p>
<ul>
<li><code>REGIONAL BY TABLE</code>：整張 table 歸屬單一 region，該 region 讀寫快、其他 region 慢。</li>
<li><code>REGIONAL BY ROW</code>：每一 row 各自歸屬一個 region，row 所在 region 讀寫快。</li>
<li><code>GLOBAL</code>：資料屬於所有 region，每個 region 本地讀都快，但寫入要跨 region 達成共識。</li>
</ul>
<p>讀者進來最常卡的三題：</p>
<ul>
<li>三種 locality 對應什麼業務形狀、判讀軸是什麼？</li>
<li><code>GLOBAL</code> 既然每區讀都快，為什麼不全部設 <code>GLOBAL</code>？</li>
<li>上線後發現 locality 設錯，重配的代價有多高、能不能無痛改？</li>
</ul>
<p>這三題都是 <em>把業務的資料歸屬與讀寫熱點，翻譯成副本拓樸</em> 的設計決策，語法層面反而簡單。</p>
<p>問題情境最常見的 trigger：<a href="/blog/backend/09-performance-capacity/cases/netflix-cockroachdb-multi-region-fleet/" data-link-title="9.C40 Netflix：380&#43; CockroachDB cluster 的 multi-active 拓樸艦隊" data-link-desc="Netflix 把 Cassandra 不夠用的 transactional workload 移到 CockroachDB、380&#43; cluster / 60&#43; 跨 region、含 Open Connect、studio cloud drive、gaming control plane">9.C40 Netflix</a> 的 60+ multi-region cluster、最大 Gaming cluster 48-node 跨 4 region。case 揭露一個反直覺判讀 — multi-region 的主要動機是 <em>region failure 0 downtime</em>、不是降 latency；跨 region quorum 物理上會 <em>增</em> 寫入 latency。這條判讀直接決定 table locality 怎麼設：當 multi-region 的目的是 survival 而非 latency，把高寫入 table 設成 <code>GLOBAL</code>（跨區同步寫）就是把成本花在錯的地方。</p>
<p><a href="/blog/backend/09-performance-capacity/cases/hard-rock-digital-cockroachdb-sports-betting/" data-link-title="9.C41 Hard Rock Digital：CockroachDB on AWS Outposts、Wire Act 合規 &#43; 跨州單一邏輯 DB" data-link-desc="Hard Rock Digital 用 CockroachDB 跨 AWS Outposts &#43; US-East-1、Wire Act 強制資料留州、單一邏輯 DB 解多州 sportsbook、100 node 32 vCPU 撐 Super Bowl">9.C41 Hard Rock Digital</a> 則提供 row-level 歸屬的 concrete framing：跨 8 州 sportsbook、bet 資料按下注州歸屬、邏輯上仍是一個 cluster。case 觀察段揭露「跨所有 region 一個 logical database」這個拓樸 fact — 也就是 row-level locality 撐起了「合規分州 placement + 單一邏輯 DB」的組合。Hard Rock 的合規驅動與 schema 設計細節在 <a href="../locality-aware-schema/">locality-aware schema</a> 展開，本文只取「row-level 歸屬」這個 locality 選擇本身。</p>
<h2 id="核心機制三種-locality-的判讀軸--survival-goal-互動">核心機制：三種 locality 的判讀軸 + survival goal 互動</h2>
<p>三種 table locality 的差異，本質是 <em>leaseholder（讀寫入口）跟資料歸屬 region 之間的關係</em>。leaseholder 機制屬前置、見 <a href="../hlc-raft-consensus/">HLC + Raft consensus</a>；本文聚焦三種 locality 把 leaseholder 放在哪、因此誰快誰慢。</p>
<h3 id="判讀軸資料歸屬的顆粒--讀寫熱點分佈">判讀軸：資料歸屬的顆粒 × 讀寫熱點分佈</h3>
<p>選 locality 的第一個判讀軸是 <em>資料歸屬的顆粒</em>：整張 table 屬於一個 region（table 級），還是每 row 各屬一個 region（row 級），還是屬於所有 region（global）。第二個判讀軸是 <em>讀寫熱點落在哪</em>：本地讀為主、本地寫為主、還是全球讀為主。</p>
<table>
  <thead>
      <tr>
          <th>Locality</th>
          <th>資料歸屬顆粒</th>
          <th>Read 快的條件</th>
          <th>Write 快的條件</th>
          <th>對應業務形狀</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>REGIONAL BY TABLE</code></td>
          <td>整張 table 一個 region</td>
          <td>從歸屬 region 讀</td>
          <td>從歸屬 region 寫</td>
          <td>整張表服務單一市場（例：日本訂單表）</td>
      </tr>
      <tr>
          <td><code>REGIONAL BY ROW</code></td>
          <td>每 row 一個 region</td>
          <td>從 row 歸屬 region 讀</td>
          <td>從 row 歸屬 region 寫</td>
          <td>資料跟用戶地理綁定（玩家、帳戶、訂單）</td>
      </tr>
      <tr>
          <td><code>GLOBAL</code></td>
          <td>所有 region 共有</td>
          <td>任何 region 本地讀都快</td>
          <td>沒有「快」的寫（跨區共識）</td>
          <td>reference data（國碼、貨幣、規則表）</td>
      </tr>
  </tbody>
</table>
<p>每一格的判讀都要回到該情境，不能只看表。</p>
<p><code>REGIONAL BY TABLE</code> 適合 <em>整張表的讀寫熱點集中在單一 region</em> 的情況。例如一張只服務日本市場的訂單表，把整張表的 leaseholder 釘在 <code>asia-northeast1</code>，日本端的應用讀寫都走本地 leaseholder，跨區應用偶爾讀則走 follower read 接受 stale。判讀訊號：這張表的寫入請求是否 95% 以上來自同一 region。如果不是，table 級歸屬會讓多數寫入吃跨區延遲。</p>
<p><code>REGIONAL BY ROW</code> 適合 <em>每一 row 跟某個地理位置強綁定、但整張表跨多 region</em> 的情況。玩家帳戶、訂單、下注紀錄都屬於這類 — 每筆資料屬於某個用戶所在 region，但整張表服務所有 region 的用戶。row 透過隱含的 <code>crdb_region</code> 欄位決定歸屬，leaseholder 跟著 row 走。判讀訊號：同一張表的不同 row，讀寫熱點是否分散在不同 region。是的話，row 級歸屬讓每個 row 都貼著自己的用戶。</p>
<p><code>GLOBAL</code> 適合 <em>讀遠多於寫、且每個 region 都要本地快讀</em> 的 reference data。國家代碼、貨幣表、運動賽事 metadata 這類資料變更稀少、但每個 region 的每次查詢都要用到。<code>GLOBAL</code> 讓每個 region 都能本地讀（讀到 closed timestamp 前的一致快照），代價是寫入要跨 region 達成共識。判讀訊號：寫入頻率是否低到「跨區寫的慢可以忽略」。</p>
<h3 id="為什麼不全部設-global">為什麼不全部設 GLOBAL</h3>
<p><code>GLOBAL</code> 的「每區讀都快」看似適合全表套用，但它對 <em>寫入</em> 收取跨 region quorum 的全額成本。<code>GLOBAL</code> table 的讀之所以能本地完成，是因為 CockroachDB 維護一個全球同步的 closed timestamp，讓每個 region 都能安全地本地讀稍早的快照；維護這個 timestamp 的代價是每次寫入都要跟所有 region 協調。</p>
<blockquote>
<p><strong>Scope warning</strong>：<code>GLOBAL</code> table 的跨 region 寫入 p99、<code>REGIONAL BY ROW</code> 的本地寫入 p99、closed timestamp 的傳播間隔等具體數字，屬 vendor 規格與部署拓樸（region 距離、replica 數）的函數，三個 anchor case（DoorDash / Netflix / Hard Rock）都未揭露單一 table 的 latency 數字。本文只給量級判讀（本地 quorum vs 跨洲 quorum 差一到兩個數量級），具體值需 benchmark 自身拓樸並 cross-verify <a href="https://www.cockroachlabs.com/docs/stable/table-localities.html">CockroachDB Table Localities 文件</a>。</p></blockquote>
<p>因此「全部設 <code>GLOBAL</code>」會把所有寫入推上跨 region 路徑，等於放棄了 distributed SQL 把寫入分散到各 region 的核心優勢。<code>GLOBAL</code> 的正確用法是限定在 <em>變更頻率低、全球都要快讀</em> 的 reference data。</p>
<h3 id="survival-goal-怎麼跟-locality-一起決定副本拓樸">Survival goal 怎麼跟 locality 一起決定副本拓樸</h3>
<p>table locality 決定 <em>leaseholder 放哪、讀寫走哪條路徑</em>；survival goal 決定 <em>副本要分佈到幾個 failure domain 才能在故障後存活</em>。兩者一起決定每張 table 的副本拓樸。</p>
<p>survival goal 的存活機制本身（<code>SURVIVE ZONE FAILURE</code> vs <code>SURVIVE REGION FAILURE</code>、怎麼從業務 SLO 倒推、RTO / RPO 怎麼算）是 <a href="../survival-goals/">survival goals</a> 的 SSoT，本文不重複展開。本文只取兩者 <em>互動</em> 的一個關鍵後果：把 <code>SURVIVE REGION FAILURE</code> 套到 <code>REGIONAL BY ROW</code> table 時，每個 region 的 row 不只需要本地 voting replica，還需要在 <em>其他 region</em> 放足夠的 voting replica 才能在整個 region 失效後仍達成 quorum。這會把跨 region 的 voting replica 數量推高，間接增加寫入要協調的範圍。</p>
<p>判讀路線：先依業務的資料歸屬與讀寫熱點選 locality（本文），再依業務的 region failure 容忍度選 survival goal（<a href="../survival-goals/">survival goals</a>），兩者疊加後才得到最終副本拓樸與 latency 結構。</p>
<h2 id="操作流程配置驗證每步檢查生效">操作流程：配置、驗證、每步檢查生效</h2>
<h3 id="第一步確認-database-已加入所有-region">第一步：確認 database 已加入所有 region</h3>
<p>table locality 的前提是 database 已宣告 region。先確認 region 列表正確，再設 table locality。</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">-- 看 database 已有哪些 region、哪個是 primary
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SHOW</span><span class="w"> </span><span class="n">REGIONS</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="k">DATABASE</span><span class="w"> </span><span class="n">mydb</span><span class="p">;</span></span></span></code></pre></div><p>驗證點：輸出的 region 數量與名稱要對齊實際部署的 region。少一個 region，後面把 table 設成該 region 的 <code>REGIONAL BY TABLE</code> 會直接報錯。</p>
<h3 id="第二步依判讀軸設定每張-table-的-locality">第二步：依判讀軸設定每張 table 的 locality</h3>





<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">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">orders_jp</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="n">LOCALITY</span><span class="w"> </span><span class="n">REGIONAL</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="k">IN</span><span class="w"> </span><span class="s2">&#34;asia-northeast1&#34;</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></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="c1">-- 資料跟用戶地理綁定
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="k">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">accounts</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="n">LOCALITY</span><span class="w"> </span><span class="n">REGIONAL</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="k">ROW</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></span><span class="line"><span class="ln">7</span><span class="cl"><span class="w"></span><span class="c1">-- 低寫入、全球本地讀的 reference data
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1"></span><span class="k">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">currency_codes</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="n">LOCALITY</span><span class="w"> </span><span class="k">GLOBAL</span><span class="p">;</span></span></span></code></pre></div><p>驗證點：</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">-- 確認每張 table 的 locality 設定符合預期
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SHOW</span><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">accounts</span><span class="p">;</span><span class="w">   </span><span class="c1">-- locality 子句會出現在輸出尾段</span></span></span></code></pre></div><h3 id="第三步驗證讀寫路徑真的走本地">第三步：驗證讀寫路徑真的走本地</h3>
<p>設了 locality 不代表查詢真的走本地路徑 — 寫入時 row 的 <code>crdb_region</code> 沒設對、或 query 沒帶上對應條件，仍會跨區。用 <code>EXPLAIN ANALYZE</code> 看實際 plan。</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">-- 看 query 是否在 row 歸屬 region 本地完成、有沒有跨 region 拉資料
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">EXPLAIN</span><span class="w"> </span><span class="k">ANALYZE</span><span class="w"> </span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">accounts</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">$</span><span class="mi">1</span><span class="p">;</span></span></span></code></pre></div><p>驗證點：plan 中不應出現大量跨 region 的 distributed scan；<code>REGIONAL BY ROW</code> 的點查應落在 row 歸屬 region 的單一 leaseholder。</p>
<h3 id="第四步驗證副本分佈符合-locality--survival-goal">第四步：驗證副本分佈符合 locality + survival goal</h3>





<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">-- 看每張 table 的 range 副本實際分佈在哪些 region
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SHOW</span><span class="w"> </span><span class="n">RANGES</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">accounts</span><span class="p">;</span></span></span></code></pre></div><p>驗證點：副本分佈要同時滿足 locality（leaseholder 在歸屬 region）跟 survival goal（跨足夠 failure domain）。兩者衝突時，CockroachDB 以 survival goal 為硬約束調整副本數，這會反過來影響 latency — 對應 <a href="../survival-goals/">survival goals</a> 的 latency 暴漲失敗模式。</p>
<h2 id="失敗模式locality-選錯的高代價回退">失敗模式：locality 選錯的高代價回退</h2>
<h3 id="global-套到高寫入-table"><code>GLOBAL</code> 套到高寫入 table</h3>
<p>把高寫入 table（訂單、下注、status 變更）設成 <code>GLOBAL</code>，每筆寫入都跨 region 共識，寫入 p99 結構性暴漲、寫入吞吐被跨區協調卡死。徵兆：CockroachDB Console 的跨 region network traffic 隨寫入量線性成長、寫入 p99 跟 region 距離正相關。</p>
<p>修法：把 table 改成 <code>REGIONAL BY ROW</code>（按用戶歸屬）或 <code>REGIONAL BY TABLE</code>（按市場歸屬）。</p>
<p>Anti-recommendation：reference data 之外的任何 table，預設都不要設 <code>GLOBAL</code>。<code>GLOBAL</code> 的判準是「寫入頻率低到跨區寫的慢可以忽略」，高寫入 workload 直接排除。</p>
<h3 id="regional-by-row-但-row-沒帶正確-crdb_region"><code>REGIONAL BY ROW</code> 但 row 沒帶正確 <code>crdb_region</code></h3>
<p><code>REGIONAL BY ROW</code> 靠 <code>crdb_region</code> 決定 row 歸屬。寫入時沒顯式指定，default 走 <code>gateway_region()</code> — application server 所在 region 變成 row 歸屬。後果是 row 被釘在 application server 那一區，而非用戶所在區，locality 形同失效（甚至在合規場景違反 data residency，見 <a href="../locality-aware-schema/">locality-aware schema</a>）。</p>
<p>修法：寫入時顯式指定 <code>crdb_region</code> 為用戶所在 region，並用 NOT NULL + CHECK constraint 把可選值鎖死。</p>
<h3 id="選錯-locality-的重配代價高代價不可逆情境的回退敘事">選錯 locality 的重配代價（高代價不可逆情境的回退敘事）</h3>
<p>table locality 選錯，重配本身語法上一行就能改（<code>ALTER TABLE ... SET LOCALITY ...</code>），但 <em>資料層面的重配代價高且有持續影響</em>，需要專屬回退計畫，不能比照「改個 config 重啟」對待。</p>
<p>重配 locality 會觸發 CockroachDB 把受影響 range 的副本搬到新拓樸對應的位置。把一張大 table 從 <code>GLOBAL</code> 改成 <code>REGIONAL BY ROW</code>，或從 single region 改成 row-level 跨多 region，意味著大量 range 要 rebalance — 期間跨 region network 流量暴增、leaseholder 反覆換手、p99 持續波動，table 越大、region 越多，rebalance 窗口越長。這是隨資料量延長的背景過程，遠非秒級操作。</p>
<p>更關鍵的是 <code>REGIONAL BY ROW</code> 的 <code>crdb_region</code> 是 <em>資料內容</em>，不只是 metadata。如果原本 row 的歸屬區設錯（例如全部落到 application server 那一區），重配 locality 不會自動把 row 搬到正確的用戶 region — 還要 <em>回填 <code>crdb_region</code> 欄位</em>，這是一次 data migration，不是 schema 變更。合規場景下，錯誤歸屬期間寫入的資料可能已經違反 data residency，回退時要連同合規證據一起盤點。</p>
<p>回退計畫的要素：</p>
<ul>
<li>重配前估算受影響 range 數量與資料量，換算 rebalance 窗口，選低流量時段執行。</li>
<li>重配 <code>REGIONAL BY ROW</code> 時，分開處理「locality 宣告變更」與「<code>crdb_region</code> 回填」兩個動作，回填走分批 update 並監控 contention。</li>
<li>重配期間監控 rebalance queue 與跨 region traffic，設好「波動超過閾值就暫停 rebalance」的 tripwire。</li>
<li>合規場景下，先盤點錯誤歸屬期間的資料是否已違規，再決定回填策略與是否需要合規通報。</li>
</ul>
<p>Anti-recommendation：不要在 production 高峰時段直接對大 table 改 locality 試效果。locality 是「上線前依業務形狀想清楚再設」的決策，不是「線上 A/B 試」的旋鈕。</p>
<h3 id="cross-region-join-跑爆-latency">Cross-region join 跑爆 latency</h3>
<p>兩張 <code>REGIONAL BY ROW</code> table join，若 join key 不保證兩邊 row 在同 region，planner 要跨 region 拉資料，p99 暴漲。</p>
<p>修法：兩張 table 用同一個歸屬 key（如 user_id），讓 join 對應的 row co-locate 在同 region；無法 co-locate 時，對容忍 stale 的查詢改走 follower read。</p>
<h2 id="容量與觀測">容量與觀測</h2>
<h3 id="必看-metric">必看 metric</h3>
<ul>
<li><code>Cross-region query count</code>：locality 是否生效的直接訊號，數值高代表查詢在跨區拉資料。</li>
<li><code>Leaseholder distribution by region</code>：leaseholder 是否落在資料歸屬 region，不均代表 locality 配置或 <code>crdb_region</code> 有偏。</li>
<li><code>Rebalance queue size</code>：locality 重配 / 副本搬遷期間的進度訊號，持續非零代表 rebalance 未收斂。</li>
<li><code>Cross-region network bytes</code>：<code>GLOBAL</code> table 寫入與 cross-region join 的成本訊號。</li>
</ul>
<h3 id="容量判讀">容量判讀</h3>
<ul>
<li><code>GLOBAL</code> table 的跨區寫入成本 ≈ 寫入 QPS × region 數，region 越多成本越高，所以 <code>GLOBAL</code> 只放低寫入 reference data。</li>
<li><code>REGIONAL BY ROW</code> 的跨區讀成本 ≈ 落到非歸屬 region 的讀 QPS，這部分若高，代表 <code>crdb_region</code> 歸屬與實際讀熱點不一致。</li>
<li>region 數量建議維持精簡 — 每多一個 region，跨區協調與重配窗口都變長。</li>
</ul>
<blockquote>
<p><strong>Scope warning</strong>：region 數量上限建議、單 range 寫入吞吐量級、closed timestamp 傳播間隔等為 vendor 通用估算，非 case 揭露數字，容量規劃前以 <a href="https://www.cockroachlabs.com/docs/stable/multiregion-overview.html">CockroachDB Multi-Region 文件</a> cross-verify 並 benchmark 自身拓樸。</p></blockquote>
<h3 id="回路徑">回路徑</h3>
<ul>
<li><a href="/blog/backend/09-performance-capacity/" data-link-title="模組九：效能工程與容量規劃" data-link-desc="把『目前配置能撐多少、要加多少機器』變成可量化、可驗證、可改進的工程流程">9.5 瓶頸定位流程</a> 判斷 cross-region-bound vs CPU-bound。</li>
<li><a href="/blog/backend/09-performance-capacity/" data-link-title="模組九：效能工程與容量規劃" data-link-desc="把『目前配置能撐多少、要加多少機器』變成可量化、可驗證、可改進的工程流程">9.6 容量規劃模型</a> region count × replica × latency budget。</li>
<li><a href="/blog/backend/knowledge-cards/latency-budget/" data-link-title="Latency Budget" data-link-desc="把 user-perceived latency 拆到每個 stage 的配額、反推架構選擇">latency budget 卡</a> 跨 region quorum 預算。</li>
</ul>
<h2 id="邊界與整合">邊界與整合</h2>
<h3 id="sibling-deep-articles">Sibling deep articles</h3>
<ul>
<li><a href="../locality-aware-schema/">locality-aware schema</a>：schema 怎麼配合 locality 設計 — 合規 boundary、跨州業務邏輯、Outposts 拓樸、<code>crdb_region</code> 作為合規欄位的管理。本文是「三種 locality 怎麼選」、該文是「選好後 schema 怎麼配合」，兩者互補不重複。</li>
<li><a href="../survival-goals/">survival goals</a>：survival goal 的存活機制與 SLO 倒推 — 本文只取「survival goal 與 locality 互動如何影響副本拓樸」這一個交點，存活機制本身以該文為 SSoT。</li>
<li><a href="../hlc-raft-consensus/">HLC + Raft consensus</a>：leaseholder 與 range 機制 — locality 決定 leaseholder 放哪，前置機制在該文。</li>
</ul>
<h3 id="跟-spanner--aurora-對照">跟 Spanner / Aurora 對照</h3>
<p>Spanner 在 GCP region 內做 placement，無 AWS Outposts 等效；Aurora 不支援 row-level locality，跨 region 只能 cluster-per-region + async replication。完整三家 distributed SQL 在 multi-region placement 的選型對比，是 <a href="../aurora-dsql-spanner-decision-tree/">aurora-dsql-spanner-decision-tree</a> 的 SSoT，本文不重展三方對比。</p>
<h3 id="1x-章節互引">1.x 章節互引</h3>
<ul>
<li><a href="/blog/backend/01-database/global-distributed-oltp/" data-link-title="1.11 全球分散式 OLTP" data-link-desc="Spanner / Aurora DSQL / Cosmos DB multi-region write / CockroachDB / TiDB 的全球一致性取捨">1.11 全球分散式 OLTP</a> 上游 latency / 一致性取捨。</li>
<li><a href="/blog/backend/knowledge-cards/stale-read/" data-link-title="Stale Read" data-link-desc="讀取到落後於最新寫入版本的舊資料">stale read 卡</a>、<a href="/blog/backend/knowledge-cards/follower-read/" data-link-title="Follower Read" data-link-desc="分散式 SQL 從 non-voting replica 讀 closed timestamp 之前的資料、不參與 Raft commit、低 latency 但 read-after-write 場景仍可能 stale">follower read 卡</a> — <code>GLOBAL</code> 與跨區讀的一致性語意。</li>
</ul>
<h3 id="何時不用本文">何時不用本文</h3>
<ul>
<li>single-region 部署：用 default locality 即可，三種 locality 在單區無差異。</li>
<li>從 PostgreSQL 遷到 CockroachDB 的整體流程：見 <a href="/blog/backend/01-database/vendors/postgresql/migrate-to-cockroachdb/" data-link-title="PostgreSQL → CockroachDB：三維皆 High 的多重歸類 migration" data-link-desc="PostgreSQL → CockroachDB 是 Schema / Operational / Paradigm 三維皆 High 的 multi-axis migration、實證 [#127](/report/content-structure-by-max-diff-dimension/) 的「多重歸類跟 tie-breaking」規則；主結構走 Type E paradigm shift、Schema 差 &#43; Operational redesign 抽出獨立段；涵蓋 transaction model 重設計、SQL dialect gap、5 個 production 踩雷">PostgreSQL → CockroachDB migration</a>，本文只處理遷移後的 table locality 配置。</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/cockroachdb/" data-link-title="CockroachDB" data-link-desc="分散式 SQL、PostgreSQL 相容、跨區強一致、Spanner 的開源 / 跨雲替代">CockroachDB vendor overview</a></li>
<li><a href="/blog/backend/09-performance-capacity/cases/netflix-cockroachdb-multi-region-fleet/" data-link-title="9.C40 Netflix：380&#43; CockroachDB cluster 的 multi-active 拓樸艦隊" data-link-desc="Netflix 把 Cassandra 不夠用的 transactional workload 移到 CockroachDB、380&#43; cluster / 60&#43; 跨 region、含 Open Connect、studio cloud drive、gaming control plane">9.C40 Netflix</a>（multi-region 動機是 survival 非 latency）</li>
<li><a href="/blog/backend/09-performance-capacity/cases/hard-rock-digital-cockroachdb-sports-betting/" data-link-title="9.C41 Hard Rock Digital：CockroachDB on AWS Outposts、Wire Act 合規 &#43; 跨州單一邏輯 DB" data-link-desc="Hard Rock Digital 用 CockroachDB 跨 AWS Outposts &#43; US-East-1、Wire Act 強制資料留州、單一邏輯 DB 解多州 sportsbook、100 node 32 vCPU 撐 Super Bowl">9.C41 Hard Rock Digital</a>（row-level 歸屬 + 單一邏輯 cluster）</li>
<li><a href="/blog/backend/knowledge-cards/distributed-sql/" data-link-title="Distributed SQL" data-link-desc="把 SQL 與交易語意延伸到多節點與多區域的資料庫形態">distributed SQL 卡</a></li>
<li>官方：<a href="https://www.cockroachlabs.com/docs/stable/table-localities.html">CockroachDB Table Localities</a> / <a href="https://www.cockroachlabs.com/docs/stable/multiregion-overview.html">Multi-Region Overview</a> / <a href="https://www.cockroachlabs.com/docs/stable/follower-reads.html">Follower Reads</a></li>
</ul>
]]></content:encoded></item><item><title>Cosmos DB 5 Consistency Levels：Session 預設、Bounded staleness、Strong 邊界跟跨 collection 分流策略</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/cosmosdb/consistency-levels-engineering/</link><pubDate>Wed, 27 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/cosmosdb/consistency-levels-engineering/</guid><description>&lt;p>Cosmos DB 文件列 &lt;em>5 個 consistency level&lt;/em>（Strong / Bounded staleness / Session / Consistent prefix / Eventual）、用 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/pacelc/" data-link-title="PACELC" data-link-desc="在 CAP 之外補上正常時段的延遲與一致性取捨框架">PACELC&lt;/a> 講概念、但沒給具體工程判準。team 啟動 Cosmos DB 第一個要決定的就是 account 預設 level、再決定哪些 query 要 per-request override。本文先講 5 個 level 的精確語義、再進 Session 為什麼是 production 預設、再進「同一 application 內不同操作選不同 level」的進階策略；&lt;em>Strong + multi-region write 互斥&lt;/em>議題 cross-link 到 &lt;a href="../multi-region-write-conflict/">multi-region-write-conflict&lt;/a>、本篇不展開。&lt;/p>
&lt;p>本文不是 Cosmos DB overview（請看 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/cosmosdb/" data-link-title="Azure Cosmos DB" data-link-desc="全球分散式 multi-model DB、5 個 consistency levels、Microsoft 自家 dogfood 證據">Cosmos DB vendor 頁&lt;/a>）— 而是 &lt;em>consistency level 工程選擇邏輯&lt;/em> 的深度展開。Case anchor 是 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/minecraft-earth-cosmos-db-global/" data-link-title="9.C11 Minecraft Earth：Azure Cosmos DB 上的全球分散式 AR 遊戲" data-link-desc="Minecraft Earth 用 Cosmos DB 跨地區分散、測試到 100 萬 RU/s 仍維持承諾延遲">9.C11 Minecraft Earth&lt;/a>（用 session consistency 撐 AR 全球同步、5 level 跨 collection 分流）+ &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/asos-cosmos-db-black-friday/" data-link-title="9.C21 ASOS：Cosmos DB 在 Black Friday 撐 1.67 億請求" data-link-desc="ASOS 在 2016 Black Friday 用 Azure Cosmos DB 撐 24 小時 1.67 億請求、3500 req/sec、48ms 平均延遲">9.C21 ASOS&lt;/a>（Black Friday 用較弱 consistency 換 throughput）。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>Cosmos DB workload 適配判讀（四層 framing）&lt;/strong>：API model 三型遷移路徑 / RU 思維轉換成本 / multi-model 差異化是否真用上 / 跨雲 hedging vs 單雲 lock-in — 判讀軸詳見 &lt;a href="../mongodb-api-vs-sql-api/#%e5%9b%9b%e5%b1%a4-framingvendor-selection-%e7%9a%84%e7%9c%9f%e5%af%a6%e6%b1%ba%e7%ad%96%e8%bb%b8">mongodb-api-vs-sql-api 開頭四層 framing&lt;/a>。本文聚焦 consistency level 選擇操作層、是 &lt;em>已選 Cosmos DB 後&lt;/em> 的 read / write 語義決策；若 workload 不適用 Cosmos DB、level 選擇無法救回 vendor 選錯的取捨。&lt;/p>&lt;/blockquote>
&lt;h2 id="問題情境">問題情境&lt;/h2>
&lt;p>典型觸發場景：team 啟動 Cosmos DB account、setup wizard 問「預設 consistency level」 — 5 個選項、文件講概念、不知道實際業務該選哪個。production 上線後使用者反映「加入購物車後立刻看『我的購物車』讀到舊狀態」、「跨 region 看到玩家瞬移回舊位置」 — debug 發現是 consistency level 沒選對。&lt;/p></description><content:encoded><![CDATA[<p>Cosmos DB 文件列 <em>5 個 consistency level</em>（Strong / Bounded staleness / Session / Consistent prefix / Eventual）、用 <a href="/blog/backend/knowledge-cards/pacelc/" data-link-title="PACELC" data-link-desc="在 CAP 之外補上正常時段的延遲與一致性取捨框架">PACELC</a> 講概念、但沒給具體工程判準。team 啟動 Cosmos DB 第一個要決定的就是 account 預設 level、再決定哪些 query 要 per-request override。本文先講 5 個 level 的精確語義、再進 Session 為什麼是 production 預設、再進「同一 application 內不同操作選不同 level」的進階策略；<em>Strong + multi-region write 互斥</em>議題 cross-link 到 <a href="../multi-region-write-conflict/">multi-region-write-conflict</a>、本篇不展開。</p>
<p>本文不是 Cosmos DB overview（請看 <a href="/blog/backend/01-database/vendors/cosmosdb/" data-link-title="Azure Cosmos DB" data-link-desc="全球分散式 multi-model DB、5 個 consistency levels、Microsoft 自家 dogfood 證據">Cosmos DB vendor 頁</a>）— 而是 <em>consistency level 工程選擇邏輯</em> 的深度展開。Case anchor 是 <a href="/blog/backend/09-performance-capacity/cases/minecraft-earth-cosmos-db-global/" data-link-title="9.C11 Minecraft Earth：Azure Cosmos DB 上的全球分散式 AR 遊戲" data-link-desc="Minecraft Earth 用 Cosmos DB 跨地區分散、測試到 100 萬 RU/s 仍維持承諾延遲">9.C11 Minecraft Earth</a>（用 session consistency 撐 AR 全球同步、5 level 跨 collection 分流）+ <a href="/blog/backend/09-performance-capacity/cases/asos-cosmos-db-black-friday/" data-link-title="9.C21 ASOS：Cosmos DB 在 Black Friday 撐 1.67 億請求" data-link-desc="ASOS 在 2016 Black Friday 用 Azure Cosmos DB 撐 24 小時 1.67 億請求、3500 req/sec、48ms 平均延遲">9.C21 ASOS</a>（Black Friday 用較弱 consistency 換 throughput）。</p>
<blockquote>
<p><strong>Cosmos DB workload 適配判讀（四層 framing）</strong>：API model 三型遷移路徑 / RU 思維轉換成本 / multi-model 差異化是否真用上 / 跨雲 hedging vs 單雲 lock-in — 判讀軸詳見 <a href="../mongodb-api-vs-sql-api/#%e5%9b%9b%e5%b1%a4-framingvendor-selection-%e7%9a%84%e7%9c%9f%e5%af%a6%e6%b1%ba%e7%ad%96%e8%bb%b8">mongodb-api-vs-sql-api 開頭四層 framing</a>。本文聚焦 consistency level 選擇操作層、是 <em>已選 Cosmos DB 後</em> 的 read / write 語義決策；若 workload 不適用 Cosmos DB、level 選擇無法救回 vendor 選錯的取捨。</p></blockquote>
<h2 id="問題情境">問題情境</h2>
<p>典型觸發場景：team 啟動 Cosmos DB account、setup wizard 問「預設 consistency level」 — 5 個選項、文件講概念、不知道實際業務該選哪個。production 上線後使用者反映「加入購物車後立刻看『我的購物車』讀到舊狀態」、「跨 region 看到玩家瞬移回舊位置」 — debug 發現是 consistency level 沒選對。</p>
<p>讀者徵兆：</p>
<ul>
<li>「Session 跟 Eventual 看起來差不多、為什麼 Session 是預設」</li>
<li>「Bounded staleness 的 K 跟 T 該設多少」</li>
<li>「Strong 在 multi-region account 為什麼有額外限制」</li>
<li>「跨 region read 拿到舊版本、是 consistency 設錯還是 partition key 問題」</li>
</ul>
<p>真實壓力：</p>
<ul>
<li>購物車場景：加入購物車後立刻看「我的購物車」、結果讀到舊狀態（user 體驗破洞）</li>
<li>遊戲場景：玩家位置同步、跨 region 看到「玩家瞬移」回舊位置（遊戲體驗 bug）</li>
<li>金融場景：跨服務寫入後立即 read confirm、看不到剛寫的 — 業務邏輯誤判「沒寫進去」、重試 / rollback</li>
</ul>
<p>consistency level 選錯不是 config 問題、是 <em>影響 user-facing 行為</em> 的 selection 決策、必須在 selection 階段釐清。</p>
<h2 id="核心機制5-個-level-的精確語義">核心機制：5 個 level 的精確語義</h2>
<h3 id="strong">Strong</h3>
<ul>
<li>機制：read 拿到最新 commit、提供 linearizable read</li>
<li>限制：<em>single-write region 限制</em>；multi-region write 不可同時用 Strong（時間敏感 claim、查 <a href="https://learn.microsoft.com/azure/cosmos-db/consistency-levels">最新文件</a>）；跨 region 配 Strong 還要付 <a href="/blog/backend/knowledge-cards/cross-region-quorum/" data-link-title="Cross-Region Quorum" data-link-desc="multi-region distributed SQL 強制 voting replica 跨 region、commit 等多 region quorum ack、跨洲 RTT 物理硬限">Cross-Region Quorum</a> 的物理 latency tax（跨洲 100-200ms）</li>
<li>適合：金融交易、庫存扣減、status 機器寫後 read confirm</li>
<li>為什麼互斥：詳見 <a href="../multi-region-write-conflict/">multi-region-write-conflict</a> 的 AP 取捨段、本篇不展開</li>
</ul>
<h3 id="bounded-staleness">Bounded staleness</h3>
<ul>
<li>機制：read 落後 <em>不超過 K 個 version 或 T 秒</em>（取較嚴格者）；單 region 內 linearizable、跨 region 有 bounded lag、跟 <a href="/blog/backend/knowledge-cards/freshness-token/" data-link-title="Freshness Token" data-link-desc="DB write 後返回的版本 token、後續 read 帶 token、保證 read 看到的資料 ≥ token 版本、解 DB &#43; cache 跨層 read-after-write">Freshness Token</a> 是兩種「跨層 read-after-write」協議的選擇（前者 vendor 內建、後者 application-level）</li>
<li>設定：K（version 上限）+ T（時間上限）兩個參數</li>
<li>適合：multi-region 但需要「有 bound 的 staleness 保證」、如 trading system 跨 region read with SLA</li>
</ul>
<h3 id="session預設最常用">Session（預設、最常用）</h3>
<ul>
<li>機制：同一 session token 內讀寫一致；session 之外 eventual</li>
<li>適合：<em>多數互動式產品的甜蜜點</em> — 使用者寫入後自己立刻讀得到、其他 session 可接受 eventual</li>
<li>為什麼是預設：cost 接近 eventual（不像 Strong 多 2x RU）、體驗接近 Strong（自己讀寫一致）— 是 trade-off 的甜蜜點</li>
</ul>
<h3 id="consistent-prefix">Consistent prefix</h3>
<ul>
<li>機制：read 不會看到亂序的寫入（看到 A→B→C、不會看到 A→C→B）、但可能落後</li>
<li>適合：時序敏感但可 stale 的場景（如新聞 feed 不能跳序、但可以晚幾秒）</li>
<li>風險：常被誤用為 Session 替代、跨 session 一樣 stale、但比 Eventual 多保證 <em>順序</em></li>
</ul>
<h3 id="eventual">Eventual</h3>
<ul>
<li>機制：最便宜、無順序保證</li>
<li>適合：完全可 stale + 不需順序的場景（分析、log 聚合、推薦系統）</li>
</ul>
<h3 id="跟-cosmos-db-account--container-的關係">跟 Cosmos DB account / container 的關係</h3>
<ul>
<li>account 預設一個 level</li>
<li>單一 request 可以 <em>降級</em>（讀更弱 level）、<em>不可升級</em>（讀更強）</li>
<li>container 層 <em>無法獨立設定 consistency level</em>（時間敏感、查最新文件）— 分流靠 <em>collection 切分</em> + <em>per-request override</em></li>
</ul>
<h3 id="ru-成本差異">RU 成本差異</h3>
<ul>
<li>Strong / Bounded read ≈ 2x Session / Eventual 的 <a href="/blog/backend/knowledge-cards/request-unit/" data-link-title="Request Unit" data-link-desc="Cosmos DB 的容量抽象單位、1 RU = 1KB document strong-consistent read 的 CPU &#43; memory &#43; IOPS 綜合 cost、寫 ~5 RU、複雜 query 數百 RU">Request Unit</a></li>
<li>write 成本不直接受 read level 影響、但 multi-region replication 開銷會（每多一個 region、寫成本 ×N）</li>
<li>selection 階段要把 consistency level 當「RU 倍數」進入容量公式、見 <a href="../ru-cost-model-sizing/">ru-cost-model-sizing</a></li>
</ul>
<h3 id="跟通用-consistency-卡片的對應">跟通用 consistency 卡片的對應</h3>
<p>Cosmos DB 是 <em>少數把 5 level 都商品化</em> 的服務、其他系統通常只給 2-3 級（MongoDB read concern majority / local / linearizable、DynamoDB strong / eventual）。對應 <a href="/blog/backend/knowledge-cards/consistency-level/" data-link-title="Consistency Level" data-link-desc="資料系統對讀寫一致性語意的可選擇層級">consistency-level</a> 卡片的概念分層。</p>
<p>跟 <a href="/blog/backend/knowledge-cards/linearizability/" data-link-title="Linearizability" data-link-desc="每次操作看起來都在單一全域順序中即時生效的一致性語意">linearizability</a> 的關係：Cosmos DB Strong = single-region linearizable、<em>不是</em> 跨 region external consistency（跟 Spanner 的 TrueTime + Paxos 不同）。這個區別是 selection 階段的常見誤判 — 別把 Cosmos DB Strong 當成 Spanner 替代品。</p>
<p>對應 knowledge cards：<a href="/blog/backend/knowledge-cards/consistency-level/" data-link-title="Consistency Level" data-link-desc="資料系統對讀寫一致性語意的可選擇層級">consistency-level</a> / <a href="/blog/backend/knowledge-cards/linearizability/" data-link-title="Linearizability" data-link-desc="每次操作看起來都在單一全域順序中即時生效的一致性語意">linearizability</a> / <a href="/blog/backend/knowledge-cards/stale-read/" data-link-title="Stale Read" data-link-desc="讀取到落後於最新寫入版本的舊資料">stale-read</a>。</p>
<h2 id="進階設計策略同一-application-內不同操作選不同-level">進階設計策略：同一 application 內不同操作選不同 level</h2>
<p>9.C11 Minecraft Earth 案例的平台特性段揭露「一致性是 spectrum、不是 binary」 — AR 遊戲玩家位置稍 stale OK（用 session / eventual）、庫存交易需要 strong；<em>同一 application 內不同 collection / container 配不同 consistency 是進階策略</em>、不一定是 account 一刀切。</p>
<p>container 層無法獨立設定 consistency level（時間敏感、查最新文件）、所以分流靠：</p>
<ul>
<li><strong>Collection / container 切分</strong>：高一致需求的資料放獨立 account、預設 Strong；低一致需求放另一 account、預設 Session</li>
<li><strong>Per-request override</strong>：account 預設 Session、特定「寫入後立即讀」場景升 Bounded、批次分析降 Eventual；用 SDK 的 <code>RequestOptions.ConsistencyLevel</code></li>
</ul>
<h3 id="per-request-override-範例c-sdk">Per-request override 範例（C# SDK）</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-csharp" data-lang="csharp"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// account 預設 Session</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1">// 但這個 read 需要 Bounded staleness</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="kt">var</span> <span class="n">response</span> <span class="p">=</span> <span class="k">await</span> <span class="n">container</span><span class="p">.</span><span class="n">ReadItemAsync</span><span class="p">&lt;</span><span class="n">Item</span><span class="p">&gt;(</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="n">id</span><span class="p">:</span> <span class="s">&#34;item-123&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="n">partitionKey</span><span class="p">:</span> <span class="k">new</span> <span class="n">PartitionKey</span><span class="p">(</span><span class="s">&#34;user-456&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="n">requestOptions</span><span class="p">:</span> <span class="k">new</span> <span class="n">ItemRequestOptions</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="n">ConsistencyLevel</span> <span class="p">=</span> <span class="n">ConsistencyLevel</span><span class="p">.</span><span class="n">BoundedStaleness</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="p">});</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1">// 批次分析、降到 Eventual 換成本</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="kt">var</span> <span class="n">queryOptions</span> <span class="p">=</span> <span class="k">new</span> <span class="n">QueryRequestOptions</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="n">ConsistencyLevel</span> <span class="p">=</span> <span class="n">ConsistencyLevel</span><span class="p">.</span><span class="n">Eventual</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">};</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="kt">var</span> <span class="n">iterator</span> <span class="p">=</span> <span class="n">container</span><span class="p">.</span><span class="n">GetItemQueryIterator</span><span class="p">&lt;</span><span class="n">Item</span><span class="p">&gt;(</span><span class="n">query</span><span class="p">,</span> <span class="n">requestOptions</span><span class="p">:</span> <span class="n">queryOptions</span><span class="p">);</span></span></span></code></pre></div><p>注意 <em>不可升級</em> 的限制：account 預設 Eventual、per-request 不能升 Strong（會 error）。要保留升級彈性、account 預設應該是 <em>最強需要的 level</em>、再 per-request 降級。</p>
<h3 id="跟-partition-key-design-的關係">跟 partition-key-design 的關係</h3>
<p>partition 失衡時即使設 Strong consistency 也看到 throttle、application 看到的是 <em>429 retry 後的高 latency</em>、不是 stale data — consistency level 跟 partition key 共同決定 <em>真實一致性體驗</em>。partition skew 把 Strong 的 SLA 拉到比 Session 還差、見 <a href="../partition-key-design/">partition-key-design</a> 的 latency budget 拆解段。</p>
<h2 id="操作流程">操作流程</h2>
<h3 id="account-層設定">account 層設定</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># Portal / ARM template / CLI</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">az cosmosdb update --name mycosmos --resource-group myrg <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --default-consistency-level Session</span></span></code></pre></div><p>切換 level 是即時生效、但 production 切換需要 audit 所有 client 的 session 邏輯（特別是 Strong → Session 的降級會讓「跨 session read 變 stale」）。</p>
<h3 id="request-層-override">Request 層 override</h3>
<p>SDK 傳 <code>RequestOptions.ConsistencyLevel</code>（C# / Java / Node SDK 行為一致）。注意 <em>只能降級</em>、升級會 reject。</p>
<h3 id="session-token-管理">Session token 管理</h3>
<p>每個 read response 帶 session token、client 下次 read 帶回去；跨 service 共享 token 需要顯式傳遞（不然每個 service 自己一個 session）。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-csharp" data-lang="csharp"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// 拿到 session token</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kt">var</span> <span class="n">response</span> <span class="p">=</span> <span class="k">await</span> <span class="n">container</span><span class="p">.</span><span class="n">ReadItemAsync</span><span class="p">&lt;</span><span class="n">Item</span><span class="p">&gt;(</span><span class="n">id</span><span class="p">,</span> <span class="n">pk</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="kt">var</span> <span class="n">sessionToken</span> <span class="p">=</span> <span class="n">response</span><span class="p">.</span><span class="n">Headers</span><span class="p">[</span><span class="s">&#34;x-ms-session-token&#34;</span><span class="p">];</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1">// 跨 service 傳遞（如 HTTP header）</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="n">httpClient</span><span class="p">.</span><span class="n">DefaultRequestHeaders</span><span class="p">.</span><span class="n">Add</span><span class="p">(</span><span class="s">&#34;X-Cosmos-Session-Token&#34;</span><span class="p">,</span> <span class="n">sessionToken</span><span class="p">);</span>
</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 class="c1">// 下游 service 取得 token、用在 SDK request</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="kt">var</span> <span class="n">requestOptions</span> <span class="p">=</span> <span class="k">new</span> <span class="n">ItemRequestOptions</span> <span class="p">{</span> <span class="n">SessionToken</span> <span class="p">=</span> <span class="n">sessionToken</span> <span class="p">};</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="kt">var</span> <span class="n">downstreamResponse</span> <span class="p">=</span> <span class="k">await</span> <span class="n">container</span><span class="p">.</span><span class="n">ReadItemAsync</span><span class="p">&lt;</span><span class="n">Item</span><span class="p">&gt;(</span><span class="n">id</span><span class="p">,</span> <span class="n">pk</span><span class="p">,</span> <span class="n">requestOptions</span><span class="p">);</span></span></span></code></pre></div><h3 id="驗證-level-行為">驗證 level 行為</h3>
<p>寫入後立即 read 同 partition key、量 staleness window。用 Cosmos DB Diagnostic Log 看 request 的實際 consistency level；對照 SDK 設定確認沒被預設 override。</p>
<h3 id="rollback-boundary">Rollback boundary</h3>
<p>account 預設可改、但 production 切換 level 需要 audit 所有 client 的 session 邏輯；container 層無法獨立設定（時間敏感、查最新文件）。</p>
<h2 id="失敗模式">失敗模式</h2>
<h3 id="failure-1全用-strong-consistency">Failure 1：全用 Strong consistency</h3>
<p>互動式產品 Session 即足夠、用 Strong 浪費 2x RU + 限制 multi-region write、cost 暴漲且 multi-region 配置受限。徵兆是「RU consumption 明顯偏高、且 multi-region write 開不起來」 — 才發現預設選 Strong。</p>
<p>修：</p>
<ul>
<li>盤點業務需求、絕大多數讀寫場景 Session 就夠</li>
<li>把需要 Strong 的少數 collection 拆獨立 account、其他 default Session</li>
<li>計算 cost：Session vs Strong 在多數 workload 差距 1.5-2x、長期成本顯著</li>
</ul>
<h3 id="failure-2session-token-沒回傳">Failure 2：Session token 沒回傳</h3>
<p>read 後拿 token、下次 read 沒帶、實際變 Eventual；徵兆是「自己的寫立刻 read 看不到」、debug 才發現 SDK 設定漏。SDK 預設會自動管理 session token、但跨 service 傳遞時容易漏。</p>
<p>修：</p>
<ul>
<li>同一 service 內用 SDK 預設行為、不要關 session token cache</li>
<li>跨 service 通信時把 session token 隨 HTTP header 傳遞</li>
<li>或改 account 層 Bounded staleness（提供跨 session 的 K/T bound、不依賴 token）</li>
</ul>
<h3 id="failure-3跨-service-共享-session-假設">Failure 3：跨 service 共享 session 假設</h3>
<p>service A 寫、service B 讀、B 沒拿到 A 的 session token → 看不到 A 的寫。常見場景：order service 寫訂單、notification service 立刻 read 訂單寄通知 — notification 沒拿到 order 的 token、讀到舊狀態（或讀不到）。</p>
<p>修：</p>
<ul>
<li>service A 寫完、把 session token 進 message（Kafka event / HTTP response）傳給 B</li>
<li>B 用 token 做 read、保證讀到 A 的寫</li>
<li>或業務上接受 eventual、design notification 有 retry / reconcile 機制</li>
</ul>
<h3 id="failure-4bounded-staleness-設太鬆">Failure 4：Bounded staleness 設太鬆</h3>
<p>K = 100,000、T = 1 hour、實際等於 Eventual、team 以為自己有保護。bounded staleness 的 K/T 要對應業務 SLA、不是 vendor 預設值。</p>
<p>修：</p>
<ul>
<li>根據業務 read-after-write SLA 設 T（如「5 秒內必須讀到」設 T=5）</li>
<li>K 通常設成「peak QPS × T」的合理倍數</li>
<li>量測：production 觀察實際 staleness 分布、調整 K/T</li>
</ul>
<h3 id="failure-5multi-region-write-配-strong">Failure 5：multi-region write 配 Strong</h3>
<p>文件不允許 / 行為退化（時間敏感、查最新）— 必須改 Bounded / Session。這是 <em>AP 取捨的硬約束</em>、不是 config 問題；詳見 <a href="../multi-region-write-conflict/">multi-region-write-conflict</a> 的 AP 取捨段。</p>
<p>修：在 selection 階段就決定「要 active-active write 還是要 Strong」、不能事後補；要全球 linearizable 轉 Spanner / Aurora DSQL、要 active-active 接受 eventual / session / bounded。</p>
<h3 id="failure-6consistent-prefix-誤用">Failure 6：Consistent prefix 誤用</h3>
<p>把它當 Session 用、跨 session read 還是 stale、但比 Eventual 多一個順序保證；用錯地方等於浪費。常見誤判：「我要『順序對』、所以選 Consistent prefix」 — 但實際業務需求是「自己讀到自己寫的」、應該是 Session 而非 Consistent prefix。</p>
<p>修：</p>
<ul>
<li>Consistent prefix 適合 <em>時序敏感但可跨 session stale</em> 場景（新聞 feed、event log）</li>
<li>「自己讀到自己寫的」場景用 Session</li>
<li>跨 session 也要強一致用 Bounded / Strong</li>
</ul>
<h2 id="容量與觀測">容量與觀測</h2>
<ul>
<li>必看 metric：<code>NormalizedRUConsumption</code>、<code>TotalRequestUnits</code>、<code>ReplicationLatency</code>（跨 region lag）</li>
<li>Diagnostic Log：每個 request 的實際 consistency level、確認沒被預設 override</li>
<li>成本計算：Strong / Bounded read 算 2x RU；multi-region 開後寫入成本 × region 數；level 跟 region 數的 cost matrix 是規劃必算</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>：consistency level 當「RU 倍數」進入容量公式</li>
<li>Alert：
<ul>
<li><code>ReplicationLatency</code> 突增（跨 region 同步異常）</li>
<li>Diagnostic log 偵測 Strong read 突增（成本失控）</li>
<li>跨 service session token 缺失導致 stale read 比例上升</li>
</ul>
</li>
</ul>
<h2 id="邊界與整合">邊界與整合</h2>
<ul>
<li>Sibling deep articles：<a href="../partition-key-design/">partition-key-design</a>（partition key 跟 consistency 共同決定真實一致性體驗）、<a href="../ru-cost-model-sizing/">ru-cost-model-sizing</a>（RU 倍數量化）、<a href="../multi-region-write-conflict/">multi-region-write-conflict</a>（multi-region 下 consistency 的特殊行為、Strong + multi-region 互斥的 SSoT 主寫位置）、<a href="../mongodb-api-vs-sql-api/">mongodb-api-vs-sql-api</a>（MongoDB read concern → Cosmos DB consistency level 對應）</li>
<li>跟 <a href="/blog/backend/01-database/vendors/spanner/" data-link-title="Google Cloud Spanner" data-link-desc="全球分散式 strong-consistency OLTP、TrueTime API、線性擴展到 10 億 req/sec">Spanner vendor</a> 對比：external consistency vs Cosmos DB Strong 不是同一個 thing</li>
<li>跟 <a href="/blog/backend/01-database/vendors/dynamodb/" data-link-title="DynamoDB" data-link-desc="AWS managed key-value、partition-based scaling、9000 萬 RPS sustained 實戰證據">DynamoDB vendor</a> 對比：DynamoDB 只 strong / eventual 兩級、Cosmos DB 5 級提供細粒度</li>
<li>跟 1.x 章節：<a href="/blog/backend/01-database/global-distributed-oltp/" data-link-title="1.11 全球分散式 OLTP" data-link-desc="Spanner / Aurora DSQL / Cosmos DB multi-region write / CockroachDB / TiDB 的全球一致性取捨">1.11 全球分散式 OLTP</a>（Cosmos DB 5 level 跟 Spanner external consistency 並陳）</li>
<li>Knowledge cards：<a href="/blog/backend/knowledge-cards/consistency-level/" data-link-title="Consistency Level" data-link-desc="資料系統對讀寫一致性語意的可選擇層級">consistency-level</a> / <a href="/blog/backend/knowledge-cards/linearizability/" data-link-title="Linearizability" data-link-desc="每次操作看起來都在單一全域順序中即時生效的一致性語意">linearizability</a> / <a href="/blog/backend/knowledge-cards/stale-read/" data-link-title="Stale Read" data-link-desc="讀取到落後於最新寫入版本的舊資料">stale-read</a></li>
<li>Anti-recommendation：別把 Cosmos DB Strong 跟 Spanner external consistency 等同視之；產品需要真正全球 linearizable transaction 時、Cosmos DB 不是替代品 — 轉 Spanner / Aurora DSQL</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/cosmosdb/" data-link-title="Azure Cosmos DB" data-link-desc="全球分散式 multi-model DB、5 個 consistency levels、Microsoft 自家 dogfood 證據">Cosmos DB vendor overview</a> — 本文是該頁尾 5 consistency levels backlog 的深度展開</li>
<li><a href="/blog/backend/09-performance-capacity/cases/minecraft-earth-cosmos-db-global/" data-link-title="9.C11 Minecraft Earth：Azure Cosmos DB 上的全球分散式 AR 遊戲" data-link-desc="Minecraft Earth 用 Cosmos DB 跨地區分散、測試到 100 萬 RU/s 仍維持承諾延遲">9.C11 Minecraft Earth case</a> — session consistency + 跨 collection 分流主案例</li>
<li><a href="/blog/backend/09-performance-capacity/cases/asos-cosmos-db-black-friday/" data-link-title="9.C21 ASOS：Cosmos DB 在 Black Friday 撐 1.67 億請求" data-link-desc="ASOS 在 2016 Black Friday 用 Azure Cosmos DB 撐 24 小時 1.67 億請求、3500 req/sec、48ms 平均延遲">9.C21 ASOS case</a> — 高 throughput + 較弱 level 補充</li>
<li><a href="../multi-region-write-conflict/">multi-region-write-conflict</a> — Strong + multi-region 互斥的 SSoT 主寫位置</li>
<li><a href="/blog/backend/knowledge-cards/consistency-level/" data-link-title="Consistency Level" data-link-desc="資料系統對讀寫一致性語意的可選擇層級">Consistency Level 卡片</a> / <a href="/blog/backend/knowledge-cards/linearizability/" data-link-title="Linearizability" data-link-desc="每次操作看起來都在單一全域順序中即時生效的一致性語意">Linearizability 卡片</a> / <a href="/blog/backend/knowledge-cards/stale-read/" data-link-title="Stale Read" data-link-desc="讀取到落後於最新寫入版本的舊資料">Stale Read 卡片</a> — 概念基底</li>
<li>官方：<a href="https://learn.microsoft.com/azure/cosmos-db/consistency-levels">Cosmos DB consistency levels</a> / <a href="https://learn.microsoft.com/azure/cosmos-db/how-to-manage-consistency">Consistency level overrides</a></li>
</ul>
]]></content:encoded></item><item><title>從自管 PostgreSQL / MySQL 遷到 Aurora：operational redesign migration playbook</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/aurora/migrate-from-self-managed-pg-mysql/</link><pubDate>Wed, 27 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/aurora/migrate-from-self-managed-pg-mysql/</guid><description>&lt;p>從自管 PostgreSQL / MySQL 遷到 Aurora 是 &lt;em>operational redesign hybrid&lt;/em>（Type C &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/migration/" data-link-title="Migration" data-link-desc="說明系統如何把資料、流量或結構從舊狀態移到新狀態">migration&lt;/a>）— wire protocol 相容、application 不改、但 HA / backup / monitoring / capacity 模型完全不同。本 playbook 走 &lt;a href="https://tarrragon.github.io/blog/posts/migration-playbook-%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84stage-0-variant-%E8%A6%8F%E5%8A%83%E6%8A%8A-collapse-%E7%8E%87%E5%BE%9E-60-%E9%99%8D%E5%88%B0-0/" data-link-title="Migration Playbook 方法論的演化紀錄：Stage 0 variant 規劃把 collapse 率從 60% 降到 0%" data-link-desc="跨 vendor migration playbook 需要獨立寫作方法論的依據，以及這套方法論從三輪 batch dogfood 中演化出來的驗證證據。">migration playbook 6 規格面&lt;/a>（Driver / Diff audit / Phase plan / Evidence / Cutover / Cleanup）、補三個 Aurora-specific 議題：(1) 合規禁止跨境複製的 no-go condition、(2) 合規驅動遷移的時程模型（市場數 × 平均審查月份）、(3) Aurora 不是 all-purpose store 邊界。每階段進入下一步前都要過 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/migration-gate/" data-link-title="Migration Gate" data-link-desc="說明遷移流程何時可以進入下一階段或正式切換">migration gate&lt;/a> — Evidence 段列出的證據是 gate 條件、不是 nice-to-have。&lt;/p>
&lt;p>本 playbook 不重複 Aurora overview（請看 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/aurora/" data-link-title="AWS Aurora" data-link-desc="AWS managed PostgreSQL / MySQL、storage / compute 分離、&amp;#43;75% 效能改善的 production 證據">Aurora vendor 頁&lt;/a>）— 前置閱讀建議 &lt;a href="../storage-architecture/">Aurora storage architecture&lt;/a>（理解為什麼 operational redesign）、&lt;a href="../cross-az-failover-rto/">Aurora cross-AZ failover RTO&lt;/a>（HA redesign 主項）、&lt;a href="../read-replica-scaling/">Aurora read replica scaling&lt;/a>（fleet 治理 SSoT、含合規 driver）。&lt;/p>
&lt;h2 id="migration-type-判定">Migration type 判定&lt;/h2>
&lt;p>本 playbook 是 &lt;em>Type C：Operational redesign hybrid&lt;/em>：&lt;/p>
&lt;ul>
&lt;li>PostgreSQL / MySQL → Aurora wire protocol 相容、application 多數不改&lt;/li>
&lt;li>但 operational model（HA / backup / monitoring / capacity）完全不同、需要 redesign&lt;/li>
&lt;li>跟 Type A schema translation 差：不需要翻譯 application SQL&lt;/li>
&lt;li>跟 Type B drop-in 差：HA / backup / monitoring / capacity 模型需要 redesign&lt;/li>
&lt;li>跟 Type E paradigm shift 差：保留 single-primary SQL 跟 ACID transaction 語意&lt;/li>
&lt;/ul>
&lt;p>對照其他 Aurora-related migration playbook：&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/migrate-to-aurora-dsql/" data-link-title="PostgreSQL → Aurora DSQL Migration：PG wire-compatible Distributed SQL 的 Paradigm Shift" data-link-desc="Aurora DSQL（2024-12 re:Invent preview / 2025-05 GA）是 AWS 推的 PG wire-compatible *active-active distributed SQL*、跟 self-managed PG / Aurora PG 不同 paradigm（OCC &amp;#43; snapshot isolation &amp;#43; multi-region strong consistency）。Migration 結構是 *protocol drop-in &amp;#43; paradigm shift*：app SQL 不太改、但 transaction retry / extension 缺位 / 多 region 一致性需重設計。本文走 DSQL vs Aurora PG vs self-managed PG 三軸對比、為什麼遷的三條 driver（global write / operational zero-touch / region resiliency）、Type E phased plan、5 production 踩雷（transaction retry 沒處理 / extension 缺位 / sequence throughput 限制 / Aurora PG 直升 DSQL 不可行 / region failover semantic）、跟 PG → Aurora 跟 PG → CockroachDB 對比">PG → Aurora DSQL&lt;/a> 是 Type E paradigm shift（distributed SQL、multi-region active-active）&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/migrate-to-cockroachdb/" data-link-title="PostgreSQL → CockroachDB：三維皆 High 的多重歸類 migration" data-link-desc="PostgreSQL → CockroachDB 是 Schema / Operational / Paradigm 三維皆 High 的 multi-axis migration、實證 [#127](/report/content-structure-by-max-diff-dimension/) 的「多重歸類跟 tie-breaking」規則；主結構走 Type E paradigm shift、Schema 差 &amp;#43; Operational redesign 抽出獨立段；涵蓋 transaction model 重設計、SQL dialect gap、5 個 production 踩雷">PG → CockroachDB&lt;/a> 是 Type E paradigm shift + cross-cloud&lt;/li>
&lt;/ul>
&lt;h2 id="driver為什麼遷">Driver：為什麼遷&lt;/h2>
&lt;h3 id="主要-driver">主要 driver&lt;/h3>
&lt;ul>
&lt;li>團隊規模成長、DBA bandwidth 飽和、backup / failover / patch 操作負擔超過產品價值&lt;/li>
&lt;li>Read replica scaling 需求（傳統 streaming replication lag 秒級、Aurora 10-30ms — 詳見 &lt;a href="../read-replica-scaling/">Aurora read replica scaling&lt;/a>）&lt;/li>
&lt;li>Storage growth 痛點（local SSD 上限、resize 要 downtime、Aurora 自動 grow 到 128 TB）&lt;/li>
&lt;/ul>
&lt;h3 id="次要-driver">次要 driver&lt;/h3>
&lt;ul>
&lt;li>HA model 簡化（Patroni / Orchestrator → Aurora cluster endpoint、見 &lt;a href="../cross-az-failover-rto/">cross-AZ failover RTO&lt;/a>）&lt;/li>
&lt;li>Backup 自動化（pgBackRest / xtrabackup → Aurora automated backup + PITR）&lt;/li>
&lt;li>Multi-region DR 需求（&lt;a href="../global-database-multi-region/">Aurora Global Database&lt;/a>、但合規場景例外）&lt;/li>
&lt;/ul>
&lt;h3 id="no-go-condition嚴格遵守">No-go condition（嚴格遵守）&lt;/h3>
&lt;p>跨雲 / on-prem 需求觸動 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/vendor-lock-in/" data-link-title="Vendor Lock-In" data-link-desc="說明採用供應商產品後，其 API 與格式滲入程式碼造成的退出成本">vendor lock-in&lt;/a> — Aurora storage layer 是 AWS 專屬、wire protocol 相容不代表退出成本低、long-term 跨雲策略未定時 self-managed PG / MySQL 反而保留路徑。&lt;/p></description><content:encoded><![CDATA[<p>從自管 PostgreSQL / MySQL 遷到 Aurora 是 <em>operational redesign hybrid</em>（Type C <a href="/blog/backend/knowledge-cards/migration/" data-link-title="Migration" data-link-desc="說明系統如何把資料、流量或結構從舊狀態移到新狀態">migration</a>）— wire protocol 相容、application 不改、但 HA / backup / monitoring / capacity 模型完全不同。本 playbook 走 <a href="/blog/posts/migration-playbook-%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84stage-0-variant-%E8%A6%8F%E5%8A%83%E6%8A%8A-collapse-%E7%8E%87%E5%BE%9E-60-%E9%99%8D%E5%88%B0-0/" data-link-title="Migration Playbook 方法論的演化紀錄：Stage 0 variant 規劃把 collapse 率從 60% 降到 0%" data-link-desc="跨 vendor migration playbook 需要獨立寫作方法論的依據，以及這套方法論從三輪 batch dogfood 中演化出來的驗證證據。">migration playbook 6 規格面</a>（Driver / Diff audit / Phase plan / Evidence / Cutover / Cleanup）、補三個 Aurora-specific 議題：(1) 合規禁止跨境複製的 no-go condition、(2) 合規驅動遷移的時程模型（市場數 × 平均審查月份）、(3) Aurora 不是 all-purpose store 邊界。每階段進入下一步前都要過 <a href="/blog/backend/knowledge-cards/migration-gate/" data-link-title="Migration Gate" data-link-desc="說明遷移流程何時可以進入下一階段或正式切換">migration gate</a> — Evidence 段列出的證據是 gate 條件、不是 nice-to-have。</p>
<p>本 playbook 不重複 Aurora overview（請看 <a href="/blog/backend/01-database/vendors/aurora/" data-link-title="AWS Aurora" data-link-desc="AWS managed PostgreSQL / MySQL、storage / compute 分離、&#43;75% 效能改善的 production 證據">Aurora vendor 頁</a>）— 前置閱讀建議 <a href="../storage-architecture/">Aurora storage architecture</a>（理解為什麼 operational redesign）、<a href="../cross-az-failover-rto/">Aurora cross-AZ failover RTO</a>（HA redesign 主項）、<a href="../read-replica-scaling/">Aurora read replica scaling</a>（fleet 治理 SSoT、含合規 driver）。</p>
<h2 id="migration-type-判定">Migration type 判定</h2>
<p>本 playbook 是 <em>Type C：Operational redesign hybrid</em>：</p>
<ul>
<li>PostgreSQL / MySQL → Aurora wire protocol 相容、application 多數不改</li>
<li>但 operational model（HA / backup / monitoring / capacity）完全不同、需要 redesign</li>
<li>跟 Type A schema translation 差：不需要翻譯 application SQL</li>
<li>跟 Type B drop-in 差：HA / backup / monitoring / capacity 模型需要 redesign</li>
<li>跟 Type E paradigm shift 差：保留 single-primary SQL 跟 ACID transaction 語意</li>
</ul>
<p>對照其他 Aurora-related migration playbook：</p>
<ul>
<li><a href="/blog/backend/01-database/vendors/postgresql/migrate-to-aurora-dsql/" data-link-title="PostgreSQL → Aurora DSQL Migration：PG wire-compatible Distributed SQL 的 Paradigm Shift" data-link-desc="Aurora DSQL（2024-12 re:Invent preview / 2025-05 GA）是 AWS 推的 PG wire-compatible *active-active distributed SQL*、跟 self-managed PG / Aurora PG 不同 paradigm（OCC &#43; snapshot isolation &#43; multi-region strong consistency）。Migration 結構是 *protocol drop-in &#43; paradigm shift*：app SQL 不太改、但 transaction retry / extension 缺位 / 多 region 一致性需重設計。本文走 DSQL vs Aurora PG vs self-managed PG 三軸對比、為什麼遷的三條 driver（global write / operational zero-touch / region resiliency）、Type E phased plan、5 production 踩雷（transaction retry 沒處理 / extension 缺位 / sequence throughput 限制 / Aurora PG 直升 DSQL 不可行 / region failover semantic）、跟 PG → Aurora 跟 PG → CockroachDB 對比">PG → Aurora DSQL</a> 是 Type E paradigm shift（distributed SQL、multi-region active-active）</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/migrate-to-cockroachdb/" data-link-title="PostgreSQL → CockroachDB：三維皆 High 的多重歸類 migration" data-link-desc="PostgreSQL → CockroachDB 是 Schema / Operational / Paradigm 三維皆 High 的 multi-axis migration、實證 [#127](/report/content-structure-by-max-diff-dimension/) 的「多重歸類跟 tie-breaking」規則；主結構走 Type E paradigm shift、Schema 差 &#43; Operational redesign 抽出獨立段；涵蓋 transaction model 重設計、SQL dialect gap、5 個 production 踩雷">PG → CockroachDB</a> 是 Type E paradigm shift + cross-cloud</li>
</ul>
<h2 id="driver為什麼遷">Driver：為什麼遷</h2>
<h3 id="主要-driver">主要 driver</h3>
<ul>
<li>團隊規模成長、DBA bandwidth 飽和、backup / failover / patch 操作負擔超過產品價值</li>
<li>Read replica scaling 需求（傳統 streaming replication lag 秒級、Aurora 10-30ms — 詳見 <a href="../read-replica-scaling/">Aurora read replica scaling</a>）</li>
<li>Storage growth 痛點（local SSD 上限、resize 要 downtime、Aurora 自動 grow 到 128 TB）</li>
</ul>
<h3 id="次要-driver">次要 driver</h3>
<ul>
<li>HA model 簡化（Patroni / Orchestrator → Aurora cluster endpoint、見 <a href="../cross-az-failover-rto/">cross-AZ failover RTO</a>）</li>
<li>Backup 自動化（pgBackRest / xtrabackup → Aurora automated backup + PITR）</li>
<li>Multi-region DR 需求（<a href="../global-database-multi-region/">Aurora Global Database</a>、但合規場景例外）</li>
</ul>
<h3 id="no-go-condition嚴格遵守">No-go condition（嚴格遵守）</h3>
<p>跨雲 / on-prem 需求觸動 <a href="/blog/backend/knowledge-cards/vendor-lock-in/" data-link-title="Vendor Lock-In" data-link-desc="說明採用供應商產品後，其 API 與格式滲入程式碼造成的退出成本">vendor lock-in</a> — Aurora storage layer 是 AWS 專屬、wire protocol 相容不代表退出成本低、long-term 跨雲策略未定時 self-managed PG / MySQL 反而保留路徑。</p>
<table>
  <thead>
      <tr>
          <th>條件</th>
          <th>為什麼是 no-go</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>跨雲 / on-prem 需求</td>
          <td>Aurora AWS-only、wire protocol 相容但 storage 是 AWS 專屬</td>
      </tr>
      <tr>
          <td>需要 latest upstream 特性</td>
          <td>Aurora 通常落後 upstream PostgreSQL / MySQL 1-2 major version</td>
      </tr>
      <tr>
          <td>預算極敏感</td>
          <td>Aurora 比 self-managed PostgreSQL / MySQL 貴 20-30%</td>
      </tr>
      <tr>
          <td>合規禁止跨境複製</td>
          <td>受監管市場 <a href="/blog/backend/knowledge-cards/data-residency/" data-link-title="Data Residency" data-link-desc="合規要求資料留在特定地理邊界內、跨境複製違反合規、推動 fleet 拓樸決策">Data Residency</a> <em>禁止跨境複製</em>、Aurora Global Database 在這種場景 <em>違反合規</em> — 要改用每市場獨立 cluster</td>
      </tr>
      <tr>
          <td>客製化 storage / I/O</td>
          <td>Aurora storage 是 AWS managed、不能客製化（vs self-managed 可以做 cgroup / quota / 自訂 storage 配置）</td>
      </tr>
  </tbody>
</table>
<p><strong>合規禁止跨境複製 no-go</strong>（<a href="/blog/backend/09-performance-capacity/cases/standard-chartered-aurora-banking/" data-link-title="9.C14 Standard Chartered：受監管銀行的 Aurora 4000 TPS 容量提升" data-link-desc="Standard Chartered 銀行遷移到 Aurora 後吞吐量提升 10 倍至 4000 TPS、跨 7 個受監管市場">9.C14 Standard Chartered 揭露</a>）：</p>
<p>受監管市場資料不能跨境複製、Aurora Global Database 在這種場景違反合規。讀者規劃 Aurora migration 時不能假設「Aurora 一定有 Global Database 選項」— 要改用每市場獨立 cluster（fleet 拓樸吸收合規邊界、見 <a href="../read-replica-scaling/">Aurora read replica scaling</a> fleet SSoT）。</p>
<h3 id="替代方案">替代方案</h3>
<ul>
<li><strong>RDS PostgreSQL / MySQL</strong>：更接近 upstream、單 AZ 便宜、不重寫 storage</li>
<li><strong>自管 + Patroni HA + pgBackRest</strong>：保留控制、跨雲可用</li>
<li><strong>CockroachDB / Aurora DSQL</strong>：multi-region active-active write 需求</li>
</ul>
<h3 id="case-anchor">Case anchor</h3>
<ul>
<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 Aurora consolidation</a>：多套 RDBMS 統一到 Aurora、driver 是 <em>operational consolidation</em>、不是純效能</li>
<li><a href="/blog/backend/09-performance-capacity/cases/draftkings-aurora-financial-ledger/" data-link-title="9.C4 DraftKings：Aurora 撐 100 萬 ops/min 的體育博彩金融帳本" data-link-desc="DraftKings 用 Aurora MySQL 跑體育博彩金融帳本、Super Bowl 流量 &#43;50% 不影響延遲">9.C4 DraftKings</a>：200 個 cluster、按業務切分（不是一個大 cluster + 200 schema）</li>
<li><a href="/blog/backend/09-performance-capacity/cases/standard-chartered-aurora-banking/" data-link-title="9.C14 Standard Chartered：受監管銀行的 Aurora 4000 TPS 容量提升" data-link-desc="Standard Chartered 銀行遷移到 Aurora 後吞吐量提升 10 倍至 4000 TPS、跨 7 個受監管市場">9.C14 Standard Chartered</a>：受監管場景、合規 lead time 是時程主項</li>
</ul>
<p><strong>Netflix scope warning（必引用）</strong>：</p>
<ul>
<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%、串流數十億小時">case「需要警惕」段第 2 點原文</a>：「Netflix 數據層遠不止 Aurora — 還有 Cassandra（playback metadata）、EVCache（cache layer）、Iceberg（data warehouse）。Aurora 主要是『需要 ACID 的 OLTP 工作負載』、不是『all-purpose store』」</li>
<li>工程含義：consolidation 是 <em>ACID OLTP 整合到 Aurora</em>、不是 <em>所有 store 整合到 Aurora</em></li>
<li>讀者規劃整合範圍時要明示什麼 workload 不在範圍（cache、analytics、time-series、search、KV 高峰）</li>
<li>「+75% performance improvement 是跨多 workload 的最大改善幅度、不是『每個 workload 都 +75%』。實際每個 workload 改善幅度從 10% 到 75% 不等」（case「需要警惕」段第 1 點）</li>
</ul>
<h2 id="diff-audit6-維-source--target-差異盤點">Diff audit：6 維 source / target 差異盤點</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>差異</th>
          <th>主導程度</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Schema</td>
          <td>PostgreSQL extension 相容性（pg_cron 改 Lambda / Step Functions、pg_partman 改 manual / native partitioning、TimescaleDB 不支援、PostGIS 支援）；MySQL plugin（HandlerSocket 不支援、audit plugin 改 CloudTrail）</td>
          <td>中</td>
      </tr>
      <tr>
          <td>Operational</td>
          <td>HA model、backup、monitoring、parameter management（postgresql.conf → DB parameter group / cluster parameter group）</td>
          <td>高（主導）</td>
      </tr>
      <tr>
          <td>Paradigm</td>
          <td>保留（single-primary SQL、ACID transaction、wire protocol）</td>
          <td>無變動</td>
      </tr>
      <tr>
          <td>Components</td>
          <td>connection pool（PgBouncer → RDS Proxy 或保留 PgBouncer in front of Aurora）、logical replication（pglogical / Debezium → Aurora 原生支援、但有版本限制）</td>
          <td>中</td>
      </tr>
      <tr>
          <td>Application</td>
          <td>保留（connection string 改 endpoint、SSL config 改 RDS CA、driver 不改）</td>
          <td>低</td>
      </tr>
      <tr>
          <td>Topology</td>
          <td>保留（single-region scaling、若要 multi-region 走另一條 playbook to DSQL）；fleet 拓樸決策（拆幾個 cluster）詳見 <a href="../read-replica-scaling/">read replica scaling</a> fleet SSoT</td>
          <td>中-高</td>
      </tr>
  </tbody>
</table>
<p><strong>主導差異</strong>：Operational layer（HA / backup / monitoring）、不是 schema 或 application。</p>
<h3 id="schema-diff-細節">Schema diff 細節</h3>
<p><strong>PostgreSQL → Aurora PostgreSQL</strong>：</p>
<table>
  <thead>
      <tr>
          <th>Extension</th>
          <th>Aurora 支援</th>
          <th>Migration 策略</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>pg_cron</td>
          <td>不支援</td>
          <td>改 Lambda 排程 + RDS event 或 Step Functions</td>
      </tr>
      <tr>
          <td>pg_partman</td>
          <td>不支援</td>
          <td>改 native declarative partitioning（PostgreSQL 11+）</td>
      </tr>
      <tr>
          <td>TimescaleDB</td>
          <td>不支援</td>
          <td>改 native partition + materialized view、或保留 self-managed</td>
      </tr>
      <tr>
          <td>PostGIS</td>
          <td>支援</td>
          <td>直接遷</td>
      </tr>
      <tr>
          <td>pgvector</td>
          <td>支援（新版）</td>
          <td>確認 Aurora PostgreSQL version、可能需要升級</td>
      </tr>
      <tr>
          <td>pglogical</td>
          <td>不支援</td>
          <td>改 Aurora 原生 logical replication（有版本限制）</td>
      </tr>
  </tbody>
</table>
<p><strong>MySQL → Aurora MySQL</strong>：</p>
<table>
  <thead>
      <tr>
          <th>Plugin</th>
          <th>Aurora 支援</th>
          <th>Migration 策略</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>HandlerSocket</td>
          <td>不支援</td>
          <td>改 SQL access 或 Aurora-specific KV cache</td>
      </tr>
      <tr>
          <td>Vault audit</td>
          <td>不支援</td>
          <td>改 AWS CloudTrail + RDS audit log</td>
      </tr>
      <tr>
          <td>MyRocks engine</td>
          <td>不支援</td>
          <td>改 InnoDB（Aurora 預設）、評估 storage 成本</td>
      </tr>
      <tr>
          <td>MaxScale</td>
          <td>不支援</td>
          <td>改 Aurora reader endpoint 或 RDS Proxy</td>
      </tr>
  </tbody>
</table>
<h3 id="operational-diff-細節">Operational diff 細節</h3>
<table>
  <thead>
      <tr>
          <th>元素</th>
          <th>Self-managed</th>
          <th>Aurora</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>HA</td>
          <td>Patroni / Orchestrator + etcd / ZooKeeper</td>
          <td>Cluster endpoint + 自動 cross-AZ failover</td>
      </tr>
      <tr>
          <td>Backup</td>
          <td>pgBackRest / xtrabackup + S3 lifecycle</td>
          <td>Automated backup + manual snapshot + PITR</td>
      </tr>
      <tr>
          <td>Monitoring</td>
          <td>Prometheus exporter + Grafana</td>
          <td>CloudWatch + Performance Insights</td>
      </tr>
      <tr>
          <td>Parameter</td>
          <td>postgresql.conf / my.cnf</td>
          <td>DB parameter group / cluster parameter group</td>
      </tr>
      <tr>
          <td>Failover testing</td>
          <td>Patroni <code>patronictl failover</code></td>
          <td><code>aws rds failover-db-cluster</code></td>
      </tr>
      <tr>
          <td>WAL / binlog 觀測</td>
          <td><code>pg_stat_wal</code> / <code>SHOW MASTER STATUS</code></td>
          <td>CloudWatch + Performance Insights wait events</td>
      </tr>
  </tbody>
</table>
<h3 id="application-diff-細節">Application diff 細節</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"># Self-managed PostgreSQL
</span></span><span class="line"><span class="ln">2</span><span class="cl">jdbc:postgresql://primary.internal:5432/mydb?ssl=true&amp;sslmode=verify-full&amp;sslrootcert=/etc/ssl/postgresql.crt
</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"># Aurora PostgreSQL
</span></span><span class="line"><span class="ln">5</span><span class="cl">jdbc:postgresql://my-cluster.cluster-xxx.us-east-1.rds.amazonaws.com:5432/mydb?ssl=true&amp;sslmode=verify-full&amp;sslrootcert=rds-ca.pem</span></span></code></pre></div><p>Application 改動量小：connection string 換 endpoint、SSL CA 換 RDS CA、driver 不變。</p>
<p>對應 knowledge card：<a href="/blog/backend/knowledge-cards/failover/" data-link-title="Failover" data-link-desc="說明主要服務或節點失效時如何切換到備援能力">failover</a>、<a href="/blog/backend/knowledge-cards/replication-lag/" data-link-title="Replication Lag" data-link-desc="說明資料副本落後正式來源多久，以及它如何影響讀取正確性">replication-lag</a>。</p>
<h2 id="phase-plan階段切換">Phase plan：階段切換</h2>
<h3 id="phase-0pre-migration-audit2-4-週">Phase 0：Pre-migration audit（2-4 週）</h3>
<p>工作：</p>
<ul>
<li>Extension audit：<code>SELECT * FROM pg_extension</code> / <code>SHOW PLUGINS</code>、列出 source 使用的 extension</li>
<li>Parameter audit：postgresql.conf vs Aurora parameter group、列差異</li>
<li>Application connection string audit：所有服務的 DB connection 點位</li>
<li>Benchmark baseline：write QPS / read QPS / p99 latency</li>
<li>Cost baseline：current self-managed monthly cost vs Aurora estimate</li>
</ul>
<p>Output：</p>
<ul>
<li>Migration feasibility report（含 no-go condition check）</li>
<li>Aurora cluster sizing 估算</li>
<li>Extension migration plan（each extension 對應的策略）</li>
</ul>
<h3 id="phase-1aurora-infra-準備1-2-週">Phase 1：Aurora infra 準備（1-2 週）</h3>
<p>工作：</p>
<ul>
<li>Aurora cluster 開設（dev / staging / prod）</li>
<li>Parameter group 對位（從 source postgresql.conf / my.cnf 翻譯到 Aurora parameter group）</li>
<li>SG / subnet / IAM 設定</li>
<li>RDS Proxy 配置（如需要）</li>
<li>CloudWatch dashboard + Performance Insights baseline</li>
<li>Backup retention 設定（1-35 天）</li>
</ul>
<p>Output：</p>
<ul>
<li>Aurora cluster 待 data load</li>
<li>Monitoring 已 ready、能對照 source 跟 target</li>
</ul>
<h3 id="phase-2data-migration2-8-週依資料量">Phase 2：Data migration（2-8 週、依資料量）</h3>
<p>三條 path、依場景選：</p>
<h4 id="path-aaws-dms-full-load--cdc">Path A：AWS DMS full load + CDC</h4>
<ul>
<li>適合：&lt; 1 TB、可接受 read-only 短窗口</li>
<li>流程：DMS full load → DMS CDC → application cutover</li>
<li>優點：managed、validation 工具齊全</li>
<li>缺點：CDC lag 受 DMS task config 影響、bulk DDL 不友善</li>
</ul>
<h4 id="path-bpg_dump--mysqldump--logical-replication-catch-up">Path B：pg_dump / mysqldump + logical replication catch-up</h4>
<ul>
<li>適合：&gt; 1 TB、要長 CDC 期、預算敏感</li>
<li>流程：snapshot → pg_dump / mysqldump → restore to Aurora → logical replication catch-up → application cutover</li>
<li>優點：成本低、可控性高</li>
<li>缺點：手動步驟多、要自己管 CDC lag</li>
</ul>
<h4 id="path-csnapshot-restore">Path C：Snapshot restore</h4>
<ul>
<li>適合：已在 RDS PostgreSQL / MySQL</li>
<li>流程：RDS snapshot → Aurora restore-from-snapshot → catch-up → application cutover</li>
<li>優點：最快、AWS-internal 操作</li>
<li>缺點：只適用 RDS source、不適用 self-managed</li>
</ul>
<h3 id="phase-3dual-read-validation1-2-週">Phase 3：Dual-read validation（1-2 週）</h3>
<p>工作：</p>
<ul>
<li>Application read 50/50 split source / target</li>
<li>比對 query 結果（per-table checksum + sampling）</li>
<li>量測 latency（Aurora p99 ≤ source × 1.2）</li>
<li>確認 stale read 比例 &lt; 0.01%</li>
</ul>
<p>Output：</p>
<ul>
<li>Validation report：query 結果差異、latency 對照</li>
<li>Go/no-go decision for cutover</li>
</ul>
<h3 id="phase-4cutover-1-小時-window">Phase 4：Cutover（&lt; 1 小時 window）</h3>
<p>工作：</p>
<ul>
<li>Source set read-only</li>
<li>CDC catch-up final（lag → 0）</li>
<li>Application switch endpoint（DNS / service discovery / config flag）</li>
<li>Smoke test（critical path query + write）</li>
<li>Monitor error rate + latency 1 小時</li>
</ul>
<p>Output：</p>
<ul>
<li>Cutover complete</li>
<li>Source 切到 read-only、保留作為 rollback 餘地</li>
</ul>
<h3 id="phase-5cleanup4-8-週">Phase 5：Cleanup（4-8 週）</h3>
<p>工作：</p>
<ul>
<li>Source 保留 1 個月 read-only（rollback window）</li>
<li>確認穩定後 snapshot → S3 archive → decommission</li>
<li>舊 monitoring / backup / runbook archive</li>
</ul>
<p>Output：</p>
<ul>
<li>Source decommissioned</li>
<li>新 runbook + monitoring 為 SSoT</li>
</ul>
<h3 id="本-phase-plan-適用範圍">本 phase plan 適用範圍</h3>
<p><strong>Non-regulated workload</strong>（一般 SaaS / e-commerce / 內部系統）。受監管場景（銀行 / 保險 / 醫療）請見下方「合規驅動遷移的時程模型」段、技術 phase 不變但 lead time 完全不同。</p>
<h2 id="合規驅動遷移的時程模型">合規驅動遷移的時程模型</h2>
<p>受監管產業遷移的關鍵時程是 <em>合規審查 lead time</em>、不是技術遷移時間 — 本段是補充給銀行 / 保險 / 醫療讀者、避免照本 playbook 走嚴重低估時程。</p>
<h3 id="standard-chartered-揭露的時程模型">Standard Chartered 揭露的時程模型</h3>
<p><a href="/blog/backend/09-performance-capacity/cases/standard-chartered-aurora-banking/" data-link-title="9.C14 Standard Chartered：受監管銀行的 Aurora 4000 TPS 容量提升" data-link-desc="Standard Chartered 銀行遷移到 Aurora 後吞吐量提升 10 倍至 4000 TPS、跨 7 個受監管市場">9.C14 Standard Chartered case</a> 「判讀」段第 3 點 + 「策略」段第 3 點原文：「每個受監管市場的審查可能 3-12 個月、合計遷移時程是『市場數 × 平均審查月份』、不是『技術遷移月份』」。</p>
<p>工程含義：</p>
<ul>
<li>技術 phase plan 假設 2-8 週 data migration + &lt; 1 小時 cutover</li>
<li>合規 lead time 是 <em>獨立軸</em>、可能比技術時程長一個數量級</li>
<li>不同市場合規進度不同步、可能要分批上線</li>
</ul>
<h3 id="合規時程組合">合規時程組合</h3>
<table>
  <thead>
      <tr>
          <th>軸</th>
          <th>時程估算</th>
          <th>不可壓縮原因</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>技術遷移</td>
          <td>2-8 週 data migration + &lt; 1 小時 cutover</td>
          <td>工程可控</td>
      </tr>
      <tr>
          <td>單市場合規審查</td>
          <td>3-12 個月（Standard Chartered case 揭露）</td>
          <td>監管機構 lead time、不是技術問題</td>
      </tr>
      <tr>
          <td>多市場合規 lead time</td>
          <td>市場數 × 平均審查月份（7 市場 × 6 個月 ≈ 3.5 年最壞情況）</td>
          <td>各市場各自審、平行度受監管機構文化影響</td>
      </tr>
      <tr>
          <td>跨境複製禁令審查</td>
          <td>包含在合規審查內、可能讓 Global Database 從候選變反指標</td>
          <td>監管要求 data residency、無 cross-region replication option</td>
      </tr>
  </tbody>
</table>
<h3 id="讀者判讀">讀者判讀</h3>
<ul>
<li>受監管場景 <em>不能</em> 用本 playbook 的「2-8 週 data migration + &lt; 1 小時 cutover」估時程交付給管理層 — 合規 lead time 是時程主項</li>
<li>受監管場景 <em>不能</em> 假設 Aurora Global Database 是 multi-region DR 選項 — 合規禁止跨境複製場景下 Global Database 違反合規（見 <a href="../global-database-multi-region/">global-database-multi-region</a>），要改用每市場獨立 cluster</li>
<li>合規場景的 phase plan 要把每市場當成獨立 mini-migration、用 <em>市場批次</em> 推進、不是一次 big bang</li>
</ul>
<p><strong>scope warning（必明示、case 自承）</strong>：Standard Chartered case 未公開是 PostgreSQL 還是 MySQL、未公開具體 cost 數字 — 引用時不能擴寫「Standard Chartered 用 Aurora PostgreSQL」這類細節（case 用「相關 case study」匿名標明）。</p>
<p><strong>合規時程 scope 警示</strong>：「3-12 個月、7 市場 × 6 個月 ≈ 3.5 年」是 Standard Chartered case 揭露範圍。實際合規 lead time 隨產業（銀行 / 保險 / 醫療）跟國家（東南亞 / 歐盟 / 北美 / 中東）差異大、不是恆定數字。讀者要把自家對應監管框架的實際 lead time 算進來、不是直接套 Standard Chartered 數字。</p>
<h2 id="evidence每階段驗證資料">Evidence：每階段驗證資料</h2>
<table>
  <thead>
      <tr>
          <th>Phase</th>
          <th>Evidence</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Phase 0</td>
          <td>extension list、parameter diff、application SQL 抽樣 test on Aurora dev cluster</td>
      </tr>
      <tr>
          <td>Phase 1</td>
          <td>Aurora cluster ready、monitoring dashboard 跟 source 對照</td>
      </tr>
      <tr>
          <td>Phase 2</td>
          <td>DMS row count match、checksum（per-table MD5）、CDC replication lag &lt; 5 秒</td>
      </tr>
      <tr>
          <td>Phase 3</td>
          <td>query result diff &lt; 0.01%、p99 latency Aurora ≤ source × 1.2、application error rate baseline</td>
      </tr>
      <tr>
          <td>Phase 4</td>
          <td>cutover 完成後 1 小時內 error rate &lt; baseline × 2、write success rate 100%</td>
      </tr>
      <tr>
          <td>Phase 5</td>
          <td>30 天無 rollback trigger、cost 月帳對齊預估</td>
      </tr>
  </tbody>
</table>
<p><strong>受監管追加 evidence</strong>：</p>
<ul>
<li>每市場合規 sign-off 文件（central bank / 金融監管機關）</li>
<li>跨境複製禁令審查記錄</li>
<li>Data residency 驗證測試（資料未流出受監管市場 boundary）</li>
<li>Audit log 連續性驗證（source / target audit log 銜接）</li>
</ul>
<p><strong>回路徑</strong>：<a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">4.20 Observability Evidence Package</a> 抽 CDC / latency evidence。</p>
<h2 id="cutover切流決策">Cutover：切流決策</h2>
<p><strong>Cutover window</strong>：</p>
<ul>
<li>建議 4 AM local time（lowest traffic）</li>
<li>預留 4 小時 buffer</li>
<li>受監管場景可能要在合規規定的 maintenance window（例如某些央行規定週日凌晨）</li>
</ul>
<p><strong>Rollback condition</strong>：</p>
<ul>
<li>error rate &gt; baseline × 5</li>
<li>write latency p99 &gt; baseline × 3 持續 10 分鐘</li>
<li>data corruption signal（checksum mismatch、unexpected row count drop）</li>
</ul>
<p><strong>Rollback path</strong>：</p>
<ul>
<li>Application connection string 切回 source</li>
<li>Source 仍 read-write（cutover 前留 read-write 路徑、若已 read-only 要先解凍）</li>
<li>CDC 反向同步（Aurora → source）catch-up</li>
</ul>
<p><strong>Decision owner</strong>：</p>
<ul>
<li>DBA lead + service owner + on-call SRE 三方 sign-off</li>
<li>受監管場景追加 compliance officer sign-off</li>
<li>Cutover decision log 記錄（<a href="/blog/backend/knowledge-cards/rollback-window/" data-link-title="Rollback Window" data-link-desc="說明變更進入 production 後還能用哪種方式回退或改路線的時間與條件">rollback window</a> / <a href="/blog/backend/knowledge-cards/rollback-condition/" data-link-title="Rollback Condition" data-link-desc="說明決策執行後出現哪些訊號時要撤回、回退或改路線">rollback condition</a> 文件化）</li>
</ul>
<p>對應 knowledge card：<a href="/blog/backend/knowledge-cards/rollback-window/" data-link-title="Rollback Window" data-link-desc="說明變更進入 production 後還能用哪種方式回退或改路線的時間與條件">rollback-window</a>、<a href="/blog/backend/knowledge-cards/rollback-condition/" data-link-title="Rollback Condition" data-link-desc="說明決策執行後出現哪些訊號時要撤回、回退或改路線">rollback-condition</a>。</p>
<h2 id="cleanup雙軌退役">Cleanup：雙軌退役</h2>
<table>
  <thead>
      <tr>
          <th>元素</th>
          <th>Cleanup 策略</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Source database</td>
          <td>read-only 1 個月、確認穩定後 snapshot → S3 archive → decommission</td>
      </tr>
      <tr>
          <td>舊 monitoring</td>
          <td>Prometheus exporter 拆、Grafana dashboard archive、CloudWatch dashboard 為 SSoT</td>
      </tr>
      <tr>
          <td>舊 backup chain</td>
          <td>pgBackRest / xtrabackup retention 保留至合規邊界（金融 7 年、一般 90 天）</td>
      </tr>
      <tr>
          <td>舊 runbook</td>
          <td>Patroni / Orchestrator runbook archive、新 runbook 對 Aurora cluster endpoint</td>
      </tr>
      <tr>
          <td>舊 CDC connector</td>
          <td>DMS task 留 7 天觀察期 → delete；自管 Debezium / pglogical 在 source decommission 同時退役</td>
      </tr>
  </tbody>
</table>
<p><strong>不可逆 cleanup 邊界</strong>：</p>
<ul>
<li>Source decommission 後資料只能從 backup restore</li>
<li>確保 backup 可用性測試通過再 decommission</li>
<li>受監管場景要保留 source backup 到合規 retention（金融 7 年、可能更長）</li>
</ul>
<h2 id="案例對照">案例對照</h2>
<h3 id="netflix-aurora-consolidationoperational-consolidation-的價值">Netflix Aurora consolidation：operational consolidation 的價值</h3>
<p><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</a> 多套 RDBMS（PostgreSQL / MySQL / Oracle）→ Aurora、+75% 效能 / -28% 成本。</p>
<p><strong>驗證的 driver</strong>：</p>
<ul>
<li>DB 種類太多本身是規模化的成本（每多一種 DB 多一套 DBA 知識 / backup / monitoring）</li>
<li>整合到 Aurora 釋放工程資源、不是純效能改善</li>
</ul>
<p><strong>case 自帶警示（必引用）</strong>：</p>
<ul>
<li>「+75% 是跨多 workload 最大改善幅度、不是每 workload 都 +75%」（case「需要警惕」段第 1 點）</li>
<li><strong>Aurora 非 all-purpose store 邊界</strong>：「Netflix 數據層遠不止 Aurora — 還有 Cassandra（playback metadata）、EVCache（cache layer）、Iceberg（data warehouse）。Aurora 主要是『需要 ACID 的 OLTP 工作負載』」（case「需要警惕」段第 2 點）</li>
</ul>
<p>工程含義：consolidation 是「ACID OLTP 整合到 Aurora」、不是「所有 store 整合到 Aurora」。讀者規劃整合範圍時要明示什麼 workload 不在範圍：</p>
<table>
  <thead>
      <tr>
          <th>Workload</th>
          <th>是否在 Aurora consolidation 範圍</th>
          <th>替代</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>ACID OLTP</td>
          <td>是</td>
          <td>-</td>
      </tr>
      <tr>
          <td>Playback metadata</td>
          <td>否（Netflix 用 Cassandra）</td>
          <td>Cassandra / ScyllaDB</td>
      </tr>
      <tr>
          <td>Cache layer</td>
          <td>否（Netflix 用 EVCache）</td>
          <td>EVCache / Redis / Memcached</td>
      </tr>
      <tr>
          <td>Data warehouse</td>
          <td>否（Netflix 用 Iceberg）</td>
          <td>Iceberg / Snowflake / Redshift</td>
      </tr>
      <tr>
          <td>Time-series</td>
          <td>否（性能不適合）</td>
          <td>InfluxDB / TimescaleDB self-managed</td>
      </tr>
      <tr>
          <td>Search</td>
          <td>否（無 inverted index 優化）</td>
          <td>Elasticsearch / OpenSearch</td>
      </tr>
  </tbody>
</table>
<h3 id="draftkingsfleet-拓樸-redesign">DraftKings：fleet 拓樸 redesign</h3>
<p><a href="/blog/backend/09-performance-capacity/cases/draftkings-aurora-financial-ledger/" data-link-title="9.C4 DraftKings：Aurora 撐 100 萬 ops/min 的體育博彩金融帳本" data-link-desc="DraftKings 用 Aurora MySQL 跑體育博彩金融帳本、Super Bowl 流量 &#43;50% 不影響延遲">9.C4 DraftKings</a> 200 個獨立 Aurora cluster、按業務切分（不是一個大 cluster + 200 schema）。</p>
<p><strong>驗證的 driver</strong>：</p>
<ul>
<li>Migration 不只是技術切換、也是 cluster 拓樸 redesign</li>
<li>業務本身可切分（每體育類別 / 每地理 / 每產品線）就在 migration 時順便拆 cluster</li>
<li>Blast radius 隔離跟容量規劃分散一起獲得</li>
</ul>
<p><strong>Fleet 拓樸決策</strong>：詳見 <a href="../read-replica-scaling/">Aurora read replica scaling</a> 邊界段 SSoT。本 playbook 提醒 <em>migration 是拆 cluster 的好時機</em>、不展開拓樸決策本身。</p>
<h3 id="standard-chartered合規-lead-time--跨境複製禁令">Standard Chartered：合規 lead time + 跨境複製禁令</h3>
<p><a href="/blog/backend/09-performance-capacity/cases/standard-chartered-aurora-banking/" data-link-title="9.C14 Standard Chartered：受監管銀行的 Aurora 4000 TPS 容量提升" data-link-desc="Standard Chartered 銀行遷移到 Aurora 後吞吐量提升 10 倍至 4000 TPS、跨 7 個受監管市場">9.C14 Standard Chartered</a> 受監管場景揭露：</p>
<ul>
<li>合規 lead time 是時程主項（3-12 個月 / 市場）</li>
<li>跨境複製禁止讓 Global Database 變反指標</li>
<li>每市場獨立 cluster + cross-AZ failover 是合規場景的標準解</li>
</ul>
<h3 id="反例aurora-不適合的場景">反例：Aurora 不適合的場景</h3>
<ul>
<li>Multi-region active-active write：見 <a href="/blog/backend/01-database/vendors/postgresql/migrate-to-aurora-dsql/" data-link-title="PostgreSQL → Aurora DSQL Migration：PG wire-compatible Distributed SQL 的 Paradigm Shift" data-link-desc="Aurora DSQL（2024-12 re:Invent preview / 2025-05 GA）是 AWS 推的 PG wire-compatible *active-active distributed SQL*、跟 self-managed PG / Aurora PG 不同 paradigm（OCC &#43; snapshot isolation &#43; multi-region strong consistency）。Migration 結構是 *protocol drop-in &#43; paradigm shift*：app SQL 不太改、但 transaction retry / extension 缺位 / 多 region 一致性需重設計。本文走 DSQL vs Aurora PG vs self-managed PG 三軸對比、為什麼遷的三條 driver（global write / operational zero-touch / region resiliency）、Type E phased plan、5 production 踩雷（transaction retry 沒處理 / extension 缺位 / sequence throughput 限制 / Aurora PG 直升 DSQL 不可行 / region failover semantic）、跟 PG → Aurora 跟 PG → CockroachDB 對比">PG → Aurora DSQL Migration</a></li>
<li>跨雲：見 <a href="/blog/backend/01-database/vendors/postgresql/migrate-to-cockroachdb/" data-link-title="PostgreSQL → CockroachDB：三維皆 High 的多重歸類 migration" data-link-desc="PostgreSQL → CockroachDB 是 Schema / Operational / Paradigm 三維皆 High 的 multi-axis migration、實證 [#127](/report/content-structure-by-max-diff-dimension/) 的「多重歸類跟 tie-breaking」規則；主結構走 Type E paradigm shift、Schema 差 &#43; Operational redesign 抽出獨立段；涵蓋 transaction model 重設計、SQL dialect gap、5 個 production 踩雷">PG → CockroachDB Migration</a></li>
<li>極端寫入吞吐（&gt; 100K WPS）：考慮 sharding、CockroachDB、或 DynamoDB</li>
</ul>
<h2 id="邊界與整合--下一步">邊界與整合 / 下一步</h2>
<p><strong>Sibling playbook</strong>：</p>
<ul>
<li><a href="/blog/backend/01-database/vendors/postgresql/migrate-to-aurora-dsql/" data-link-title="PostgreSQL → Aurora DSQL Migration：PG wire-compatible Distributed SQL 的 Paradigm Shift" data-link-desc="Aurora DSQL（2024-12 re:Invent preview / 2025-05 GA）是 AWS 推的 PG wire-compatible *active-active distributed SQL*、跟 self-managed PG / Aurora PG 不同 paradigm（OCC &#43; snapshot isolation &#43; multi-region strong consistency）。Migration 結構是 *protocol drop-in &#43; paradigm shift*：app SQL 不太改、但 transaction retry / extension 缺位 / 多 region 一致性需重設計。本文走 DSQL vs Aurora PG vs self-managed PG 三軸對比、為什麼遷的三條 driver（global write / operational zero-touch / region resiliency）、Type E phased plan、5 production 踩雷（transaction retry 沒處理 / extension 缺位 / sequence throughput 限制 / Aurora PG 直升 DSQL 不可行 / region failover semantic）、跟 PG → Aurora 跟 PG → CockroachDB 對比">PG → Aurora DSQL</a> — paradigm shift、Type E、multi-region active-active</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/migrate-to-cockroachdb/" data-link-title="PostgreSQL → CockroachDB：三維皆 High 的多重歸類 migration" data-link-desc="PostgreSQL → CockroachDB 是 Schema / Operational / Paradigm 三維皆 High 的 multi-axis migration、實證 [#127](/report/content-structure-by-max-diff-dimension/) 的「多重歸類跟 tie-breaking」規則；主結構走 Type E paradigm shift、Schema 差 &#43; Operational redesign 抽出獨立段；涵蓋 transaction model 重設計、SQL dialect gap、5 個 production 踩雷">PG → CockroachDB</a> — cross-cloud、paradigm shift</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/migrate-to-aurora/" data-link-title="PostgreSQL → Aurora Migration：protocol 相容、operational 重設計" data-link-desc="Aurora 號稱 PostgreSQL-compatible 但 operational model 不同（storage decouple / cluster endpoint / instance class / 自家備份）；遷移流程是混合（protocol drop-in &#43; operational phased）、5 個 production 踩雷（extension 不支援 / replication slot 不直通 / autovacuum 行為差 / IAM 認證強制 / cost model 換算）、跟 Patroni / read replica / DR 對位">PG → Aurora</a> — 既有 PG-specific playbook、可對照本 playbook 的 vendor-neutral 版本</li>
</ul>
<p><strong>Sibling deep article</strong>：</p>
<ul>
<li><a href="../storage-architecture/">Aurora storage architecture</a> — 理解 storage 設計才知道為什麼 operational redesign</li>
<li><a href="../cross-az-failover-rto/">Aurora cross-AZ failover RTO</a> — HA redesign 主項</li>
<li><a href="../read-replica-scaling/">Aurora read replica scaling</a> — fleet 治理 SSoT、含合規 driver</li>
<li><a href="../global-database-multi-region/">Aurora Global Database</a> — 合規禁止跨境複製的 anti-recommendation</li>
</ul>
<p><strong>1.x 章節互引</strong>：</p>
<ul>
<li><a href="/blog/backend/01-database/large-scale-db-migration/" data-link-title="1.12 大規模 DB 遷移實戰" data-link-desc="跨 DB 遷移的 dual-write、[shadow read](/backend/knowledge-cards/shadow-read/)、cutover、rollback 流程 — 從實戰案例提煉的工程做法">1.12 大規模 DB 遷移實戰</a> — migration 上游 framework</li>
</ul>
<p><strong>何時不用本 playbook</strong>：</p>
<ul>
<li>從 Aurora 遷到別處（反向、走對應的反向 playbook）</li>
<li>從 RDS PostgreSQL 升 Aurora PostgreSQL 是 in-place upgrade、用 RDS console「Convert to Aurora」即可、不需要這套 playbook</li>
<li>跨雲遷移：本 playbook 不涵蓋 GCP / Azure SQL → Aurora 流程</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/aurora/" data-link-title="AWS Aurora" data-link-desc="AWS managed PostgreSQL / MySQL、storage / compute 分離、&#43;75% 效能改善的 production 證據">Aurora vendor overview</a> — 服務定位、適用 / 不適用場景</li>
<li><a href="/blog/backend/knowledge-cards/failover/" data-link-title="Failover" data-link-desc="說明主要服務或節點失效時如何切換到備援能力">Failover 卡片</a> — 概念基底</li>
<li><a href="/blog/backend/knowledge-cards/replication-lag/" data-link-title="Replication Lag" data-link-desc="說明資料副本落後正式來源多久，以及它如何影響讀取正確性">Replication Lag 卡片</a> — operational diff 主軸</li>
<li><a href="/blog/backend/knowledge-cards/rollback-window/" data-link-title="Rollback Window" data-link-desc="說明變更進入 production 後還能用哪種方式回退或改路線的時間與條件">Rollback Window 卡片</a> — cutover decision</li>
<li><a href="/blog/backend/knowledge-cards/rollback-condition/" data-link-title="Rollback Condition" data-link-desc="說明決策執行後出現哪些訊號時要撤回、回退或改路線">Rollback Condition 卡片</a> — rollback trigger</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</a> — operational consolidation 跟 Aurora 非 all-purpose store 邊界</li>
<li><a href="/blog/backend/09-performance-capacity/cases/draftkings-aurora-financial-ledger/" data-link-title="9.C4 DraftKings：Aurora 撐 100 萬 ops/min 的體育博彩金融帳本" data-link-desc="DraftKings 用 Aurora MySQL 跑體育博彩金融帳本、Super Bowl 流量 &#43;50% 不影響延遲">9.C4 DraftKings</a> — fleet 拓樸 redesign</li>
<li><a href="/blog/backend/09-performance-capacity/cases/standard-chartered-aurora-banking/" data-link-title="9.C14 Standard Chartered：受監管銀行的 Aurora 4000 TPS 容量提升" data-link-desc="Standard Chartered 銀行遷移到 Aurora 後吞吐量提升 10 倍至 4000 TPS、跨 7 個受監管市場">9.C14 Standard Chartered</a> — 合規 lead time + 跨境複製禁令</li>
<li><a href="/blog/posts/migration-playbook-%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84stage-0-variant-%E8%A6%8F%E5%8A%83%E6%8A%8A-collapse-%E7%8E%87%E5%BE%9E-60-%E9%99%8D%E5%88%B0-0/" data-link-title="Migration Playbook 方法論的演化紀錄：Stage 0 variant 規劃把 collapse 率從 60% 降到 0%" data-link-desc="跨 vendor migration playbook 需要獨立寫作方法論的依據，以及這套方法論從三輪 batch dogfood 中演化出來的驗證證據。">Migration Playbook 寫作方法論</a> — 本文遵循的 6 規格面寫作模板</li>
<li>官方：<a href="https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/AuroraMySQL.Migrating.html">Aurora migration documentation</a></li>
</ul>
]]></content:encoded></item><item><title>Cosmos DB Change Feed (CDC)：persistent change log、Azure Functions trigger、latest-version vs all-versions-and-deletes 與跟 DynamoDB Streams 對照</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/cosmosdb/change-feed-cdc/</link><pubDate>Tue, 02 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/cosmosdb/change-feed-cdc/</guid><description>&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/cosmosdb/" data-link-title="Azure Cosmos DB" data-link-desc="全球分散式 multi-model DB、5 個 consistency levels、Microsoft 自家 dogfood 證據">Cosmos DB&lt;/a> overview 的 deep article、寫作參照 &lt;a href="https://tarrragon.github.io/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">vendor deep article methodology&lt;/a>。Change Feed 是 Cosmos DB 把 container 內每次寫入按 logical partition 順序持久化成一條可重讀變更序列的能力、對應 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/change-data-capture/" data-link-title="Change Data Capture" data-link-desc="說明資料變更如何被捕捉並傳送到其他系統">Change Data Capture&lt;/a> 的概念分層。它讓「寫入後要做的後續工作」（投影、cache 失效、事件發布、跨 store 同步）從 application 寫入路徑解耦出來、由獨立 consumer 按自己的進度消費。本文先講 Change Feed 的精確語義與兩種模式、再進 change feed processor 與 Azure Functions trigger 的操作流程、最後拆失敗模式與跟 DynamoDB Streams 的對照。&lt;/p>
&lt;p>Case anchor 是 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/asos-cosmos-db-black-friday/" data-link-title="9.C21 ASOS：Cosmos DB 在 Black Friday 撐 1.67 億請求" data-link-desc="ASOS 在 2016 Black Friday 用 Azure Cosmos DB 撐 24 小時 1.67 億請求、3500 req/sec、48ms 平均延遲">9.C21 ASOS&lt;/a>（85,000 SKU、每週新增 5,000 件的高更新頻率 catalog、寫入後需要 search index / 推薦排序投影）。ASOS case 本身沒有揭露 Change Feed 的實作細節、本文只取它的 catalog 寫入投影壓力當情境 anchor、機制以 Azure vendor 規格與通用工程展開。&lt;/p>
&lt;h2 id="問題情境">問題情境&lt;/h2>
&lt;p>典型觸發場景：catalog 寫入 Cosmos DB 後、下游還有一連串工作要做 — 把商品同步到 search index、刷新推薦排序、讓 cache 失效、發 event 給庫存服務。團隊一開始把這些工作塞進寫入 API 的同步路徑、寫一筆商品要等 search index 更新完才返回、寫入 latency 被下游拖垮；高峰時下游 search service 變慢、整條寫入鏈一起阻塞。&lt;/p>
&lt;p>讀者徵兆：&lt;/p>
&lt;ul>
&lt;li>「寫入 API latency 被下游投影工作拖高、想把它非同步化」&lt;/li>
&lt;li>「下游 consumer 掛掉一段時間、重啟後要怎麼補回漏掉的變更」&lt;/li>
&lt;li>「同一筆 document 在短時間內改三次、下游只需要最終狀態還是每次都要」&lt;/li>
&lt;li>「要做 audit / 要知道刪除事件、但 Change Feed 預設讀不到 delete」&lt;/li>
&lt;/ul>
&lt;p>真實壓力：寫入路徑與下游處理耦合會讓寫入 SLA 受制於最不穩的 consumer；而把投影改成「掃全表」的 batch job 又有延遲與成本問題。Change Feed 提供的是 &lt;em>持久、可重讀、按 partition 有序&lt;/em> 的變更來源、讓下游用 pull 或 trigger 模式按自己的進度消費。&lt;/p>
&lt;h2 id="核心機制partition-scoped-persistent-change-log">核心機制：partition-scoped persistent change log&lt;/h2>
&lt;p>Change Feed 是 container 的內建能力、把每個 logical partition 內的寫入按發生順序記錄成一條持久序列。它的關鍵語義有幾個面向。&lt;/p>
&lt;p>順序保證是 &lt;em>per logical partition&lt;/em>、不是 container 全域。同一 partition key 內的變更嚴格有序、跨 partition 之間沒有全域順序 — 這跟 &lt;a href="../partition-key-design/">partition-key-design&lt;/a> 的設計直接相關、consumer 必須假設不同 partition 的事件可能交錯到達。&lt;/p></description><content:encoded><![CDATA[<p>本文是 <a href="/blog/backend/01-database/vendors/cosmosdb/" data-link-title="Azure Cosmos DB" data-link-desc="全球分散式 multi-model DB、5 個 consistency levels、Microsoft 自家 dogfood 證據">Cosmos DB</a> overview 的 deep article、寫作參照 <a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">vendor deep article methodology</a>。Change Feed 是 Cosmos DB 把 container 內每次寫入按 logical partition 順序持久化成一條可重讀變更序列的能力、對應 <a href="/blog/backend/knowledge-cards/change-data-capture/" data-link-title="Change Data Capture" data-link-desc="說明資料變更如何被捕捉並傳送到其他系統">Change Data Capture</a> 的概念分層。它讓「寫入後要做的後續工作」（投影、cache 失效、事件發布、跨 store 同步）從 application 寫入路徑解耦出來、由獨立 consumer 按自己的進度消費。本文先講 Change Feed 的精確語義與兩種模式、再進 change feed processor 與 Azure Functions trigger 的操作流程、最後拆失敗模式與跟 DynamoDB Streams 的對照。</p>
<p>Case anchor 是 <a href="/blog/backend/09-performance-capacity/cases/asos-cosmos-db-black-friday/" data-link-title="9.C21 ASOS：Cosmos DB 在 Black Friday 撐 1.67 億請求" data-link-desc="ASOS 在 2016 Black Friday 用 Azure Cosmos DB 撐 24 小時 1.67 億請求、3500 req/sec、48ms 平均延遲">9.C21 ASOS</a>（85,000 SKU、每週新增 5,000 件的高更新頻率 catalog、寫入後需要 search index / 推薦排序投影）。ASOS case 本身沒有揭露 Change Feed 的實作細節、本文只取它的 catalog 寫入投影壓力當情境 anchor、機制以 Azure vendor 規格與通用工程展開。</p>
<h2 id="問題情境">問題情境</h2>
<p>典型觸發場景：catalog 寫入 Cosmos DB 後、下游還有一連串工作要做 — 把商品同步到 search index、刷新推薦排序、讓 cache 失效、發 event 給庫存服務。團隊一開始把這些工作塞進寫入 API 的同步路徑、寫一筆商品要等 search index 更新完才返回、寫入 latency 被下游拖垮；高峰時下游 search service 變慢、整條寫入鏈一起阻塞。</p>
<p>讀者徵兆：</p>
<ul>
<li>「寫入 API latency 被下游投影工作拖高、想把它非同步化」</li>
<li>「下游 consumer 掛掉一段時間、重啟後要怎麼補回漏掉的變更」</li>
<li>「同一筆 document 在短時間內改三次、下游只需要最終狀態還是每次都要」</li>
<li>「要做 audit / 要知道刪除事件、但 Change Feed 預設讀不到 delete」</li>
</ul>
<p>真實壓力：寫入路徑與下游處理耦合會讓寫入 SLA 受制於最不穩的 consumer；而把投影改成「掃全表」的 batch job 又有延遲與成本問題。Change Feed 提供的是 <em>持久、可重讀、按 partition 有序</em> 的變更來源、讓下游用 pull 或 trigger 模式按自己的進度消費。</p>
<h2 id="核心機制partition-scoped-persistent-change-log">核心機制：partition-scoped persistent change log</h2>
<p>Change Feed 是 container 的內建能力、把每個 logical partition 內的寫入按發生順序記錄成一條持久序列。它的關鍵語義有幾個面向。</p>
<p>順序保證是 <em>per logical partition</em>、不是 container 全域。同一 partition key 內的變更嚴格有序、跨 partition 之間沒有全域順序 — 這跟 <a href="../partition-key-design/">partition-key-design</a> 的設計直接相關、consumer 必須假設不同 partition 的事件可能交錯到達。</p>
<p>進度由 continuation token 表達。consumer 讀到哪裡、用一個 continuation token 標記；下次帶 token 回來、從上次的位置繼續。token 是 per partition range 的、container 做 partition split 時 token 要能跟著 range 拆分 — 這是 change feed processor 幫忙處理的部分。</p>
<p>讀取是 pull-based 持久來源、不是 push 通知。Change Feed 不主動推、是 consumer 主動拉。Azure Functions 的 Cosmos DB trigger 看起來像 push、底層仍是 trigger runtime 持續 poll Change Feed。</p>
<h3 id="兩種模式latest-version-vs-all-versions-and-deletes">兩種模式：latest-version vs all-versions-and-deletes</h3>
<p>Change Feed 有兩種模式、語義差很大、選錯會在 audit / 補償場景出問題（模式名稱與可用性屬時間敏感、查 <a href="https://learn.microsoft.com/azure/cosmos-db/change-feed">最新文件</a>）。</p>
<p>Latest-version 模式（過去稱 incremental feed）只給每個 document 的 <em>最新狀態</em>。同一 document 在兩次消費之間改了三次、consumer 只會看到最後一個版本、中間版本看不到；delete 也看不到（document 消失、feed 裡沒有對應的 tombstone）。這個模式適合「我只要把最終狀態投影到下游」的場景 — search index 同步、cache 刷新、物化視圖更新。</p>
<p>All-versions-and-deletes 模式給 <em>每一次</em> 變更、包含中間版本與 delete / TTL 過期事件。同一 document 改三次、feed 給三筆；刪掉給一筆刪除事件。這個模式適合需要完整變更歷史的場景 — audit log、event sourcing、需要對 delete 做反應的跨 store 同步。代價是事件量更大、且這個模式對 retention 與 partition 行為有額外約束（時間敏感、查文件）。</p>
<p>選擇判準：問「我需要中間版本與刪除事件嗎」。投影類工作（只要最終狀態）用 latest-version；audit 與需要對刪除反應的同步用 all-versions-and-deletes。預設選 latest-version、只有明確需要歷史與 delete 時才升級。</p>
<h3 id="change-feed-processor-的角色">change feed processor 的角色</h3>
<p>直接讀 Change Feed 要自己管 partition range、lease、continuation token、failover — 這些 plumbing 用 change feed processor library 處理。它的核心元件是 <em>lease container</em>：一個獨立的 Cosmos DB container、記錄每個 partition range 由哪個 consumer instance 處理、處理到哪個 continuation token。多個 consumer instance 共用同一個 lease container 時、processor 自動把 partition range 分配到不同 instance、達成水平擴展與 failover。</p>
<h2 id="操作流程">操作流程</h2>
<h3 id="啟用與確認">啟用與確認</h3>
<p>Change Feed 對 SQL API container 是預設啟用的、不需要額外開關（latest-version 模式）。all-versions-and-deletes 模式需要在 container 層設定、且要設 retention window。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 確認 container 存在、Change Feed 自動可用（latest-version）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">az cosmosdb sql container show <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --account-name mycosmos --resource-group myrg <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --database-name catalog --name products <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  --query <span class="s2">&#34;resource.id&#34;</span></span></span></code></pre></div><p>驗證：container 存在即可讀 latest-version feed。要用 all-versions-and-deletes、先確認 account / SDK 版本支援（時間敏感、查文件）並設好 retention。</p>
<h3 id="change-feed-processorc-sdk">change feed processor（C# SDK）</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-csharp" data-lang="csharp"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// lease container 獨立於 monitored container</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">Container</span> <span class="n">monitored</span> <span class="p">=</span> <span class="n">client</span><span class="p">.</span><span class="n">GetContainer</span><span class="p">(</span><span class="s">&#34;catalog&#34;</span><span class="p">,</span> <span class="s">&#34;products&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">Container</span> <span class="n">leases</span> <span class="p">=</span> <span class="n">client</span><span class="p">.</span><span class="n">GetContainer</span><span class="p">(</span><span class="s">&#34;catalog&#34;</span><span class="p">,</span> <span class="s">&#34;leases&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="n">ChangeFeedProcessor</span> <span class="n">processor</span> <span class="p">=</span> <span class="n">monitored</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="p">.</span><span class="n">GetChangeFeedProcessorBuilder</span><span class="p">&lt;</span><span class="n">Product</span><span class="p">&gt;(</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="n">processorName</span><span class="p">:</span> <span class="s">&#34;search-index-sync&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="n">onChangesDelegate</span><span class="p">:</span> <span class="n">HandleChangesAsync</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">.</span><span class="n">WithInstanceName</span><span class="p">(</span><span class="n">Environment</span><span class="p">.</span><span class="n">MachineName</span><span class="p">)</span>  <span class="c1">// 每個 instance 唯一</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="p">.</span><span class="n">WithLeaseContainer</span><span class="p">(</span><span class="n">leases</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="p">.</span><span class="n">Build</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="k">await</span> <span class="n">processor</span><span class="p">.</span><span class="n">StartAsync</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="kd">async</span> <span class="n">Task</span> <span class="n">HandleChangesAsync</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="n">IReadOnlyCollection</span><span class="p">&lt;</span><span class="n">Product</span><span class="p">&gt;</span> <span class="n">changes</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="n">CancellationToken</span> <span class="n">ct</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="k">foreach</span> <span class="p">(</span><span class="kt">var</span> <span class="n">product</span> <span class="k">in</span> <span class="n">changes</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="c1">// 投影到 search index — 必須 idempotent</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">        <span class="k">await</span> <span class="n">searchIndex</span><span class="p">.</span><span class="n">UpsertAsync</span><span class="p">(</span><span class="n">product</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="c1">// delegate 正常返回 = processor 自動推進 lease 的 continuation token</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>驗證：lease container 內會出現每個 partition range 的 lease document、<code>ContinuationToken</code> 欄位隨消費推進；多開一個 instance、觀察 lease 被重新分配到兩個 instance。失敗時 delegate 拋例外、processor 不推進該 range 的 token、下次重讀同一批（at-least-once、所以 handler 要 idempotent）。</p>
<h3 id="azure-functions-trigger消費端最省維運的形態">Azure Functions trigger（消費端最省維運的形態）</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-csharp" data-lang="csharp"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="na">[FunctionName(&#34;SyncSearchIndex&#34;)]</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kd">public</span> <span class="kd">static</span> <span class="kd">async</span> <span class="n">Task</span> <span class="n">Run</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="na">    [CosmosDBTrigger(
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="na">        databaseName: &#34;catalog&#34;,
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="na">        containerName: &#34;products&#34;,
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="na">        Connection = &#34;CosmosConnection&#34;,
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="na">        LeaseContainerName = &#34;leases&#34;,
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="na">        CreateLeaseContainerIfNotExists = true)]</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="n">IReadOnlyList</span><span class="p">&lt;</span><span class="n">Product</span><span class="p">&gt;</span> <span class="n">changes</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="n">ILogger</span> <span class="n">log</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">foreach</span> <span class="p">(</span><span class="kt">var</span> <span class="n">p</span> <span class="k">in</span> <span class="n">changes</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="k">await</span> <span class="n">searchIndex</span><span class="p">.</span><span class="n">UpsertAsync</span><span class="p">(</span><span class="n">p</span><span class="p">);</span>  <span class="c1">// idempotent</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Functions trigger 底層就是 change feed processor、lease 與 scale-out 由 Functions runtime 管。驗證：function 的 invocation count 隨寫入增加、Application Insights 看 <code>changes</code> batch size 與 lag。</p>
<h3 id="rollback-boundary">Rollback boundary</h3>
<p>Change Feed 是讀取側機制、停掉 consumer 不影響寫入。要重放：刪掉 lease container 的對應 lease（或建新 processor name）會從 container 起點或指定時間點重讀。重放前確認下游投影是 idempotent、否則重放會重複寫。</p>
<h2 id="失敗模式">失敗模式</h2>
<h3 id="把-handler-寫成非-idempotent">把 handler 寫成非 idempotent</h3>
<p>Change Feed 是 at-least-once。consumer 在處理一批後、推進 token 前 crash、重啟會重讀同一批。handler 若是「append 一筆 audit row」這種非 idempotent 操作、重放會產生重複。徵兆是下游出現重複事件、且重複數對應 consumer 重啟次數。修法是讓投影用 upsert（以 document id + version 為 key）、audit 用 dedup key、發 event 帶 idempotency key 讓下游去重 — 對應 <a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a> 的設計。</p>
<h3 id="用-latest-version-模式卻期待看到-delete">用 latest-version 模式卻期待看到 delete</h3>
<p>team 用預設 latest-version feed 做跨 store 同步、上線後發現「source 刪掉的 document、target 還在」。latest-version 模式不發 delete 事件、刪除在 feed 裡是「該 document 不再出現」、consumer 無從得知。修法是 audit / 需要刪除反應的場景改 all-versions-and-deletes 模式；或在 application 層用 soft delete（寫一個 <code>deleted: true</code> 的版本、latest-version feed 就看得到這次寫入）。</p>
<h3 id="lease-container-配置不足成為瓶頸">lease container 配置不足成為瓶頸</h3>
<p>lease container 自己也吃 RU、且 processor 對它有頻繁讀寫。lease container RU 配太低、processor 推進 token 被 throttle、表現成 Change Feed 消費 lag 升高、但 monitored container 看起來健康。徵兆是消費 lag 持續增長、診斷發現 429 來自 lease container 而非 source。修法是給 lease container 足夠 RU、把它跟 source container 的容量分開規劃、見 <a href="../ru-cost-model-sizing/">ru-cost-model-sizing</a>。</p>
<h3 id="假設-change-feed-有跨-partition-全域順序">假設 Change Feed 有跨 partition 全域順序</h3>
<p>consumer 假設事件按全域時間到達、做了依賴順序的邏輯（例如「先建立帳號事件、後消費事件」）。Change Feed 只保證 per logical partition 有序、跨 partition 交錯。徵兆是偶發的「後續事件先到、依賴的前置事件後到」。修法是讓有順序依賴的 document 落在同一 partition key、或在 consumer 端用業務 timestamp / version 做排序與 buffer、不依賴 feed 到達順序。</p>
<h3 id="anti-recommendation不是所有寫入後工作都要-change-feed">Anti-recommendation：不是所有「寫入後工作」都要 Change Feed</h3>
<p>寫入後若只是同一 request 內、同一 partition 的小量同步工作、直接在 application 寫入路徑處理、或用 stored procedure 在 partition 內做（見 <a href="../stored-procedure-trigger/">stored-procedure-trigger</a>）更簡單。Change Feed 的價值在 <em>解耦下游、可重放、水平擴展</em> — 當下游處理慢、會失敗、需要重放、或要被多個獨立 consumer 各自消費時才成立。下游工作輕、不需要重放、強耦合在寫入語義內時、引入 Change Feed + lease container 是多一層維運成本。</p>
<h2 id="容量與觀測">容量與觀測</h2>
<ul>
<li>必看 metric：Change Feed 消費 lag（最新寫入時間 vs consumer 已處理位置）、processor 每批 <code>changes</code> 數量、lease container 的 <code>NormalizedRUConsumption</code></li>
<li>consumer 端 throughput 受 partition range 數限制 — 並行度上限約等於 physical partition 數；range 不夠多時加 consumer instance 不會更快</li>
<li>成本：Change Feed 讀取本身吃 RU、all-versions-and-deletes 模式事件量更大、lease container 額外 RU — 三項都進容量公式、見 <a href="../ru-cost-model-sizing/">ru-cost-model-sizing</a></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>：把 Change Feed consumer 當獨立 throughput 單位、不要跟 OLTP 寫入共用同一個 RU budget 估算</li>
<li>Alert：消費 lag 持續增長（consumer 跟不上寫入）、lease container 429、handler 例外率上升</li>
</ul>
<h2 id="邊界與整合">邊界與整合</h2>
<ul>
<li>Sibling deep articles：<a href="../stored-procedure-trigger/">stored-procedure-trigger</a>（partition 內同步邏輯 vs Change Feed 的非同步解耦）、<a href="../synapse-link-federation/">synapse-link-federation</a>（分析 workload 用 analytical store、不要用 Change Feed 自己搭 analytics pipeline）、<a href="../partition-key-design/">partition-key-design</a>（per-partition 順序的來源）、<a href="../ru-cost-model-sizing/">ru-cost-model-sizing</a>（Change Feed + lease container 的 RU 成本）</li>
<li>跟 DynamoDB Streams 對照：兩者都是 partition-ordered 變更 log + at-least-once consumer。差異在 DynamoDB Streams 有固定 24 小時 retention、原生發 INSERT / MODIFY / REMOVE（含 delete）；Cosmos DB latest-version 模式預設不發 delete、要 all-versions-and-deletes 模式才有完整事件與 delete。從 DynamoDB Streams 思維過來的 team 容易假設「delete 一定看得到」、要先確認模式。對照 <a href="/blog/backend/01-database/vendors/dynamodb/" data-link-title="DynamoDB" data-link-desc="AWS managed key-value、partition-based scaling、9000 萬 RPS sustained 實戰證據">DynamoDB vendor</a></li>
<li>Knowledge card：<a href="/blog/backend/knowledge-cards/change-data-capture/" data-link-title="Change Data Capture" data-link-desc="說明資料變更如何被捕捉並傳送到其他系統">Change Data Capture</a> / <a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a></li>
<li>回 overview：<a href="/blog/backend/01-database/vendors/cosmosdb/" data-link-title="Azure Cosmos DB" data-link-desc="全球分散式 multi-model DB、5 個 consistency levels、Microsoft 自家 dogfood 證據">Cosmos DB vendor overview</a> 的「忽略 Change Feed」常見陷阱</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/cosmosdb/" data-link-title="Azure Cosmos DB" data-link-desc="全球分散式 multi-model DB、5 個 consistency levels、Microsoft 自家 dogfood 證據">Cosmos DB vendor overview</a> — 本文是該頁尾 Change Feed backlog 的深度展開</li>
<li><a href="/blog/backend/09-performance-capacity/cases/asos-cosmos-db-black-friday/" data-link-title="9.C21 ASOS：Cosmos DB 在 Black Friday 撐 1.67 億請求" data-link-desc="ASOS 在 2016 Black Friday 用 Azure Cosmos DB 撐 24 小時 1.67 億請求、3500 req/sec、48ms 平均延遲">9.C21 ASOS case</a> — 高更新頻率 catalog 投影壓力的情境 anchor</li>
<li><a href="../stored-procedure-trigger/">stored-procedure-trigger</a> — partition 內同步邏輯的對照</li>
<li><a href="../partition-key-design/">partition-key-design</a> — per-partition 順序的設計來源</li>
<li><a href="/blog/backend/01-database/vendors/dynamodb/" data-link-title="DynamoDB" data-link-desc="AWS managed key-value、partition-based scaling、9000 萬 RPS sustained 實戰證據">DynamoDB vendor</a> — DynamoDB Streams 對照</li>
<li><a href="/blog/backend/knowledge-cards/change-data-capture/" data-link-title="Change Data Capture" data-link-desc="說明資料變更如何被捕捉並傳送到其他系統">Change Data Capture 卡片</a> / <a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">Idempotency 卡片</a> — 概念基底</li>
<li>官方：<a href="https://learn.microsoft.com/azure/cosmos-db/change-feed">Change feed in Azure Cosmos DB</a> / <a href="https://learn.microsoft.com/azure/cosmos-db/nosql/change-feed-processor">Change feed processor</a></li>
</ul>
]]></content:encoded></item><item><title>Cosmos DB Stored Procedure / Trigger（JavaScript）：partition-scoped 交易、server-side 邏輯邊界、何時用何時讓 application 層處理</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/cosmosdb/stored-procedure-trigger/</link><pubDate>Tue, 02 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/cosmosdb/stored-procedure-trigger/</guid><description>&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/cosmosdb/" data-link-title="Azure Cosmos DB" data-link-desc="全球分散式 multi-model DB、5 個 consistency levels、Microsoft 自家 dogfood 證據">Cosmos DB&lt;/a> overview 的 deep article、寫作參照 &lt;a href="https://tarrragon.github.io/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">vendor deep article methodology&lt;/a>。Cosmos DB 的 stored procedure、trigger 與 user-defined function 是用 JavaScript 寫、執行在 Cosmos DB engine 內的 server-side 邏輯。它最有價值的能力是把同一 logical partition 內的多個操作包成一個原子交易 — 這是 application 層無法用 SDK 單獨做到的。本文先講這層 server-side 邏輯的精確語義與限制、再進操作流程、最後重點放在「何時用、何時不用」的判準 — 因為多數應用邏輯放在 application 層更好維護、stored procedure 應該是少數有明確理由的場景。&lt;/p>
&lt;p>本文沒有專屬 production case anchor：stored procedure 的設計取捨在公開 case 庫覆蓋稀薄、機制以 Azure vendor 規格與通用工程展開、情境用 partition 內原子交易這個具體需求驅動。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>Scope warning&lt;/strong>：本文涉及的 script 大小上限、執行時間上限、bounded execution 行為等具體限制屬時間敏感、不同 account 配置可能不同、實作前以 &lt;a href="https://learn.microsoft.com/azure/cosmos-db/nosql/stored-procedures-triggers-udfs">Cosmos DB stored procedure 官方文件&lt;/a> cross-verify。&lt;/p>&lt;/blockquote>
&lt;h2 id="問題情境">問題情境&lt;/h2>
&lt;p>典型觸發場景：業務需要「讀一筆庫存、檢查數量、扣減、寫一筆扣減記錄」這四步必須原子完成 — 中間不能被別的請求插入。用 application 層 SDK 做、四步是四個獨立 round-trip、中間有 race window；兩個請求同時扣同一筆庫存、可能都讀到 10、各扣 1、結果是 9 而非 8。這類 read-modify-write 在同一 partition 內、需要 server-side 原子性。&lt;/p>
&lt;p>讀者徵兆：&lt;/p>
&lt;ul>
&lt;li>「同一 partition 內的 read-modify-write 有 race、想要原子交易」&lt;/li>
&lt;li>「想做批次 upsert、減少 round-trip 與 RU」&lt;/li>
&lt;li>「想在寫入時自動加 timestamp / 算衍生欄位、用 pre-trigger 行不行」&lt;/li>
&lt;li>「stored procedure 能不能跨 partition 做交易」（不行 — 這是常見誤解）&lt;/li>
&lt;/ul>
&lt;p>真實壓力：Cosmos DB 的 transaction 邊界是 &lt;em>single logical partition&lt;/em>、跨 partition 沒有原生 ACID 交易。partition 內需要原子性時、SDK 多次 round-trip 無法保證、stored procedure 是 vendor 提供的 partition-scoped transaction 機制。但這個能力有強約束、且容易被濫用成「把業務邏輯都搬進 DB」。&lt;/p>
&lt;h2 id="核心機制partition-scoped-javascript-execution">核心機制：partition-scoped JavaScript execution&lt;/h2>
&lt;p>Cosmos DB 的 server-side 邏輯有三類、責任不同。&lt;/p>
&lt;p>Stored procedure 是執行在單一 logical partition 內的 JavaScript 函式、它內部對該 partition 的所有 document 操作包在一個 &lt;em>隱式交易&lt;/em> 裡 — 全部成功 commit、任一失敗整個 rollback。呼叫時必須指定 partition key、procedure 的所有操作都限定在那個 partition。&lt;/p>
&lt;p>Trigger 分 pre-trigger 與 post-trigger、綁在 create / replace / delete 等操作上、但 &lt;em>不會自動觸發&lt;/em> — 必須在 request 明確指定要跑哪個 trigger（這跟關聯式 DB 的 trigger 自動執行不同）。pre-trigger 在操作前跑（常用來補欄位、驗證）、post-trigger 在操作後跑（常用來更新同 partition 的彙總 document）。&lt;/p></description><content:encoded><![CDATA[<p>本文是 <a href="/blog/backend/01-database/vendors/cosmosdb/" data-link-title="Azure Cosmos DB" data-link-desc="全球分散式 multi-model DB、5 個 consistency levels、Microsoft 自家 dogfood 證據">Cosmos DB</a> overview 的 deep article、寫作參照 <a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">vendor deep article methodology</a>。Cosmos DB 的 stored procedure、trigger 與 user-defined function 是用 JavaScript 寫、執行在 Cosmos DB engine 內的 server-side 邏輯。它最有價值的能力是把同一 logical partition 內的多個操作包成一個原子交易 — 這是 application 層無法用 SDK 單獨做到的。本文先講這層 server-side 邏輯的精確語義與限制、再進操作流程、最後重點放在「何時用、何時不用」的判準 — 因為多數應用邏輯放在 application 層更好維護、stored procedure 應該是少數有明確理由的場景。</p>
<p>本文沒有專屬 production case anchor：stored procedure 的設計取捨在公開 case 庫覆蓋稀薄、機制以 Azure vendor 規格與通用工程展開、情境用 partition 內原子交易這個具體需求驅動。</p>
<blockquote>
<p><strong>Scope warning</strong>：本文涉及的 script 大小上限、執行時間上限、bounded execution 行為等具體限制屬時間敏感、不同 account 配置可能不同、實作前以 <a href="https://learn.microsoft.com/azure/cosmos-db/nosql/stored-procedures-triggers-udfs">Cosmos DB stored procedure 官方文件</a> cross-verify。</p></blockquote>
<h2 id="問題情境">問題情境</h2>
<p>典型觸發場景：業務需要「讀一筆庫存、檢查數量、扣減、寫一筆扣減記錄」這四步必須原子完成 — 中間不能被別的請求插入。用 application 層 SDK 做、四步是四個獨立 round-trip、中間有 race window；兩個請求同時扣同一筆庫存、可能都讀到 10、各扣 1、結果是 9 而非 8。這類 read-modify-write 在同一 partition 內、需要 server-side 原子性。</p>
<p>讀者徵兆：</p>
<ul>
<li>「同一 partition 內的 read-modify-write 有 race、想要原子交易」</li>
<li>「想做批次 upsert、減少 round-trip 與 RU」</li>
<li>「想在寫入時自動加 timestamp / 算衍生欄位、用 pre-trigger 行不行」</li>
<li>「stored procedure 能不能跨 partition 做交易」（不行 — 這是常見誤解）</li>
</ul>
<p>真實壓力：Cosmos DB 的 transaction 邊界是 <em>single logical partition</em>、跨 partition 沒有原生 ACID 交易。partition 內需要原子性時、SDK 多次 round-trip 無法保證、stored procedure 是 vendor 提供的 partition-scoped transaction 機制。但這個能力有強約束、且容易被濫用成「把業務邏輯都搬進 DB」。</p>
<h2 id="核心機制partition-scoped-javascript-execution">核心機制：partition-scoped JavaScript execution</h2>
<p>Cosmos DB 的 server-side 邏輯有三類、責任不同。</p>
<p>Stored procedure 是執行在單一 logical partition 內的 JavaScript 函式、它內部對該 partition 的所有 document 操作包在一個 <em>隱式交易</em> 裡 — 全部成功 commit、任一失敗整個 rollback。呼叫時必須指定 partition key、procedure 的所有操作都限定在那個 partition。</p>
<p>Trigger 分 pre-trigger 與 post-trigger、綁在 create / replace / delete 等操作上、但 <em>不會自動觸發</em> — 必須在 request 明確指定要跑哪個 trigger（這跟關聯式 DB 的 trigger 自動執行不同）。pre-trigger 在操作前跑（常用來補欄位、驗證）、post-trigger 在操作後跑（常用來更新同 partition 的彙總 document）。</p>
<p>UDF（user-defined function）是 query 內可呼叫的純函式、用來在 query projection / filter 階段做自訂計算、沒有寫入能力。</p>
<h3 id="交易邊界與-bounded-execution">交易邊界與 bounded execution</h3>
<p>交易嚴格限 single logical partition。stored procedure 不能跨 partition 寫、傳不同 partition key 的操作會失敗。跨 partition 的原子需求要改 workflow（saga / 補償）或重新設計 partition key 讓相關資料同 partition、見 <a href="../partition-key-design/">partition-key-design</a>。</p>
<p>執行有 bounded execution 限制：每次呼叫有時間與 resource 上限（時間敏感、查文件）、跑太久 Cosmos DB 會中止。處理大量 document 的 stored procedure 必須自己檢查每個操作的回傳、發現「快到上限」時停下、回傳一個 continuation 標記、讓 client 帶著標記再呼叫一次 — 這個 continuation 模式是寫批次 stored procedure 的必備 pattern。</p>
<h3 id="ru-成本">RU 成本</h3>
<p>stored procedure 內每個 document 操作都吃 RU、整個 procedure 的 RU 是內部所有操作的總和、由 response header 回報。一個掃很多 document 的 procedure 可能很貴、且因為 bounded execution 要分多次呼叫、成本與複雜度都比想像高、見 <a href="../ru-cost-model-sizing/">ru-cost-model-sizing</a>。</p>
<h2 id="操作流程">操作流程</h2>
<h3 id="寫一個-partition-scoped-原子扣減">寫一個 partition-scoped 原子扣減</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// deductStock.js — 在單一 partition 內原子扣減庫存
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="kd">function</span> <span class="nx">deductStock</span><span class="p">(</span><span class="nx">productId</span><span class="p">,</span> <span class="nx">qty</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="kd">var</span> <span class="nx">context</span> <span class="o">=</span> <span class="nx">getContext</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="kd">var</span> <span class="nx">container</span> <span class="o">=</span> <span class="nx">context</span><span class="p">.</span><span class="nx">getCollection</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="kd">var</span> <span class="nx">response</span> <span class="o">=</span> <span class="nx">context</span><span class="p">.</span><span class="nx">getResponse</span><span class="p">();</span>
</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 class="kd">var</span> <span class="nx">query</span> <span class="o">=</span> <span class="s2">&#34;SELECT * FROM c WHERE c.id = &#39;&#34;</span> <span class="o">+</span> <span class="nx">productId</span> <span class="o">+</span> <span class="s2">&#34;&#39;&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="kd">var</span> <span class="nx">accepted</span> <span class="o">=</span> <span class="nx">container</span><span class="p">.</span><span class="nx">queryDocuments</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nx">container</span><span class="p">.</span><span class="nx">getSelfLink</span><span class="p">(),</span> <span class="nx">query</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="kd">function</span> <span class="p">(</span><span class="nx">err</span><span class="p">,</span> <span class="nx">docs</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">            <span class="k">if</span> <span class="p">(</span><span class="nx">err</span><span class="p">)</span> <span class="k">throw</span> <span class="nx">err</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">            <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">docs</span> <span class="o">||</span> <span class="nx">docs</span><span class="p">.</span><span class="nx">length</span> <span class="o">===</span> <span class="mi">0</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">                <span class="k">throw</span> <span class="k">new</span> <span class="nb">Error</span><span class="p">(</span><span class="s2">&#34;product not found&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl">            <span class="kd">var</span> <span class="nx">product</span> <span class="o">=</span> <span class="nx">docs</span><span class="p">[</span><span class="mi">0</span><span class="p">];</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">            <span class="k">if</span> <span class="p">(</span><span class="nx">product</span><span class="p">.</span><span class="nx">stock</span> <span class="o">&lt;</span> <span class="nx">qty</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">                <span class="k">throw</span> <span class="k">new</span> <span class="nb">Error</span><span class="p">(</span><span class="s2">&#34;insufficient stock&#34;</span><span class="p">);</span>  <span class="c1">// 整個交易 rollback
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="c1"></span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">            <span class="nx">product</span><span class="p">.</span><span class="nx">stock</span> <span class="o">-=</span> <span class="nx">qty</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">            <span class="kd">var</span> <span class="nx">ok</span> <span class="o">=</span> <span class="nx">container</span><span class="p">.</span><span class="nx">replaceDocument</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">                <span class="nx">product</span><span class="p">.</span><span class="nx">_self</span><span class="p">,</span> <span class="nx">product</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">                <span class="kd">function</span> <span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">{</span> <span class="k">if</span> <span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="k">throw</span> <span class="nx">e</span><span class="p">;</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">            <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">ok</span><span class="p">)</span> <span class="k">throw</span> <span class="k">new</span> <span class="nb">Error</span><span class="p">(</span><span class="s2">&#34;replace not accepted&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">            <span class="nx">response</span><span class="p">.</span><span class="nx">setBody</span><span class="p">({</span> <span class="nx">remaining</span><span class="o">:</span> <span class="nx">product</span><span class="p">.</span><span class="nx">stock</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">        <span class="p">});</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">accepted</span><span class="p">)</span> <span class="k">throw</span> <span class="k">new</span> <span class="nb">Error</span><span class="p">(</span><span class="s2">&#34;query not accepted&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>註冊與呼叫（C# SDK）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-csharp" data-lang="csharp"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">await</span> <span class="n">container</span><span class="p">.</span><span class="n">Scripts</span><span class="p">.</span><span class="n">CreateStoredProcedureAsync</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">new</span> <span class="n">StoredProcedureProperties</span><span class="p">(</span><span class="s">&#34;deductStock&#34;</span><span class="p">,</span> <span class="n">File</span><span class="p">.</span><span class="n">ReadAllText</span><span class="p">(</span><span class="s">&#34;deductStock.js&#34;</span><span class="p">)));</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="kt">var</span> <span class="n">result</span> <span class="p">=</span> <span class="k">await</span> <span class="n">container</span><span class="p">.</span><span class="n">Scripts</span><span class="p">.</span><span class="n">ExecuteStoredProcedureAsync</span><span class="p">&lt;</span><span class="kt">dynamic</span><span class="p">&gt;(</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="s">&#34;deductStock&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="k">new</span> <span class="n">PartitionKey</span><span class="p">(</span><span class="n">productId</span><span class="p">),</span>   <span class="c1">// 必須指定 partition key</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="k">new</span> <span class="kt">dynamic</span><span class="p">[]</span> <span class="p">{</span> <span class="n">productId</span><span class="p">,</span> <span class="m">1</span> <span class="p">});</span></span></span></code></pre></div><p>驗證：兩個並行請求扣同一筆、總扣減量等於兩次之和、不會 lost update（交易原子性）。庫存不足時拋例外、整個 procedure rollback、stock 不變。回傳 header 的 <code>x-ms-request-charge</code> 是這次交易的總 RU。</p>
<h3 id="批次操作的-continuation-模式">批次操作的 continuation 模式</h3>
<p>掃多筆 document 的 procedure 要在 callback 內檢查回傳的 <code>accepted</code>、為 false（快到上限）時停下並回傳已處理數量、由 client loop 呼叫直到全部處理完。驗證：對一個大 partition 跑、觀察需要多次呼叫、每次回傳的已處理數累加到總數。</p>
<h3 id="pre-trigger-補欄位">pre-trigger 補欄位</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">function</span> <span class="nx">addTimestamp</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="kd">var</span> <span class="nx">doc</span> <span class="o">=</span> <span class="nx">getContext</span><span class="p">().</span><span class="nx">getRequest</span><span class="p">().</span><span class="nx">getBody</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">doc</span><span class="p">.</span><span class="nx">createdAt</span> <span class="o">=</span> <span class="k">new</span> <span class="nb">Date</span><span class="p">().</span><span class="nx">toISOString</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">getContext</span><span class="p">().</span><span class="nx">getRequest</span><span class="p">().</span><span class="nx">setBody</span><span class="p">(</span><span class="nx">doc</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>呼叫時要明確指定 trigger、否則不執行：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-csharp" data-lang="csharp"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">await</span> <span class="n">container</span><span class="p">.</span><span class="n">CreateItemAsync</span><span class="p">(</span><span class="n">item</span><span class="p">,</span> <span class="k">new</span> <span class="n">PartitionKey</span><span class="p">(</span><span class="n">item</span><span class="p">.</span><span class="n">pk</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">new</span> <span class="n">ItemRequestOptions</span> <span class="p">{</span> <span class="n">PreTriggers</span> <span class="p">=</span> <span class="k">new</span><span class="p">[]</span> <span class="p">{</span> <span class="s">&#34;addTimestamp&#34;</span> <span class="p">}</span> <span class="p">});</span></span></span></code></pre></div><p>驗證：帶 trigger 的寫入有 <code>createdAt</code>、不帶 trigger 的寫入沒有 — 確認 trigger 非自動。</p>
<h3 id="rollback-boundary">Rollback boundary</h3>
<p>stored procedure 本身的交易是 all-or-nothing、procedure 內拋例外即整個 rollback。部署層面：stored procedure / trigger 是 container 內的 resource、replace 即更新、delete 即移除、不影響 data。</p>
<h2 id="何時用何時不用">何時用、何時不用</h2>
<p>這是本文的主判讀段：多數應用邏輯放在 application 層更好、stored procedure 只有少數場景值得。</p>
<p>值得用 stored procedure 的條件：</p>
<ul>
<li><em>partition 內的多步原子交易</em> — read-modify-write、需要 all-or-nothing、且相關資料確實在同一 partition。這是 stored procedure 不可替代的能力。</li>
<li><em>省 round-trip 的批次操作</em> — 一次寫入幾百筆同 partition document、用 stored procedure 比幾百次 SDK 呼叫省 latency 與部分 RU overhead。</li>
</ul>
<p>讓 application 層處理的條件（多數情況）：</p>
<ul>
<li>業務邏輯複雜、會頻繁變動 — JavaScript stored procedure 的版本管理、測試、debug、observability 都比 application 層差；邏輯放 DB 內、CI / 單元測試 / log / APM 都接不上。</li>
<li>不需要原子性、或跨 partition — 跨 partition 的協調用 application 層 workflow 或 saga、stored procedure 做不到。</li>
<li>寫入後的非同步工作（投影、通知、同步）— 用 <a href="../change-feed-cdc/">Change Feed</a> 解耦、不要塞進 stored procedure 拖長寫入路徑。</li>
<li>衍生欄位 / 計算 — 簡單的放 application 層或 pre-trigger、複雜的不要進 DB 邏輯。</li>
</ul>
<p>判讀句：stored procedure 的正當理由幾乎只有「partition-scoped atomicity」與「批次 round-trip 縮減」。看到「想把業務規則集中到 DB」「想讓 DB 自動做某件事」這類動機、優先回 application 層 — server-side JavaScript 的維護成本長期高於它省下的東西。</p>
<h2 id="失敗模式">失敗模式</h2>
<h3 id="期待跨-partition-交易">期待跨 partition 交易</h3>
<p>team 把多個不同 partition key 的寫入放進一個 stored procedure、期待原子性。procedure 對非當前 partition 的操作會失敗。徵兆是「跨用戶 / 跨類別的原子操作報錯或部分寫入」。修法是重新設計 partition key 讓相關資料同 partition（若業務允許）、或改用 application 層補償 / saga workflow 處理跨 partition 一致性。</p>
<h3 id="沒處理-bounded-execution">沒處理 bounded execution</h3>
<p>批次 stored procedure 假設「一次呼叫處理完所有 document」、資料量大時被中止、只處理了一部分、client 以為全做完。徵兆是大 partition 上批次操作結果不完整、且沒有錯誤（procedure 被 bounded execution 截斷但回傳了部分成功）。修法是實作 continuation 模式、每個操作檢查 <code>accepted</code>、回傳已處理數、client loop 直到完成。</p>
<h3 id="把可變業務邏輯固化進-stored-procedure">把可變業務邏輯固化進 stored procedure</h3>
<p>把定價規則、折扣計算、狀態機這類會變的邏輯寫進 JavaScript stored procedure、之後每次改規則都要改 DB resource、無法走正常 application CI / code review / 測試流程、且 production debug 缺 log。徵兆是「改一個業務規則要動 DB、且改完不確定對不對」。修法是把邏輯搬回 application 層、stored procedure 只保留無法在 application 層做的 partition-scoped atomicity。</p>
<h3 id="依賴-trigger-自動執行">依賴 trigger 自動執行</h3>
<p>從關聯式 DB 過來的 team 假設 trigger 像 SQL trigger 一樣自動跑、寫了 audit / 補欄位的 trigger 卻發現大部分寫入沒觸發 — 因為 Cosmos DB trigger 必須 per-request 指定。徵兆是「trigger 有時跑有時不跑」、實際是只有明確帶 trigger 的 request 才跑。修法是確認所有相關寫入路徑都指定 trigger、或把「必須每次都做」的邏輯放 application 層 / pre-trigger 並在 SDK wrapper 統一帶上。</p>
<h2 id="容量與觀測">容量與觀測</h2>
<ul>
<li>必看 metric：stored procedure 執行的 <code>x-ms-request-charge</code>（整個交易的總 RU）、執行例外率、bounded execution 中止比例</li>
<li>成本：一個掃多 document 的 procedure 可能比等量單筆操作貴、且 continuation 多次呼叫累加 — 把它當「一個複合操作的總 RU」進容量公式、見 <a href="../ru-cost-model-sizing/">ru-cost-model-sizing</a></li>
<li>observability gap：stored procedure 內部沒有 application APM / structured log、debug 靠回傳 body 與例外訊息 — 這個 gap 本身是「邏輯不該放這裡」的訊號之一</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>：partition-scoped transaction 的 RU 要算進該 partition 的 budget、熱門 partition 上跑重 procedure 會放大 hot partition、見 <a href="/blog/backend/knowledge-cards/hot-partition/" data-link-title="Hot Partition" data-link-desc="說明分散式 KV / OLTP 中、單一 partition 流量遠超其他的容量問題">Hot Partition</a></li>
<li>Alert：stored procedure 例外率上升、執行 RU 異常偏高、bounded execution 截斷比例升高</li>
</ul>
<h2 id="邊界與整合">邊界與整合</h2>
<ul>
<li>Sibling deep articles：<a href="../change-feed-cdc/">change-feed-cdc</a>（寫入後的非同步工作走 Change Feed、不要塞 stored procedure）、<a href="../partition-key-design/">partition-key-design</a>（transaction 邊界 = partition 邊界、跨 partition 原子需求要重設計 partition key）、<a href="../ru-cost-model-sizing/">ru-cost-model-sizing</a>（複合交易的 RU 估算）、<a href="../consistency-levels-engineering/">consistency-levels-engineering</a>（partition 內原子性 vs 跨 session consistency 是兩個不同議題）</li>
<li>跟 Spanner 對照：需要 <em>跨 partition / 全域</em> ACID 交易時、Cosmos DB stored procedure 做不到 — 轉 <a href="/blog/backend/01-database/vendors/spanner/" data-link-title="Google Cloud Spanner" data-link-desc="全球分散式 strong-consistency OLTP、TrueTime API、線性擴展到 10 億 req/sec">Spanner vendor</a> 或 Aurora DSQL</li>
<li>跟 DynamoDB 對照：DynamoDB 的 TransactWriteItems 提供跨 item（含跨 partition、有上限）的交易、語義跟 Cosmos DB 的 single-partition stored procedure 不同 — 從 DynamoDB transaction 過來的 team 要注意 Cosmos DB 沒有等價的開箱跨 partition 交易、見 <a href="/blog/backend/01-database/vendors/dynamodb/" data-link-title="DynamoDB" data-link-desc="AWS managed key-value、partition-based scaling、9000 萬 RPS sustained 實戰證據">DynamoDB vendor</a></li>
<li>回 overview：<a href="/blog/backend/01-database/vendors/cosmosdb/" data-link-title="Azure Cosmos DB" data-link-desc="全球分散式 multi-model DB、5 個 consistency levels、Microsoft 自家 dogfood 證據">Cosmos DB vendor overview</a> 的「跨 partition transaction 要改 workflow / stored procedure 邊界」</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/cosmosdb/" data-link-title="Azure Cosmos DB" data-link-desc="全球分散式 multi-model DB、5 個 consistency levels、Microsoft 自家 dogfood 證據">Cosmos DB vendor overview</a> — 本文是該頁尾 stored procedure / trigger backlog 的深度展開</li>
<li><a href="../change-feed-cdc/">change-feed-cdc</a> — 寫入後非同步工作的對照路徑</li>
<li><a href="../partition-key-design/">partition-key-design</a> — transaction 邊界 = partition 邊界</li>
<li><a href="../ru-cost-model-sizing/">ru-cost-model-sizing</a> — 複合交易 RU 估算</li>
<li><a href="/blog/backend/01-database/vendors/spanner/" data-link-title="Google Cloud Spanner" data-link-desc="全球分散式 strong-consistency OLTP、TrueTime API、線性擴展到 10 億 req/sec">Spanner vendor</a> / <a href="/blog/backend/01-database/vendors/dynamodb/" data-link-title="DynamoDB" data-link-desc="AWS managed key-value、partition-based scaling、9000 萬 RPS sustained 實戰證據">DynamoDB vendor</a> — 跨 partition 交易能力對照</li>
<li><a href="/blog/backend/knowledge-cards/hot-partition/" data-link-title="Hot Partition" data-link-desc="說明分散式 KV / OLTP 中、單一 partition 流量遠超其他的容量問題">Hot Partition 卡片</a> — 熱 partition 上的重交易放大效應</li>
<li>官方：<a href="https://learn.microsoft.com/azure/cosmos-db/nosql/stored-procedures-triggers-udfs">Stored procedures, triggers, and UDFs</a></li>
</ul>
]]></content:encoded></item><item><title>從 MongoDB / Cassandra 遷入 Cosmos DB：protocol-compat API drop-in vs native API paradigm shift、相容性邊界與 dual-write cutover</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/cosmosdb/migrate-from-mongodb-cassandra/</link><pubDate>Tue, 02 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/cosmosdb/migrate-from-mongodb-cassandra/</guid><description>&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/cosmosdb/" data-link-title="Azure Cosmos DB" data-link-desc="全球分散式 multi-model DB、5 個 consistency levels、Microsoft 自家 dogfood 證據">Cosmos DB&lt;/a> overview 的 migration playbook、寫作參照 &lt;a href="https://tarrragon.github.io/blog/posts/migration-playbook-%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84stage-0-variant-%E8%A6%8F%E5%8A%83%E6%8A%8A-collapse-%E7%8E%87%E5%BE%9E-60-%E9%99%8D%E5%88%B0-0/" data-link-title="Migration Playbook 方法論的演化紀錄：Stage 0 variant 規劃把 collapse 率從 60% 降到 0%" data-link-desc="跨 vendor migration playbook 需要獨立寫作方法論的依據，以及這套方法論從三輪 batch dogfood 中演化出來的驗證證據。">Migration Playbook 寫作方法論&lt;/a>。從 MongoDB 或 Cassandra 遷入 Cosmos DB 的核心決策是 &lt;em>選哪條路徑&lt;/em> — 用 Cosmos 的 protocol-compat API（MongoDB API / Cassandra API）做 wire-protocol drop-in、driver 與 query 大致不動；還是換 native SQL API、把 application 重寫成 Cosmos native paradigm。這兩條路的 diff 維度、風險、不可逆性都不同、是一個 multi-element 的 migration 規劃。本文先把 driver 與 no-go 講清楚、再做 6 維 diff audit 分出兩條路徑、再進各自的 phase plan、evidence 與 cutover。&lt;/p>
&lt;p>API &lt;em>選擇判斷&lt;/em> 本身（MongoDB API vs SQL API 的四層 framing、dogfood signal、multi-model、跨雲 hedging）由 &lt;a href="../mongodb-api-vs-sql-api/">mongodb-api-vs-sql-api&lt;/a> 主寫、本文不重複展開那層對比；本文主寫 &lt;em>遷移流程&lt;/em> — 選定路徑後怎麼安全把資料與流量搬過去。&lt;/p>
&lt;p>Case anchor：&lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/microsoft-365-cosmos-db-analytics/" data-link-title="9.C30 Microsoft 365：從 MongoDB 遷移到 Cosmos DB 的分析平台" data-link-desc="Microsoft 365 把使用分析平台從 MongoDB 遷移到 Cosmos DB、planet-scale 全球分散式分析">9.C30 Microsoft 365&lt;/a>（MongoDB → Cosmos DB MongoDB API、planet-scale、dogfood）、&lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/forbes-mongodb-atlas-multi-cloud-migration/" data-link-title="9.C37 Forbes：自管 MongoDB → Atlas on GCP、build 時間 25 → 9 分鐘" data-link-desc="Forbes 把自管 MongoDB 遷到 Atlas on Google Cloud、6 個月完成、build 25 → 9 分鐘、120M 不重複訪客單月承接">9.C37 Forbes&lt;/a>（自管 → Atlas、6 個月、同 DB 換託管的時程對照）、&lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/coinbase-mongodb-document-platform/" data-link-title="9.C36 Coinbase：MongoDB 撐 Ruby 單體 &amp;#43; 1.5M reads/sec identity 服務" data-link-desc="Coinbase 以 MongoDB 為主資料層、自建 mongobetween connection proxy、users 服務在加密貨幣 surge 時撐 1.5M reads/sec">9.C36 Coinbase&lt;/a>（保留 MongoDB 補周邊、對照「不一定要遷」）。Microsoft 365 case 自承沒揭露 throughput / latency / cost 數字、本文不拿它當 benchmark、只取遷移路徑 frame。&lt;/p>
&lt;h2 id="driver為什麼遷什麼條件不遷">Driver：為什麼遷、什麼條件不遷&lt;/h2>
&lt;p>有效的遷移 driver 不是「Cosmos DB 比較好」、而是具體壓力：team 已綁 Azure 生態、需要 turnkey global distribution、自管 MongoDB / Cassandra cluster 的 ops 負擔要轉移、或需要 multi-model 把多個 NoSQL 集中治理。Microsoft 365 的 driver 是 planet-scale 全球分散 + Azure dogfood、不是 query 性能。&lt;/p></description><content:encoded><![CDATA[<p>本文是 <a href="/blog/backend/01-database/vendors/cosmosdb/" data-link-title="Azure Cosmos DB" data-link-desc="全球分散式 multi-model DB、5 個 consistency levels、Microsoft 自家 dogfood 證據">Cosmos DB</a> overview 的 migration playbook、寫作參照 <a href="/blog/posts/migration-playbook-%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84stage-0-variant-%E8%A6%8F%E5%8A%83%E6%8A%8A-collapse-%E7%8E%87%E5%BE%9E-60-%E9%99%8D%E5%88%B0-0/" data-link-title="Migration Playbook 方法論的演化紀錄：Stage 0 variant 規劃把 collapse 率從 60% 降到 0%" data-link-desc="跨 vendor migration playbook 需要獨立寫作方法論的依據，以及這套方法論從三輪 batch dogfood 中演化出來的驗證證據。">Migration Playbook 寫作方法論</a>。從 MongoDB 或 Cassandra 遷入 Cosmos DB 的核心決策是 <em>選哪條路徑</em> — 用 Cosmos 的 protocol-compat API（MongoDB API / Cassandra API）做 wire-protocol drop-in、driver 與 query 大致不動；還是換 native SQL API、把 application 重寫成 Cosmos native paradigm。這兩條路的 diff 維度、風險、不可逆性都不同、是一個 multi-element 的 migration 規劃。本文先把 driver 與 no-go 講清楚、再做 6 維 diff audit 分出兩條路徑、再進各自的 phase plan、evidence 與 cutover。</p>
<p>API <em>選擇判斷</em> 本身（MongoDB API vs SQL API 的四層 framing、dogfood signal、multi-model、跨雲 hedging）由 <a href="../mongodb-api-vs-sql-api/">mongodb-api-vs-sql-api</a> 主寫、本文不重複展開那層對比；本文主寫 <em>遷移流程</em> — 選定路徑後怎麼安全把資料與流量搬過去。</p>
<p>Case anchor：<a href="/blog/backend/09-performance-capacity/cases/microsoft-365-cosmos-db-analytics/" data-link-title="9.C30 Microsoft 365：從 MongoDB 遷移到 Cosmos DB 的分析平台" data-link-desc="Microsoft 365 把使用分析平台從 MongoDB 遷移到 Cosmos DB、planet-scale 全球分散式分析">9.C30 Microsoft 365</a>（MongoDB → Cosmos DB MongoDB API、planet-scale、dogfood）、<a href="/blog/backend/09-performance-capacity/cases/forbes-mongodb-atlas-multi-cloud-migration/" data-link-title="9.C37 Forbes：自管 MongoDB → Atlas on GCP、build 時間 25 → 9 分鐘" data-link-desc="Forbes 把自管 MongoDB 遷到 Atlas on Google Cloud、6 個月完成、build 25 → 9 分鐘、120M 不重複訪客單月承接">9.C37 Forbes</a>（自管 → Atlas、6 個月、同 DB 換託管的時程對照）、<a href="/blog/backend/09-performance-capacity/cases/coinbase-mongodb-document-platform/" data-link-title="9.C36 Coinbase：MongoDB 撐 Ruby 單體 &#43; 1.5M reads/sec identity 服務" data-link-desc="Coinbase 以 MongoDB 為主資料層、自建 mongobetween connection proxy、users 服務在加密貨幣 surge 時撐 1.5M reads/sec">9.C36 Coinbase</a>（保留 MongoDB 補周邊、對照「不一定要遷」）。Microsoft 365 case 自承沒揭露 throughput / latency / cost 數字、本文不拿它當 benchmark、只取遷移路徑 frame。</p>
<h2 id="driver為什麼遷什麼條件不遷">Driver：為什麼遷、什麼條件不遷</h2>
<p>有效的遷移 driver 不是「Cosmos DB 比較好」、而是具體壓力：team 已綁 Azure 生態、需要 turnkey global distribution、自管 MongoDB / Cassandra cluster 的 ops 負擔要轉移、或需要 multi-model 把多個 NoSQL 集中治理。Microsoft 365 的 driver 是 planet-scale 全球分散 + Azure dogfood、不是 query 性能。</p>
<p>No-go condition（這些情況不該遷入 Cosmos DB）：</p>
<ul>
<li>跨雲是核心需求 — Cosmos DB 只在 Azure；跨雲彈性高於 Azure 整合時、MongoDB 留 <a href="/blog/backend/01-database/vendors/mongodb/" data-link-title="MongoDB" data-link-desc="Document database 代表、Atlas managed、跨雲可用、許多大規模平台從 MongoDB 起家">Atlas</a>（Forbes 路徑、跨 AWS / GCP / Azure）、Cassandra 留自管或 ScyllaDB。</li>
<li>需要 native MongoDB / Cassandra 最新 feature — Cosmos DB 的 protocol-compat API server version 落後原生、且部分 feature 行為不同。</li>
<li>未來雲商策略未定 — hedging 價值高於當下整合、見 <a href="/blog/backend/knowledge-cards/vendor-lock-in/" data-link-title="Vendor Lock-In" data-link-desc="說明採用供應商產品後，其 API 與格式滲入程式碼造成的退出成本">vendor lock-in</a> 的退出成本。</li>
<li>現有 cluster 補周邊就夠用 — Coinbase 保留 MongoDB 加 proxy / cache / predictive scaling、沒遷出。遷移成本高、先確認「補周邊」解不了問題再遷。</li>
</ul>
<h2 id="diff-audit6-維度分出兩條路徑">Diff audit：6 維度分出兩條路徑</h2>
<p>source（MongoDB / Cassandra）與 target（Cosmos DB）的差異按 6 維度盤點、兩條路徑的維度高低不同、這也是 type 判定的依據。</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>protocol-compat API（MongoDB / Cassandra API）</th>
          <th>native SQL API</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Schema</td>
          <td>Low — document / table shape 大致保留</td>
          <td>Medium — 重新建模成 Cosmos native document</td>
      </tr>
      <tr>
          <td>Operational</td>
          <td>High — 自管 cluster → managed RU/s + region</td>
          <td>High — 同左</td>
      </tr>
      <tr>
          <td>Paradigm</td>
          <td>Low — 仍 document / wide-column 語意</td>
          <td>High — 換 query 模型、index policy、RU 思維</td>
      </tr>
      <tr>
          <td>Components</td>
          <td>Medium — driver 保留、aggregation / CQL 部分要改</td>
          <td>High — driver、query layer、ORM 全換</td>
      </tr>
      <tr>
          <td>Application</td>
          <td>Medium — connection string、auth、consistency 對應</td>
          <td>High — 整個 data access layer 重寫</td>
      </tr>
      <tr>
          <td>Data topology</td>
          <td>High — replica set / ring → partition + multi-region</td>
          <td>High — 同左</td>
      </tr>
  </tbody>
</table>
<p>主導差異決定 type：</p>
<ul>
<li>protocol-compat 路徑 — 最大差異是 operational 與 data topology、paradigm 維持 Low、是 wire-compat 的 drop-in 但有相容 gap。對應 <strong>Type B drop-in（partial）</strong>：driver 不換、但每個 query pattern 要驗證相容性、不是無腦切換。</li>
<li>native API 路徑 — paradigm High + application High、是 <strong>Type E paradigm shift</strong>：不只搬資料、要重寫 application 的整個 data access layer。</li>
</ul>
<p>判讀句：protocol-compat 是「換底層儲存與運維、保留 query 介面」、native API 是「連 query 範式一起換」。多數遷移先走 protocol-compat 把資料與 ops 搬過去、native API 是後續若要拿完整 Cosmos feature（Change Feed、stored procedure 原生支援、SQL API query）才考慮的二次遷移 — 一次到位 native API 的工程複雜度與風險顯著更高。</p>
<h3 id="cassandra-路徑的專屬差異">Cassandra 路徑的專屬差異</h3>
<p>Cassandra → Cosmos DB Cassandra API 跟 MongoDB 路徑有一個關鍵不同：Cassandra 的資料建模是 <em>query-driven</em>（partition key + clustering key 對應 access pattern）、這套建模思維跟 Cosmos DB 的 logical partition 概念部分對齊、但 Cosmos DB 的 per-partition RU 上限（目前約 10,000 RU/s、vendor 規格、實作時 cross-verify Azure doc 當前值）與 RU 計費會讓原本 Cassandra 上「寬 partition + 大量 clustering row」的設計變成 hot partition 風險。CQL 的 consistency level（QUORUM / LOCAL_ONE 等）要對應到 Cosmos DB 的 5 個 consistency level、語義不是一對一、見 <a href="../consistency-levels-engineering/">consistency-levels-engineering</a>。Cassandra 的 secondary index / materialized view 在 Cassandra API 的支援度要逐項驗證（時間敏感、查文件）。</p>
<h2 id="phase-plan">Phase plan</h2>
<p>兩條路徑共用大架構、protocol-compat 的相容 audit 較輕、native API 多一段 application 重寫。</p>
<h3 id="protocol-compat-路徑type-b-drop-in">protocol-compat 路徑（Type B drop-in）</h3>
<ul>
<li>Phase 0：相容性 audit — 把 production query / aggregation pipeline（MongoDB）或 CQL statement（Cassandra）拉出來、逐條對照 Cosmos DB 對應 API 的 <a href="https://learn.microsoft.com/azure/cosmos-db/mongodb/feature-support-60">feature support</a> 清單、列出 unsupported 與行為不同的部分。</li>
<li>Phase 1：partition key 設計 — MongoDB shard key / Cassandra partition key 翻譯成 Cosmos logical partition key、檢查 10,000 RU/s 上限與 hot partition 風險、見 <a href="../partition-key-design/">partition-key-design</a>。</li>
<li>Phase 2：bulk export-import — 初始資料用 Data Migration Tool / mongodump / sstable export 灌入。</li>
<li>Phase 3：CDC sync — source 的持續變更（MongoDB oplog / Cassandra CDC）同步到 Cosmos DB、收斂初始 load 後的增量。</li>
<li>Phase 4：shadow read — production query 在兩邊各跑一遍、對 result checksum、量 Cosmos 端 RU baseline、見 <a href="../ru-cost-model-sizing/">ru-cost-model-sizing</a>。</li>
<li>Phase 5：read cutover — 讀切 Cosmos、寫仍 source（可回退）。</li>
<li>Phase 6：write cutover — 寫切 Cosmos。</li>
<li>Phase 7：cleanup — 退役 source cluster、保留 export 與最終 checksum。</li>
</ul>
<h3 id="native-api-路徑type-e-paradigm-shift多出的工作">native API 路徑（Type E paradigm shift）多出的工作</h3>
<p>native API 路徑在 Phase 0 與 Phase 1 之間插入 <em>application 重寫 stream</em>、與資料遷移 stream 並行：</p>
<ul>
<li>重新建模 document（從 MongoDB document / Cassandra table 設計 Cosmos native shape、決定 embed vs reference）</li>
<li>重寫 data access layer（換掉 MongoDB driver / CQL、改用 Cosmos SQL API SDK、重寫所有 query）</li>
<li>重寫 aggregation（Cosmos SQL API 沒有 JOIN、aggregation 模型不同、部分邏輯移到 application 或用 stored procedure / Change Feed 物化）</li>
</ul>
<p>這條 application stream 是 native API 路徑的主要風險與工期來源、必須跟資料遷移 stream 用獨立 owner 並行、shadow read 階段要對 <em>重寫後的 query</em> 與 <em>原 query</em> 的結果一致性、不只是資料一致性。</p>
<h3 id="時程現實">時程現實</h3>
<p>Forbes 同 DB 換託管（自管 → Atlas、paradigm 不變）用 6 個月、中型團隊多 squad 並行。protocol-compat 遷入 Cosmos DB 的工程複雜度高於 Forbes 型（多了 RU / partition / region 範式與相容 gap）、native API 路徑再高一個量級（加 application 重寫）。拿 Forbes 6 個月當 native API 路徑 baseline 會從第一天 over-commit。</p>
<h2 id="evidence">Evidence</h2>
<p>每個 phase 用資料證明可前進、不靠感覺：</p>
<ul>
<li>Phase 0：unsupported feature 清單已窮舉、每條有對應策略（改寫 / 移 application 層 / 接受降級）</li>
<li>Phase 2-3：row / document count 對齊、CDC replication lag 收斂到穩定</li>
<li>Phase 4：query result checksum 一致（protocol-compat 比原 query 結果；native API 比重寫 query 與原 query 結果）、RU baseline 量到、aggregation result 逐條對齊</li>
<li>Phase 5-6：error rate、p99 latency、RU consumption 在 cutover 後在預期範圍</li>
<li>對應 <a href="/blog/backend/01-database/schema-migration-rollout-evidence/" data-link-title="1.7 Schema Migration Rollout 證據（Schema Migration Rollout Evidence）實作示範" data-link-desc="以訂單付款狀態欄位演進示範 schema migration 如何產出 evidence、release gate 與 incident decision log。">schema-migration-rollout-evidence</a> 的 dual-write 驗證</li>
</ul>
<h2 id="cutover">Cutover</h2>
<ul>
<li>read cutover window：先切讀、寫留 source、Cosmos 端 read error rate 與 latency 達標再進 write cutover</li>
<li>write cutover window：read-only freeze &lt; 10 分鐘、切寫、最終 checksum 對齊</li>
<li>Rollback condition：query error rate 超過閾值（如 &gt; 1%）、RU consumption 顯著高於估算（protocol-compat 翻譯層 overhead 比預期高）、或 result mismatch — 任一成立回退到 source、對應 <a href="/blog/backend/knowledge-cards/rollback-condition/" data-link-title="Rollback Condition" data-link-desc="說明決策執行後出現哪些訊號時要撤回、回退或改路線">rollback condition</a></li>
<li>decision owner：cutover 期間誰有權回退要事前定、資料庫切流失敗代價高、不靠臨場判斷</li>
<li>不可逆點：API kind 是 account 層、建 account 時選定、無法事後切換 — protocol-compat 與 native API 是 <em>兩個不同 account</em>；選 protocol-compat 後想升 native API 是 export → 新 account → import + 重寫 application 的二次全量遷移、不是 in-place 升級。這個不可逆性要在 Phase 0 就決定方向、不能 cutover 後反悔</li>
</ul>
<h2 id="cleanup">Cleanup</h2>
<ul>
<li>退役 source cluster 前確認最終 checksum、保留 export dump 90 天作為 rollback 後路</li>
<li>移除 dual-write writer、CDC connector、shadow read harness</li>
<li>保留 RU baseline 與 partition 分布觀測進 production dashboard、見 <a href="../ru-cost-model-sizing/">ru-cost-model-sizing</a></li>
<li>incident write-back：把相容 gap 與翻譯層成本意外寫回 runbook、給未來同類遷移</li>
</ul>
<h2 id="失敗模式">失敗模式</h2>
<h3 id="假設-wire-compat--100-行為相同">假設 wire-compat = 100% 行為相同</h3>
<p>protocol-compat API 是「在某些 query pattern 下相容」、不是普遍相容。MongoDB 的部分 aggregation stage（<code>$graphLookup</code> / <code>$facet</code> 等）、Cassandra 的部分 CQL feature 在對應 API 行為不同或不支援、dev 環境 sample data 看不出、production 才爆。修法是 Phase 0 把 <em>所有</em> production query 拉出來逐條驗證、Phase 4 shadow read 對 checksum、不能假設相容。</p>
<h3 id="shard-key--partition-key-直接照搬">shard key / partition key 直接照搬</h3>
<p>MongoDB shard key 或 Cassandra partition key 直接當 Cosmos logical partition key、忽略 10,000 RU/s per partition 上限。原本 Cassandra 寬 partition 在 Cosmos 變 hot partition、throttle。修法是 Phase 1 按 Cosmos 的 partition 上限重新評估、必要時用 synthetic / composite key 強制分散、見 <a href="../partition-key-design/">partition-key-design</a> 與 <a href="/blog/backend/knowledge-cards/hot-partition/" data-link-title="Hot Partition" data-link-desc="說明分散式 KV / OLTP 中、單一 partition 流量遠超其他的容量問題">Hot Partition</a>。</p>
<h3 id="把-native-api-二次遷移當升級低估">把 native API 二次遷移當「升級」低估</h3>
<p>選 protocol-compat 上線後、想拿 Change Feed / SQL query 等 native 能力、以為「升級到 SQL API」是改設定。實際是新 account + 全量資料遷 + application 重寫的第二次完整遷移。修法是 Phase 0 就決定終態方向 — 若終態確定要 native feature 且團隊能承擔重寫、直接走 native API 路徑、不要兩段遷。</p>
<h3 id="consistency-level-對應錯">consistency level 對應錯</h3>
<p>CQL 的 QUORUM / MongoDB 的 read concern majority 直接假設等價於 Cosmos 某個 level、語義不是一對一。修法是按 <a href="../consistency-levels-engineering/">consistency-levels-engineering</a> 把 read-after-write 與順序需求逐場景對應、不照字面翻譯 consistency 名稱。</p>
<h2 id="邊界與整合">邊界與整合</h2>
<ul>
<li>主對比 SSoT：<a href="../mongodb-api-vs-sql-api/">mongodb-api-vs-sql-api</a> — API <em>選擇判斷</em> 與三型遷移路徑分類在它主寫、本文主寫選定後的 <em>遷移流程</em></li>
<li>Sibling deep articles：<a href="../partition-key-design/">partition-key-design</a>（shard / partition key 翻譯）、<a href="../ru-cost-model-sizing/">ru-cost-model-sizing</a>（翻譯層 RU overhead 與 baseline）、<a href="../consistency-levels-engineering/">consistency-levels-engineering</a>（read concern / CQL consistency 對應）、<a href="../change-feed-cdc/">change-feed-cdc</a>（native API 才有原生 Change Feed、是 native 路徑的 feature driver 之一）</li>
<li>不遷的對照：<a href="/blog/backend/09-performance-capacity/cases/coinbase-mongodb-document-platform/" data-link-title="9.C36 Coinbase：MongoDB 撐 Ruby 單體 &#43; 1.5M reads/sec identity 服務" data-link-desc="Coinbase 以 MongoDB 為主資料層、自建 mongobetween connection proxy、users 服務在加密貨幣 surge 時撐 1.5M reads/sec">Coinbase</a> 保留 MongoDB 補周邊 — 確認「補周邊」解不了再遷</li>
<li>跨雲對照：<a href="/blog/backend/09-performance-capacity/cases/forbes-mongodb-atlas-multi-cloud-migration/" data-link-title="9.C37 Forbes：自管 MongoDB → Atlas on GCP、build 時間 25 → 9 分鐘" data-link-desc="Forbes 把自管 MongoDB 遷到 Atlas on Google Cloud、6 個月完成、build 25 → 9 分鐘、120M 不重複訪客單月承接">Forbes</a> 留 Atlas 跨雲 — 跨雲需求是 Cosmos DB 的 no-go</li>
<li>共通遷移模型：<a href="/blog/backend/01-database/large-scale-db-migration/" data-link-title="1.12 大規模 DB 遷移實戰" data-link-desc="跨 DB 遷移的 dual-write、[shadow read](/backend/knowledge-cards/shadow-read/)、cutover、rollback 流程 — 從實戰案例提煉的工程做法">1.12 大規模 DB 遷移實戰</a></li>
<li>Knowledge card：<a href="/blog/backend/knowledge-cards/vendor-lock-in/" data-link-title="Vendor Lock-In" data-link-desc="說明採用供應商產品後，其 API 與格式滲入程式碼造成的退出成本">vendor lock-in</a> / <a href="/blog/backend/knowledge-cards/hot-partition/" data-link-title="Hot Partition" data-link-desc="說明分散式 KV / OLTP 中、單一 partition 流量遠超其他的容量問題">Hot Partition</a></li>
<li>回 overview：<a href="/blog/backend/01-database/vendors/cosmosdb/" data-link-title="Azure Cosmos DB" data-link-desc="全球分散式 multi-model DB、5 個 consistency levels、Microsoft 自家 dogfood 證據">Cosmos DB vendor overview</a> 的「從 MongoDB / Cassandra 遷入」backlog</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/cosmosdb/" data-link-title="Azure Cosmos DB" data-link-desc="全球分散式 multi-model DB、5 個 consistency levels、Microsoft 自家 dogfood 證據">Cosmos DB vendor overview</a> — 本文是該頁尾遷入 backlog 的深度展開</li>
<li><a href="../mongodb-api-vs-sql-api/">mongodb-api-vs-sql-api</a> — API 選擇判斷與三型遷移路徑 SSoT</li>
<li><a href="/blog/backend/09-performance-capacity/cases/microsoft-365-cosmos-db-analytics/" data-link-title="9.C30 Microsoft 365：從 MongoDB 遷移到 Cosmos DB 的分析平台" data-link-desc="Microsoft 365 把使用分析平台從 MongoDB 遷移到 Cosmos DB、planet-scale 全球分散式分析">9.C30 Microsoft 365</a> — MongoDB → Cosmos DB MongoDB API dogfood</li>
<li><a href="/blog/backend/09-performance-capacity/cases/forbes-mongodb-atlas-multi-cloud-migration/" data-link-title="9.C37 Forbes：自管 MongoDB → Atlas on GCP、build 時間 25 → 9 分鐘" data-link-desc="Forbes 把自管 MongoDB 遷到 Atlas on Google Cloud、6 個月完成、build 25 → 9 分鐘、120M 不重複訪客單月承接">9.C37 Forbes</a> — 同 DB 換託管時程對照</li>
<li><a href="/blog/backend/09-performance-capacity/cases/coinbase-mongodb-document-platform/" data-link-title="9.C36 Coinbase：MongoDB 撐 Ruby 單體 &#43; 1.5M reads/sec identity 服務" data-link-desc="Coinbase 以 MongoDB 為主資料層、自建 mongobetween connection proxy、users 服務在加密貨幣 surge 時撐 1.5M reads/sec">9.C36 Coinbase</a> — 保留 MongoDB 不遷的對照</li>
<li><a href="../partition-key-design/">partition-key-design</a> / <a href="../ru-cost-model-sizing/">ru-cost-model-sizing</a> / <a href="../consistency-levels-engineering/">consistency-levels-engineering</a> — 遷移各 phase 的 sibling</li>
<li><a href="/blog/backend/01-database/large-scale-db-migration/" data-link-title="1.12 大規模 DB 遷移實戰" data-link-desc="跨 DB 遷移的 dual-write、[shadow read](/backend/knowledge-cards/shadow-read/)、cutover、rollback 流程 — 從實戰案例提煉的工程做法">1.12 大規模 DB 遷移實戰</a> — 跨 vendor 共通模型</li>
<li><a href="/blog/backend/knowledge-cards/vendor-lock-in/" data-link-title="Vendor Lock-In" data-link-desc="說明採用供應商產品後，其 API 與格式滲入程式碼造成的退出成本">Vendor Lock-in 卡片</a> — 跨雲 no-go 判讀</li>
<li>官方：<a href="https://learn.microsoft.com/azure/cosmos-db/mongodb/">Migrate to Cosmos DB for MongoDB</a> / <a href="https://learn.microsoft.com/azure/cosmos-db/cassandra/">Cosmos DB for Apache Cassandra</a></li>
</ul>
]]></content:encoded></item><item><title>Cosmos DB for PostgreSQL：基於 Citus 的分散式 PostgreSQL、跟核心 Cosmos DB 是不同產品、何時選它而非核心 Cosmos 或一般 PG</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/cosmosdb/cosmos-for-postgresql/</link><pubDate>Tue, 02 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/cosmosdb/cosmos-for-postgresql/</guid><description>&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/cosmosdb/" data-link-title="Azure Cosmos DB" data-link-desc="全球分散式 multi-model DB、5 個 consistency levels、Microsoft 自家 dogfood 證據">Cosmos DB&lt;/a> overview 的 deep article、寫作參照 &lt;a href="https://tarrragon.github.io/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">vendor deep article methodology&lt;/a>。Cosmos DB for PostgreSQL 是 Azure 在 2022 把 Citus（PostgreSQL 的分散式 extension）納入後推出的 &lt;em>分散式 PostgreSQL&lt;/em> 託管服務 — 它跑真正的 PostgreSQL engine、支援標準 SQL / JOIN / ACID 交易、把單表水平分片到多個 worker node。它跟本 vendor 頁主講的核心 Cosmos DB（NoSQL、multi-model、RU/s 計費）是 &lt;em>兩個不同產品&lt;/em>、只是共用品牌名稱。本文的主責任是釐清這個定位混淆、再講它的架構與選型判準：何時選它、何時該回核心 Cosmos DB、何時一般 PostgreSQL 就夠。&lt;/p>
&lt;p>本文沒有專屬 production case anchor：Cosmos DB for PostgreSQL 的公開 case 覆蓋稀薄、機制以 Azure / Citus vendor 規格與分散式 PostgreSQL 通用工程展開、選型判準用「scale-out PG vs NoSQL vs single-node PG」這個具體決策驅動。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>Scope warning&lt;/strong>：本文涉及的服務命名、node 規格上限、Citus 版本、PostgreSQL major version 支援屬時間敏感、Azure 服務命名歷史上有變動、實作前以 &lt;a href="https://learn.microsoft.com/azure/cosmos-db/postgresql/">Cosmos DB for PostgreSQL 官方文件&lt;/a> cross-verify。&lt;/p>&lt;/blockquote>
&lt;h2 id="問題情境">問題情境&lt;/h2>
&lt;p>典型觸發場景：team 在 Azure 上跑 PostgreSQL、單機 primary 撐到上限 — write throughput、資料量、或單表太大導致 index / vacuum / query 變慢。看到「Cosmos DB」以為是要把資料搬進 NoSQL、重寫 application 成 document model；或反過來、看到「Cosmos DB for PostgreSQL」以為它就是核心 Cosmos DB 的一個 PostgreSQL API、結果發現它是完全不同的東西。命名混淆讓選型從一開始就走偏。&lt;/p>
&lt;p>讀者徵兆：&lt;/p>
&lt;ul>
&lt;li>「單機 PostgreSQL 撐不住、但 application 是 SQL / JOIN / 交易重、不想重寫成 NoSQL」&lt;/li>
&lt;li>「Cosmos DB for PostgreSQL 跟核心 Cosmos DB 是同一個東西嗎」&lt;/li>
&lt;li>「它跟一般 Azure Database for PostgreSQL 差在哪、什麼時候才需要它」&lt;/li>
&lt;li>「跟 CockroachDB / Aurora / Spanner 這些 distributed SQL 怎麼選」&lt;/li>
&lt;/ul>
&lt;p>真實壓力：SQL workload 撐到單機上限時、選錯方向的成本是年級的。誤以為要遷 NoSQL 而重寫 application 是浪費；誤以為核心 Cosmos DB 有「PostgreSQL 相容」而選錯產品也是浪費。正確的選型要先把這個服務放回它真正的分類 — &lt;em>分散式 SQL&lt;/em>、見 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/distributed-sql/" data-link-title="Distributed SQL" data-link-desc="把 SQL 與交易語意延伸到多節點與多區域的資料庫形態">distributed SQL&lt;/a>。&lt;/p>
&lt;h2 id="核心機制citus-based-coordinator-worker-分散式-postgresql">核心機制：Citus-based coordinator-worker 分散式 PostgreSQL&lt;/h2>
&lt;p>Cosmos DB for PostgreSQL 的底層是 Citus、把 PostgreSQL 從單機擴展成 coordinator + worker 的分散式叢集。它的關鍵概念有幾個。&lt;/p></description><content:encoded><![CDATA[<p>本文是 <a href="/blog/backend/01-database/vendors/cosmosdb/" data-link-title="Azure Cosmos DB" data-link-desc="全球分散式 multi-model DB、5 個 consistency levels、Microsoft 自家 dogfood 證據">Cosmos DB</a> overview 的 deep article、寫作參照 <a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">vendor deep article methodology</a>。Cosmos DB for PostgreSQL 是 Azure 在 2022 把 Citus（PostgreSQL 的分散式 extension）納入後推出的 <em>分散式 PostgreSQL</em> 託管服務 — 它跑真正的 PostgreSQL engine、支援標準 SQL / JOIN / ACID 交易、把單表水平分片到多個 worker node。它跟本 vendor 頁主講的核心 Cosmos DB（NoSQL、multi-model、RU/s 計費）是 <em>兩個不同產品</em>、只是共用品牌名稱。本文的主責任是釐清這個定位混淆、再講它的架構與選型判準：何時選它、何時該回核心 Cosmos DB、何時一般 PostgreSQL 就夠。</p>
<p>本文沒有專屬 production case anchor：Cosmos DB for PostgreSQL 的公開 case 覆蓋稀薄、機制以 Azure / Citus vendor 規格與分散式 PostgreSQL 通用工程展開、選型判準用「scale-out PG vs NoSQL vs single-node PG」這個具體決策驅動。</p>
<blockquote>
<p><strong>Scope warning</strong>：本文涉及的服務命名、node 規格上限、Citus 版本、PostgreSQL major version 支援屬時間敏感、Azure 服務命名歷史上有變動、實作前以 <a href="https://learn.microsoft.com/azure/cosmos-db/postgresql/">Cosmos DB for PostgreSQL 官方文件</a> cross-verify。</p></blockquote>
<h2 id="問題情境">問題情境</h2>
<p>典型觸發場景：team 在 Azure 上跑 PostgreSQL、單機 primary 撐到上限 — write throughput、資料量、或單表太大導致 index / vacuum / query 變慢。看到「Cosmos DB」以為是要把資料搬進 NoSQL、重寫 application 成 document model；或反過來、看到「Cosmos DB for PostgreSQL」以為它就是核心 Cosmos DB 的一個 PostgreSQL API、結果發現它是完全不同的東西。命名混淆讓選型從一開始就走偏。</p>
<p>讀者徵兆：</p>
<ul>
<li>「單機 PostgreSQL 撐不住、但 application 是 SQL / JOIN / 交易重、不想重寫成 NoSQL」</li>
<li>「Cosmos DB for PostgreSQL 跟核心 Cosmos DB 是同一個東西嗎」</li>
<li>「它跟一般 Azure Database for PostgreSQL 差在哪、什麼時候才需要它」</li>
<li>「跟 CockroachDB / Aurora / Spanner 這些 distributed SQL 怎麼選」</li>
</ul>
<p>真實壓力：SQL workload 撐到單機上限時、選錯方向的成本是年級的。誤以為要遷 NoSQL 而重寫 application 是浪費；誤以為核心 Cosmos DB 有「PostgreSQL 相容」而選錯產品也是浪費。正確的選型要先把這個服務放回它真正的分類 — <em>分散式 SQL</em>、見 <a href="/blog/backend/knowledge-cards/distributed-sql/" data-link-title="Distributed SQL" data-link-desc="把 SQL 與交易語意延伸到多節點與多區域的資料庫形態">distributed SQL</a>。</p>
<h2 id="核心機制citus-based-coordinator-worker-分散式-postgresql">核心機制：Citus-based coordinator-worker 分散式 PostgreSQL</h2>
<p>Cosmos DB for PostgreSQL 的底層是 Citus、把 PostgreSQL 從單機擴展成 coordinator + worker 的分散式叢集。它的關鍵概念有幾個。</p>
<p>它跑 <em>真正的 PostgreSQL</em>。不是 wire-compat、不是 PostgreSQL API on top of NoSQL — 是 PostgreSQL engine 加 Citus extension。標準 SQL、JOIN、ACID 交易、PostgreSQL extension 生態（含部分如 PostGIS）都在。這跟核心 Cosmos DB（自己的 query language、SQL-like 但無 JOIN、RU/s 計費）是根本不同的東西。</p>
<p>架構是 coordinator-worker。coordinator node 接 query、根據 distribution column 把 query 路由 / 拆分到 worker node、worker 存實際的 shard。application 連 coordinator、看起來像連一個 PostgreSQL。</p>
<p>distribution column 是核心設計決策、類比核心 Cosmos DB 的 partition key 之於 NoSQL、也類比 <a href="../partition-key-design/">partition-key-design</a> 講的分散原則。表按 distribution column 的值分片到 worker；同一 distribution column 值的 row 落在同一 shard。JOIN 與交易若在同一 distribution column 值內、可以下推到單一 worker 高效執行（co-location）；跨 distribution column 的 JOIN / 交易要跨 worker 協調、較貴。</p>
<p>表分三種：distributed table（按 distribution column 分片、大表用）、reference table（每個 worker 全複本、小的維度表用、讓 JOIN co-locate）、local table（只在 coordinator）。建模的關鍵是把常一起 JOIN 的大表用 <em>同一 distribution column</em> 分片、達成 co-location。</p>
<h2 id="選型判準三方對照">選型判準：三方對照</h2>
<p>這是本文主判讀段。Cosmos DB for PostgreSQL 的正確位置是「single-node PG 不夠、但 workload 仍是 SQL 範式」的中間地帶。</p>
<p>選 Cosmos DB for PostgreSQL 的條件：</p>
<ul>
<li>workload 是 SQL 範式（關聯 schema、JOIN、交易）、不想 / 不能重寫成 NoSQL</li>
<li>single-node PostgreSQL 已達上限（write throughput / 資料量 / 單表大小）、且資料有好的 distribution column（多租戶的 tenant_id、time-series 的某維度）</li>
<li>工作負載偏向多租戶 SaaS 或 real-time analytics over fresh data — Citus 的典型適配場景</li>
<li>想留在 PostgreSQL 生態（SQL、extension、既有 tooling）而非進 NoSQL</li>
</ul>
<p>回核心 Cosmos DB（NoSQL）的條件：</p>
<ul>
<li>資料形狀已是 document / KV、access pattern 固定、不需要 JOIN 與複雜 SQL</li>
<li>需要 multi-model（document + graph + KV）、5 個 consistency level、turnkey multi-region active-active write</li>
<li>RU/s 容量抽象與 serverless 計費更符合 workload — 見 <a href="../ru-cost-model-sizing/">ru-cost-model-sizing</a></li>
</ul>
<p>一般 Azure Database for PostgreSQL（single-node managed PG）就夠的條件：</p>
<ul>
<li>single-node 還沒到上限 — 多數 OLTP baseline 用 vertical scaling + read replica 就夠、不需要分散式</li>
<li>沒有好的 distribution column — 分散式 PostgreSQL 沒有均勻 distribution column 會 hot worker、好處拿不到、複雜度卻全付</li>
<li>不想承擔 distributed SQL 的複雜度（distribution column 設計、co-location 規劃、跨 shard query 成本）</li>
</ul>
<p>判讀句：先確認 single-node PG 真的到上限、再確認 workload 是 SQL 範式（否則考慮 NoSQL）、最後確認有好的 distribution column。三個都成立、Cosmos DB for PostgreSQL 才是對的；缺任一個、回 single-node PG 或核心 Cosmos DB。</p>
<h3 id="跟其他-distributed-sql-的位置">跟其他 distributed SQL 的位置</h3>
<p>Cosmos DB for PostgreSQL 是 Azure 上、PostgreSQL-native、scale-out（co-location 設計驅動）的 distributed SQL。跟 <a href="/blog/backend/01-database/vendors/spanner/" data-link-title="Google Cloud Spanner" data-link-desc="全球分散式 strong-consistency OLTP、TrueTime API、線性擴展到 10 億 req/sec">Spanner</a>（全球 external consistency、自己的 SQL 方言）、<a href="/blog/backend/01-database/vendors/cockroachdb/" data-link-title="CockroachDB" data-link-desc="分散式 SQL、PostgreSQL 相容、跨區強一致、Spanner 的開源 / 跨雲替代">CockroachDB</a>（跨雲、PostgreSQL wire、自動 range 分散）、Aurora DSQL（AWS、全球 active-active）位置不同：Cosmos DB for PostgreSQL 強在「真 PostgreSQL engine + extension 生態 + co-location 控制」、弱在它的分散需要 distribution column 設計（不像 CockroachDB / Spanner 自動分 range）、且綁 Azure。</p>
<h2 id="操作流程">操作流程</h2>
<h3 id="建叢集與設定-distribution-column">建叢集與設定 distribution column</h3>





<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">-- 建 distributed table、按 tenant_id 分片（多租戶 SaaS 典型）
</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">events</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">tenant_id</span><span class="w">   </span><span class="nb">bigint</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"> 4</span><span class="cl"><span class="w">    </span><span class="n">event_id</span><span class="w">    </span><span class="nb">bigint</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">payload</span><span class="w">     </span><span class="n">jsonb</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">created_at</span><span class="w">  </span><span class="n">timestamptz</span><span class="w"> </span><span class="k">DEFAULT</span><span class="w"> </span><span class="n">now</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="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="k">SELECT</span><span class="w"> </span><span class="n">create_distributed_table</span><span class="p">(</span><span class="s1">&#39;events&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;tenant_id&#39;</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></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w"></span><span class="c1">-- 維度小表設 reference table、讓 JOIN co-locate
</span></span></span><span class="line"><span class="ln">11</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">tenants</span><span class="w"> </span><span class="p">(</span><span class="n">tenant_id</span><span class="w"> </span><span class="nb">bigint</span><span class="w"> </span><span class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</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">12</span><span class="cl"><span class="w"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">create_reference_table</span><span class="p">(</span><span class="s1">&#39;tenants&#39;</span><span class="p">);</span></span></span></code></pre></div><p>驗證：<code>SELECT * FROM citus_tables;</code> 看每張表的 distribution column 與 shard 分布；對 distributed table 的查詢若帶 distribution column filter、<code>EXPLAIN</code> 顯示下推到單一 shard、不帶則 fan-out 到所有 worker。</p>
<h3 id="驗證-co-location">驗證 co-location</h3>





<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">-- 同 distribution column 的兩張 distributed table JOIN 應 co-located
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">colocation_id</span><span class="p">,</span><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></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="n">citus_tables</span><span class="w"> </span><span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">colocation_id</span><span class="p">;</span></span></span></code></pre></div><p>驗證：常一起 JOIN 的大表落在同一 colocation group、JOIN 在 worker 本地完成、不跨 worker shuffle。</p>
<h3 id="加-worker-擴容">加 worker 擴容</h3>
<p>加 worker node 後 rebalance shard。驗證：rebalance 後 shard 在新舊 worker 間分布均勻、單一 worker 不再是 hot spot。</p>
<h3 id="rollback-boundary">Rollback boundary</h3>
<p>Cosmos DB for PostgreSQL 是叢集級服務、scale worker 是運維操作、可逆（縮回去）。但 <em>distribution column 一旦選定、改它要重建表 + 重灌資料</em> — 跟核心 Cosmos DB 的 partition key 不可改是同一類不可逆設計、見 <a href="../partition-key-design/">partition-key-design</a>。</p>
<h2 id="失敗模式">失敗模式</h2>
<h3 id="把它跟核心-cosmos-db-當同一產品選">把它跟核心 Cosmos DB 當同一產品選</h3>
<p>選型時把「Cosmos DB for PostgreSQL」當成「核心 Cosmos DB 的 PostgreSQL 介面」、規劃用 RU/s、找 consistency level 設定、結果整套 mental model 對不上 — 因為它是分散式 PostgreSQL、用 node 規格計費、用 PostgreSQL 的交易隔離級別。修法是選型第一步就確認「這是分散式 SQL、不是 NoSQL」、規劃按 PostgreSQL + Citus 的模型走、不要套核心 Cosmos DB 的概念。</p>
<h3 id="沒有好的-distribution-column-硬上分散式">沒有好的 distribution column 硬上分散式</h3>
<p>workload 沒有均勻的 distribution column（例如資料天然集中在少數 tenant）、硬分片後變 hot worker、分散式的好處拿不到、複雜度全付。徵兆是少數 worker CPU / IO 飽和、其他 worker 閒置。修法是選型階段就評估 distribution column 的 cardinality 與均勻度；不均勻時、要嘛留 single-node PG（垂直擴 + read replica）、要嘛重新設計 distribution column（如多租戶用 composite 或對 hot tenant 特殊處理）。</p>
<h3 id="大量跨-shard-query--非-co-located-join">大量跨 shard query / 非 co-located JOIN</h3>
<p>application query 大多不帶 distribution column filter、或常做跨 distribution column 的 JOIN、每個 query fan-out 到所有 worker + shuffle、latency 與成本都差。徵兆是 <code>EXPLAIN</code> 顯示 query 打所有 worker、p99 latency 高。修法是重新設計 schema 讓常一起查的表 co-located、把 distribution column 放進熱 query 的 filter；改不動時、這個 workload 可能不適合 scale-out PG、回 single-node 或考慮其他方案。</p>
<h3 id="該用-nosql-卻選了分散式-pg或反之">該用 NoSQL 卻選了分散式 PG（或反之）</h3>
<p>document / KV、固定 access pattern、不需要 JOIN 的 workload 選了 Cosmos DB for PostgreSQL、付了 SQL / distribution column 設計的複雜度卻沒用到關聯能力 — 這類 workload 核心 Cosmos DB（NoSQL）更自然。反過來、SQL / JOIN / 交易重的 workload 被推去核心 Cosmos DB（NoSQL）要重寫成 document model 也是錯。修法是回到「workload 是 SQL 範式還是 document / KV 範式」的根本判斷、見本文選型判準段與 <a href="../mongodb-api-vs-sql-api/">mongodb-api-vs-sql-api</a> 的範式判讀。</p>
<h3 id="anti-recommendationsingle-node-pg-沒到上限不要上">Anti-recommendation：single-node PG 沒到上限不要上</h3>
<p>分散式 PostgreSQL 帶來 distribution column 設計、co-location 規劃、跨 shard query 成本、rebalance 運維。single-node managed PostgreSQL 加 vertical scaling 與 read replica 能撐的 OLTP baseline 比多數團隊以為的大。沒有觸及 single-node 真實上限（write throughput 飽和、單表大到 maintenance 困難、資料量超出單機）就上分散式、是用複雜度換不存在的容量需求。</p>
<h2 id="容量與觀測">容量與觀測</h2>
<ul>
<li>必看 metric：各 worker node 的 CPU / IO / 連線（找 hot worker）、shard 在 worker 間的分布均勻度、跨 shard query 比例、coordinator 連線數</li>
<li>容量單位：node 規格（不是 RU/s）— 規劃是 coordinator + N worker 的 vCPU / memory / storage、跟核心 Cosmos DB 的 RU 思維完全不同、不要混用 <a href="../ru-cost-model-sizing/">ru-cost-model-sizing</a> 的 RU 模型來估這個服務</li>
<li>distribution column 均勻度是容量上限的真實決定因素 — 跟 <a href="/blog/backend/knowledge-cards/hot-partition/" data-link-title="Hot Partition" data-link-desc="說明分散式 KV / OLTP 中、單一 partition 流量遠超其他的容量問題">Hot Partition</a> 同模型、hot worker 讓名義叢集容量達不到</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>：scale-out 的有效容量 = node 數 × 單 node 容量 × distribution 均勻度</li>
<li>Alert：單一 worker 飽和（distribution skew）、跨 shard query 比例上升、rebalance 後仍不均</li>
</ul>
<h2 id="邊界與整合">邊界與整合</h2>
<ul>
<li>定位釐清：本服務是 <em>分散式 PostgreSQL</em>、不是核心 Cosmos DB（NoSQL）— 共用品牌名稱、產品不同、選型不要混淆</li>
<li>跟核心 Cosmos DB 的分界：SQL / JOIN / 交易 + 到單機上限 → 本服務；document / KV / multi-model / multi-region active-active → 核心 Cosmos DB、見 <a href="../mongodb-api-vs-sql-api/">mongodb-api-vs-sql-api</a></li>
<li>跟 PostgreSQL vendor 的分界：single-node 沒到上限 → <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 的相容目標">Azure Database for PostgreSQL / 一般 PG</a>；PostgreSQL 既有的 <a href="/blog/backend/01-database/vendors/postgresql/specialized-pg-variants/" data-link-title="Specialized PostgreSQL Variants" data-link-desc="pgvectorscale、Citus、TimescaleDB、PostGIS、AlloyDB、Cosmos DB for PostgreSQL、serverless PG 等 PostgreSQL 變體的選型邊界">Specialized PostgreSQL Variants</a> 段已把 Cosmos DB for PostgreSQL 列為 Citus-based 變體之一</li>
<li>跟其他 distributed SQL：<a href="/blog/backend/01-database/vendors/spanner/" data-link-title="Google Cloud Spanner" data-link-desc="全球分散式 strong-consistency OLTP、TrueTime API、線性擴展到 10 億 req/sec">Spanner</a>（全球強一致）、<a href="/blog/backend/01-database/vendors/cockroachdb/" data-link-title="CockroachDB" data-link-desc="分散式 SQL、PostgreSQL 相容、跨區強一致、Spanner 的開源 / 跨雲替代">CockroachDB</a>（跨雲、自動 range）— 本服務強在真 PostgreSQL engine + co-location 控制、弱在需 distribution column 設計 + 綁 Azure</li>
<li>distribution column 不可改：跟 <a href="../partition-key-design/">partition-key-design</a> 的 partition key 不可改是同類不可逆設計</li>
<li>Knowledge card：<a href="/blog/backend/knowledge-cards/distributed-sql/" data-link-title="Distributed SQL" data-link-desc="把 SQL 與交易語意延伸到多節點與多區域的資料庫形態">distributed SQL</a> / <a href="/blog/backend/knowledge-cards/hot-partition/" data-link-title="Hot Partition" data-link-desc="說明分散式 KV / OLTP 中、單一 partition 流量遠超其他的容量問題">Hot Partition</a></li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/cosmosdb/" data-link-title="Azure Cosmos DB" data-link-desc="全球分散式 multi-model DB、5 個 consistency levels、Microsoft 自家 dogfood 證據">Cosmos DB vendor overview</a> — 本文是該頁尾 Cosmos DB for PostgreSQL backlog 的深度展開</li>
<li><a href="../mongodb-api-vs-sql-api/">mongodb-api-vs-sql-api</a> — SQL 範式 vs document / KV 範式的根本判讀</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 vendor</a> / <a href="/blog/backend/01-database/vendors/postgresql/specialized-pg-variants/" data-link-title="Specialized PostgreSQL Variants" data-link-desc="pgvectorscale、Citus、TimescaleDB、PostGIS、AlloyDB、Cosmos DB for PostgreSQL、serverless PG 等 PostgreSQL 變體的選型邊界">Specialized PostgreSQL Variants</a> — single-node PG 與 Citus 變體定位</li>
<li><a href="/blog/backend/01-database/vendors/spanner/" data-link-title="Google Cloud Spanner" data-link-desc="全球分散式 strong-consistency OLTP、TrueTime API、線性擴展到 10 億 req/sec">Spanner vendor</a> / <a href="/blog/backend/01-database/vendors/cockroachdb/" data-link-title="CockroachDB" data-link-desc="分散式 SQL、PostgreSQL 相容、跨區強一致、Spanner 的開源 / 跨雲替代">CockroachDB vendor</a> — 其他 distributed SQL 對照</li>
<li><a href="../partition-key-design/">partition-key-design</a> — distribution column 不可改的同類設計</li>
<li><a href="/blog/backend/knowledge-cards/distributed-sql/" data-link-title="Distributed SQL" data-link-desc="把 SQL 與交易語意延伸到多節點與多區域的資料庫形態">Distributed SQL 卡片</a> / <a href="/blog/backend/knowledge-cards/hot-partition/" data-link-title="Hot Partition" data-link-desc="說明分散式 KV / OLTP 中、單一 partition 流量遠超其他的容量問題">Hot Partition 卡片</a> — 概念基底</li>
<li>官方：<a href="https://learn.microsoft.com/azure/cosmos-db/postgresql/">Azure Cosmos DB for PostgreSQL</a> / <a href="https://learn.microsoft.com/azure/cosmos-db/postgresql/concepts-distributed-data">Citus distributed tables</a></li>
</ul>
]]></content:encoded></item><item><title>Cosmos DB ↔ Azure Synapse Link：analytical store、HTAP federation、何時把分析 workload 從 OLTP 分出去</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/cosmosdb/synapse-link-federation/</link><pubDate>Tue, 02 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/cosmosdb/synapse-link-federation/</guid><description>&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/cosmosdb/" data-link-title="Azure Cosmos DB" data-link-desc="全球分散式 multi-model DB、5 個 consistency levels、Microsoft 自家 dogfood 證據">Cosmos DB&lt;/a> overview 的 deep article、寫作參照 &lt;a href="https://tarrragon.github.io/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">vendor deep article methodology&lt;/a>。Azure Synapse Link 把 Cosmos DB 的交易型資料自動同步到一個 column-oriented 的 analytical store、讓 Synapse（或其他 analytics engine）直接查分析資料、而 &lt;em>不消耗 OLTP 的 RU、不打 transactional store&lt;/em>。它是一種 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/federation/" data-link-title="Federation" data-link-desc="跨系統信任與授權交換的聯邦機制">federation&lt;/a> — 同一份資料的 OLTP 與 OLAP 存取被分到兩個各自最佳化的 store、由平台保持同步。本文先講 analytical store 與 HTAP federation 的精確語義、再進啟用流程、最後拆「何時把分析 workload 分出去、何時 federate 到專用 OLAP」的判準。&lt;/p>
&lt;p>Case anchor 是 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/microsoft-365-cosmos-db-analytics/" data-link-title="9.C30 Microsoft 365：從 MongoDB 遷移到 Cosmos DB 的分析平台" data-link-desc="Microsoft 365 把使用分析平台從 MongoDB 遷移到 Cosmos DB、planet-scale 全球分散式分析">9.C30 Microsoft 365&lt;/a> — Microsoft 自家把使用分析平台建在 Cosmos DB 上、planet-scale 全球分散式分析。case 自承沒揭露具體 throughput / latency / cost 數字、也沒明說用了 Synapse Link、本文只取「analytics workload 建在 Cosmos 上」這個情境 anchor、機制以 Azure vendor 規格與 HTAP / federation 通用工程展開。&lt;/p>
&lt;h2 id="問題情境">問題情境&lt;/h2>
&lt;p>典型觸發場景：交易資料在 Cosmos DB、business 想跑分析 — 跨日期彙總、跨 partition 聚合、ad-hoc 報表、餵 ML。直接在 Cosmos OLTP container 上跑這些 query 有兩個問題：一是 NoSQL query 引擎不擅長大範圍掃描與聚合、二是 &lt;em>分析 query 吃掉 OLTP 的 RU&lt;/em>、跑一個全表聚合可能把線上交易的 RU budget 耗光、造成 OLTP throttle（429）。團隊被迫在「分析準確性」與「OLTP 穩定性」之間二選一。&lt;/p>
&lt;p>讀者徵兆：&lt;/p>
&lt;ul>
&lt;li>「在 Cosmos OLTP container 跑分析 query、把線上交易的 RU 吃光、OLTP 開始 429」&lt;/li>
&lt;li>「想做 analytics 但不想自己搭 ETL pipeline 把資料抽到 data warehouse」&lt;/li>
&lt;li>「分析資料可以晚幾分鐘、但不想為了分析犧牲 OLTP 容量」&lt;/li>
&lt;li>「什麼時候 Synapse Link 夠、什麼時候要把資料 ETL 到專用 OLAP（BigQuery / Snowflake）」&lt;/li>
&lt;/ul>
&lt;p>真實壓力：OLTP store 為點查與小範圍寫入最佳化、分析 query 為大範圍掃描與聚合最佳化、兩者對 storage layout 與資源的需求衝突。在同一個 store 同時服務兩者、不是 RU 互搶就是 query 形狀不對。Synapse Link 的價值是用 federation 把這個衝突拆開 — OLTP 與 OLAP 各有最佳化的 store、平台自動同步。&lt;/p></description><content:encoded><![CDATA[<p>本文是 <a href="/blog/backend/01-database/vendors/cosmosdb/" data-link-title="Azure Cosmos DB" data-link-desc="全球分散式 multi-model DB、5 個 consistency levels、Microsoft 自家 dogfood 證據">Cosmos DB</a> overview 的 deep article、寫作參照 <a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">vendor deep article methodology</a>。Azure Synapse Link 把 Cosmos DB 的交易型資料自動同步到一個 column-oriented 的 analytical store、讓 Synapse（或其他 analytics engine）直接查分析資料、而 <em>不消耗 OLTP 的 RU、不打 transactional store</em>。它是一種 <a href="/blog/backend/knowledge-cards/federation/" data-link-title="Federation" data-link-desc="跨系統信任與授權交換的聯邦機制">federation</a> — 同一份資料的 OLTP 與 OLAP 存取被分到兩個各自最佳化的 store、由平台保持同步。本文先講 analytical store 與 HTAP federation 的精確語義、再進啟用流程、最後拆「何時把分析 workload 分出去、何時 federate 到專用 OLAP」的判準。</p>
<p>Case anchor 是 <a href="/blog/backend/09-performance-capacity/cases/microsoft-365-cosmos-db-analytics/" data-link-title="9.C30 Microsoft 365：從 MongoDB 遷移到 Cosmos DB 的分析平台" data-link-desc="Microsoft 365 把使用分析平台從 MongoDB 遷移到 Cosmos DB、planet-scale 全球分散式分析">9.C30 Microsoft 365</a> — Microsoft 自家把使用分析平台建在 Cosmos DB 上、planet-scale 全球分散式分析。case 自承沒揭露具體 throughput / latency / cost 數字、也沒明說用了 Synapse Link、本文只取「analytics workload 建在 Cosmos 上」這個情境 anchor、機制以 Azure vendor 規格與 HTAP / federation 通用工程展開。</p>
<h2 id="問題情境">問題情境</h2>
<p>典型觸發場景：交易資料在 Cosmos DB、business 想跑分析 — 跨日期彙總、跨 partition 聚合、ad-hoc 報表、餵 ML。直接在 Cosmos OLTP container 上跑這些 query 有兩個問題：一是 NoSQL query 引擎不擅長大範圍掃描與聚合、二是 <em>分析 query 吃掉 OLTP 的 RU</em>、跑一個全表聚合可能把線上交易的 RU budget 耗光、造成 OLTP throttle（429）。團隊被迫在「分析準確性」與「OLTP 穩定性」之間二選一。</p>
<p>讀者徵兆：</p>
<ul>
<li>「在 Cosmos OLTP container 跑分析 query、把線上交易的 RU 吃光、OLTP 開始 429」</li>
<li>「想做 analytics 但不想自己搭 ETL pipeline 把資料抽到 data warehouse」</li>
<li>「分析資料可以晚幾分鐘、但不想為了分析犧牲 OLTP 容量」</li>
<li>「什麼時候 Synapse Link 夠、什麼時候要把資料 ETL 到專用 OLAP（BigQuery / Snowflake）」</li>
</ul>
<p>真實壓力：OLTP store 為點查與小範圍寫入最佳化、分析 query 為大範圍掃描與聚合最佳化、兩者對 storage layout 與資源的需求衝突。在同一個 store 同時服務兩者、不是 RU 互搶就是 query 形狀不對。Synapse Link 的價值是用 federation 把這個衝突拆開 — OLTP 與 OLAP 各有最佳化的 store、平台自動同步。</p>
<h2 id="核心機制analytical-store--htap-federation">核心機制：analytical store + HTAP federation</h2>
<p>Synapse Link 的核心是 Cosmos DB container 的 <em>analytical store</em>。</p>
<p>analytical store 是 column-oriented 的自動複本。在 container 啟用 analytical store 後、Cosmos DB 把 transactional store（row / document、為 OLTP 最佳化）的資料自動同步到一份 column-oriented 表示（為大範圍掃描與聚合最佳化）。兩份共存、同一份資料兩種 layout。</p>
<p>同步是 no-ETL、auto-sync。寫入 transactional store 後、平台在背景把變更同步到 analytical store（通常分鐘級延遲、時間敏感、查文件）。team 不寫 ETL、不維護 pipeline。</p>
<p>關鍵隔離：analytical store query <em>不消耗 OLTP 的 RU</em>。Synapse engine 查 analytical store、走的是 analytical store 的計費與資源、跟 transactional store 的 provisioned RU 分離。這是 federation 對 OLTP 的核心保護 — 分析跑再重也不會 throttle 線上交易。</p>
<p>這是 HTAP（Hybrid Transactional/Analytical Processing）的一種實現：同一資料源、OLTP 與 OLAP 共存、不需要把資料搬到獨立 warehouse 就能做近即時分析。對應 <a href="/blog/backend/knowledge-cards/federation/" data-link-title="Federation" data-link-desc="跨系統信任與授權交換的聯邦機制">federation</a> 的「同一份資料、多個各自最佳化的存取路徑」概念。</p>
<h3 id="跟自己搭-change-feed-pipeline-的差別">跟自己搭 Change Feed pipeline 的差別</h3>
<p><a href="../change-feed-cdc/">Change Feed</a> 也能把資料同步到別處做分析、但那要自己寫 consumer、自己維護 target store、自己處理 schema 演進與 backfill。Synapse Link 是平台託管的 analytical store + auto-sync、省掉這整條 pipeline。判準：需求是「Cosmos 資料的近即時 column-oriented 分析」、Synapse Link 直接給；需求是「自訂 transform、餵特定下游、複雜 routing」、Change Feed 提供控制權但要自己搭。</p>
<h2 id="操作流程">操作流程</h2>
<h3 id="在-container-啟用-analytical-store">在 container 啟用 analytical store</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 建 container 時開 analytical store TTL（-1 = 跟 transactional 同壽命）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">az cosmosdb sql container create <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --account-name mycosmos --resource-group myrg <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --database-name catalog --name orders <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  --partition-key-path <span class="s2">&#34;/customerId&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="se"></span>  --analytical-storage-ttl -1</span></span></code></pre></div><p>驗證：container 的 <code>analyticalStorageTtl</code> 已設；account 層的 Synapse Link feature 已啟用（account 設定、時間敏感、查文件）。注意 analytical store 通常需要 <em>建 container 時</em> 啟用、既有 container 的開啟支援度要查文件。</p>
<h3 id="從-synapse-查-analytical-store">從 Synapse 查 analytical store</h3>





<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">-- Synapse serverless SQL pool 直接查 analytical store、不打 OLTP
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">customerId</span><span class="p">,</span><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">AS</span><span class="w"> </span><span class="n">orders</span><span class="p">,</span><span class="w"> </span><span class="k">SUM</span><span class="p">(</span><span class="n">amount</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">revenue</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="n">OPENROWSET</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="n">PROVIDER</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;CosmosDB&#39;</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">CONNECTION</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;Account=mycosmos;Database=catalog&#39;</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="k">OBJECT</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;orders&#39;</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">SERVER_CREDENTIAL</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;cosmos-cred&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="w"></span><span class="p">)</span><span class="w"> </span><span class="k">WITH</span><span class="w"> </span><span class="p">(</span><span class="n">customerId</span><span class="w"> </span><span class="nb">varchar</span><span class="p">(</span><span class="mi">64</span><span class="p">),</span><span class="w"> </span><span class="n">amount</span><span class="w"> </span><span class="nb">float</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">orders</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">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">customerId</span><span class="p">;</span></span></span></code></pre></div><p>驗證：query 跑大範圍聚合期間、Cosmos OLTP container 的 <code>NormalizedRUConsumption</code> <em>不受影響</em>（這是 federation 隔離生效的關鍵證據）。對照同樣 query 直接打 transactional store、會看到 RU 飆升甚至 429。</p>
<h3 id="驗證同步延遲">驗證同步延遲</h3>
<p>寫一筆到 transactional store、隔一段時間在 analytical store 查到 — 量同步延遲（分鐘級）。驗證：延遲在業務可接受的分析新鮮度範圍內；要秒級新鮮度的分析、Synapse Link 不是對的工具。</p>
<h3 id="rollback-boundary">Rollback boundary</h3>
<p>Synapse Link 是讀取側 federation、停用不影響 transactional store 的 OLTP。analytical store 是衍生複本、刪掉重建可重新同步（從 transactional store）。OLTP 寫入路徑完全不受 analytical store 啟用與否影響。</p>
<h2 id="何時分出去何時-federate-到專用-olap">何時分出去、何時 federate 到專用 OLAP</h2>
<p>這是本文主判讀段。Synapse Link 在「OLTP 資料要近即時分析、但不想犧牲 OLTP 容量也不想搭 ETL」的場景成立；它不是所有分析需求的答案。</p>
<p>用 Synapse Link（在 Cosmos federation 內做分析）的條件：</p>
<ul>
<li>分析的主資料源就是 Cosmos OLTP container、且分析可接受分鐘級新鮮度</li>
<li>主要痛點是「分析 query 搶 OLTP 的 RU」— federation 的 RU 隔離直接解這個</li>
<li>不想維護 ETL pipeline — no-ETL auto-sync 省掉這條</li>
<li>分析 query 形狀適合 column-oriented 掃描聚合（多數 BI / 報表 / 彙總）</li>
</ul>
<p>把分析 workload federate 到專用 OLAP（BigQuery / Snowflake / 專用 warehouse）的條件：</p>
<ul>
<li>分析要 <em>跨多個資料源</em> join（Cosmos + 其他 DB + 外部資料）— 需要一個獨立的 warehouse 做集中、Synapse Link 只給 Cosmos 單源</li>
<li>分析是重型 data warehouse workload（複雜多表 join、長期歷史、大規模 transform）— 專用 OLAP 的引擎與成本模型更合適</li>
<li>已有成熟的 data platform（Snowflake / BigQuery / lakehouse）、Cosmos 只是其中一個 source — 把 Cosmos 資料用 Change Feed / connector 餵進既有 platform、不另起 Synapse Link</li>
</ul>
<p>判讀句：Synapse Link 是 <em>Cosmos 單源、近即時、column-oriented</em> 分析的省力路徑；分析需求一旦跨源、變重型 warehouse、或已有集中 data platform、就 federate 到專用 OLAP。Cosmos DB overview 已標明「純 OLAP 分析」交給 Synapse / BigQuery / Snowflake — Synapse Link 是兩者之間的橋、不是把 Cosmos 變成 data warehouse。</p>
<h2 id="失敗模式">失敗模式</h2>
<h3 id="不啟用-synapse-link直接在-oltp-跑分析">不啟用 Synapse Link、直接在 OLTP 跑分析</h3>
<p>team 在 OLTP container 直接跑全表聚合報表、分析 query 吃光 provisioned RU、線上交易 429。徵兆是「跑月報的時段、線上交易 latency 飆 / 出現 throttle」。修法是啟用 analytical store + Synapse Link、分析 query 改打 analytical store、RU 隔離後 OLTP 不再受影響；或退一步、把分析 query 移到離峰、但這只是緩解、根本解是 federation 隔離。</p>
<h3 id="期待-analytical-store-即時反映寫入">期待 analytical store 即時反映寫入</h3>
<p>把 Synapse Link 當即時分析用、寫入後立刻在 analytical store 查、查不到剛寫的。analytical store 同步是分鐘級、不是即時。徵兆是「剛下的訂單在分析報表看不到」。修法是接受分析的分鐘級新鮮度、需要即時數字的場景（如即時庫存）走 OLTP 點查、不走 analytical store。</p>
<h3 id="把-synapse-link-當跨源-data-warehouse">把 Synapse Link 當跨源 data warehouse</h3>
<p>分析需要 join Cosmos 資料與其他系統的資料、期待 Synapse Link 解決、發現 analytical store 只有 Cosmos 單一 container / account 的資料。徵兆是「分析做到一半發現缺其他系統的維度資料、Synapse Link 帶不進來」。修法是跨源分析用獨立 warehouse（BigQuery / Snowflake / Synapse dedicated pool）集中、Cosmos 資料用 Synapse Link 或 Change Feed 餵進去當其中一個 source、不期待 Synapse Link 自己做跨源 join。</p>
<h3 id="既有-container-才想開發現要重建">既有 container 才想開、發現要重建</h3>
<p>analytical store 通常要建 container 時啟用、production 跑一陣子才想開、發現既有 container 的開啟有限制（時間敏感、查文件）、可能要新建 container + 遷資料。徵兆是「想開 analytical store 但介面不讓開 / 要重建」。修法是新 container 規劃時就評估未來是否需要分析、預先開 analytical store TTL（不用時成本影響有限）；既有 container 要開時、按文件評估是否需建新 container 遷移。</p>
<h3 id="anti-recommendation分析需求很輕不要起-federation">Anti-recommendation：分析需求很輕不要起 federation</h3>
<p>分析只是偶爾跑、資料量小、OLTP RU 有餘裕扛、且新鮮度要求即時 — 這種場景直接在 OLTP 上 query 或加少量 read 容量更簡單、不需要 analytical store 的額外儲存與 Synapse 的接入。Synapse Link 的價值在「分析會搶 OLTP 容量」或「不想搭 ETL」這兩個痛點明確時才成立；痛點不存在就引入 federation 是多一層東西要管。</p>
<h2 id="容量與觀測">容量與觀測</h2>
<ul>
<li>必看 metric：OLTP container 的 <code>NormalizedRUConsumption</code>（驗證分析 query 沒污染它）、analytical store 同步延遲、Synapse 端 query 的掃描量與成本</li>
<li>成本模型分離：analytical store 有獨立的 storage + 寫入計費、Synapse query 有自己的計費（serverless 按掃描量、dedicated 按 pool）— 跟 OLTP 的 RU 完全分開、不要混進 <a href="../ru-cost-model-sizing/">ru-cost-model-sizing</a> 的 RU 公式、那篇主寫 transactional store 的 RU</li>
<li>federation 的隔離證據：跑重型分析時 OLTP RU 平穩、就是 federation 生效；若 OLTP RU 仍隨分析波動、表示分析 query 其實打到了 transactional store、要檢查 query 是否真的走 analytical store</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>：OLTP 容量與 analytical 容量分兩條 budget 規劃、這正是 federation 的容量規劃價值 — 兩個 workload 不再互相競爭資源</li>
<li>Alert：analytical store 同步延遲異常增長、OLTP RU 出現非預期的分析時段波動（隔離失效）</li>
</ul>
<h2 id="邊界與整合">邊界與整合</h2>
<ul>
<li>Sibling deep articles：<a href="../change-feed-cdc/">change-feed-cdc</a>（自訂 transform / 跨源 routing 用 Change Feed、近即時 Cosmos 單源分析用 Synapse Link）、<a href="../ru-cost-model-sizing/">ru-cost-model-sizing</a>（analytical store 成本獨立於 OLTP RU、不混算）、<a href="../consistency-levels-engineering/">consistency-levels-engineering</a>（analytical store 是分鐘級延遲的衍生複本、不適用 OLTP 的 consistency level 語義）</li>
<li>federation 概念：<a href="/blog/backend/knowledge-cards/federation/" data-link-title="Federation" data-link-desc="跨系統信任與授權交換的聯邦機制">federation</a> — OLTP / OLAP 各自最佳化 store + 平台同步</li>
<li>跨源 / 重型分析的升級路由：Synapse dedicated pool / BigQuery / Snowflake — Cosmos DB overview「純 OLAP 分析」段已標明</li>
<li>回 overview：<a href="/blog/backend/01-database/vendors/cosmosdb/" data-link-title="Azure Cosmos DB" data-link-desc="全球分散式 multi-model DB、5 個 consistency levels、Microsoft 自家 dogfood 證據">Cosmos DB vendor overview</a> 的「跟 Azure Synapse Link 整合（OLTP / OLAP federation）」backlog 與「純 OLAP 分析」不適用場景</li>
<li>Microsoft 365 analytics 主 anchor：<a href="/blog/backend/09-performance-capacity/cases/microsoft-365-cosmos-db-analytics/" data-link-title="9.C30 Microsoft 365：從 MongoDB 遷移到 Cosmos DB 的分析平台" data-link-desc="Microsoft 365 把使用分析平台從 MongoDB 遷移到 Cosmos DB、planet-scale 全球分散式分析">9.C30</a> — analytics workload 建在 Cosmos 上的情境</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/cosmosdb/" data-link-title="Azure Cosmos DB" data-link-desc="全球分散式 multi-model DB、5 個 consistency levels、Microsoft 自家 dogfood 證據">Cosmos DB vendor overview</a> — 本文是該頁尾 Synapse Link backlog 的深度展開</li>
<li><a href="/blog/backend/09-performance-capacity/cases/microsoft-365-cosmos-db-analytics/" data-link-title="9.C30 Microsoft 365：從 MongoDB 遷移到 Cosmos DB 的分析平台" data-link-desc="Microsoft 365 把使用分析平台從 MongoDB 遷移到 Cosmos DB、planet-scale 全球分散式分析">9.C30 Microsoft 365 case</a> — Cosmos 上的全球分析平台情境 anchor</li>
<li><a href="../change-feed-cdc/">change-feed-cdc</a> — 自訂 pipeline 的對照路徑</li>
<li><a href="../ru-cost-model-sizing/">ru-cost-model-sizing</a> — OLTP RU 與 analytical 成本的分離</li>
<li><a href="/blog/backend/knowledge-cards/federation/" data-link-title="Federation" data-link-desc="跨系統信任與授權交換的聯邦機制">Federation 卡片</a> — OLTP / OLAP federation 概念基底</li>
<li>官方：<a href="https://learn.microsoft.com/azure/cosmos-db/synapse-link">Azure Synapse Link for Cosmos DB</a> / <a href="https://learn.microsoft.com/azure/cosmos-db/analytical-store-introduction">Analytical store</a></li>
</ul>
]]></content:encoded></item><item><title>CockroachDB Cloud Serverless 適用判斷：按用量 vs dedicated 的取捨與 RU 計費結構</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/cockroachdb/cloud-serverless/</link><pubDate>Tue, 02 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/cockroachdb/cloud-serverless/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/cockroachdb/" data-link-title="CockroachDB" data-link-desc="分散式 SQL、PostgreSQL 相容、跨區強一致、Spanner 的開源 / 跨雲替代">CockroachDB vendor overview&lt;/a> 的 implementation-layer deep article。寫作參照 &lt;a href="https://tarrragon.github.io/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">vendor deep article methodology&lt;/a>。本文聚焦 &lt;em>Cockroach Cloud serverless 與 dedicated 的取捨判讀、RU 計費結構、冷啟動 / scale 行為、何時用 serverless&lt;/em>。Self-managed 規模化的運維責任（Netflix Platform Team 養 380+ cluster）跟賽季型擴縮（Hard Rock 100 ↔ 33 node）作為 &lt;em>對照軸&lt;/em> 引用、不重展 self-host 運維細節。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="問題情境要-managed-cockroachdb但-serverless-跟-dedicated-該選哪個">問題情境：要 managed CockroachDB、但 serverless 跟 dedicated 該選哪個&lt;/h2>
&lt;p>團隊決定不自管 Raft / backup / upgrade，改走 Cockroach Cloud managed，接著面對的是 serverless 跟 dedicated 兩種 managed 形態的取捨。這個取捨不是「哪個比較好」，而是 &lt;em>容量壓力的形狀對應哪種計費與 scale 模型&lt;/em>。&lt;/p>
&lt;p>Cockroach Cloud serverless 是 &lt;em>把容量決策從「預先 provision 節點」換成「按實際用量計費 + 自動 scale」&lt;/em> 的 managed 形態。它消去了 cluster sizing 這個決策 — 沒有「要開幾個 node」的問題，資源隨 workload 自動伸縮，甚至閒置時 scale 到接近零。代價是計費單位變成抽象的 Request Unit（RU），用量暴衝時成本跟著暴衝，且共享底層資源帶來冷啟動與性能可預測性的取捨。&lt;/p>
&lt;p>dedicated 則保留 &lt;em>固定的 cluster 容量 + 可預測的計費&lt;/em>，由 vendor 代管運維但容量仍是團隊決策。&lt;/p>
&lt;p>讀者進來最常卡的三題：&lt;/p>
&lt;ul>
&lt;li>serverless 的 RU 計費到底計什麼、怎麼估自己的 workload 會花多少？&lt;/li>
&lt;li>serverless 閒置會 scale 到零，那冷啟動會不會讓第一個請求變慢？&lt;/li>
&lt;li>什麼 workload 適合 serverless、什麼時候該選 dedicated 或乾脆 self-managed？&lt;/li>
&lt;/ul>
&lt;p>這三題的共同核心是 &lt;em>把 workload 的流量形狀（穩定 vs 突發、可預測 vs 不可預測、高峰 vs 長尾）翻譯成計費與 scale 模型&lt;/em>。&lt;/p>
&lt;p>問題情境的對照 trigger 來自兩個 self-managed 規模的 case，它們界定了「什麼時候 serverless / dedicated 都不對、要 self-host」的邊界。&lt;/p>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/netflix-cockroachdb-multi-region-fleet/" data-link-title="9.C40 Netflix：380&amp;#43; CockroachDB cluster 的 multi-active 拓樸艦隊" data-link-desc="Netflix 把 Cassandra 不夠用的 transactional workload 移到 CockroachDB、380&amp;#43; cluster / 60&amp;#43; 跨 region、含 Open Connect、studio cloud drive、gaming control plane">9.C40 Netflix&lt;/a> 是 self-managed 380+ cluster（case 揭露 380+ 為含非 production 的總數、production cluster 160+），case 明確揭露這需要 &lt;em>專屬 Database Platform Team&lt;/em>（backup、upgrade、incident response、capacity review），並警示「沒這量級團隊就走 Cockroach Cloud managed、不要 self-host」。這條判讀的反向就是本文的入口 — 大多數團隊沒有 Platform Team，managed 才是合理起點，問題只剩 serverless 還是 dedicated。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/cockroachdb/" data-link-title="CockroachDB" data-link-desc="分散式 SQL、PostgreSQL 相容、跨區強一致、Spanner 的開源 / 跨雲替代">CockroachDB vendor overview</a> 的 implementation-layer deep article。寫作參照 <a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">vendor deep article methodology</a>。本文聚焦 <em>Cockroach Cloud serverless 與 dedicated 的取捨判讀、RU 計費結構、冷啟動 / scale 行為、何時用 serverless</em>。Self-managed 規模化的運維責任（Netflix Platform Team 養 380+ cluster）跟賽季型擴縮（Hard Rock 100 ↔ 33 node）作為 <em>對照軸</em> 引用、不重展 self-host 運維細節。</p></blockquote>
<hr>
<h2 id="問題情境要-managed-cockroachdb但-serverless-跟-dedicated-該選哪個">問題情境：要 managed CockroachDB、但 serverless 跟 dedicated 該選哪個</h2>
<p>團隊決定不自管 Raft / backup / upgrade，改走 Cockroach Cloud managed，接著面對的是 serverless 跟 dedicated 兩種 managed 形態的取捨。這個取捨不是「哪個比較好」，而是 <em>容量壓力的形狀對應哪種計費與 scale 模型</em>。</p>
<p>Cockroach Cloud serverless 是 <em>把容量決策從「預先 provision 節點」換成「按實際用量計費 + 自動 scale」</em> 的 managed 形態。它消去了 cluster sizing 這個決策 — 沒有「要開幾個 node」的問題，資源隨 workload 自動伸縮，甚至閒置時 scale 到接近零。代價是計費單位變成抽象的 Request Unit（RU），用量暴衝時成本跟著暴衝，且共享底層資源帶來冷啟動與性能可預測性的取捨。</p>
<p>dedicated 則保留 <em>固定的 cluster 容量 + 可預測的計費</em>，由 vendor 代管運維但容量仍是團隊決策。</p>
<p>讀者進來最常卡的三題：</p>
<ul>
<li>serverless 的 RU 計費到底計什麼、怎麼估自己的 workload 會花多少？</li>
<li>serverless 閒置會 scale 到零，那冷啟動會不會讓第一個請求變慢？</li>
<li>什麼 workload 適合 serverless、什麼時候該選 dedicated 或乾脆 self-managed？</li>
</ul>
<p>這三題的共同核心是 <em>把 workload 的流量形狀（穩定 vs 突發、可預測 vs 不可預測、高峰 vs 長尾）翻譯成計費與 scale 模型</em>。</p>
<p>問題情境的對照 trigger 來自兩個 self-managed 規模的 case，它們界定了「什麼時候 serverless / dedicated 都不對、要 self-host」的邊界。</p>
<p><a href="/blog/backend/09-performance-capacity/cases/netflix-cockroachdb-multi-region-fleet/" data-link-title="9.C40 Netflix：380&#43; CockroachDB cluster 的 multi-active 拓樸艦隊" data-link-desc="Netflix 把 Cassandra 不夠用的 transactional workload 移到 CockroachDB、380&#43; cluster / 60&#43; 跨 region、含 Open Connect、studio cloud drive、gaming control plane">9.C40 Netflix</a> 是 self-managed 380+ cluster（case 揭露 380+ 為含非 production 的總數、production cluster 160+），case 明確揭露這需要 <em>專屬 Database Platform Team</em>（backup、upgrade、incident response、capacity review），並警示「沒這量級團隊就走 Cockroach Cloud managed、不要 self-host」。這條判讀的反向就是本文的入口 — 大多數團隊沒有 Platform Team，managed 才是合理起點，問題只剩 serverless 還是 dedicated。</p>
<p><a href="/blog/backend/09-performance-capacity/cases/hard-rock-digital-cockroachdb-sports-betting/" data-link-title="9.C41 Hard Rock Digital：CockroachDB on AWS Outposts、Wire Act 合規 &#43; 跨州單一邏輯 DB" data-link-desc="Hard Rock Digital 用 CockroachDB 跨 AWS Outposts &#43; US-East-1、Wire Act 強制資料留州、單一邏輯 DB 解多州 sportsbook、100 node 32 vCPU 撐 Super Bowl">9.C41 Hard Rock Digital</a> 是 self-managed、賽季型擴縮（高峰 ~100 node、淡季 ~33 node，case 觀察段揭露）。這個 100 ↔ 33 的擺盪是 <em>已知時間點的年度循環</em>（NFL / NBA 賽季切換），不是不可預測的突發。case 還揭露合規驅動需要 AWS Outposts 把運算放進州內 — 這把它鎖死在 self-managed。Hard Rock 的形狀正好對照出 serverless 的適配範圍：serverless 擅長 <em>不可預測</em> 的突發與長尾閒置，而非 <em>可預測且需要特定部署位置</em> 的賽季擴縮。</p>
<h2 id="核心機制ru-計費--自動-scale--冷啟動">核心機制：RU 計費 + 自動 scale + 冷啟動</h2>
<h3 id="request-unit把多維資源用量折算成單一計費單位">Request Unit：把多維資源用量折算成單一計費單位</h3>
<p>serverless 的計費核心是 Request Unit（RU）— 一個把 <em>CPU、IO、network、storage 存取</em> 等多維資源用量折算成的抽象單位。每個 SQL 請求依其實際消耗的資源換算成若干 RU，帳單按 RU 總量計。這跟 dedicated「按 provision 的節點數 × 時間」計費是兩種不同的成本心智模型。</p>
<p>RU 模型的好處是 <em>用多少付多少</em> — 閒置時段不付運算費。風險是 RU 跟「人類直覺的請求數」不是線性對應：一個全表掃描的 query 可能吃掉相當於上千個點查的 RU。estimate workload 成本時，要以 <em>資源消耗</em> 為單位思考，不是以「請求數」。</p>
<blockquote>
<p><strong>Scope warning</strong>：RU 的具體換算係數、serverless 免費額度、scale-to-zero 的觸發閒置時間、冷啟動延遲量級、serverless 的 region / 一致性 / 規模上限，都屬 Cockroach Cloud 的計費與規格、且隨方案版本演進，三個 anchor case（DoorDash / Netflix / Hard Rock 全為 self-managed）都未揭露 serverless 計費數字。本文只給結構性判讀（RU = 多維資源折算、scale-to-zero 帶來冷啟動），具體數值與當前方案邊界需 cross-verify <a href="https://www.cockroachlabs.com/docs/cockroachcloud/plan-your-cluster">Cockroach Cloud Pricing 文件</a> 與官方計費頁。</p></blockquote>
<h3 id="自動-scale-與-scale-to-zero">自動 scale 與 scale-to-zero</h3>
<p>serverless 隨 workload 自動伸縮資源，無需團隊 provision。閒置時可 scale 到接近零，這正是「閒置不付運算費」的來源。對 <em>突發 + 長閒置</em> 的 workload（開發 / 測試環境、低流量 side project、流量極不均的早期產品），這個模型把成本壓到只反映實際活躍時段。</p>
<p>scale-to-zero 的代價是冷啟動 — 從近零狀態接到請求時，要先把資源拉起來，第一個請求的延遲高於 warm 狀態。對開發環境這通常可接受；對「閒置後第一個用戶請求就要快」的面向用戶 production 路徑，冷啟動是要先評估的取捨。</p>
<h3 id="serverless-vs-dedicated-的責任與成本對照">serverless vs dedicated 的責任與成本對照</h3>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>serverless</th>
          <th>dedicated</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>容量決策</td>
          <td>自動 scale、無需 sizing</td>
          <td>團隊決定 cluster 規模</td>
      </tr>
      <tr>
          <td>計費單位</td>
          <td>RU（按實際資源用量）</td>
          <td>按 provision 的節點 × 時間</td>
      </tr>
      <tr>
          <td>閒置成本</td>
          <td>接近零（scale-to-zero）</td>
          <td>仍付 provisioned 容量費</td>
      </tr>
      <tr>
          <td>冷啟動</td>
          <td>閒置後第一請求有冷啟動延遲</td>
          <td>無（容量常駐）</td>
      </tr>
      <tr>
          <td>成本可預測性</td>
          <td>隨用量浮動、突發時可能暴衝</td>
          <td>固定、可預算</td>
      </tr>
      <tr>
          <td>性能可預測性</td>
          <td>共享底層、受鄰居影響</td>
          <td>專屬資源、更可預測</td>
      </tr>
  </tbody>
</table>
<p>每一行都要回到 workload 形狀判讀。</p>
<p>容量決策這一行是兩種模型的根本差異：serverless 把「要開幾個節點」這個決策從團隊手上拿走，對沒有容量規劃經驗或流量極不可預測的場景能降低團隊的容量規劃負擔；但對流量已知、需要性能可預測的 production，dedicated 的「自己定容量」反而是想要的控制權。</p>
<p>成本可預測性這一行是 serverless 的主要風險面。RU 隨用量浮動意味著 <em>一次失控的查詢模式、一波爬蟲、一個沒加 LIMIT 的全表掃描</em> 都會把帳單推高，而 dedicated 的成本上限就是 provisioned 容量。流量可預測的 production，dedicated 的可預算性往往比 serverless 的「用多少付多少」更重要。</p>
<h2 id="操作流程選型判讀配置用量驗證">操作流程：選型判讀、配置、用量驗證</h2>
<h3 id="第一步用流量形狀做-serverless--dedicated-初判">第一步：用流量形狀做 serverless / dedicated 初判</h3>
<p>選型的判讀軸是 workload 的 <em>流量形狀</em>，不是規模大小。</p>
<ul>
<li>流量突發 + 長閒置（dev / test、低流量產品、不可預測早期 workload）→ serverless 的 scale-to-zero 與按用量計費直接受益。</li>
<li>流量穩定 + 可預測 + 需要性能可預測 → dedicated 的固定容量與可預算成本更合適。</li>
<li>流量大 + 有專屬 Platform Team + 需要跨雲 / on-prem / 特定部署位置（如 Hard Rock 的合規 Outposts）→ 兩種 managed 都不對，走 self-managed（見 vendor overview 的容量規劃段）。</li>
</ul>
<p>判讀訊號：把過去一段時間的 QPS 畫成時間序列，看「活躍時段佔比」與「峰谷比」。活躍佔比低、峰谷比高 → serverless;活躍佔比高、波動平緩 → dedicated。</p>
<h3 id="第二步serverless-建立-cluster-並設成本上限">第二步：serverless 建立 cluster 並設成本上限</h3>
<p>serverless 的成本風險來自用量浮動，所以建立後第一件事是設 <em>消費上限</em>，把「用量暴衝 = 帳單暴衝」的尾部風險封住。</p>
<p>驗證點：cluster 建立後，確認消費上限已設、且設了接近上限的告警閾值（例如達上限 80% 告警）。沒設上限的 serverless cluster 等於把成本曝險完全交給 workload 行為。</p>
<h3 id="第三步驗證-ru-消耗與預期一致">第三步：驗證 RU 消耗與預期一致</h3>
<p>上線後監控 RU 消耗速率，對照第一步的流量形狀預估。</p>
<p>驗證點：RU 消耗速率若遠高於預估，通常是某類 query 的資源消耗被低估（全表掃描、缺索引、N+1 查詢）。這時要回到 query 層優化，而非直接加預算 — serverless 的計費把「低效 query」直接翻譯成「高帳單」，是一個比 dedicated 更直接的成本訊號。</p>
<h3 id="第四步評估冷啟動對-production-路徑的影響">第四步：評估冷啟動對 production 路徑的影響</h3>
<p>若 serverless cluster 服務面向用戶的 production 路徑，驗證閒置後第一個請求的延遲是否在 SLO 內。</p>
<p>驗證點：模擬閒置後的首請求延遲，對照面向用戶路徑的 latency SLO。超出 SLO 代表這條路徑不適合 scale-to-zero，要嘛保持一定 warm 流量、要嘛改 dedicated。</p>
<h2 id="失敗模式成本失控與選型誤判">失敗模式：成本失控與選型誤判</h2>
<h3 id="ru-用量暴衝帳單失控高代價情境的回退敘事">RU 用量暴衝、帳單失控（高代價情境的回退敘事）</h3>
<p>serverless 最常見的事故是 <em>帳單暴衝</em> — 一波非預期流量、一個低效查詢上線、一次爬蟲，把 RU 消耗推到遠超預算。跟 dedicated「成本上限 = provisioned 容量」不同，serverless 的成本上限要靠人為設定，沒設就沒有天花板。</p>
<p>這個情境的回退代價特殊之處在於 <em>成本已經發生</em>：rebalance 可以暫停、locality 可以改回，但已計的 RU 帳單不會退回。所以 serverless 成本失控的「回退」重點在 <em>事前封頂</em> 與 <em>事中熔斷</em>，而非事後補救。</p>
<p>回退與防護要素：</p>
<ul>
<li>事前一定設消費上限與分級告警（接近上限前就要收到訊號），把尾部風險封在可承受範圍。</li>
<li>事中發現 RU 暴衝，先定位來源 — 是流量真的漲（業務事件），還是某個 query 模式失控（缺索引、全表掃描、無 LIMIT）。前者考慮是否該轉 dedicated，後者回 query 層修。</li>
<li>設「RU 消耗速率超過閾值就告警 + 自動限流」的 tripwire，避免單一失控 query 在無人值守時段燒完整月預算。</li>
<li>若 workload 已穩定成長到「serverless 浮動成本 &gt; dedicated 固定成本」的交叉點，規劃轉 dedicated。</li>
</ul>
<h3 id="serverless--dedicated-遷移的代價">serverless → dedicated 遷移的代價</h3>
<p>當 workload 從「突發長尾」成長為「穩定高量」，serverless 的按用量成本會超過 dedicated 的固定成本，此時要遷移。這個遷移不是改個開關 — serverless 與 dedicated 是不同的 cluster 形態，遷移意味著資料搬遷與 cutover，要走 backup / restore 或資料複製流程，並承擔 cutover 窗口。</p>
<p>回退敘事：把 serverless → dedicated 當成一次小型 migration 規劃 — 估資料量與遷移窗口、雙寫或 backup/restore 路徑、cutover 條件與回退條件，而非「線上無痛切換」。提早在用量逼近成本交叉點時規劃，避免在帳單已經失控時倉促遷移。</p>
<p>Anti-recommendation：不要因為「serverless 聽起來更現代」就把已知穩定、可預測、高流量的 production workload 開在 serverless。這類 workload 的可預算性與性能可預測性，dedicated 給得更直接，serverless 反而引入成本浮動與冷啟動兩個非必要風險。</p>
<h3 id="把賽季型--可預測擴縮誤當-serverless-場景">把賽季型 / 可預測擴縮誤當 serverless 場景</h3>
<p>可預測的擴縮（如 Hard Rock 的 NFL / NBA 賽季 100 ↔ 33 node 年度循環）不是 serverless 的適配範圍。serverless 擅長 <em>不可預測</em> 的突發，而可預測的擴縮可以用 dedicated 的計畫內 scale 直接規劃容量、保留性能可預測性。把可預測擴縮交給 serverless，是用「成本浮動 + 冷啟動」換一個本來就能用排程解決的問題。</p>
<p>修法：可預測的容量循環，用 dedicated + 排程 scale；只有真正不可預測的突發長尾才用 serverless。</p>
<h3 id="冷啟動拖垮面向用戶路徑">冷啟動拖垮面向用戶路徑</h3>
<p>scale-to-zero 的 serverless cluster 服務面向用戶 production，閒置後首請求冷啟動延遲超出 SLO，用戶感受到第一次訪問特別慢。</p>
<p>修法：面向用戶且對首請求延遲敏感的路徑，要嘛維持低頻 warm 流量避免完全 scale-to-zero，要嘛改 dedicated；scale-to-zero 留給容忍冷啟動的 dev / test / 後台 batch 路徑。</p>
<h2 id="容量與觀測">容量與觀測</h2>
<h3 id="必看-metric">必看 metric</h3>
<ul>
<li><code>RU 消耗速率</code>：serverless 成本的直接訊號，速率異常上升要立刻定位 query 來源。</li>
<li><code>當期累計消費 vs 上限</code>：成本封頂的剩餘空間，逼近上限要告警。</li>
<li><code>冷啟動 / 首請求延遲</code>：scale-to-zero 對面向用戶路徑的影響。</li>
<li><code>query 資源消耗分佈</code>：哪些 query 吃掉最多 RU，是 serverless 成本優化的入口。</li>
</ul>
<h3 id="容量與成本判讀">容量與成本判讀</h3>
<ul>
<li>serverless 月成本 ≈ Σ(各 query RU × 頻率)，所以成本優化等於 query 效率優化 — 缺索引、全表掃描在 serverless 直接體現為帳單。</li>
<li>serverless / dedicated 成本交叉點 ≈ 「serverless 浮動成本」與「dedicated 固定容量成本」相等的用量水準，逼近交叉點是規劃遷移的訊號。</li>
<li>dedicated 的容量規劃回到節點數 × replica × latency budget（見 vendor overview 容量規劃段）。</li>
</ul>
<blockquote>
<p><strong>Scope warning</strong>：RU 換算係數、免費額度、serverless 的規模 / region / 一致性上限、serverless ↔ dedicated 成本交叉點的具體用量水準，均為 Cockroach Cloud 計費與規格、隨方案版本變動，非 case 揭露數字，成本建模前以 <a href="https://www.cockroachlabs.com/docs/cockroachcloud/">Cockroach Cloud 文件</a> cross-verify。</p></blockquote>
<h3 id="回路徑">回路徑</h3>
<ul>
<li><a href="/blog/backend/09-performance-capacity/" data-link-title="模組九：效能工程與容量規劃" data-link-desc="把『目前配置能撐多少、要加多少機器』變成可量化、可驗證、可改進的工程流程">9.6 容量規劃模型</a> 流量形狀 → 計費模型對應。</li>
<li><a href="/blog/backend/09-performance-capacity/" data-link-title="模組九：效能工程與容量規劃" data-link-desc="把『目前配置能撐多少、要加多少機器』變成可量化、可驗證、可改進的工程流程">9.7 成本邊界與 efficiency</a> managed vs self-managed 的人力 + 資源成本權衡。</li>
</ul>
<h2 id="邊界與整合">邊界與整合</h2>
<h3 id="sibling-deep-articles">Sibling deep articles</h3>
<ul>
<li><a href="../survival-goals/">survival goals</a>：managed 形態下 survival goal 仍是團隊決策 — serverless / dedicated 都要對齊業務 RTO / RPO，存活機制以該文為 SSoT。</li>
<li><a href="../multi-region-table-config/">multi-region table config</a>：serverless 與 dedicated 對 multi-region table locality 的支援邊界不同，跨 region 強一致需求要先確認所選 managed 形態是否覆蓋。</li>
<li><a href="../aurora-dsql-spanner-decision-tree/">aurora-dsql-spanner-decision-tree</a>：Aurora DSQL 本身是 serverless distributed SQL，三家 managed distributed SQL 的選型對比以該文為 SSoT，本文不重展。</li>
</ul>
<h3 id="跟-aurora-dsql--spanner-serverless-對照">跟 Aurora DSQL / Spanner serverless 對照</h3>
<p>Aurora DSQL（AWS）以 serverless 為核心形態、AWS-only；Spanner 提供 managed 但計費與 scale 模型不同。三家在 serverless / managed 維度的完整對比是 <a href="../aurora-dsql-spanner-decision-tree/">aurora-dsql-spanner-decision-tree</a> 的 SSoT，本文只處理 Cockroach Cloud 自身的 serverless / dedicated 取捨。</p>
<h3 id="跟-self-managed-對照">跟 self-managed 對照</h3>
<p>self-managed（如 Netflix 380+ cluster、Hard Rock 合規 Outposts）給最大控制權（跨雲 / on-prem / 特定部署位置），代價是專屬 Platform Team 的運維責任。判讀軸：沒有 Platform Team → managed（serverless / dedicated）；有 Platform Team + 需要特定部署位置或跨雲 → self-managed。</p>
<h3 id="1x-章節互引">1.x 章節互引</h3>
<ul>
<li><a href="/blog/backend/01-database/global-distributed-oltp/" data-link-title="1.11 全球分散式 OLTP" data-link-desc="Spanner / Aurora DSQL / Cosmos DB multi-region write / CockroachDB / TiDB 的全球一致性取捨">1.11 全球分散式 OLTP</a> 上游選型。</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/migrate-to-cockroachdb/" data-link-title="PostgreSQL → CockroachDB：三維皆 High 的多重歸類 migration" data-link-desc="PostgreSQL → CockroachDB 是 Schema / Operational / Paradigm 三維皆 High 的 multi-axis migration、實證 [#127](/report/content-structure-by-max-diff-dimension/) 的「多重歸類跟 tie-breaking」規則；主結構走 Type E paradigm shift、Schema 差 &#43; Operational redesign 抽出獨立段；涵蓋 transaction model 重設計、SQL dialect gap、5 個 production 踩雷">PostgreSQL → CockroachDB migration</a> — 從 PostgreSQL 遷入後再選 managed 形態。</li>
</ul>
<h3 id="何時不用本文">何時不用本文</h3>
<ul>
<li>已決定 self-managed（有 Platform Team 或需要 on-prem / 合規 Outposts）→ 看 vendor overview 容量規劃段與 self-host 運維，本文的 serverless / dedicated 取捨不適用。</li>
<li>single-region 小 workload 且 PostgreSQL 已夠用 → 先確認是否真需要 distributed SQL，見 vendor overview 不適用場景。</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/cockroachdb/" data-link-title="CockroachDB" data-link-desc="分散式 SQL、PostgreSQL 相容、跨區強一致、Spanner 的開源 / 跨雲替代">CockroachDB vendor overview</a></li>
<li><a href="/blog/backend/09-performance-capacity/cases/netflix-cockroachdb-multi-region-fleet/" data-link-title="9.C40 Netflix：380&#43; CockroachDB cluster 的 multi-active 拓樸艦隊" data-link-desc="Netflix 把 Cassandra 不夠用的 transactional workload 移到 CockroachDB、380&#43; cluster / 60&#43; 跨 region、含 Open Connect、studio cloud drive、gaming control plane">9.C40 Netflix</a>（self-managed 需 Platform Team 的反向 = managed 入口）</li>
<li><a href="/blog/backend/09-performance-capacity/cases/hard-rock-digital-cockroachdb-sports-betting/" data-link-title="9.C41 Hard Rock Digital：CockroachDB on AWS Outposts、Wire Act 合規 &#43; 跨州單一邏輯 DB" data-link-desc="Hard Rock Digital 用 CockroachDB 跨 AWS Outposts &#43; US-East-1、Wire Act 強制資料留州、單一邏輯 DB 解多州 sportsbook、100 node 32 vCPU 撐 Super Bowl">9.C41 Hard Rock Digital</a>（可預測賽季擴縮 vs serverless 突發適配範圍的對照）</li>
<li><a href="/blog/backend/knowledge-cards/distributed-sql/" data-link-title="Distributed SQL" data-link-desc="把 SQL 與交易語意延伸到多節點與多區域的資料庫形態">distributed SQL 卡</a></li>
<li>官方：<a href="https://www.cockroachlabs.com/docs/cockroachcloud/">Cockroach Cloud Documentation</a> / <a href="https://www.cockroachlabs.com/docs/cockroachcloud/plan-your-cluster">Plan Your Cluster</a></li>
</ul>
]]></content:encoded></item><item><title>CockroachDB vs Aurora DSQL vs Spanner：撞牆訊號分型 + 七問題決策樹</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/cockroachdb/aurora-dsql-spanner-decision-tree/</link><pubDate>Wed, 27 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/cockroachdb/aurora-dsql-spanner-decision-tree/</guid><description>&lt;blockquote>
&lt;p>本文是 DB4 distributed SQL 選型的 &lt;em>entry point&lt;/em> deep article — 讀者進來時還沒決定哪個 vendor、甚至還沒釐清「我是不是該換 distributed SQL」。本文先用 &lt;em>撞牆訊號分型&lt;/em> 幫讀者識別自己屬哪條 driver path、再進三軸 vendor 對比、最後落到 team size + sizing 邊界檢查。配合 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/cockroachdb/" data-link-title="CockroachDB" data-link-desc="分散式 SQL、PostgreSQL 相容、跨區強一致、Spanner 的開源 / 跨雲替代">CockroachDB vendor overview&lt;/a> + &lt;a href="https://tarrragon.github.io/blog/backend/01-database/global-distributed-oltp/" data-link-title="1.11 全球分散式 OLTP" data-link-desc="Spanner / Aurora DSQL / Cosmos DB multi-region write / CockroachDB / TiDB 的全球一致性取捨">1.11 全球分散式 OLTP&lt;/a> 閱讀。寫作參照 &lt;a href="https://tarrragon.github.io/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">vendor deep article methodology&lt;/a>。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="為什麼先講-driver-path不直接比-vendor">為什麼先講 driver path、不直接比 vendor&lt;/h2>
&lt;p>團隊評估「全球分散式 OLTP 三選一」時最常見的源頭錯誤：先比 vendor、再回頭問「我為什麼要 distributed SQL」。三家 vendor 文件都說「跨 region 強一致 SQL」、看不出實際取捨；做錯選擇後遷移成本極高。&lt;/p>
&lt;p>正確順序應該反過來：先識別 &lt;em>自己為什麼要評估 distributed SQL&lt;/em>、再進 vendor 比較。三條 driver path 各自的訊號、適配 vendor、決策路徑都不同 — 不識別 driver path 直接比 vendor 是源頭錯誤。&lt;/p>
&lt;p>讀者進來最常問的問題（多數會問錯順序）：&lt;/p>
&lt;ul>
&lt;li>我是不是真該換 distributed SQL、還是 Aurora / Cloud SQL 還能撐？&lt;/li>
&lt;li>Spanner 在 Google 跑了 10 年、CockroachDB 跟 DSQL 比較新、成熟度差多少？&lt;/li>
&lt;li>我有 PostgreSQL 應用、三家相容性差在哪？&lt;/li>
&lt;li>跨雲是硬需求還是被 fear 推的？&lt;/li>
&lt;li>DSQL 2024 才 GA、production 風險多大？&lt;/li>
&lt;li>我團隊 50 人能不能養 self-managed CockroachDB？&lt;/li>
&lt;li>Spanner 100 pu 起跳對我中小 PG workload 划算嗎？&lt;/li>
&lt;/ul>
&lt;p>7 題本文都會回答、但先回答「你是哪條 driver path」這個前置問題 0。&lt;/p>
&lt;h3 id="三條-driver-path-的-case-anchor">三條 driver path 的 case anchor&lt;/h3>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/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&lt;/a>：Aurora Postgres 1.636 M QPS single-primary 撞牆 → 換 multi-primary、PostgreSQL wire 相容降低遷移阻力（F4.1 / F4.2 / F4.4）&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/netflix-cockroachdb-multi-region-fleet/" data-link-title="9.C40 Netflix：380&amp;#43; CockroachDB cluster 的 multi-active 拓樸艦隊" data-link-desc="Netflix 把 Cassandra 不夠用的 transactional workload 移到 CockroachDB、380&amp;#43; cluster / 60&amp;#43; 跨 region、含 Open Connect、studio cloud drive、gaming control plane">9.C40 Netflix&lt;/a>：Cassandra eventual consistency 撐不住 transactional → 補 distributed SQL、self-managed 380+ cluster + Database Platform Team（F4.6 / F4.9）&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/hard-rock-digital-cockroachdb-sports-betting/" data-link-title="9.C41 Hard Rock Digital：CockroachDB on AWS Outposts、Wire Act 合規 &amp;#43; 跨州單一邏輯 DB" data-link-desc="Hard Rock Digital 用 CockroachDB 跨 AWS Outposts &amp;#43; US-East-1、Wire Act 強制資料留州、單一邏輯 DB 解多州 sportsbook、100 node 32 vCPU 撐 Super Bowl">9.C41 Hard Rock Digital&lt;/a>：Wire Act 合規驅動 + 50 人 tech team + Outposts 混合部署（F4.10 / F4.14）&lt;/li>
&lt;/ul>
&lt;p>對照 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/spanner-planetary-scale-database-gcp/" data-link-title="9.C10 Cloud Spanner：每秒 10 億請求的全球一致性資料庫" data-link-desc="Google Cloud Spanner 內部峰值 10 億 req/sec、跨地區強一致 — 全球分散式 OLTP 容量參考">9.C10 Spanner planetary scale&lt;/a> 提供 Spanner ground truth（含 sizing barrier、F3.16）、&lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/standard-chartered-aurora-banking/" data-link-title="9.C14 Standard Chartered：受監管銀行的 Aurora 4000 TPS 容量提升" data-link-desc="Standard Chartered 銀行遷移到 Aurora 後吞吐量提升 10 倍至 4000 TPS、跨 7 個受監管市場">9.C14 Standard Chartered&lt;/a> 提供 Aurora 受監管金融的另一條路徑、&lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/draftkings-aurora-financial-ledger/" data-link-title="9.C4 DraftKings：Aurora 撐 100 萬 ops/min 的體育博彩金融帳本" data-link-desc="DraftKings 用 Aurora MySQL 跑體育博彩金融帳本、Super Bowl 流量 &amp;#43;50% 不影響延遲">9.C4 DraftKings Aurora financial ledger&lt;/a> 提供 Aurora 內 business sharding 路徑（不換引擎）。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 DB4 distributed SQL 選型的 <em>entry point</em> deep article — 讀者進來時還沒決定哪個 vendor、甚至還沒釐清「我是不是該換 distributed SQL」。本文先用 <em>撞牆訊號分型</em> 幫讀者識別自己屬哪條 driver path、再進三軸 vendor 對比、最後落到 team size + sizing 邊界檢查。配合 <a href="/blog/backend/01-database/vendors/cockroachdb/" data-link-title="CockroachDB" data-link-desc="分散式 SQL、PostgreSQL 相容、跨區強一致、Spanner 的開源 / 跨雲替代">CockroachDB vendor overview</a> + <a href="/blog/backend/01-database/global-distributed-oltp/" data-link-title="1.11 全球分散式 OLTP" data-link-desc="Spanner / Aurora DSQL / Cosmos DB multi-region write / CockroachDB / TiDB 的全球一致性取捨">1.11 全球分散式 OLTP</a> 閱讀。寫作參照 <a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">vendor deep article methodology</a>。</p></blockquote>
<hr>
<h2 id="為什麼先講-driver-path不直接比-vendor">為什麼先講 driver path、不直接比 vendor</h2>
<p>團隊評估「全球分散式 OLTP 三選一」時最常見的源頭錯誤：先比 vendor、再回頭問「我為什麼要 distributed SQL」。三家 vendor 文件都說「跨 region 強一致 SQL」、看不出實際取捨；做錯選擇後遷移成本極高。</p>
<p>正確順序應該反過來：先識別 <em>自己為什麼要評估 distributed SQL</em>、再進 vendor 比較。三條 driver path 各自的訊號、適配 vendor、決策路徑都不同 — 不識別 driver path 直接比 vendor 是源頭錯誤。</p>
<p>讀者進來最常問的問題（多數會問錯順序）：</p>
<ul>
<li>我是不是真該換 distributed SQL、還是 Aurora / Cloud SQL 還能撐？</li>
<li>Spanner 在 Google 跑了 10 年、CockroachDB 跟 DSQL 比較新、成熟度差多少？</li>
<li>我有 PostgreSQL 應用、三家相容性差在哪？</li>
<li>跨雲是硬需求還是被 fear 推的？</li>
<li>DSQL 2024 才 GA、production 風險多大？</li>
<li>我團隊 50 人能不能養 self-managed CockroachDB？</li>
<li>Spanner 100 pu 起跳對我中小 PG workload 划算嗎？</li>
</ul>
<p>7 題本文都會回答、但先回答「你是哪條 driver path」這個前置問題 0。</p>
<h3 id="三條-driver-path-的-case-anchor">三條 driver path 的 case anchor</h3>
<ul>
<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</a>：Aurora Postgres 1.636 M QPS single-primary 撞牆 → 換 multi-primary、PostgreSQL wire 相容降低遷移阻力（F4.1 / F4.2 / F4.4）</li>
<li><a href="/blog/backend/09-performance-capacity/cases/netflix-cockroachdb-multi-region-fleet/" data-link-title="9.C40 Netflix：380&#43; CockroachDB cluster 的 multi-active 拓樸艦隊" data-link-desc="Netflix 把 Cassandra 不夠用的 transactional workload 移到 CockroachDB、380&#43; cluster / 60&#43; 跨 region、含 Open Connect、studio cloud drive、gaming control plane">9.C40 Netflix</a>：Cassandra eventual consistency 撐不住 transactional → 補 distributed SQL、self-managed 380+ cluster + Database Platform Team（F4.6 / F4.9）</li>
<li><a href="/blog/backend/09-performance-capacity/cases/hard-rock-digital-cockroachdb-sports-betting/" data-link-title="9.C41 Hard Rock Digital：CockroachDB on AWS Outposts、Wire Act 合規 &#43; 跨州單一邏輯 DB" data-link-desc="Hard Rock Digital 用 CockroachDB 跨 AWS Outposts &#43; US-East-1、Wire Act 強制資料留州、單一邏輯 DB 解多州 sportsbook、100 node 32 vCPU 撐 Super Bowl">9.C41 Hard Rock Digital</a>：Wire Act 合規驅動 + 50 人 tech team + Outposts 混合部署（F4.10 / F4.14）</li>
</ul>
<p>對照 <a href="/blog/backend/09-performance-capacity/cases/spanner-planetary-scale-database-gcp/" data-link-title="9.C10 Cloud Spanner：每秒 10 億請求的全球一致性資料庫" data-link-desc="Google Cloud Spanner 內部峰值 10 億 req/sec、跨地區強一致 — 全球分散式 OLTP 容量參考">9.C10 Spanner planetary scale</a> 提供 Spanner ground truth（含 sizing barrier、F3.16）、<a href="/blog/backend/09-performance-capacity/cases/standard-chartered-aurora-banking/" data-link-title="9.C14 Standard Chartered：受監管銀行的 Aurora 4000 TPS 容量提升" data-link-desc="Standard Chartered 銀行遷移到 Aurora 後吞吐量提升 10 倍至 4000 TPS、跨 7 個受監管市場">9.C14 Standard Chartered</a> 提供 Aurora 受監管金融的另一條路徑、<a href="/blog/backend/09-performance-capacity/cases/draftkings-aurora-financial-ledger/" data-link-title="9.C4 DraftKings：Aurora 撐 100 萬 ops/min 的體育博彩金融帳本" data-link-desc="DraftKings 用 Aurora MySQL 跑體育博彩金融帳本、Super Bowl 流量 &#43;50% 不影響延遲">9.C4 DraftKings Aurora financial ledger</a> 提供 Aurora 內 business sharding 路徑（不換引擎）。</p>
<h2 id="撞牆訊號分型你的-driver-path-是哪一條前置問題-0f4-frame-1">撞牆訊號分型：你的 driver path 是哪一條（前置問題 0、F4 Frame 1）</h2>
<p>讀者進來前先回答：你 <em>為什麼</em> 要評估 distributed SQL？三條 driver path 各自的訊號、適配 vendor、決策路徑都不同。</p>
<h3 id="path-a--single-primary-寫入撞牆9c39-doordash-路徑f42--f46">Path A — single-primary 寫入撞牆（9.C39 DoorDash 路徑、F4.2 + F4.6）</h3>
<p>訊號：</p>
<ul>
<li>寫入量持續成長、Aurora / RDS / Cloud SQL primary CPU + WAL flush rate 接近上限</li>
<li>轉折點 <em>不是 IOPS、是 primary CPU + WAL flush rate</em>（F4.2、DoorDash 策略段 1）</li>
<li>已嘗試 vertical scale primary、撞 instance ceiling</li>
</ul>
<p>DoorDash concrete reference：2020-04-17 高峰 &gt; 1.636 M QPS、multi-hour outage（觀察段表格）。<strong>Scope warning（F4.1、case 自帶警示）</strong>：1.636 M QPS 是 <em>Aurora 撞牆的痛點</em> — 不是「CockroachDB throughput claim」、case 沒揭露遷移後單一 CockroachDB cluster 的峰值、只說「跑更多 cluster、alert volume 反而下降」。</p>
<p>適配 vendor：CockroachDB / Aurora DSQL / Spanner 都解、選擇看其他軸。</p>
<h3 id="path-b--eventual-consistency-缺口9c40-netflix-路徑f46">Path B — eventual consistency 缺口（9.C40 Netflix 路徑、F4.6）</h3>
<p>訊號：原本用 Cassandra / Riak / DynamoDB eventual consistency、遇到 <em>5 條件並存</em> 需求：</p>
<ol>
<li>multi-active topology（多 region 都可寫）</li>
<li>global consistent secondary index（跨 region 一致的二級索引）</li>
<li>global transaction（跨 row / 跨 region 的 ACID）</li>
<li>open source</li>
<li>SQL</li>
</ol>
<p>Cassandra 在 transactional 場景下 <em>湊不齊</em> 這五項。Netflix 2019 評估後選 CockroachDB（5 條件 case 直接列出、判讀段 1）。具體場景：Studio Cloud Drive（強一致 metadata + 全球可寫）、Open Connect 控制平面、Spinnaker（持續交付）、Maestro（ML / 資料 workflow）、Gaming 控制平面。</p>
<p>適配 vendor：CockroachDB（open source + SQL 兩條件硬卡）、Spanner（若 GCP-only 可放鬆 open source 要求）。</p>
<h3 id="path-c--合規驅動的地理邊界--跨-boundary-業務邏輯需求9c41-hard-rock-路徑f410">Path C — 合規驅動的地理邊界 + 跨 boundary 業務邏輯需求（9.C41 Hard Rock 路徑、F4.10）</h3>
<p>訊號：</p>
<ul>
<li>法規要求資料留某地理邊界（Wire Act 跨州、GDPR 跨國、各州博彩牌照）</li>
<li><em>同時</em> 業務邏輯需要跨 boundary（跨州統一帳戶 / 跨州 reporting / 欺詐偵測）</li>
</ul>
<p>Hard Rock concrete reference：跨 8 州（AZ / IN / TN / FL / OH / IL / NJ / VA）+ AWS Outposts + 邏輯一個 cluster（觀察段表格）。詳細 schema 配置見 <a href="../locality-aware-schema/">locality-aware schema</a>。</p>
<p>適配 vendor：CockroachDB（locality + placement + Outposts）、Spanner（GCP region 內 placement、無 Outposts 等效）、Aurora DSQL 跨 region 強一致但 Outpost 部署現階段未完整覆蓋。</p>
<h3 id="不該換-distributed-sql-的訊號">不該換 distributed SQL 的訊號</h3>
<ul>
<li>single-region OLTP 已足夠</li>
<li>寫入量未撞 single-primary 天花板（Aurora db.r6g.16xlarge 還沒滿）</li>
<li>無跨 region 業務需求</li>
<li>無跨 boundary 合規需求</li>
</ul>
<p>→ PostgreSQL / Aurora 足夠、distributed SQL overhead（寫入 2-5x latency、ops 複雜度）不划算。對應 <a href="/blog/backend/09-performance-capacity/cases/draftkings-aurora-financial-ledger/" data-link-title="9.C4 DraftKings：Aurora 撐 100 萬 ops/min 的體育博彩金融帳本" data-link-desc="DraftKings 用 Aurora MySQL 跑體育博彩金融帳本、Super Bowl 流量 &#43;50% 不影響延遲">9.C4 DraftKings</a> 走 Aurora + application sharding 的路徑、不換引擎也能解單主寫入瓶頸。</p>
<blockquote>
<p><strong>數字口徑</strong>：本段「2-5x latency」屬通用工程估算（Raft / Paxos round trip 跟 single-leader replication 的 latency ratio）、case 未直接揭露對照數字、實際值依拓樸 / 寫入大小 / 一致性層次而異、應該以自家 benchmark 驗證。</p></blockquote>
<h2 id="核心機制三軸-vendor-對比">核心機制：三軸 vendor 對比</h2>
<p>完成 driver path 識別後、進三軸 vendor 對比。</p>
<h3 id="軸-1--部署-topology">軸 1 — 部署 topology</h3>
<table>
  <thead>
      <tr>
          <th>Vendor</th>
          <th>部署</th>
          <th>何時是硬條件</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>CockroachDB</td>
          <td>cross-cloud + on-prem + Cockroach Cloud</td>
          <td>跨雲 / on-prem hybrid 必要時</td>
      </tr>
      <tr>
          <td>Spanner</td>
          <td>GCP-only</td>
          <td>不適合非 GCP 環境</td>
      </tr>
      <tr>
          <td>Aurora DSQL</td>
          <td>AWS-only</td>
          <td>不適合非 AWS 環境</td>
      </tr>
  </tbody>
</table>
<p>Path C 場景（Hard Rock Outposts hybrid）強制走 CockroachDB — 另兩家不提供等效部署。</p>
<h3 id="軸-2--managed-成熟度">軸 2 — Managed 成熟度</h3>
<p><strong>Scope warning（來源分層）</strong>：3 case 都沒揭露成熟度比對、本軸依 case + vendor 公開文件 + 外部知識合成：</p>
<ul>
<li><strong>Spanner</strong>：10+ 年 Google 內部 + 外部 GA（依 9.C10 case + Google research paper、屬 vendor 公開文件 + dogfood frame）</li>
<li><strong>CockroachDB</strong>：自管 + Cockroach Cloud（managed 較新、依 Cockroach Labs 公告）</li>
<li><strong>Aurora DSQL</strong>：2024-05 GA（依 AWS 公告）</li>
</ul>
<p>引用紀律：「Spanner 10+ 年」是 vendor 公開 + Google dogfood 的合成、不是 case 直接揭露的 production stability 數字。Aurora DSQL「2024-05 GA」屬 AWS 公開公告、production case ground truth 還在累積。引用時要明示來源層次。</p>
<h3 id="軸-3--sql-相容性">軸 3 — SQL 相容性</h3>
<table>
  <thead>
      <tr>
          <th>Vendor</th>
          <th>SQL</th>
          <th>相容程度</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>CockroachDB</td>
          <td>PostgreSQL wire protocol</td>
          <td><em>protocol-level</em> 相容、SQL 行為要 audit</td>
      </tr>
      <tr>
          <td>Spanner</td>
          <td>GoogleSQL + 部分 PostgreSQL 方言</td>
          <td>GoogleSQL native、PG 方言是子集</td>
      </tr>
      <tr>
          <td>Aurora DSQL</td>
          <td>PostgreSQL（AWS managed control plane）</td>
          <td>PostgreSQL-compatible、AWS 操作模型</td>
      </tr>
  </tbody>
</table>
<h3 id="postgresql-相容性-audit-checklist-4-項f44doordash-揭露">PostgreSQL 相容性 audit checklist 4 項（F4.4、DoorDash 揭露）</h3>
<p>DoorDash case 揭露 PG wire <em>protocol-level</em> 相容、SQL 行為「仍要驗證」。把這個警語展開成 audit checklist：</p>
<ol>
<li><strong>Serializable default</strong>：CockroachDB default SERIALIZABLE、PG default READ COMMITTED → application transaction 行為差異（細節見 <a href="../transaction-retry-pattern/">transaction retry pattern</a>）。Aurora DSQL 預設行為要看 AWS 公告。</li>
<li><strong>Retry semantics</strong>：CockroachDB 發 <code>40001 serialization_failure</code>、application 必須包 retry loop。PG / Aurora 預設不需要、application 沒 retry middleware。Aurora DSQL 比照 CockroachDB 模型、需要 retry loop。</li>
<li><strong>Partial index</strong>：CockroachDB 支援程度與 PG 有差異、application 用到的 partial index 要逐一驗證。Spanner GoogleSQL 跟 PG 行為不同。</li>
<li><strong>其他 SQL 行為</strong>：sequence、auto-increment、stored procedure、custom function、extension 等都需 case-by-case audit。</li>
</ol>
<p>引用紀律：DoorDash 揭露的是「PG wire protocol-level 相容、SQL 行為要 audit」這個 fact、本章把 audit 內容展開成 4 項屬通用工程議題、不是 DoorDash case 直接揭露。</p>
<h3 id="consensus-機制差">Consensus 機制差</h3>
<table>
  <thead>
      <tr>
          <th>Vendor</th>
          <th>共識</th>
          <th>硬體依賴</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>CockroachDB</td>
          <td><a href="/blog/backend/knowledge-cards/hybrid-logical-clock/" data-link-title="Hybrid Logical Clock" data-link-desc="用 physical wall clock &#43; monotonic logical counter 給每個事件 timestamp、靠軟體 max-offset 保證跨節點時鐘差不超過上限、超過 panic 保護一致性">Hybrid Logical Clock</a> + Raft</td>
          <td>純軟體 + NTP</td>
      </tr>
      <tr>
          <td>Spanner</td>
          <td>TrueTime + Paxos</td>
          <td>GPS + atomic clock</td>
      </tr>
      <tr>
          <td>Aurora DSQL</td>
          <td>類 Spanner 概念、AWS 專屬</td>
          <td>AWS timing infra（未完全公開）</td>
      </tr>
  </tbody>
</table>
<p>三家共識機制的差異直接決定 <a href="/blog/backend/knowledge-cards/external-consistency/" data-link-title="External Consistency" data-link-desc="交易可見順序與外部真實時間順序一致的強一致性語意">external consistency</a> 的實作路徑：Spanner 用 TrueTime + commit-wait 撐 external consistency；CockroachDB 用 HLC + max-offset 撐 linearizability、不保證 external consistency；Aurora DSQL 走類 Spanner 路徑但細節未完全公開。三家 multi-region 配置都吃 <a href="/blog/backend/knowledge-cards/cross-region-quorum/" data-link-title="Cross-Region Quorum" data-link-desc="multi-region distributed SQL 強制 voting replica 跨 region、commit 等多 region quorum ack、跨洲 RTT 物理硬限">Cross-Region Quorum</a> 的物理 latency tax。詳細機制見 <a href="../hlc-raft-consensus/">HLC + Raft consensus</a>。</p>
<h3 id="pricing-model-差">Pricing model 差</h3>
<ul>
<li><strong>CockroachDB self-managed</strong>：node × resource、cluster 至少 3 node</li>
<li><strong>Cockroach Cloud / Spanner / DSQL</strong>：consumption-based（read / write / storage / network）</li>
</ul>
<h3 id="sizing-barrier-邊界f3169c10-spanner-case-揭露">Sizing barrier 邊界（F3.16、9.C10 Spanner case 揭露）</h3>
<p>Spanner 100 processing unit 起跳是 <em>最小 footprint</em> — 對中小 PostgreSQL workload 是 cost 邊界：</p>
<ul>
<li>workload 月寫入若只夠 PG db.m6g.large 級別、付 Spanner 100 pu 起跳 cost 不對</li>
<li>CockroachDB 最小 3 node、storage / compute 線性 — 中小 workload 較友善</li>
<li>Aurora DSQL consumption-based 無 minimum、中小 workload 最友善（但 production case 累積較少）</li>
</ul>
<p>判讀：sizing barrier 是 <em>vendor 強制最小 footprint</em>、不是「啟動成本」— 即使 workload 縮小、minimum 不會降。中小 PG workload 直接套 Spanner = 付不必要的 minimum cost。</p>
<p>對應 <a href="/blog/backend/knowledge-cards/distributed-sql/" data-link-title="Distributed SQL" data-link-desc="把 SQL 與交易語意延伸到多節點與多區域的資料庫形態">distributed SQL 卡</a>、<a href="/blog/backend/knowledge-cards/quorum/" data-link-title="Quorum" data-link-desc="分散式系統以多數節點同意作為提交或讀取有效性的門檻">quorum 卡</a>、<a href="/blog/backend/knowledge-cards/vendor-lock-in/" data-link-title="Vendor Lock-In" data-link-desc="說明採用供應商產品後，其 API 與格式滲入程式碼造成的退出成本">vendor lock-in 卡</a>。</p>
<h2 id="決策樹七問題">決策樹：七問題</h2>
<p>前置問題 0 在 <em>撞牆訊號分型</em> 段已回答（你的 driver path 是 A / B / C 哪一條）。以下進三家 vendor 對比的七個問題。</p>
<h3 id="問題-1是否硬需求跨雲--on-prem">問題 1：是否硬需求跨雲 / on-prem？</h3>
<ul>
<li><strong>Yes</strong> → CockroachDB（唯一選項；對應 9.C40 Netflix 跨 AWS region、9.C41 Hard Rock AWS Outposts 混合）</li>
<li><strong>No</strong> → 進問題 2</li>
</ul>
<p>跨雲是 <em>硬需求</em> 而不是 <em>fear-driven</em> 訊號：</p>
<ul>
<li>真硬需求：法規明文跨雲、acquisition 後多雲整合、vendor risk 政策強制</li>
<li>fear-driven：「萬一 AWS 全球 outage」（多數公司實際走 single-cloud、跨雲 portability premium 卻沒實際 multi-cloud 部署）</li>
</ul>
<blockquote>
<p><strong>數字口徑</strong>：本段「多數公司 single-cloud」屬通用工程估算、case 未揭露明確比例、實際分佈依產業 / 監管 / 規模而異。判斷自己是否需要跨雲時、看具體規範跟 risk 條款、不直接套通用比例。</p></blockquote>
<h3 id="問題-2已在-aws-還是-gcp-還是中立">問題 2：已在 AWS 還是 GCP 還是中立？</h3>
<ul>
<li><strong>AWS 深</strong> → Aurora DSQL（操作模型對齊、PostgreSQL 相容）</li>
<li><strong>GCP 深</strong> → Spanner（10 年成熟、Google 內部驗證）</li>
<li><strong>中立 / 多雲</strong> → CockroachDB（可 portable）</li>
</ul>
<p>雲商生態深度判讀：IAM / VPC / monitoring / cost mgmt 已深度整合 AWS → Aurora DSQL 整合阻力低；同樣道理 GCP → Spanner。</p>
<h3 id="問題-3production-風險預算">問題 3：production 風險預算？</h3>
<ul>
<li><strong>低</strong>（金融 / 醫療）→ Spanner（最成熟）或 CockroachDB（&gt;5 年外部 production case）</li>
<li><strong>中</strong> → 三者皆可</li>
<li><strong>高</strong>（願意當 early adopter）→ Aurora DSQL（2024 GA）</li>
</ul>
<p>風險預算對應的不是「會不會掛」、是「邊界 case 文件成熟度 + production troubleshooting case 量」。Aurora DSQL 2024 GA、production case 累積中、邊界 case 仍在被發現。</p>
<h3 id="問題-4postgresql-相容性是-hard-requirement">問題 4：PostgreSQL 相容性是 hard requirement？</h3>
<ul>
<li><strong>Yes</strong>（既有 application）→ CockroachDB 或 Aurora DSQL（兩者都做 PG 相容、但走 audit checklist 驗證 SQL 行為）</li>
<li><strong>No</strong> → Spanner（GoogleSQL 也可）</li>
</ul>
<p>PG hard requirement 訊號：application 用 PostgreSQL-specific feature（partial index、JSONB operator、PostGIS、PG extension 生態）、ORM / driver 深度綁 PostgreSQL wire。</p>
<h3 id="問題-5管理負擔誰承擔">問題 5：管理負擔誰承擔？</h3>
<ul>
<li><strong>自管</strong> → CockroachDB（唯一可自管）</li>
<li><strong>Managed</strong> → 都行、依雲商生態</li>
</ul>
<p>自管 vs managed 不只是「省人月」、是「邊界 case 出現時誰修」— managed 的 vendor 負責、自管的自己負責。</p>
<h3 id="問題-6team-size-是否撐得起-self-managedf4149c41-hard-rock--9c40-netflix-揭露">問題 6：team size 是否撐得起 self-managed（F4.14、9.C41 Hard Rock + 9.C40 Netflix 揭露）</h3>
<p>distributed SQL 的 ops 槓桿來自系統內建 Raft / placement 把「DBA 養單區、跨區 sync 養運維」工作量壓進系統內。</p>
<p>Hard Rock 50 人 tech team 估「若用 PostgreSQL 需多加 10-20 工程師」（觀察段表格 + 策略段 4）。<strong>Case 自帶警示</strong>：「省了 10-20 工程師」是 <em>機會成本</em>（沒招那麼多 DBA）、<em>不是</em> 節省支出（已 hire 後解雇）。引用必須明示口徑：</p>
<ul>
<li>正確：「distributed SQL 對小團隊的 ops 槓桿 = 不必招那麼多 DBA」</li>
<li>錯誤：「上 CockroachDB 可裁員」、「節省人月支出」</li>
</ul>
<p>Self-managed 規模化的另一極：Netflix 養 380+ cluster 需要 <em>專屬 Database Platform Team</em>（含 backup / upgrade / incident response / capacity review、F4.9）。沒這量級團隊直接 self-host 大規模 cluster 是 ops 自殺、Cockroach Cloud 才是合理路徑。判讀訊號：「self-managed cluster 數量 vs 平台團隊規模」轉折點 case 沒講具體閾值、引用時不可宣稱閾值、但方向清楚：</p>
<ul>
<li>team size 小（&lt; 100 人 tech team、無專屬 DB platform team）→ Cockroach Cloud / Spanner / DSQL（managed）優先</li>
<li>team size 大 + 有專屬 DB platform team → self-managed CockroachDB 可考慮</li>
<li>team size 中等但要 self-host 大規模 cluster → 評估專屬 platform team 投資後再決定</li>
</ul>
<h3 id="問題-7sizing-是否撐得起-vendor-minimumf316">問題 7：sizing 是否撐得起 vendor minimum（F3.16）</h3>
<ul>
<li>Spanner 100 processing unit 起跳對中小 PG workload 是成本門檻、月寫入 &lt; 某 baseline 時付 Spanner 起跳費不划算</li>
<li>中小 workload 但需 multi-region 強一致 → CockroachDB 3 node 起 / Aurora DSQL consumption-based 較友善</li>
<li>大 workload（已過 single-primary 撞牆訊號）→ 三家皆可、進問題 1-6 再篩</li>
</ul>
<h2 id="cluster-boundary-顆粒per-app-cluster-vs-邏輯一個-clustercockroachdb-cluster-boundary-ssot">Cluster boundary 顆粒：per-app cluster vs 邏輯一個 cluster（CockroachDB cluster boundary SSoT）</h2>
<blockquote>
<p><strong>位置標</strong>：本段是 _module-outline.md Section G「CockroachDB cluster boundary 顆粒」的 SSoT 主寫段、是 <em>已選 CockroachDB 後</em> 的拓樸決策（跟前面七問題 vendor 選擇分流）。其他 vendor cluster boundary 議題不在本段重複展開 — Aurora fleet 治理（business sharding / 200 cluster 模式）見 <a href="../../aurora/read-replica-scaling/">aurora/read-replica-scaling</a>、MongoDB blast radius 切多 cluster（Toyota 20 DB 模式）見 <a href="../../mongodb/shard-key-selection/">mongodb/shard-key-selection</a>。</p></blockquote>
<p>選完 vendor 還有一個正交的拓樸決策：CockroachDB cluster 的「顆粒」要切多細。一個微服務一個 cluster（per-app）、還是多個微服務共用一個邏輯 cluster（shared / 邏輯一個 cluster）。這條軸的判讀獨立於跨雲 / 風險預算 / 管理負擔等七問題、是 <em>cluster 拓樸</em> 議題、不是 vendor 選擇議題。判讀核心是 <a href="/blog/backend/knowledge-cards/blast-radius/" data-link-title="Blast Radius" data-link-desc="說明事故影響面如何估算與隔離">blast radius</a> 的取捨 — 是把故障半徑限縮在單服務（per-app）、還是接受邏輯 cluster 內事故跨業務影響但換 transactional cross-domain 能力（邏輯一個 cluster）。本段是 CockroachDB cluster boundary 顆粒的主寫位置、其他 sibling 文章（<a href="../hlc-raft-consensus/">hlc-raft-consensus</a>、<a href="../survival-goals/">survival-goals</a>、<a href="../locality-aware-schema/">locality-aware-schema</a>）cross-link 不重複展開。</p>
<h3 id="per-app-clusternetflix-380-路徑f47-揭露">Per-app cluster（Netflix 380+ 路徑、F4.7 揭露）</h3>
<p>每個微服務 / 每個業務邊界各自獨立 cluster。Netflix 揭露的具體形貌：380+ cluster、每個 cluster 規模小（屬「artery of small DBs」哲學、不是巨型 DB）、每個服務 own 自己的 schema 跟容量。</p>
<p>判讀訊號：</p>
<ul>
<li>服務之間資料 <em>硬隔離</em>（compliance / blast radius / 不同 SLA tier）— 共用 cluster 一旦 schema migration / hot range 出事、影響面跨服務</li>
<li>跨服務 query 需求低（沒有 cross-domain JOIN 場景）</li>
<li>容量規劃可以 per-cluster（每個服務自己估、不需共池）</li>
<li>有專屬 Database Platform Team 養 cluster lifecycle（backup / upgrade / incident response / capacity review、F4.9）— ops surface area 隨 cluster 數 <em>線性成長</em></li>
</ul>
<p>代價：ops surface area 大、每個 cluster 都要獨立 upgrade / monitoring / capacity review。沒這量級平台團隊直接 self-host 380 cluster 是 ops 自殺。</p>
<h3 id="邏輯一個-clusterhard-rock-路徑f410-揭露">邏輯一個 cluster（Hard Rock 路徑、F4.10 揭露）</h3>
<p>業務邏輯上是 <em>一個</em> CockroachDB cluster、物理上跨多地理 placement（locality + replication zone 把 range 釘到特定 region / AZ / Outpost）。Hard Rock 揭露的具體形貌：跨 8 州 + AWS Outposts、邏輯一個 cluster、跨州統一帳戶 / 跨州 reporting / 欺詐偵測在同一 cluster 內做 transactional query。</p>
<p>判讀訊號：</p>
<ul>
<li>跨服務 / 跨地理需要 <em>transactional</em> query（跨州統一帳戶、跨業務統合 reporting）— 拆獨立 cluster 會破壞業務邏輯</li>
<li>合規顆粒 <em>細</em> 到 region / 州 / AZ、但 <em>不要求</em> 完全隔離 cluster（Wire Act 要求州內運算、但允許跨州 application 邏輯）</li>
<li>Team size 中小（Hard Rock 50 人 tech team）、ops surface area 集中比攤平好管</li>
<li>容量規劃集中、跨服務資源共享（不同服務的 range 可以 colocate 同 cluster）</li>
</ul>
<p>代價：cluster 內複雜度高（要設計 placement / locality / replication zone 把 range 釘對地方）、blast radius 是 <em>整個邏輯 cluster</em>、cluster 級事故影響跨業務。</p>
<h3 id="兩條路徑的判讀軸">兩條路徑的判讀軸</h3>
<table>
  <thead>
      <tr>
          <th>判讀軸</th>
          <th>Per-app cluster（Netflix）</th>
          <th>邏輯一個 cluster（Hard Rock）</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>服務隔離度</td>
          <td>硬隔離（不同 SLA / compliance tier）</td>
          <td>弱隔離（同業務域、共用 placement 策略）</td>
      </tr>
      <tr>
          <td>跨服務 query 需求</td>
          <td>低</td>
          <td>高（transactional cross-domain）</td>
      </tr>
      <tr>
          <td>Blast radius</td>
          <td>限縮在單服務</td>
          <td>整個邏輯 cluster</td>
      </tr>
      <tr>
          <td>Ops surface area</td>
          <td>線性成長（每 cluster 獨立 lifecycle）</td>
          <td>集中但複雜度高（cluster 內 placement）</td>
      </tr>
      <tr>
          <td>容量規劃顆粒</td>
          <td>Per-cluster 獨立估</td>
          <td>集中估、跨服務共池</td>
      </tr>
      <tr>
          <td>平台團隊要求</td>
          <td>高（cluster 數越多越剛性）</td>
          <td>中（cluster 數少但 placement 複雜度高）</td>
      </tr>
  </tbody>
</table>
<p>判讀順序：先問「跨服務 query 需要 transactional 嗎」— Yes 偏邏輯一個 cluster、No 進下一條；再問「服務之間 SLA / compliance 是否硬隔離」— Yes 偏 per-app、No 看 team / ops 槓桿。</p>
<h3 id="跟-aurora-fleet-治理的本質差異">跟 Aurora fleet 治理的本質差異</h3>
<p>Aurora <a href="../../aurora/read-replica-scaling/">fleet 治理 SSoT</a>（read-replica-scaling 邊界段）展開的是 <em>Aurora cluster 之間</em> 怎麼拆（business sharding / blast radius / read fanout），cluster 是 single-primary 抽象、拆 cluster 是 <em>繞過</em> single-primary 上限。</p>
<p>CockroachDB cluster boundary 的問題不一樣 — CockroachDB 本身就是 distributed、單 cluster 內可橫向擴展、cluster boundary 是 <em>業務 / 合規 / blast radius 邊界</em>、不是繞 single-primary。</p>
<table>
  <thead>
      <tr>
          <th>軸</th>
          <th>Aurora fleet</th>
          <th>CockroachDB cluster boundary</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>拆 cluster 動機</td>
          <td>繞過 single-primary 寫入上限</td>
          <td>隔離 blast radius / 合規邊界 / 平台分權</td>
      </tr>
      <tr>
          <td>單 cluster 上限</td>
          <td>寫入 capacity（single-primary）</td>
          <td>範圍大（distributed、Raft 內擴）</td>
      </tr>
      <tr>
          <td>跨 cluster query</td>
          <td>應用層拼（無 transactional 保證）</td>
          <td>一樣應用層拼（除非邏輯一個 cluster）</td>
      </tr>
      <tr>
          <td>典型形貌</td>
          <td>DraftKings 200 cluster（business sharding）</td>
          <td>Netflix 380+（per-app）/ Hard Rock 1（logical）</td>
      </tr>
  </tbody>
</table>
<p>兩條路徑的 <em>拆與不拆</em> 動機本質不同。Aurora 拆是 <em>被迫</em>（單 cluster 撐不住）、CockroachDB 拆是 <em>選擇</em>（單 cluster 撐得住、拆是為了治理）。</p>
<h3 id="跨-vendor-路徑對照">跨 vendor 路徑對照</h3>
<ul>
<li><strong>Aurora fleet</strong>（DraftKings 200 cluster）— business sharding 繞 single-primary 上限、每 cluster 仍可多 service、平均負載低（<a href="/blog/backend/09-performance-capacity/cases/draftkings-aurora-financial-ledger/" data-link-title="9.C4 DraftKings：Aurora 撐 100 萬 ops/min 的體育博彩金融帳本" data-link-desc="DraftKings 用 Aurora MySQL 跑體育博彩金融帳本、Super Bowl 流量 &#43;50% 不影響延遲">9.C4 case</a> 揭露單 cluster ~80 ops/sec、200 cluster 加總 17K ops/sec）</li>
<li><strong>CockroachDB per-app</strong>（Netflix 380+）— 微服務級拆 cluster、artery of small DBs、需要專屬 Database Platform Team；單 cluster 內 <a href="/blog/backend/knowledge-cards/range-sharding/" data-link-title="Range Sharding" data-link-desc="分散式 SQL 把 key space 切成可自動 split / merge 的 range、每個 range 自己的 consensus group、application 透明">Range Sharding</a> + <a href="/blog/backend/knowledge-cards/leaseholder/" data-link-title="Leaseholder" data-link-desc="分散式 SQL 每個 range 在任一時間點的 read / write entry point、通常等於 Raft leader、承擔該 range 的 coordination">Leaseholder</a> 負責內部 scaling</li>
<li><strong>CockroachDB 邏輯一個</strong>（Hard Rock）— 跨地理單一 cluster、locality + placement 撐合規 + transactional 跨域、本地化讀靠 <a href="/blog/backend/knowledge-cards/follower-read/" data-link-title="Follower Read" data-link-desc="分散式 SQL 從 non-voting replica 讀 closed timestamp 之前的資料、不參與 Raft commit、低 latency 但 read-after-write 場景仍可能 stale">Follower Read</a> 降低跨 region cost</li>
<li><strong>CockroachDB fleet per-jurisdiction</strong>（Standard Chartered）— 每監管市場一個 cluster、合規 <em>禁止</em> 跨市場資料流動時的 forced pattern、跟 Hard Rock 對照（合規顆粒粗到要拆 vs 細到能用 placement）</li>
</ul>
<p>進階閱讀：合規驅動的 cluster boundary 選擇見 <a href="../locality-aware-schema/">locality-aware-schema</a>；單 cluster 容量規劃見 <a href="../hlc-raft-consensus/">hlc-raft-consensus</a> 容量與觀測段。</p>
<h2 id="失敗模式常見錯配">失敗模式：常見錯配</h2>
<h3 id="過度-fear-aws--gcp-lock-in">過度 fear AWS / GCP lock-in</h3>
<p>承接 <em>問題 1：是否硬需求跨雲</em> 段的 fear-driven 訊號（多數場景單雲、跨雲是想像中需求）— 把 fear 當硬需求選 CockroachDB，付 portability premium（自管 ops + Cockroach Cloud 較新）卻沒實際 multi-cloud 部署，結果付的是 lock-in 保險、實際沒用上。</p>
<p>判讀：跨雲訊號要 <em>具體場景</em>（acquisition 後整合 / 法規明文 / vendor risk 政策強制）、不是 fear。</p>
<h3 id="低估-dsql-成熟度風險">低估 DSQL 成熟度風險</h3>
<p>2024-05 GA、production case 少、邊界 case 文件不全 — early adopter 才適合。production 風險預算低的場景（金融 / 醫療 / 合規嚴格）不應該選最新 GA 的服務。</p>
<h3 id="spanner-假設-postgresql-全相容">Spanner 假設 PostgreSQL 全相容</h3>
<p>Spanner PostgreSQL interface 是 <em>子集</em>、部分 PostgreSQL feature 不支援。應用 migration 仍需 audit、不可直接 lift-and-shift。</p>
<h3 id="self-managed-cockroachdb-低估-ops-cost9c40-netflix-concrete-referencef49">Self-managed CockroachDB 低估 ops cost（9.C40 Netflix concrete reference、F4.9）</h3>
<p>Raft / backup / upgrade / monitoring 自管比 PostgreSQL 複雜、DBA bandwidth 沒到位變 disaster。Netflix 養 380+ cluster 需要 <em>專屬 Database Platform Team</em> — 含 backup、upgrade、incident response、capacity review。</p>
<p>判讀訊號：「self-managed cluster 數量 vs 平台團隊規模」轉折點 case 沒講具體閾值、引用時不可宣稱閾值、但方向清楚 — 小規模 self-managed 不需要、大規模一定需要、之間有 grey zone 要實際評估團隊能力。</p>
<h3 id="用-distributed-sql-解-single-region-oltp">用 distributed SQL 解 single-region OLTP</h3>
<p>90% 場景 PostgreSQL / Aurora 夠用、distributed SQL overhead 是 2-5x latency（Raft round trip 額外成本）。沒撞 single-primary 寫入上限的情況下、上 distributed SQL 是付不必要的 latency premium。</p>
<h3 id="合規邊界誤判">合規邊界誤判</h3>
<p>受監管市場可能 <em>不能</em> 用任何跨境 distributed SQL（Standard Chartered 模式）、要拆每市場獨立 cluster。反過來、合規顆粒小（跨州 vs 跨國）+ 跨 boundary 業務邏輯需求高（跨州統一帳戶）時、Standard Chartered fleet 拓樸不適合、需走 Hard Rock locality + placement 路徑（細節見 <a href="../locality-aware-schema/">locality-aware schema</a>）。</p>
<h3 id="sizing-barrier-誤判f316">Sizing barrier 誤判（F3.16）</h3>
<p>中小 PG workload 直接套 Spanner 100 pu 起跳、付的是不必要的 minimum cost。中小規模的硬一致 multi-region workload、CockroachDB 3 node / Aurora DSQL consumption-based 更划算。</p>
<h3 id="team-size-誤判f414">Team size 誤判（F4.14）</h3>
<p>把「省 10-20 工程師」當已 hire 後可裁員的節省支出、實際是 <em>機會成本</em>（沒招那麼多 DBA）。上 CockroachDB 不代表可裁掉現有 DBA — 現有 DBA 反而要轉型成 distributed SQL 運維。</p>
<h2 id="容量與觀測">容量與觀測</h2>
<h3 id="三家共同-metric">三家共同 metric</h3>
<ul>
<li>write QPS</li>
<li>cross-region latency p99</li>
<li>storage growth</li>
<li>replica lag（CockroachDB Raft / Spanner Paxos / DSQL replica）</li>
</ul>
<h3 id="觀測黑箱程度">觀測黑箱程度</h3>
<ul>
<li><strong>CockroachDB Console</strong>：暴露 Raft / range / leaseholder 細節、observability 細</li>
<li><strong>Spanner / DSQL</strong>：managed、metric 經 GCP Cloud Monitoring / AWS CloudWatch、observability 黑箱程度高 — 邊界 case troubleshooting 仰賴 vendor support</li>
</ul>
<h3 id="容量公式">容量公式</h3>
<p>write QPS × replication factor × cross-region latency = required node / capacity。中小 workload 撞 vendor minimum 才是真實 cost 下界。</p>
<h3 id="cost-signal">Cost signal</h3>
<p>三家定價模式不同、cross-region traffic 對 cost 影響都大：</p>
<ul>
<li>CockroachDB self-managed：node × resource、可控但要自運維</li>
<li>Spanner：100 pu minimum + consumption、適合穩定 workload、中小 burst 不划算</li>
<li>Aurora DSQL：consumption-based、burst 友善、長期穩定 workload 累計可能比 Spanner 高</li>
</ul>
<h3 id="回路徑">回路徑</h3>
<ul>
<li><a href="/blog/backend/09-performance-capacity/" data-link-title="模組九：效能工程與容量規劃" data-link-desc="把『目前配置能撐多少、要加多少機器』變成可量化、可驗證、可改進的工程流程">9.6 容量規劃模型</a></li>
<li><a href="/blog/backend/01-database/global-distributed-oltp/" data-link-title="1.11 全球分散式 OLTP" data-link-desc="Spanner / Aurora DSQL / Cosmos DB multi-region write / CockroachDB / TiDB 的全球一致性取捨">1.11 全球分散式 OLTP</a> 完整對比</li>
</ul>
<h2 id="邊界與整合">邊界與整合</h2>
<h3 id="sibling-deep-articles">Sibling deep articles</h3>
<ul>
<li><a href="../hlc-raft-consensus/">HLC + Raft consensus</a>（軟體時鐘 vs TrueTime）</li>
<li><a href="../locality-aware-schema/">locality-aware schema</a>（locality model 對比）</li>
<li><a href="../survival-goals/">survival goals</a>（HA model 對比）</li>
<li><a href="../transaction-retry-pattern/">transaction retry pattern</a>（application contract 重塑）</li>
</ul>
<h3 id="sibling-跨-vendor">Sibling 跨 vendor</h3>
<ul>
<li><a href="/blog/backend/01-database/vendors/aurora/" data-link-title="AWS Aurora" data-link-desc="AWS managed PostgreSQL / MySQL、storage / compute 分離、&#43;75% 效能改善的 production 證據">Aurora vendor overview</a>（async cross-region、不是 distributed SQL）</li>
<li><a href="/blog/backend/01-database/vendors/spanner/" data-link-title="Google Cloud Spanner" data-link-desc="全球分散式 strong-consistency OLTP、TrueTime API、線性擴展到 10 億 req/sec">Spanner vendor overview</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 vendor overview</a>（單區 OLTP fallback）</li>
</ul>
<h3 id="migration-playbook">Migration playbook</h3>
<ul>
<li><a href="/blog/backend/01-database/vendors/postgresql/migrate-to-cockroachdb/" data-link-title="PostgreSQL → CockroachDB：三維皆 High 的多重歸類 migration" data-link-desc="PostgreSQL → CockroachDB 是 Schema / Operational / Paradigm 三維皆 High 的 multi-axis migration、實證 [#127](/report/content-structure-by-max-diff-dimension/) 的「多重歸類跟 tie-breaking」規則；主結構走 Type E paradigm shift、Schema 差 &#43; Operational redesign 抽出獨立段；涵蓋 transaction model 重設計、SQL dialect gap、5 個 production 踩雷">PG → CockroachDB</a></li>
<li><a href="/blog/backend/01-database/vendors/postgresql/migrate-to-aurora-dsql/" data-link-title="PostgreSQL → Aurora DSQL Migration：PG wire-compatible Distributed SQL 的 Paradigm Shift" data-link-desc="Aurora DSQL（2024-12 re:Invent preview / 2025-05 GA）是 AWS 推的 PG wire-compatible *active-active distributed SQL*、跟 self-managed PG / Aurora PG 不同 paradigm（OCC &#43; snapshot isolation &#43; multi-region strong consistency）。Migration 結構是 *protocol drop-in &#43; paradigm shift*：app SQL 不太改、但 transaction retry / extension 缺位 / 多 region 一致性需重設計。本文走 DSQL vs Aurora PG vs self-managed PG 三軸對比、為什麼遷的三條 driver（global write / operational zero-touch / region resiliency）、Type E phased plan、5 production 踩雷（transaction retry 沒處理 / extension 缺位 / sequence throughput 限制 / Aurora PG 直升 DSQL 不可行 / region failover semantic）、跟 PG → Aurora 跟 PG → CockroachDB 對比">PG → Aurora DSQL</a></li>
</ul>
<h3 id="1x-章節互引">1.x 章節互引</h3>
<ul>
<li><a href="/blog/backend/01-database/global-distributed-oltp/" data-link-title="1.11 全球分散式 OLTP" data-link-desc="Spanner / Aurora DSQL / Cosmos DB multi-region write / CockroachDB / TiDB 的全球一致性取捨">1.11 全球分散式 OLTP</a> 上游</li>
<li><a href="/blog/backend/knowledge-cards/vendor-lock-in/" data-link-title="Vendor Lock-In" data-link-desc="說明採用供應商產品後，其 API 與格式滲入程式碼造成的退出成本">vendor lock-in 卡</a></li>
<li><a href="/blog/backend/knowledge-cards/distributed-sql/" data-link-title="Distributed SQL" data-link-desc="把 SQL 與交易語意延伸到多節點與多區域的資料庫形態">distributed SQL 卡</a></li>
</ul>
<h3 id="何時不用本文">何時不用本文</h3>
<ul>
<li>single-region OLTP 已夠（90% 場景）→ 用 PostgreSQL / Aurora、不必走 distributed SQL</li>
<li>無 multi-region requirement、無跨 boundary 合規需求 → 同上</li>
<li>workload 規模未撞 single-primary 寫入上限 → 走 Aurora vertical scale + read replica 即可</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/cockroachdb/" data-link-title="CockroachDB" data-link-desc="分散式 SQL、PostgreSQL 相容、跨區強一致、Spanner 的開源 / 跨雲替代">CockroachDB vendor overview</a></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</a>（Path A — single-primary 寫入撞牆）</li>
<li><a href="/blog/backend/09-performance-capacity/cases/netflix-cockroachdb-multi-region-fleet/" data-link-title="9.C40 Netflix：380&#43; CockroachDB cluster 的 multi-active 拓樸艦隊" data-link-desc="Netflix 把 Cassandra 不夠用的 transactional workload 移到 CockroachDB、380&#43; cluster / 60&#43; 跨 region、含 Open Connect、studio cloud drive、gaming control plane">9.C40 Netflix</a>（Path B — Cassandra 缺口、Database Platform Team）</li>
<li><a href="/blog/backend/09-performance-capacity/cases/hard-rock-digital-cockroachdb-sports-betting/" data-link-title="9.C41 Hard Rock Digital：CockroachDB on AWS Outposts、Wire Act 合規 &#43; 跨州單一邏輯 DB" data-link-desc="Hard Rock Digital 用 CockroachDB 跨 AWS Outposts &#43; US-East-1、Wire Act 強制資料留州、單一邏輯 DB 解多州 sportsbook、100 node 32 vCPU 撐 Super Bowl">9.C41 Hard Rock Digital</a>（Path C — 合規驅動 + team size 槓桿）</li>
<li><a href="/blog/backend/09-performance-capacity/cases/spanner-planetary-scale-database-gcp/" data-link-title="9.C10 Cloud Spanner：每秒 10 億請求的全球一致性資料庫" data-link-desc="Google Cloud Spanner 內部峰值 10 億 req/sec、跨地區強一致 — 全球分散式 OLTP 容量參考">9.C10 Spanner planetary scale</a>（Spanner ground truth + sizing barrier）</li>
<li><a href="/blog/backend/09-performance-capacity/cases/standard-chartered-aurora-banking/" data-link-title="9.C14 Standard Chartered：受監管銀行的 Aurora 4000 TPS 容量提升" data-link-desc="Standard Chartered 銀行遷移到 Aurora 後吞吐量提升 10 倍至 4000 TPS、跨 7 個受監管市場">9.C14 Standard Chartered</a>（合規邊界 anti-recommendation）</li>
<li><a href="/blog/backend/09-performance-capacity/cases/draftkings-aurora-financial-ledger/" data-link-title="9.C4 DraftKings：Aurora 撐 100 萬 ops/min 的體育博彩金融帳本" data-link-desc="DraftKings 用 Aurora MySQL 跑體育博彩金融帳本、Super Bowl 流量 &#43;50% 不影響延遲">9.C4 DraftKings</a>（Aurora sharding 不換引擎路徑）</li>
<li><a href="/blog/backend/01-database/global-distributed-oltp/" data-link-title="1.11 全球分散式 OLTP" data-link-desc="Spanner / Aurora DSQL / Cosmos DB multi-region write / CockroachDB / TiDB 的全球一致性取捨">1.11 全球分散式 OLTP</a></li>
<li><a href="/blog/backend/knowledge-cards/distributed-sql/" data-link-title="Distributed SQL" data-link-desc="把 SQL 與交易語意延伸到多節點與多區域的資料庫形態">distributed SQL 卡</a> / <a href="/blog/backend/knowledge-cards/vendor-lock-in/" data-link-title="Vendor Lock-In" data-link-desc="說明採用供應商產品後，其 API 與格式滲入程式碼造成的退出成本">vendor lock-in 卡</a> / <a href="/blog/backend/knowledge-cards/quorum/" data-link-title="Quorum" data-link-desc="分散式系統以多數節點同意作為提交或讀取有效性的門檻">quorum 卡</a></li>
<li>官方：<a href="https://www.cockroachlabs.com/docs/">Cockroach Labs Documentation</a> / <a href="https://cloud.google.com/spanner/docs">Spanner Documentation</a> / <a href="https://docs.aws.amazon.com/aurora-dsql/">Aurora DSQL Documentation</a></li>
</ul>
]]></content:encoded></item><item><title>PostgreSQL pgBouncer 配置 + 連線池治理</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/pgbouncer-config/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/pgbouncer-config/</guid><description>&lt;p>PostgreSQL 的 connection 是 &lt;em>昂貴的 process&lt;/em>、每個 connection ~10MB RAM、idle connection 也吃 backend slot。當 application instance 數量爆炸（K8s replica × 多 deployment × pool size）、直接連 PostgreSQL 會把 backend slot 耗盡、新 connection 全 refuse — 即使 active query 不多。pgBouncer 是 &lt;em>connection pool proxy&lt;/em>、把幾千個 application connection 收斂成幾百個 PostgreSQL backend connection、production-grade PostgreSQL 部署的標配。&lt;/p>
&lt;p>本文不是 pgBouncer overview（請看 &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 vendor 頁&lt;/a> 中 connection pool 段）— 而是 &lt;em>production 部署 + 故障演練&lt;/em> 的實作層教學。覆蓋三層 pool（application → pgBouncer → PostgreSQL）的對齊、transaction pooling 跟 session pooling 的選擇陷阱、跟 HA failover 的整合、容量規劃。&lt;/p>
&lt;h2 id="問題情境">問題情境&lt;/h2>
&lt;p>典型觸發場景：團隊規模從 50 人爬到 200 人、microservice 從 20 個爬到 100 個、K8s replica 從 3 個爬到每服務 5-10 個。直連 PostgreSQL 的 connection 計算：&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">100 service × 6 replica × 30 application pool = 18000 connection&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>PostgreSQL 預設 &lt;code>max_connections = 100&lt;/code>、production 設 &lt;code>max_connections = 500-1000&lt;/code> 已經是上限（每多一個都加 memory + context switch cost）。18000 連線打 PostgreSQL 直接打爆。&lt;/p>
&lt;p>進一步問題：&lt;/p>
&lt;ul>
&lt;li>一半 connection 是 &lt;em>idle&lt;/em>（application pool 預留、實際沒查詢）— 浪費 backend slot&lt;/li>
&lt;li>Cold start 時所有 replica 同時建 connection、瞬間 spike&lt;/li>
&lt;li>DB failover 時所有 application 同時 reconnect、prod-test pattern 跑不通&lt;/li>
&lt;li>DNS-based failover 時 application connection pool 不知道 backend 換了&lt;/li>
&lt;/ul>
&lt;p>pgBouncer 解這四個問題。但 &lt;em>引入 pgBouncer&lt;/em> 後又會引入新的問題層（pgBouncer 跟 application pool 不對齊、transaction pooling 的 session state 限制、HA 故障時 pgBouncer 也要 failover）— 本文討論這些。&lt;/p>
&lt;h2 id="核心概念pool-mode--sizing">核心概念：pool mode + sizing&lt;/h2>
&lt;p>pgBouncer 的 first-class concept 是 &lt;em>pool mode&lt;/em>、決定 application connection 跟 PostgreSQL backend connection 的綁定方式：&lt;/p></description><content:encoded><![CDATA[<p>PostgreSQL 的 connection 是 <em>昂貴的 process</em>、每個 connection ~10MB RAM、idle connection 也吃 backend slot。當 application instance 數量爆炸（K8s replica × 多 deployment × pool size）、直接連 PostgreSQL 會把 backend slot 耗盡、新 connection 全 refuse — 即使 active query 不多。pgBouncer 是 <em>connection pool proxy</em>、把幾千個 application connection 收斂成幾百個 PostgreSQL backend connection、production-grade PostgreSQL 部署的標配。</p>
<p>本文不是 pgBouncer overview（請看 <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 vendor 頁</a> 中 connection pool 段）— 而是 <em>production 部署 + 故障演練</em> 的實作層教學。覆蓋三層 pool（application → pgBouncer → PostgreSQL）的對齊、transaction pooling 跟 session pooling 的選擇陷阱、跟 HA failover 的整合、容量規劃。</p>
<h2 id="問題情境">問題情境</h2>
<p>典型觸發場景：團隊規模從 50 人爬到 200 人、microservice 從 20 個爬到 100 個、K8s replica 從 3 個爬到每服務 5-10 個。直連 PostgreSQL 的 connection 計算：</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">100 service × 6 replica × 30 application pool = 18000 connection</span></span></code></pre></div><p>PostgreSQL 預設 <code>max_connections = 100</code>、production 設 <code>max_connections = 500-1000</code> 已經是上限（每多一個都加 memory + context switch cost）。18000 連線打 PostgreSQL 直接打爆。</p>
<p>進一步問題：</p>
<ul>
<li>一半 connection 是 <em>idle</em>（application pool 預留、實際沒查詢）— 浪費 backend slot</li>
<li>Cold start 時所有 replica 同時建 connection、瞬間 spike</li>
<li>DB failover 時所有 application 同時 reconnect、prod-test pattern 跑不通</li>
<li>DNS-based failover 時 application connection pool 不知道 backend 換了</li>
</ul>
<p>pgBouncer 解這四個問題。但 <em>引入 pgBouncer</em> 後又會引入新的問題層（pgBouncer 跟 application pool 不對齊、transaction pooling 的 session state 限制、HA 故障時 pgBouncer 也要 failover）— 本文討論這些。</p>
<h2 id="核心概念pool-mode--sizing">核心概念：pool mode + sizing</h2>
<p>pgBouncer 的 first-class concept 是 <em>pool mode</em>、決定 application connection 跟 PostgreSQL backend connection 的綁定方式：</p>
<ul>
<li><strong>Session pooling</strong>：application connection 拿到 backend connection 後、整個 session 期間都綁同一個 backend。tear-down 才釋放。語義跟「直連」一樣、不破壞 session state。但 <em>idle connection 仍占 backend slot</em>、收斂效率低、適合 <em>連線數不多但要保留 session state</em>（用了 prepared statement、temporary table、advisory lock 等）的場景。</li>
<li><strong>Transaction pooling</strong>：application connection 在 <em>transaction 邊界</em> 才綁 backend、commit / rollback 後立即釋放。同一個 application connection 不同 transaction 可能拿到不同 backend。收斂效率高（idle connection 完全不占 backend slot）、但 <em>session state 限制嚴</em> — 不能用 <code>SET</code> 改 session-level setting、不能用 prepared statement（除非 application 端禁用）、不能用 advisory lock 跨 transaction。</li>
<li><strong>Statement pooling</strong>：每個 statement 完就釋放 backend。極端高收斂但 <em>連 transaction 都不能跨 statement</em>、絕大多數 application 用不了、只在 batch query 場景。</li>
</ul>
<p><strong>Production 預設選 transaction pooling</strong>、application 端禁用 prepared statement（或用 <a href="https://www.pgbouncer.org/config.html#max_prepared_statements">PgBouncer-supported prepared statement</a>、需 pgBouncer 1.21+）。例外場景才開 session pooling。</p>
<p><strong>Pool sizing 公式</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">PostgreSQL max_connections     = pgBouncer N × default_pool_size + reserve
</span></span><span class="line"><span class="ln">2</span><span class="cl">pgBouncer default_pool_size    = per-database backend connection 上限
</span></span><span class="line"><span class="ln">3</span><span class="cl">Application pool size          = 每 application instance 拿幾個 pgBouncer connection</span></span></code></pre></div><p>實例：50 個 application replica、每 instance pool 30 個、pgBouncer 後 default_pool_size = 20（per database）、3 個 database。</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 application → pgBouncer = 50 × 30 = 1500 connection
</span></span><span class="line"><span class="ln">2</span><span class="cl">pgBouncer → PostgreSQL        = 3 × 20 = 60 connection
</span></span><span class="line"><span class="ln">3</span><span class="cl">PostgreSQL max_connections    = 60 + reserve (50 預留 admin / migration) = 110</span></span></code></pre></div><p>1500 → 110 收斂 13.6 倍、PostgreSQL 還在合理上限內。</p>
<h2 id="step-by-step-配置">Step-by-step 配置</h2>
<p><strong>pgBouncer.ini</strong>：</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="k">[databases]</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="na">mydb</span> <span class="o">=</span> <span class="s">host=postgres-primary.internal port=5432 dbname=mydb auth_user=pgbouncer</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="k">[pgbouncer]</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="na">listen_port</span> <span class="o">=</span> <span class="s">6432</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="na">listen_addr</span> <span class="o">=</span> <span class="s">0.0.0.0</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="na">auth_type</span> <span class="o">=</span> <span class="s">scram-sha-256</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="na">auth_file</span> <span class="o">=</span> <span class="s">/etc/pgbouncer/userlist.txt</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="na">auth_query</span> <span class="o">=</span> <span class="s">SELECT usename, passwd FROM pg_shadow WHERE usename=$1</span>
</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 class="na">pool_mode</span> <span class="o">=</span> <span class="s">transaction</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="na">default_pool_size</span> <span class="o">=</span> <span class="s">20</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="na">min_pool_size</span> <span class="o">=</span> <span class="s">5</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="na">reserve_pool_size</span> <span class="o">=</span> <span class="s">10</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="na">reserve_pool_timeout</span> <span class="o">=</span> <span class="s">5</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="na">max_client_conn</span> <span class="o">=</span> <span class="s">2000</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="na">max_db_connections</span> <span class="o">=</span> <span class="s">100</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="na">server_idle_timeout</span> <span class="o">=</span> <span class="s">600</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="na">server_lifetime</span> <span class="o">=</span> <span class="s">3600</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="na">server_connect_timeout</span> <span class="o">=</span> <span class="s">15</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="na">server_login_retry</span> <span class="o">=</span> <span class="s">5</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">
</span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="na">client_idle_timeout</span> <span class="o">=</span> <span class="s">0</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl"><span class="na">client_login_timeout</span> <span class="o">=</span> <span class="s">60</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">
</span></span><span class="line"><span class="ln">28</span><span class="cl"><span class="na">stats_period</span> <span class="o">=</span> <span class="s">60</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl"><span class="na">log_connections</span> <span class="o">=</span> <span class="s">0</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl"><span class="na">log_disconnections</span> <span class="o">=</span> <span class="s">0</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl"><span class="na">log_pooler_errors</span> <span class="o">=</span> <span class="s">1</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">
</span></span><span class="line"><span class="ln">33</span><span class="cl"><span class="na">admin_users</span> <span class="o">=</span> <span class="s">pgbouncer_admin</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl"><span class="na">stats_users</span> <span class="o">=</span> <span class="s">pgbouncer_stats</span></span></span></code></pre></div><p>關鍵欄位解釋：</p>
<ul>
<li><code>pool_mode = transaction</code>：絕大多數 production 場景</li>
<li><code>default_pool_size = 20</code>：每 database 對 PostgreSQL 的 backend connection 上限、調整時要算進 PostgreSQL <code>max_connections</code></li>
<li><code>reserve_pool_size = 10</code> + <code>reserve_pool_timeout = 5</code>：當 default_pool_size 用滿、等 5 秒還拿不到 connection 才用 reserve pool — 是 <em>突發 spike</em> 的 buffer、不是 baseline</li>
<li><code>max_client_conn = 2000</code>：application 端能連 pgBouncer 的最大數</li>
<li><code>server_lifetime = 3600</code>：每 1 小時強制 recycle backend connection、避免 long-lived connection 累積 memory bloat（PostgreSQL <code>pg_stat_activity</code> 看 connection age）</li>
<li><code>auth_query</code>：pgBouncer 直接從 PostgreSQL <code>pg_shadow</code> 拉密碼、不需要在 pgBouncer 本地維護 userlist — production 推薦做法</li>
</ul>
<p><strong>Application 端 pool 設定</strong>：</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="c"># 例：Spring Boot HikariCP</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">spring.datasource.url</span><span class="p">:</span><span class="w"> </span><span class="l">jdbc:postgresql://pgbouncer.internal:6432/mydb</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">spring.datasource.hikari.maximum-pool-size</span><span class="p">:</span><span class="w"> </span><span class="m">30</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">spring.datasource.hikari.minimum-idle</span><span class="p">:</span><span class="w"> </span><span class="m">5</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">spring.datasource.hikari.connection-timeout</span><span class="p">:</span><span class="w"> </span><span class="m">30000</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w"></span><span class="nt">spring.datasource.hikari.idle-timeout</span><span class="p">:</span><span class="w"> </span><span class="m">600000</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w"></span><span class="nt">spring.datasource.hikari.max-lifetime</span><span class="p">:</span><span class="w"> </span><span class="m">1800000</span><span class="w">  </span><span class="c"># 30 min &lt; pgBouncer server_lifetime 60 min</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w"></span><span class="c"># 例：SQLAlchemy</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w"></span><span class="l">engine = create_engine(</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">    </span><span class="s2">&#34;postgresql://pgbouncer.internal:6432/mydb&#34;</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="l">pool_size=30,</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">    </span><span class="l">max_overflow=5,</span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w">    </span><span class="l">pool_pre_ping=True,       </span><span class="w"> </span><span class="c"># 必開、檢測 stale connection</span><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w">    </span><span class="l">pool_recycle=1800,        </span><span class="w"> </span><span class="c"># 30 min、跟 pgBouncer server_lifetime 對齊</span><span class="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w"></span><span class="l">)</span></span></span></code></pre></div><p><strong>Application 跟 pgBouncer 對齊</strong>：</p>
<ul>
<li>application <code>max-lifetime</code> &lt; pgBouncer <code>server_lifetime</code>：避免 application 拿到已被 pgBouncer recycle 的 connection</li>
<li><code>pool_pre_ping = True</code>：每次 checkout 前 send <code>SELECT 1</code>、檢測 stale connection — 對 transaction pooling 是必要的</li>
<li>application 端 <em>不要</em> 用 prepared statement（除非 pgBouncer 1.21+ 設 <code>max_prepared_statements</code>）</li>
</ul>
<h2 id="故障演練--邊界-case">故障演練 / 邊界 case</h2>
<h3 id="case-1pool-exhaustiondefault_pool_size-用滿">Case 1：Pool exhaustion（default_pool_size 用滿）</h3>
<p>徵兆：application log <code>ERROR: no more connections allowed</code>、pgBouncer log <code>pool is full</code>、pgBouncer admin console <code>SHOW POOLS</code> 顯示 <code>cl_waiting &gt; 0</code>。</p>
<p>Debug：</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">-- 連 pgBouncer admin
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="err">\</span><span class="k">c</span><span class="w"> </span><span class="n">pgbouncer</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">SHOW</span><span class="w"> </span><span class="n">POOLS</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="c1">-- 看 cl_active / cl_waiting / sv_active / sv_idle
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="k">SHOW</span><span class="w"> </span><span class="n">SERVERS</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="c1">-- 看 server connection state（active / idle / used）</span></span></span></code></pre></div><p>修：</p>
<ul>
<li>短期：調高 <code>default_pool_size</code> 跟 PostgreSQL <code>max_connections</code>、配合 reserve pool</li>
<li>中期：找 <em>long-running query</em>（PostgreSQL <code>pg_stat_activity</code> 看 <code>query_start</code>、kill 過長 query）</li>
<li>長期：拆 database / 改 read replica / 移 OLAP query 到 data warehouse</li>
</ul>
<h3 id="case-2transaction-pooling-下-session-state-漏洞">Case 2：Transaction pooling 下 session state 漏洞</h3>
<p>徵兆：random 失敗 <code>prepared statement &quot;S_3&quot; does not exist</code>、<code>relation &quot;tmp_xxx&quot; does not exist</code>、advisory lock 不釋放。</p>
<p>原因：application 用了 prepared statement / temporary table / advisory lock、但 transaction commit 後 backend connection 釋放、下一個 transaction 拿到不同 backend、session state 不存在。</p>
<p>修：</p>
<ul>
<li>Application 框架禁用 prepared statement（JDBC <code>prepareThreshold=0</code>、SQLAlchemy <code>use_native_prepared_statements=False</code>）</li>
<li>temporary table 改 <a href="https://www.postgresql.org/docs/current/sql-createtable.html#SQL-CREATETABLE-UNLOGGED-TABLES">unlogged table</a> + cleanup</li>
<li>advisory lock 改 row-level lock 或 application-level lock（Redis）</li>
<li>或：切到 session pooling、犧牲收斂效率</li>
</ul>
<h3 id="case-3dns-based-failover-後-application-連到舊-master">Case 3：DNS-based failover 後 application 連到舊 master</h3>
<p>徵兆：PostgreSQL 切換 master 後、application 寫操作 <em>時好時壞</em>（看連到哪台）。</p>
<p>原因：pgBouncer 在 application 跟 PostgreSQL 之間、application 不知道 backend 換了；pgBouncer 自己也需要 reload config 才會連新 master。</p>
<p>修：</p>
<ul>
<li>pgBouncer 用 <code>RECONNECT</code> admin command 強制 close all backend connection、重連</li>
<li>配 Patroni / Stolon 等 HA 工具自動 trigger pgBouncer reconnect</li>
<li>application 端 <code>pool_pre_ping</code> 開啟、stale connection 自動踢</li>
</ul>
<h3 id="case-4server-lifetime-recycle-跟-in-flight-transaction-衝突">Case 4：Server lifetime recycle 跟 in-flight transaction 衝突</h3>
<p>徵兆：偶發 <code>server closed the connection unexpectedly</code>、跟 long-running transaction 重疊。</p>
<p>原因：pgBouncer <code>server_lifetime = 3600</code> 強制 recycle、但有 transaction 在跑時 pgBouncer 不會切、超過時間後仍會切。</p>
<p>修：</p>
<ul>
<li>確認沒有 <em>超過 1 小時</em> 的 transaction（PostgreSQL <code>pg_stat_activity</code> 看 <code>xact_start</code>）</li>
<li>必要時調高 <code>server_lifetime</code>、但 memory bloat 風險上升</li>
<li>application 端做 transaction timeout</li>
</ul>
<h3 id="case-5pgbouncer-自己-crash--oom">Case 5：pgBouncer 自己 crash / OOM</h3>
<p>徵兆：所有 application 同時失去 PostgreSQL 連線。</p>
<p>原因：pgBouncer 是 single-process（除非 1.21+ 用 <code>so_reuseport</code> 多 process）、memory leak / OOM / 部署事件都會打掉整個 connection layer。</p>
<p>修：</p>
<ul>
<li>多 pgBouncer instance + load balancer（HAProxy / Envoy）前置、application 連 LB</li>
<li><code>so_reuseport = 1</code>（1.21+）讓多個 pgBouncer process 共用 port</li>
<li>Resource limit 跟 alert：RSS &gt; N、connection count &gt; M</li>
<li>HA mode：active-passive 配 keepalived</li>
</ul>
<h2 id="容量--cost-規劃">容量 / cost 規劃</h2>
<p><strong>單一 pgBouncer 容量上限</strong>：</p>
<ul>
<li><code>max_client_conn</code>：實務 &lt; 5000 per instance（再高 CPU 跟 file descriptor 緊）</li>
<li><code>default_pool_size × database 數</code>：實務 &lt; 200 per instance</li>
<li>single process CPU bound：在 10K QPS 等級已經是瓶頸、要橫向 scale</li>
</ul>
<p><strong>何時加 pgBouncer instance</strong>：</p>
<ul>
<li>application connection 數突破 3000 / pgBouncer instance</li>
<li>pgBouncer CPU usage &gt; 60%（baseline、不算 spike）</li>
<li>跨 region application 需要 region-local pgBouncer</li>
</ul>
<p><strong>何時改架構（pgBouncer 不夠用）</strong>：</p>
<ul>
<li>PostgreSQL backend connection 數突破 500（即使有 pgBouncer 也撐不住）→ 改 read replica / partitioning / sharding</li>
<li>write 量太大（每秒 50K+ TPS）→ 改 sharding（<a href="https://vitess.io">Vitess</a> / <a href="https://www.citusdata.com">Citus</a>）或全球分散式 SQL（<a href="/blog/backend/01-database/global-distributed-oltp/" data-link-title="1.11 全球分散式 OLTP" data-link-desc="Spanner / Aurora DSQL / Cosmos DB multi-region write / CockroachDB / TiDB 的全球一致性取捨">1.11 全球分散式 OLTP</a>）</li>
<li>application 大量 prepared statement / session state 需求 → 改 <a href="https://github.com/postgresml/pgcat">PgCat</a>（Rust 寫、支援更完整的 session feature）或回 session pooling</li>
</ul>
<h2 id="整合--下一步">整合 / 下一步</h2>
<p><strong>跟 HA failover 整合</strong>（<a href="https://github.com/zalando/patroni">Patroni</a>）：</p>
<ul>
<li>Patroni 切換 master 後 trigger pgBouncer <code>RECONNECT</code></li>
<li>pgBouncer 透過 service discovery（Consul / etcd）拿新 master 位址、不是寫死在 config</li>
<li>application 不需感知 failover、connection 從 pgBouncer 拿到新 master 的 backend</li>
</ul>
<p><strong>跟監控整合</strong>：</p>
<ul>
<li>pgBouncer admin console <code>SHOW STATS</code> / <code>SHOW POOLS</code> / <code>SHOW SERVERS</code> 拉到 Prometheus（<a href="https://github.com/jbub/pgbouncer_exporter">pgbouncer_exporter</a>）</li>
<li>必看 metric：<code>cl_waiting</code>（等 backend 的 client 數）、<code>sv_active</code>（active backend 數）、<code>avg_query_time</code>、<code>avg_xact_time</code></li>
<li>Alert：<code>cl_waiting &gt; 0 持續 30s</code>、<code>server connection error rate &gt; 0</code></li>
</ul>
<p><strong>跟 application observability 整合</strong>：</p>
<ul>
<li>Application APM（<a href="/blog/backend/04-observability/vendors/datadog/" data-link-title="Datadog" data-link-desc="All-in-one SaaS 觀測平台、APM / Logs / Metrics / RUM / Security">Datadog</a> / Honeycomb / OpenTelemetry）的 DB span 顯示 <em>application 看到的 latency</em>、pgBouncer metric 顯示 <em>pgBouncer ↔ PostgreSQL latency</em> — 兩者差異揭露 connection wait time</li>
</ul>
<p><strong>何時 revisit 這個配置</strong>：</p>
<ul>
<li>application 數量倍增（trigger pool sizing 重算）</li>
<li>PostgreSQL 升級（pgBouncer 跟 PostgreSQL 版本相容性）</li>
<li>跨 region 部署（要不要 region-local pgBouncer）</li>
<li>切換到 RDS Proxy / Aurora Cluster Endpoint（managed alternative）</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<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 vendor overview</a> — 本文是該頁尾「pgBouncer / PgCat 配置 best practice」backlog 的深度展開</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/connection-scaling/" data-link-title="PostgreSQL Connection Scaling：process-per-connection model 跟為什麼 pooler 是必裝" data-link-desc="PG 每個 client connection fork 一個 backend process（不是 thread）、RAM 成本 5-15MB/connection、context switch 跟 fork() cost 在 100&#43; connection 後線性放大、所以 pooler 不是 *optional optimization* 而是 *production prerequisite*。本文走 process-per-connection model 跟 MySQL thread-per-connection 對比、max_connections &#43; shared_buffers &#43; work_mem 三 GUC 互動、application-side pool vs middleware pool vs RDS Proxy 三層選擇、5 production 踩雷（connection storm / fork() cost 在 burst 流量 / shared_buffers 跟 connection 數壓縮 / double-pool 配置錯誤 / max_connections 設太大反而慢）、跟 PgBouncer config 互補不重複">Connection Scaling Deep Dive</a> — connection-per-process model 跟為什麼 pooler 是必裝（根因 vs 配置）</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 高併發資料存取</a> — 上游：什麼時候需要 connection pool</li>
<li><a href="/blog/backend/knowledge-cards/connection-pool/" data-link-title="Connection Pool" data-link-desc="說明連線池如何限制下游資源並影響服務容量">Connection Pool 卡片</a> — 概念基底</li>
<li><a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章方法論</a> — 本文是該方法論的 demo #1</li>
<li><a href="/blog/backend/09-performance-capacity/cases/ntt-docomo-lemino-japanese-streaming/" data-link-title="9.C29 NTT DOCOMO Lemino：3 個月達 500 萬 MAU 的串流後端" data-link-desc="Lemino 用 DynamoDB &#43; AWS Media Services 撐 30 channels live &#43; 5M MAU、工程工時下降 90%">9.C29 Lemino RDB connection limit case</a> — connection 爆是 streaming surge 場景的 vendor-switch 主因</li>
<li>官方：<a href="https://www.pgbouncer.org/usage.html">pgBouncer Documentation</a></li>
</ul>
]]></content:encoded></item><item><title>SQLite file lifecycle 與 backup boundary</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/file-lifecycle-backup-boundary/</link><pubDate>Thu, 21 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/file-lifecycle-backup-boundary/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/" data-link-title="SQLite" data-link-desc="embedded、單檔案、test / CLI / edge 場景的標準選擇、近年因 Cloudflare D1 / Turso 等服務復興">SQLite&lt;/a> overview 的 implementation-layer deep article。Overview 已說明 SQLite 適合 embedded、local-first、edge 與低操作成本場景；本文聚焦 &lt;em>SQLite 檔案生命週期 + backup / restore 邊界&lt;/em>。&lt;/p>&lt;/blockquote>
&lt;p>SQLite 的 file lifecycle 是把「一個資料庫檔案」升級成正式狀態的操作契約。SQLite 省掉 server process、帳號管理與網路連線，但它把 durability、backup、restore、locking 與 corruption recovery 放回 application process、filesystem 與 runbook；讀者要判斷的是這些責任是否已經有人承擔。&lt;/p>
&lt;p>這篇文章適合三種情境。第一種是 CLI、desktop、mobile 或 edge service 已經用 SQLite 保存正式資料；第二種是 single-instance backend 想用 SQLite 降低操作成本；第三種是 test fixture 用 SQLite，但需要知道哪些差異會讓 production database 的 bug 漏掉。&lt;/p>
&lt;h2 id="核心模型資料庫檔案是一組受-sqlite-管理的狀態檔">核心模型：資料庫檔案是一組受 SQLite 管理的狀態檔&lt;/h2>
&lt;p>SQLite 的資料庫狀態由 main database file 與 journal / WAL sidecar 共同構成。Rollback journal mode 會在寫入期間產生 journal file；WAL mode 會讓寫入先進入 &lt;code>-wal&lt;/code> 檔，並用 &lt;code>-shm&lt;/code> 檔協調 reader / writer。操作上看似「一個 &lt;code>.db&lt;/code> 檔」，production runbook 要把 sidecar file、checkpoint、backup API 與 restore test 一起納入。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>檔案 / 機制&lt;/th>
 &lt;th>服務責任&lt;/th>
 &lt;th>操作判讀&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>.db&lt;/code>&lt;/td>
 &lt;td>持久化資料、schema、index&lt;/td>
 &lt;td>file owner、permission、storage durability、snapshot 位置&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>-wal&lt;/code>&lt;/td>
 &lt;td>WAL mode 下尚未 checkpoint 的寫入&lt;/td>
 &lt;td>WAL growth、checkpoint cadence、backup 是否包含一致快照&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>-shm&lt;/code>&lt;/td>
 &lt;td>WAL index 與跨 connection 協調&lt;/td>
 &lt;td>local filesystem lock 是否可靠、部署是否跨 process 共用檔案&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>checkpoint&lt;/td>
 &lt;td>把 WAL 內容合併回 main database&lt;/td>
 &lt;td>checkpoint latency、writer pause、檔案大小是否持續膨脹&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>backup API&lt;/td>
 &lt;td>線上複製一致 snapshot&lt;/td>
 &lt;td>backup 是否在 application 還活著時仍能取得一致狀態&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這張表的讀法是先找「誰有權改檔案」。SQLite 的核心風險多半來自繞過 SQLite library 的檔案操作，例如直接 copy 活躍 WAL database、把 database 放在 lock 語意不可靠的 filesystem、或讓多個不協調的 process 同時寫同一份檔案。&lt;/p>
&lt;h2 id="wal-mode讀取並發提升後writer-boundary-仍然存在">WAL mode：讀取並發提升後，writer boundary 仍然存在&lt;/h2>
&lt;p>WAL mode 的工程價值是讓 reader 與 writer 的衝突下降。讀取可以看 main database 加上 WAL 中的 snapshot，寫入則 append 到 WAL；這讓 read-heavy workload 比 rollback journal mode 更容易撐住互動式服務。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/sqlite/" data-link-title="SQLite" data-link-desc="embedded、單檔案、test / CLI / edge 場景的標準選擇、近年因 Cloudflare D1 / Turso 等服務復興">SQLite</a> overview 的 implementation-layer deep article。Overview 已說明 SQLite 適合 embedded、local-first、edge 與低操作成本場景；本文聚焦 <em>SQLite 檔案生命週期 + backup / restore 邊界</em>。</p></blockquote>
<p>SQLite 的 file lifecycle 是把「一個資料庫檔案」升級成正式狀態的操作契約。SQLite 省掉 server process、帳號管理與網路連線，但它把 durability、backup、restore、locking 與 corruption recovery 放回 application process、filesystem 與 runbook；讀者要判斷的是這些責任是否已經有人承擔。</p>
<p>這篇文章適合三種情境。第一種是 CLI、desktop、mobile 或 edge service 已經用 SQLite 保存正式資料；第二種是 single-instance backend 想用 SQLite 降低操作成本；第三種是 test fixture 用 SQLite，但需要知道哪些差異會讓 production database 的 bug 漏掉。</p>
<h2 id="核心模型資料庫檔案是一組受-sqlite-管理的狀態檔">核心模型：資料庫檔案是一組受 SQLite 管理的狀態檔</h2>
<p>SQLite 的資料庫狀態由 main database file 與 journal / WAL sidecar 共同構成。Rollback journal mode 會在寫入期間產生 journal file；WAL mode 會讓寫入先進入 <code>-wal</code> 檔，並用 <code>-shm</code> 檔協調 reader / writer。操作上看似「一個 <code>.db</code> 檔」，production runbook 要把 sidecar file、checkpoint、backup API 與 restore test 一起納入。</p>
<table>
  <thead>
      <tr>
          <th>檔案 / 機制</th>
          <th>服務責任</th>
          <th>操作判讀</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>.db</code></td>
          <td>持久化資料、schema、index</td>
          <td>file owner、permission、storage durability、snapshot 位置</td>
      </tr>
      <tr>
          <td><code>-wal</code></td>
          <td>WAL mode 下尚未 checkpoint 的寫入</td>
          <td>WAL growth、checkpoint cadence、backup 是否包含一致快照</td>
      </tr>
      <tr>
          <td><code>-shm</code></td>
          <td>WAL index 與跨 connection 協調</td>
          <td>local filesystem lock 是否可靠、部署是否跨 process 共用檔案</td>
      </tr>
      <tr>
          <td>checkpoint</td>
          <td>把 WAL 內容合併回 main database</td>
          <td>checkpoint latency、writer pause、檔案大小是否持續膨脹</td>
      </tr>
      <tr>
          <td>backup API</td>
          <td>線上複製一致 snapshot</td>
          <td>backup 是否在 application 還活著時仍能取得一致狀態</td>
      </tr>
  </tbody>
</table>
<p>這張表的讀法是先找「誰有權改檔案」。SQLite 的核心風險多半來自繞過 SQLite library 的檔案操作，例如直接 copy 活躍 WAL database、把 database 放在 lock 語意不可靠的 filesystem、或讓多個不協調的 process 同時寫同一份檔案。</p>
<h2 id="wal-mode讀取並發提升後writer-boundary-仍然存在">WAL mode：讀取並發提升後，writer boundary 仍然存在</h2>
<p>WAL mode 的工程價值是讓 reader 與 writer 的衝突下降。讀取可以看 main database 加上 WAL 中的 snapshot，寫入則 append 到 WAL；這讓 read-heavy workload 比 rollback journal mode 更容易撐住互動式服務。</p>
<p>WAL mode 同時保留 single writer boundary。SQLite 仍以檔案鎖與 transaction serialisation 控制寫入；寫入交易越長，其他 writer 等待時間越長，application 看到的訊號通常是 <code>SQLITE_BUSY</code>、latency spike 或 background job 卡住。</p>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>常見原因</th>
          <th>第一輪處理</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>SQLITE_BUSY</code> 增加</td>
          <td>長交易、background migration、慢 disk</td>
          <td>縮短 write transaction、加 busy timeout、把批次寫入切小</td>
      </tr>
      <tr>
          <td><code>-wal</code> 檔持續變大</td>
          <td>checkpoint 追不上、long reader 卡住</td>
          <td>找出長讀取、調整 checkpoint cadence、把 analytics query 移出路徑</td>
      </tr>
      <tr>
          <td>restore 後資料落差</td>
          <td>backup 沒取得一致 snapshot</td>
          <td>改用 <code>.backup</code> / backup API / <code>VACUUM INTO</code>，並演練 restore</td>
      </tr>
      <tr>
          <td>latency 受 fsync 拉高</td>
          <td><code>synchronous=FULL</code> + 高寫入頻率</td>
          <td>重新定義 durability 需求，評估 server SQL 或 managed service</td>
      </tr>
  </tbody>
</table>
<p>WAL mode 的 capacity gate 是「寫入是否仍能用一個 writer 排隊」。如果服務壓力來自大量並行寫入、多 instance active write 或跨 region 寫入，SQLite 的簡單性開始變成排隊與恢復成本；這時候要回到 <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>、<a href="/blog/backend/01-database/vendors/mysql/" data-link-title="MySQL" data-link-desc="高併發網路服務常用關聯式資料庫、Vitess / PlanetScale 分片生態、GitHub / Shopify / Facebook 規模驗證">MySQL</a> 或 <a href="/blog/backend/01-database/global-distributed-oltp/" data-link-title="1.11 全球分散式 OLTP" data-link-desc="Spanner / Aurora DSQL / Cosmos DB multi-region write / CockroachDB / TiDB 的全球一致性取捨">global distributed OLTP</a>。</p>
<h2 id="backup-boundary複製檔案與取得一致-snapshot-是兩件事">Backup boundary：複製檔案與取得一致 snapshot 是兩件事</h2>
<p>SQLite backup 的核心責任是取得某一時間點的一致 snapshot。當 database live 且 WAL mode 開啟時，直接複製 <code>.db</code> 檔容易漏掉 <code>-wal</code> 中尚未 checkpoint 的寫入；即使同時複製 sidecar file，也要面對複製期間狀態變動的 race。正式服務應使用 SQLite 提供的 backup path 或可驗證的 filesystem snapshot。</p>
<table>
  <thead>
      <tr>
          <th>方法</th>
          <th>適合情境</th>
          <th>邊界</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>.backup</code> / Backup API</td>
          <td>live database、application 仍在服務</td>
          <td>SQLite 管理 source lock，產出開始備份時的一致 snapshot</td>
      </tr>
      <tr>
          <td><code>VACUUM INTO</code></td>
          <td>想同時 compact + 輸出新檔</td>
          <td>需要 I/O 空間與時間，適合 maintenance 或低流量窗口</td>
      </tr>
      <tr>
          <td>filesystem snapshot</td>
          <td>VM / volume 層已有一致 snapshot 能力</td>
          <td>要確認 snapshot 包含 main file 與 WAL sidecar，且 lock 語意清楚</td>
      </tr>
      <tr>
          <td>Litestream</td>
          <td>single-primary SQLite 的持續備份</td>
          <td>適合 DR / restore，不把 SQLite 變成 multi-primary database</td>
      </tr>
      <tr>
          <td>手動 <code>cp</code></td>
          <td>database 已關閉或已完成 checkpoint</td>
          <td>live WAL database 的一致性風險高，production runbook 應改路由</td>
      </tr>
  </tbody>
</table>
<p>Backup method 的選擇要先回到 <a href="/blog/backend/knowledge-cards/rpo/" data-link-title="RPO" data-link-desc="說明恢復點目標如何定義可接受資料損失範圍">RPO</a> 與 <a href="/blog/backend/knowledge-cards/rto/" data-link-title="RTO" data-link-desc="說明恢復時間目標如何約束事故回復策略">RTO</a>。如果產品可以接受每天一次快照，<code>VACUUM INTO</code> 或 scheduled backup 足夠；如果資料損失窗口要降到分鐘級或秒級，就要看 Litestream 類連續複製，或直接升級到 server database 的 PITR / replica 模型。</p>
<h2 id="restore-drillsqlite-production-readiness-看還原不只看備份成功">Restore drill：SQLite production readiness 看還原，不只看備份成功</h2>
<p>Restore drill 的責任是證明備份能在事故時接回服務。SQLite 的備份檔通常只有一個 target file，表面上比 PostgreSQL PITR 或 MySQL binlog recovery 簡單；真正的風險在 application binary、schema migration version、file permission、deployment path 與舊 WAL sidecar 是否一起對齊。</p>
<p>一個最小 restore drill 應保留五個檢查點：</p>
<ol>
<li>從備份產出新的 database file，不覆蓋 production path。</li>
<li>用 application binary 啟動 read-only smoke test，確認 schema version 與 migration table。</li>
<li>跑 row count、critical query、checksum 或 domain validation query。</li>
<li>驗證 file owner、permission、disk path、SELinux / container mount 或 volume 設定。</li>
<li>以 incident decision log 記錄 restore time、data freshness、known gap 與 owner。</li>
</ol>
<p>Restore drill 的交付物應接回 <a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">Observability Evidence Package</a> 與 <a href="/blog/backend/08-incident-response/incident-decision-log/" data-link-title="8.19 Incident Decision Log" data-link-desc="把事中假設、決策、證據、回退條件與責任人留下可復盤紀錄">Incident Decision Log</a>。SQLite 的低操作成本來自日常元件少；事故時仍需要 evidence、owner 與 rollback condition。</p>
<h2 id="corruption-recovery先保全證據再決定修復或還原">Corruption recovery：先保全證據，再決定修復或還原</h2>
<p>SQLite <a href="/blog/backend/knowledge-cards/corruption-recovery/" data-link-title="Corruption Recovery" data-link-desc="說明資料損毀事故如何先辨識來源、保全證據，再決定修復或還原">corruption recovery</a> 的核心責任是區分「資料庫檔案本身受損」與「application 寫入了錯誤資料」。前者要走 file-level evidence、<code>.recover</code>、backup restore 與 filesystem / hardware investigation；後者要走資料修復、migration rollback 或 business reconciliation。</p>
<table>
  <thead>
      <tr>
          <th>觀察訊號</th>
          <th>優先判讀</th>
          <th>下一步路由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>SQLITE_CORRUPT</code></td>
          <td>database page / btree 受損</td>
          <td>複製原檔保存證據、用 <code>.recover</code> 嘗試導出、從最近 backup 建新檔</td>
      </tr>
      <tr>
          <td>power loss 後啟動異常</td>
          <td>journal / WAL recovery 問題</td>
          <td>確認 sidecar file 是否仍在、檢查 storage sync 與 <code>synchronous</code> 設定</td>
      </tr>
      <tr>
          <td>restore 後 business data 錯誤</td>
          <td>備份點或 migration 錯誤</td>
          <td>對照 validation query、migration log、事件補償與 <a href="/blog/backend/01-database/reconciliation-data-repair/" data-link-title="1.9 Reconciliation 與 Data Repair" data-link-desc="資料不一致的分類、偵測模式、修復策略、audit trail、跟 backup / PITR 整合">reconciliation</a></td>
      </tr>
      <tr>
          <td>network filesystem 上偶發錯誤</td>
          <td>lock 語意與 filesystem 問題</td>
          <td>把 SQLite 移回 local disk，或升級 server database</td>
      </tr>
  </tbody>
</table>
<p>Corruption 事件的第一個操作是保存原始檔案與 sidecar。直接在疑似受損檔案上跑修復、vacuum 或 application migration，會讓後續 root cause analysis 失去證據；比較穩定的流程是複製原檔、在副本上嘗試 <code>.recover</code>，同時從備份恢復服務路徑。</p>
<h2 id="anti-recommendation維持-sqlite-的條件要可被操作驗證">Anti-recommendation：維持 SQLite 的條件要可被操作驗證</h2>
<p>SQLite 的合理使用條件是「單一 writer、檔案生命週期清楚、restore drill 成立」。只要這三件事能被 runbook 驗證，SQLite 在 embedded、desktop、mobile、edge-local 或 small backend 場景可以是 production state。</p>
<p>升級條件則來自操作責任外溢。需要 database user / role、中心化 audit、多人同時寫、跨 instance failover、online schema migration、PITR、read replica 或跨 region transaction 時，server SQL 或 managed SQL 的操作模型會比繼續包裝 SQLite 清楚。</p>
<table>
  <thead>
      <tr>
          <th>目前壓力</th>
          <th>留在 SQLite 的條件</th>
          <th>升級路由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>read-heavy local store</td>
          <td>WAL + restore drill 成立</td>
          <td>維持 SQLite，補 observability 與 backup evidence</td>
      </tr>
      <tr>
          <td>single-instance backend</td>
          <td>writer queue 可接受、RPO / RTO 明確</td>
          <td>SQLite + Litestream；或升級 PostgreSQL / MySQL</td>
      </tr>
      <tr>
          <td>edge / serverless</td>
          <td>平台已提供 SQLite-compatible 運作模型</td>
          <td>Cloudflare D1 / Turso；跨 region transaction 回到 global DB</td>
      </tr>
      <tr>
          <td>multi-tenant SaaS</td>
          <td>tenant 數少且 file ownership 清楚</td>
          <td>PostgreSQL / Aurora / CockroachDB</td>
      </tr>
      <tr>
          <td>regulated data</td>
          <td>backup encryption、audit、restore 可驗證</td>
          <td>PostgreSQL / managed SQL + audit / PITR</td>
      </tr>
  </tbody>
</table>
<p>這張表的核心是把操作責任具體化，而非替 SQLite 設流量天花板。小型服務可能用 SQLite 長期穩定運作；同樣流量下，一旦合規、稽核、多人操作或 HA 需求進來，server database 的長期成本會更容易被治理。</p>
<h2 id="操作檢查清單">操作檢查清單</h2>
<p>SQLite production runbook 至少要能回答下列問題：</p>
<ol>
<li>Database file、WAL sidecar 與 backup target 在哪個 volume、由誰擁有。</li>
<li><code>journal_mode</code>、<code>synchronous</code>、busy timeout、checkpoint cadence 與 migration policy 如何設定。</li>
<li>Backup 用 <code>.backup</code> / backup API / <code>VACUUM INTO</code> / Litestream 的哪一條路徑。</li>
<li>Restore drill 最近一次何時執行，RPO / RTO 是否符合產品承諾。</li>
<li><code>SQLITE_BUSY</code>、WAL growth、disk full、backup failure 與 restore failure 如何告警。</li>
<li>Corruption recovery 時誰保存原檔、誰啟動 restore、誰決定修復或 fail-forward。</li>
</ol>
<p>這份清單要接到服務 ownership，而非留在工程師個人習慣。SQLite 的優勢是 deployment surface 小；production 化的代價是把檔案、備份與恢復流程寫進同一份可交接 runbook。</p>
<h2 id="引用路徑">引用路徑</h2>
<ul>
<li>上游 overview：<a href="/blog/backend/01-database/vendors/sqlite/" data-link-title="SQLite" data-link-desc="embedded、單檔案、test / CLI / edge 場景的標準選擇、近年因 Cloudflare D1 / Turso 等服務復興">SQLite vendor page</a></li>
<li>服務責任：<a href="/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">Source of Truth</a>、<a href="/blog/backend/knowledge-cards/database/" data-link-title="Database" data-link-desc="說明 database 在後端系統中如何承擔正式狀態、查詢與一致性責任">Database</a></li>
<li>恢復目標：<a href="/blog/backend/knowledge-cards/rpo/" data-link-title="RPO" data-link-desc="說明恢復點目標如何定義可接受資料損失範圍">RPO</a>、<a href="/blog/backend/knowledge-cards/rto/" data-link-title="RTO" data-link-desc="說明恢復時間目標如何約束事故回復策略">RTO</a></li>
<li>證據交接：<a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">Observability Evidence Package</a>、<a href="/blog/backend/08-incident-response/incident-decision-log/" data-link-title="8.19 Incident Decision Log" data-link-desc="把事中假設、決策、證據、回退條件與責任人留下可復盤紀錄">Incident Decision Log</a></li>
<li>官方文件：<a href="https://www.sqlite.org/wal.html">SQLite Write-Ahead Logging</a>、<a href="https://www.sqlite.org/backup.html">SQLite Backup API</a>、<a href="https://www.sqlite.org/howtocorrupt.html">How To Corrupt An SQLite Database File</a>、<a href="https://www.sqlite.org/recovery.html">Recovering Data From A Corrupt SQLite Database</a>、<a href="https://www.sqlite.org/whentouse.html">Appropriate Uses For SQLite</a>、<a href="https://www.sqlite.org/mostdeployed.html">Most Widely Deployed SQL Database Engine</a></li>
<li>延伸工具：<a href="https://litestream.io/reference/restore/">Litestream restore reference</a>、<a href="https://litestream.io/getting-started/">Litestream getting started</a></li>
</ul>
]]></content:encoded></item><item><title>SQLite Local-first Sync Boundary</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/local-first-sync-boundary/</link><pubDate>Thu, 21 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/local-first-sync-boundary/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/" data-link-title="SQLite" data-link-desc="embedded、單檔案、test / CLI / edge 場景的標準選擇、近年因 Cloudflare D1 / Turso 等服務復興">SQLite&lt;/a> overview 的 implementation-layer deep article。Overview 已說明 SQLite 適合 local-first / offline-first 場景；本文聚焦 &lt;em>SQLite local store 與 multi-device sync protocol 的責任分界&lt;/em>。&lt;/p>&lt;/blockquote>
&lt;p>SQLite local-first sync boundary 的核心責任是把「本機可用」和「多端一致」分成兩個問題。SQLite 很適合保存 device-local state；但它不提供 identity、transport、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/conflict-resolution/" data-link-title="Conflict Resolution" data-link-desc="說明並發或離線寫入產生衝突時，如何偵測、呈現與合併成可接受狀態">conflict resolution&lt;/a>、delete propagation、server authority 或 audit trail。當資料要跨裝置、跨使用者或跨服務同步時，SQLite 只是 local replica / working copy。&lt;/p>
&lt;p>本文的判讀錨點是：&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/local-first/" data-link-title="Local-First" data-link-desc="說明本機優先的資料架構如何讓離線可用，並把同步當成獨立問題">local-first&lt;/a> 的產品價值來自離線可用，工程成本來自同步語意。SQLite 解的是 local durability；sync layer 解的是資料合併、順序、權威來源與錯誤修復。&lt;/p>
&lt;h2 id="local-state-taxonomy">Local state taxonomy&lt;/h2>
&lt;p>Local-first 設計的第一步是標記本機資料角色。不同資料角色對 sync、backup、conflict 與 delete 的要求不同。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>資料角色&lt;/th>
 &lt;th>例子&lt;/th>
 &lt;th>Sync 語意&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Local cache&lt;/td>
 &lt;td>API response cache、thumbnail metadata&lt;/td>
 &lt;td>可清除、可重抓&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Draft / working copy&lt;/td>
 &lt;td>草稿、離線表單、未送出 action&lt;/td>
 &lt;td>需要 upload / retry / conflict handling&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Local source of truth&lt;/td>
 &lt;td>單裝置日記、CLI state&lt;/td>
 &lt;td>需要 backup / export，可能不需要 server&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Local replica&lt;/td>
 &lt;td>server record 的本地副本&lt;/td>
 &lt;td>server authority、stale read、sync lag&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sync queue&lt;/td>
 &lt;td>pending mutation / event log&lt;/td>
 &lt;td>ordering、idempotency、replay&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這張表的重點是資料角色先於 sync 工具。若所有資料都只是 cache，SQLite + TTL 足夠；若有 pending mutation 或 multi-device edit，就需要 sync protocol。&lt;/p>
&lt;h2 id="authority-boundary">Authority boundary&lt;/h2>
&lt;p>Authority boundary 的核心責任是決定衝突時誰說了算。Local-first app 可以讓 device、server、field-level merge 或 CRDT 成為不同層的 authority；SQLite 本身只保存狀態，不替系統決策。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Authority model&lt;/th>
 &lt;th>適合情境&lt;/th>
 &lt;th>代價&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Server authority&lt;/td>
 &lt;td>帳務、權限、共享資料&lt;/td>
 &lt;td>離線寫入要排隊，回線後可能被拒絕&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Device authority&lt;/td>
 &lt;td>單使用者、單裝置資料&lt;/td>
 &lt;td>多裝置同步能力弱&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Last-write-wins&lt;/td>
 &lt;td>低價值設定、簡單 preference&lt;/td>
 &lt;td>資料覆蓋風險&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Field merge&lt;/td>
 &lt;td>profile、表單、可分欄位資料&lt;/td>
 &lt;td>merge rule 要測，使用者理解成本上升&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>CRDT / operation log&lt;/td>
 &lt;td>協作編輯、順序敏感操作&lt;/td>
 &lt;td>實作與除錯成本高&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Authority model 要和 product semantics 對齊。庫存、付款、權限這類資料通常需要 server authority；notes、draft、local settings 可以接受更偏 local 的權威模型。&lt;/p>
&lt;h2 id="sync-transport-與-local-log">Sync transport 與 local log&lt;/h2>
&lt;p>Sync transport 的核心責任是把 SQLite local state 轉成可重送、可去重、可驗證的資料流。最常見做法是本地維護 pending mutation table 或 change log，再由 background sync worker 送到 server。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/sqlite/" data-link-title="SQLite" data-link-desc="embedded、單檔案、test / CLI / edge 場景的標準選擇、近年因 Cloudflare D1 / Turso 等服務復興">SQLite</a> overview 的 implementation-layer deep article。Overview 已說明 SQLite 適合 local-first / offline-first 場景；本文聚焦 <em>SQLite local store 與 multi-device sync protocol 的責任分界</em>。</p></blockquote>
<p>SQLite local-first sync boundary 的核心責任是把「本機可用」和「多端一致」分成兩個問題。SQLite 很適合保存 device-local state；但它不提供 identity、transport、<a href="/blog/backend/knowledge-cards/conflict-resolution/" data-link-title="Conflict Resolution" data-link-desc="說明並發或離線寫入產生衝突時，如何偵測、呈現與合併成可接受狀態">conflict resolution</a>、delete propagation、server authority 或 audit trail。當資料要跨裝置、跨使用者或跨服務同步時，SQLite 只是 local replica / working copy。</p>
<p>本文的判讀錨點是：<a href="/blog/backend/knowledge-cards/local-first/" data-link-title="Local-First" data-link-desc="說明本機優先的資料架構如何讓離線可用，並把同步當成獨立問題">local-first</a> 的產品價值來自離線可用，工程成本來自同步語意。SQLite 解的是 local durability；sync layer 解的是資料合併、順序、權威來源與錯誤修復。</p>
<h2 id="local-state-taxonomy">Local state taxonomy</h2>
<p>Local-first 設計的第一步是標記本機資料角色。不同資料角色對 sync、backup、conflict 與 delete 的要求不同。</p>
<table>
  <thead>
      <tr>
          <th>資料角色</th>
          <th>例子</th>
          <th>Sync 語意</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Local cache</td>
          <td>API response cache、thumbnail metadata</td>
          <td>可清除、可重抓</td>
      </tr>
      <tr>
          <td>Draft / working copy</td>
          <td>草稿、離線表單、未送出 action</td>
          <td>需要 upload / retry / conflict handling</td>
      </tr>
      <tr>
          <td>Local source of truth</td>
          <td>單裝置日記、CLI state</td>
          <td>需要 backup / export，可能不需要 server</td>
      </tr>
      <tr>
          <td>Local replica</td>
          <td>server record 的本地副本</td>
          <td>server authority、stale read、sync lag</td>
      </tr>
      <tr>
          <td>Sync queue</td>
          <td>pending mutation / event log</td>
          <td>ordering、idempotency、replay</td>
      </tr>
  </tbody>
</table>
<p>這張表的重點是資料角色先於 sync 工具。若所有資料都只是 cache，SQLite + TTL 足夠；若有 pending mutation 或 multi-device edit，就需要 sync protocol。</p>
<h2 id="authority-boundary">Authority boundary</h2>
<p>Authority boundary 的核心責任是決定衝突時誰說了算。Local-first app 可以讓 device、server、field-level merge 或 CRDT 成為不同層的 authority；SQLite 本身只保存狀態，不替系統決策。</p>
<table>
  <thead>
      <tr>
          <th>Authority model</th>
          <th>適合情境</th>
          <th>代價</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Server authority</td>
          <td>帳務、權限、共享資料</td>
          <td>離線寫入要排隊，回線後可能被拒絕</td>
      </tr>
      <tr>
          <td>Device authority</td>
          <td>單使用者、單裝置資料</td>
          <td>多裝置同步能力弱</td>
      </tr>
      <tr>
          <td>Last-write-wins</td>
          <td>低價值設定、簡單 preference</td>
          <td>資料覆蓋風險</td>
      </tr>
      <tr>
          <td>Field merge</td>
          <td>profile、表單、可分欄位資料</td>
          <td>merge rule 要測，使用者理解成本上升</td>
      </tr>
      <tr>
          <td>CRDT / operation log</td>
          <td>協作編輯、順序敏感操作</td>
          <td>實作與除錯成本高</td>
      </tr>
  </tbody>
</table>
<p>Authority model 要和 product semantics 對齊。庫存、付款、權限這類資料通常需要 server authority；notes、draft、local settings 可以接受更偏 local 的權威模型。</p>
<h2 id="sync-transport-與-local-log">Sync transport 與 local log</h2>
<p>Sync transport 的核心責任是把 SQLite local state 轉成可重送、可去重、可驗證的資料流。最常見做法是本地維護 pending mutation table 或 change log，再由 background sync worker 送到 server。</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">pending_mutations</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">TEXT</span><span class="w"> </span><span class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</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">entity_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"> 4</span><span class="cl"><span class="w">  </span><span class="n">entity_id</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="k">operation</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">payload</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">created_at</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"> 8</span><span class="cl"><span class="w">  </span><span class="n">retry_count</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">0</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">last_error</span><span class="w"> </span><span class="nb">TEXT</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w"></span><span class="p">);</span></span></span></code></pre></div><table>
  <thead>
      <tr>
          <th>設計點</th>
          <th>判讀</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>idempotency</td>
          <td>每個 mutation 需要穩定 id，避免重送副作用</td>
      </tr>
      <tr>
          <td>ordering</td>
          <td>同 entity 操作是否必須按順序</td>
      </tr>
      <tr>
          <td>retry</td>
          <td>transient failure、backoff、dead-letter</td>
      </tr>
      <tr>
          <td>compaction</td>
          <td>已同步 local log 何時清除</td>
      </tr>
      <tr>
          <td>reconciliation</td>
          <td>server / local 差異如何修復</td>
      </tr>
  </tbody>
</table>
<p>這裡和 backend queue 概念相通：pending mutation table 是本機版 durable queue。它需要 <a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a>、retry 與 replay 思維，而不只是「存一張表」。</p>
<h2 id="conflict-resolution">Conflict resolution</h2>
<p>Conflict resolution 的核心責任是讓兩個合法 local write 合併成可接受狀態。SQLite 可以保存 local write；sync layer 要決定衝突偵測、呈現與合併。</p>
<table>
  <thead>
      <tr>
          <th>衝突型態</th>
          <th>例子</th>
          <th>處理策略</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Same field update</td>
          <td>兩台裝置改同一個 display name</td>
          <td>LWW、server reject、manual merge</td>
      </tr>
      <tr>
          <td>Disjoint field update</td>
          <td>一台改 phone，一台改 address</td>
          <td>field merge</td>
      </tr>
      <tr>
          <td>Delete vs update</td>
          <td>一台刪除，一台修改</td>
          <td>tombstone、manual review</td>
      </tr>
      <tr>
          <td>Ordered operation</td>
          <td>task reorder、ledger append</td>
          <td>operation log、server sequence</td>
      </tr>
  </tbody>
</table>
<p>Conflict policy 要在資料模型設計時決定。等衝突發生後才補策略，通常會導致資料修復、客服流程與 audit evidence 同時缺位。</p>
<h2 id="delete-propagation-與-privacy">Delete propagation 與 privacy</h2>
<p>Delete propagation 的核心責任是讓 server、device、backup 與 sync queue 對「刪除」有一致語意。Local-first app 常見風險是 server 已刪，但 device local DB、pending queue 或 OS backup 還留著資料。</p>
<table>
  <thead>
      <tr>
          <th>刪除語意</th>
          <th>適合情境</th>
          <th>SQLite 設計</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Soft delete</td>
          <td>可恢復、需要 sync tombstone</td>
          <td><code>deleted_at</code>、sync tombstone、retention job</td>
      </tr>
      <tr>
          <td>Hard delete</td>
          <td>privacy / compliance</td>
          <td>local purge、backup exclusion、sync confirmation</td>
      </tr>
      <tr>
          <td>Redaction</td>
          <td>support bundle / log</td>
          <td>export 時遮罩 sensitive fields</td>
      </tr>
  </tbody>
</table>
<p>刪除在同步系統裡是一個跨裝置生命週期。若資料跨裝置同步，delete 需要 <a href="/blog/backend/knowledge-cards/tombstone/" data-link-title="Tombstone" data-link-desc="說明刪除如何用一筆標記記錄下來，讓刪除事件能跨副本與裝置傳播">tombstone</a>、ack、retry、backup retention 與 evidence；這些責任要接到 <a href="/blog/backend/07-security-data-protection/data-protection-and-masking-governance/" data-link-title="7.4 資料保護與遮罩治理" data-link-desc="以問題驅動方式整理資料分級、遮罩、匯出與備份治理">Data Protection</a>。</p>
<h2 id="production-踩雷">Production 踩雷</h2>
<h3 id="case-1pending-mutation-沒有-idempotency-key">Case 1：pending mutation 沒有 idempotency key</h3>
<p>Pending mutation 沒有 idempotency key 的核心風險是重送造成重複副作用。網路 timeout 後 worker 重送，server 已經處理第一次請求，第二次又建立一筆資料或扣一次庫存。</p>
<p>修正方向是每個 mutation 生成 stable id，server 以 idempotency key 去重，local SQLite 保存 retry state 與 server ack。</p>
<h3 id="case-2lww-覆蓋使用者資料">Case 2：LWW 覆蓋使用者資料</h3>
<p>Last-write-wins 的核心風險是把衝突靜默變成資料遺失。Preference 類資料可接受；草稿、文件、表單、付款資料通常需要更清楚的 conflict handling。</p>
<p>修正方向是依資料價值分層。低價值設定用 LWW；高價值內容用 field merge、manual conflict 或 operation log。</p>
<h3 id="case-3delete-沒傳到離線裝置">Case 3：delete 沒傳到離線裝置</h3>
<p>Delete propagation 失敗的核心風險是 privacy / compliance 失效。使用者刪除 server 資料後，一台長期離線裝置重新上線又把舊資料同步回來。</p>
<p>修正方向是 tombstone + server authority。Server 要能拒絕過期 mutation，device 要能接收 delete tombstone 並 purge local state。</p>
<h2 id="操作檢查清單">操作檢查清單</h2>
<p>Local-first SQLite 設計要回答：</p>
<ol>
<li>哪些 table 是 local source of truth，哪些是 server replica。</li>
<li>Pending mutation 是否有 idempotency key 與 retry state。</li>
<li>Conflict policy 是 LWW、field merge、manual merge 還是 operation log。</li>
<li>Delete 是否有 tombstone、ack 與 local purge。</li>
<li>Sync worker 是否有 backoff、dead-letter、reconciliation。</li>
<li>Device backup 是否會保存已刪資料。</li>
<li>Server 是否能拒絕過期 local write。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>上游：<a href="/blog/backend/01-database/vendors/sqlite/mobile-desktop-embedded-store/" data-link-title="SQLite Mobile / Desktop Embedded Store" data-link-desc="SQLite 在 mobile、desktop、CLI、browser profile 與 embedded device 中承擔 local formal state 的資料責任、backup、privacy 與 sync boundary">Mobile / Desktop Embedded Store</a></li>
<li>Sibling：<a href="/blog/backend/01-database/vendors/sqlite/migrate-to-d1-turso/" data-link-title="SQLite to D1 / Turso Migration" data-link-desc="SQLite 轉向 Cloudflare D1、Turso / libSQL 的 edge driver、compatibility audit、data movement 與 rollback">SQLite to D1 / Turso</a>、<a href="/blog/backend/01-database/vendors/sqlite/d1-turso-libsql-comparison/" data-link-title="SQLite D1 / Turso / libSQL Comparison" data-link-desc="Cloudflare D1、Turso、libSQL 與 local SQLite 在 edge、replication、consistency、migration 與 vendor boundary 的比較">D1 / Turso / libSQL Comparison</a></li>
<li>卡片：<a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">Idempotency</a>、<a href="/blog/backend/knowledge-cards/eventual-consistency/" data-link-title="Eventual Consistency" data-link-desc="允許短暫不一致、最終收斂到同一資料狀態的一致性語意">Eventual Consistency</a>、<a href="/blog/backend/knowledge-cards/stale-read/" data-link-title="Stale Read" data-link-desc="讀取到落後於最新寫入版本的舊資料">Stale Read</a></li>
<li>跨模組：<a href="/blog/backend/07-security-data-protection/data-protection-and-masking-governance/" data-link-title="7.4 資料保護與遮罩治理" data-link-desc="以問題驅動方式整理資料分級、遮罩、匯出與備份治理">Data Protection</a></li>
</ul>
]]></content:encoded></item><item><title>SQLite Mobile / Desktop Embedded Store</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/mobile-desktop-embedded-store/</link><pubDate>Thu, 21 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/mobile-desktop-embedded-store/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/" data-link-title="SQLite" data-link-desc="embedded、單檔案、test / CLI / edge 場景的標準選擇、近年因 Cloudflare D1 / Turso 等服務復興">SQLite&lt;/a> overview 的 implementation-layer deep article。Overview 已說明 SQLite 適合 mobile、desktop、CLI 與 embedded device；本文聚焦 &lt;em>device-local formal state 的資料責任、backup、privacy 與 sync boundary&lt;/em>。&lt;/p>&lt;/blockquote>
&lt;p>SQLite embedded store 的核心責任是讓 application process 在本機持有正式狀態。Mobile app、desktop app、browser profile、CLI tool 與 embedded device 常用 SQLite 保存 local data；這些資料可能只是 cache，也可能是使用者唯一資料來源。教學上要先判斷它是否承擔 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">source of truth&lt;/a>，再決定 backup、sync、privacy 與 migration 責任。&lt;/p>
&lt;p>本文的判讀錨點是：embedded SQLite 的 production boundary 在 device lifecycle，database server 層的邊界在這裡不適用。OS backup、app upgrade、device loss、profile corruption、local PII、multi-device sync 與 user export / delete 都是資料庫責任的一部分。&lt;/p>
&lt;h2 id="embedded-state-model">Embedded state model&lt;/h2>
&lt;p>Embedded state model 的核心責任是把 local database file 放回 application lifecycle。SQLite 是典型的 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/embedded-database/" data-link-title="Embedded Database" data-link-desc="說明嵌入式資料庫如何隨 application process 運作，並把檔案生命週期責任交回應用">embedded database&lt;/a>：database file 通常跟著 app sandbox、user profile、CLI config directory 或 device storage 存在，它的 owner 是 application，而非獨立 DBA。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>場景&lt;/th>
 &lt;th>SQLite 資料角色&lt;/th>
 &lt;th>主要風險&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Mobile app&lt;/td>
 &lt;td>offline state、draft、cache、local profile&lt;/td>
 &lt;td>app upgrade、device loss、cloud backup leakage&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Desktop app&lt;/td>
 &lt;td>user profile、history、settings&lt;/td>
 &lt;td>profile corruption、manual file copy、multi-version app&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>CLI tool&lt;/td>
 &lt;td>local index、metadata、state cache&lt;/td>
 &lt;td>command interruption、portable file path&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Browser / profile&lt;/td>
 &lt;td>cookies、history、bookmark 類資料&lt;/td>
 &lt;td>privacy、profile migration、lock collision&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Embedded device&lt;/td>
 &lt;td>offline event、sensor / config state&lt;/td>
 &lt;td>power loss、flash wear、delayed sync&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這張表的重點是資料角色而非產品名稱。同樣是 SQLite file，cache 可以清掉重建；draft、local-only note、sensor event 或 user history 可能需要正式 backup / export / delete。&lt;/p>
&lt;h2 id="backup-與-export">Backup 與 export&lt;/h2>
&lt;p>Embedded backup 的核心責任是讓使用者或服務能從 device / profile failure 復原。Mobile / desktop / CLI 的 backup 路徑常和 OS backup、app export、cloud sync 或手動複製混在一起；SQLite file lifecycle 要明確。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/sqlite/" data-link-title="SQLite" data-link-desc="embedded、單檔案、test / CLI / edge 場景的標準選擇、近年因 Cloudflare D1 / Turso 等服務復興">SQLite</a> overview 的 implementation-layer deep article。Overview 已說明 SQLite 適合 mobile、desktop、CLI 與 embedded device；本文聚焦 <em>device-local formal state 的資料責任、backup、privacy 與 sync boundary</em>。</p></blockquote>
<p>SQLite embedded store 的核心責任是讓 application process 在本機持有正式狀態。Mobile app、desktop app、browser profile、CLI tool 與 embedded device 常用 SQLite 保存 local data；這些資料可能只是 cache，也可能是使用者唯一資料來源。教學上要先判斷它是否承擔 <a href="/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">source of truth</a>，再決定 backup、sync、privacy 與 migration 責任。</p>
<p>本文的判讀錨點是：embedded SQLite 的 production boundary 在 device lifecycle，database server 層的邊界在這裡不適用。OS backup、app upgrade、device loss、profile corruption、local PII、multi-device sync 與 user export / delete 都是資料庫責任的一部分。</p>
<h2 id="embedded-state-model">Embedded state model</h2>
<p>Embedded state model 的核心責任是把 local database file 放回 application lifecycle。SQLite 是典型的 <a href="/blog/backend/knowledge-cards/embedded-database/" data-link-title="Embedded Database" data-link-desc="說明嵌入式資料庫如何隨 application process 運作，並把檔案生命週期責任交回應用">embedded database</a>：database file 通常跟著 app sandbox、user profile、CLI config directory 或 device storage 存在，它的 owner 是 application，而非獨立 DBA。</p>
<table>
  <thead>
      <tr>
          <th>場景</th>
          <th>SQLite 資料角色</th>
          <th>主要風險</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Mobile app</td>
          <td>offline state、draft、cache、local profile</td>
          <td>app upgrade、device loss、cloud backup leakage</td>
      </tr>
      <tr>
          <td>Desktop app</td>
          <td>user profile、history、settings</td>
          <td>profile corruption、manual file copy、multi-version app</td>
      </tr>
      <tr>
          <td>CLI tool</td>
          <td>local index、metadata、state cache</td>
          <td>command interruption、portable file path</td>
      </tr>
      <tr>
          <td>Browser / profile</td>
          <td>cookies、history、bookmark 類資料</td>
          <td>privacy、profile migration、lock collision</td>
      </tr>
      <tr>
          <td>Embedded device</td>
          <td>offline event、sensor / config state</td>
          <td>power loss、flash wear、delayed sync</td>
      </tr>
  </tbody>
</table>
<p>這張表的重點是資料角色而非產品名稱。同樣是 SQLite file，cache 可以清掉重建；draft、local-only note、sensor event 或 user history 可能需要正式 backup / export / delete。</p>
<h2 id="backup-與-export">Backup 與 export</h2>
<p>Embedded backup 的核心責任是讓使用者或服務能從 device / profile failure 復原。Mobile / desktop / CLI 的 backup 路徑常和 OS backup、app export、cloud sync 或手動複製混在一起；SQLite file lifecycle 要明確。</p>
<table>
  <thead>
      <tr>
          <th>路徑</th>
          <th>適合資料</th>
          <th>注意事項</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>OS / device backup</td>
          <td>user-owned local state</td>
          <td>local PII、encryption、restore compatibility</td>
      </tr>
      <tr>
          <td>App export</td>
          <td>使用者可攜資料</td>
          <td>schema version、format stability、privacy</td>
      </tr>
      <tr>
          <td><code>.backup</code> / snapshot</td>
          <td>application-managed backup</td>
          <td>live DB consistency、WAL sidecar handling</td>
      </tr>
      <tr>
          <td>Cloud sync</td>
          <td>multi-device state</td>
          <td>conflict、server authority、delete propagation</td>
      </tr>
  </tbody>
</table>
<p>Backup 設計要先決定 restore target。Restore 到同 app version、未來 app version、或不同 device，會帶來不同 schema compatibility 與 privacy requirement。</p>
<h2 id="privacy-與-local-pii">Privacy 與 local PII</h2>
<p>Embedded SQLite 的 privacy 責任是治理 device-local data。資料在 server DB 中通常有 access log、IAM、DLP 與 retention policy；進入 SQLite file 後，風險轉到 device encryption、app sandbox、backup retention、debug export 與 support bundle。</p>
<table>
  <thead>
      <tr>
          <th>風險</th>
          <th>真實情境</th>
          <th>控制方向</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Local PII</td>
          <td>profile、token、message、draft</td>
          <td>最小化欄位、加密敏感值、限制 export</td>
      </tr>
      <tr>
          <td>Backup leakage</td>
          <td>OS cloud backup 含 database file</td>
          <td>設定 backup exclusion 或加密</td>
      </tr>
      <tr>
          <td>Support bundle</td>
          <td>使用者回報問題附上 DB</td>
          <td>scrub / redaction、只匯出必要 table</td>
      </tr>
      <tr>
          <td>Delete request</td>
          <td>server 刪除但 device local 留存</td>
          <td>sync delete、local purge、retention evidence</td>
      </tr>
  </tbody>
</table>
<p>SQLite file 要進入資料保護盤點。若 local DB 保存敏感資料，應連到 <a href="/blog/backend/07-security-data-protection/data-protection-and-masking-governance/" data-link-title="7.4 資料保護與遮罩治理" data-link-desc="以問題驅動方式整理資料分級、遮罩、匯出與備份治理">Data Protection</a> 與 <a href="/blog/backend/knowledge-cards/audit-log/" data-link-title="Audit Log" data-link-desc="說明高風險操作如何留下可追溯、可稽核的紀錄">Audit Log</a> 的相同問題，只是控制面改在 device / app。</p>
<h2 id="app-upgrade-與-schema-compatibility">App upgrade 與 schema compatibility</h2>
<p>App upgrade 的核心責任是保證新版 binary 能安全打開舊 database file。Mobile / desktop app 的使用者不會按照 backend deployment order 升級；同一時間可能存在多個 app version 與多個 DB schema version。</p>
<table>
  <thead>
      <tr>
          <th>問題</th>
          <th>設計策略</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>新 app 打舊 DB</td>
          <td>startup migration、<code>user_version</code>、backup before migration</td>
      </tr>
      <tr>
          <td>舊 app 打新 DB</td>
          <td>backward-compatible column、feature gate、minimum supported version</td>
      </tr>
      <tr>
          <td>使用者降版</td>
          <td>export / import、read-only fallback、no-downgrade notice</td>
      </tr>
      <tr>
          <td>多裝置不同版本</td>
          <td>sync protocol version、server-side compatibility</td>
      </tr>
  </tbody>
</table>
<p>這些策略要和 <a href="/blog/backend/01-database/vendors/sqlite/schema-migration-versioning/" data-link-title="SQLite Schema Migration and Versioning" data-link-desc="SQLite schema migration、user_version、table rebuild、ALTER TABLE 限制、app release compatibility 與 migration evidence">Schema Migration / Versioning</a> 對齊。Embedded app 的 migration failure 通常直接影響使用者啟動體驗，因此 migration 要能快速、可恢復、可診斷。</p>
<h2 id="sync-boundary">Sync boundary</h2>
<p>Sync boundary 的核心責任是把 single-device SQLite 和 multi-device state 分開。SQLite 保存本地狀態；跨裝置同步需要 transport、identity、conflict resolution、delete propagation 與 server authority。</p>
<table>
  <thead>
      <tr>
          <th>Sync 需求</th>
          <th>SQLite 角色</th>
          <th>下一步路由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>單裝置 offline</td>
          <td>local source of truth</td>
          <td>SQLite + backup / export</td>
      </tr>
      <tr>
          <td>多裝置同步</td>
          <td>local replica / cache</td>
          <td><a href="/blog/backend/01-database/vendors/sqlite/local-first-sync-boundary/" data-link-title="SQLite Local-first Sync Boundary" data-link-desc="SQLite local-first app、multi-device sync、server authority、conflict resolution、delete propagation 與 offline-first trade-off">Local-first sync boundary</a></td>
      </tr>
      <tr>
          <td>即時多人協作</td>
          <td>local working copy</td>
          <td>server authority、CRDT、event log</td>
      </tr>
      <tr>
          <td>Server reporting</td>
          <td>local data upload / ETL</td>
          <td>API sync、queue、analytics store</td>
      </tr>
  </tbody>
</table>
<p>當 sync 需求出現時，SQLite 仍可作為 local store，但不再單獨承擔完整資料一致性。完整性要由 sync protocol 與 server-side validation 補上。</p>
<h2 id="production-踩雷">Production 踩雷</h2>
<h3 id="case-1把-cache-當正式資料">Case 1：把 cache 當正式資料</h3>
<p>Cache 被誤當正式資料的核心風險是清除 local DB 會造成不可恢復資料損失。許多 app 初期把 SQLite 當 cache；後來加入 draft、offline action 或 local-only setting，資料責任就改變了。</p>
<p>修正方向是逐 table 標示資料角色。Cache table 可清；formal state table 要 backup、migration、export 與 delete policy。</p>
<h3 id="case-2os-backup-帶走敏感資料">Case 2：OS backup 帶走敏感資料</h3>
<p>OS backup 的核心風險是 device-local PII 進入使用者或平台雲端備份。Server 端已刪除的資料，可能仍存在 device backup。</p>
<p>修正方向是決定哪些資料可被備份。Token、secret、敏感 PII 可排除或加密；user-owned content 則要提供 export / restore 語意。</p>
<h3 id="case-3app-upgrade-migration-失敗讓使用者卡在啟動頁">Case 3：App upgrade migration 失敗讓使用者卡在啟動頁</h3>
<p>Startup migration 失敗的核心風險是使用者卡在 app 啟動前，且修復能力有限。SQLite file 在使用者裝置上，SRE 通常需要透過 app update、support bundle 或 restore flow 處理。</p>
<p>修正方向是保留 pre-migration snapshot、提供 safe mode、收集匿名 schema / error evidence，並避免長 migration 放在 cold start。</p>
<h2 id="操作檢查清單">操作檢查清單</h2>
<p>Embedded SQLite 設計要回答：</p>
<ol>
<li>每張 table 是 cache、formal state、derived state 還是 sync queue。</li>
<li>Database file 在 app / OS 的哪個 storage boundary。</li>
<li>OS backup 是否包含 database file。</li>
<li>敏感欄位是否加密、排除或可清除。</li>
<li>App upgrade migration 是否有 pre-migration backup。</li>
<li>使用者 export / delete / support bundle 如何處理 SQLite data。</li>
<li>Multi-device sync 是否有 conflict 與 server authority 設計。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>上游：<a href="/blog/backend/01-database/vendors/sqlite/" data-link-title="SQLite" data-link-desc="embedded、單檔案、test / CLI / edge 場景的標準選擇、近年因 Cloudflare D1 / Turso 等服務復興">SQLite overview</a></li>
<li>Sibling：<a href="/blog/backend/01-database/vendors/sqlite/local-first-sync-boundary/" data-link-title="SQLite Local-first Sync Boundary" data-link-desc="SQLite local-first app、multi-device sync、server authority、conflict resolution、delete propagation 與 offline-first trade-off">Local-first Sync Boundary</a>、<a href="/blog/backend/01-database/vendors/sqlite/schema-migration-versioning/" data-link-title="SQLite Schema Migration and Versioning" data-link-desc="SQLite schema migration、user_version、table rebuild、ALTER TABLE 限制、app release compatibility 與 migration evidence">Schema Migration / Versioning</a></li>
<li>操作：<a href="/blog/backend/01-database/vendors/sqlite/hands-on/" data-link-title="SQLite Hands-on 操作路線" data-link-desc="SQLite local file lab、backup / restore drill、WAL busy reproduction、migration fixture、D1 / Turso preview 的操作型章節設計">SQLite Hands-on</a></li>
<li>跨模組：<a href="/blog/backend/07-security-data-protection/data-protection-and-masking-governance/" data-link-title="7.4 資料保護與遮罩治理" data-link-desc="以問題驅動方式整理資料分級、遮罩、匯出與備份治理">Data Protection</a></li>
<li>官方：<a href="https://www.sqlite.org/whentouse.html">SQLite Appropriate Uses</a>、<a href="https://www.sqlite.org/backup.html">SQLite Backup API</a></li>
</ul>
]]></content:encoded></item><item><title>SQLite PRAGMA Tuning and Performance</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/pragma-tuning-performance/</link><pubDate>Thu, 21 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/pragma-tuning-performance/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/" data-link-title="SQLite" data-link-desc="embedded、單檔案、test / CLI / edge 場景的標準選擇、近年因 Cloudflare D1 / Turso 等服務復興">SQLite&lt;/a> overview 的 implementation-layer deep article。Overview 已說明 SQLite 的容量規劃要點；本文聚焦 &lt;em>PRAGMA 設定如何變成 durability、latency、檔案大小與 restore risk 的取捨&lt;/em>。&lt;/p>&lt;/blockquote>
&lt;p>SQLite PRAGMA tuning 的核心責任是把單檔資料庫的行為固定成可重複、可觀測、可回退的操作契約。SQLite 的許多重要行為由 connection-level 或 database-level PRAGMA 控制；這些設定看起來像小開關，實際上會影響 crash recovery、commit latency、reader / writer 衝突、檔案大小與測試一致性。&lt;/p>
&lt;p>本文的判讀錨點是：PRAGMA 是 durability / latency / maintenance 的顯性取捨，而非效能魔法。Production runbook 要記錄設定值、設定時機、驗證 query 與回退條件，避免不同 process、test runner 或 migration tool 用不同 SQLite 行為。&lt;/p>
&lt;h2 id="baseline-pragma">Baseline PRAGMA&lt;/h2>
&lt;p>SQLite baseline PRAGMA 的責任是讓 application 每次啟動都進入同一個資料庫模式。對 production-like local store、small backend 或 test fixture，建議把 journal、sync、foreign key、busy timeout 與 checkpoint 明確設定，而非依賴語言 binding 預設值。&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="n">PRAGMA&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">journal_mode&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">WAL&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">PRAGMA&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">synchronous&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">NORMAL&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">PRAGMA&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">foreign_keys&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&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="n">PRAGMA&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">busy_timeout&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">5000&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">PRAGMA&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">wal_autocheckpoint&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">1000&lt;/span>&lt;span class="p">;&lt;/span>&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;th>驗證方式&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>journal_mode=WAL&lt;/code>&lt;/td>
 &lt;td>降低 reader / writer 衝突&lt;/td>
 &lt;td>回傳值為 &lt;code>wal&lt;/code>，觀察 &lt;code>-wal&lt;/code> file&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>synchronous=NORMAL&lt;/code>&lt;/td>
 &lt;td>平衡 fsync cost 與 crash durability&lt;/td>
 &lt;td>查 &lt;code>PRAGMA synchronous&lt;/code>，跑 restore drill&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>foreign_keys=ON&lt;/code>&lt;/td>
 &lt;td>啟用 FK enforcement&lt;/td>
 &lt;td>&lt;code>PRAGMA foreign_key_check&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>busy_timeout&lt;/code>&lt;/td>
 &lt;td>吸收短暫 writer queue&lt;/td>
 &lt;td>記錄 busy wait 與 timeout rate&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>wal_autocheckpoint&lt;/code>&lt;/td>
 &lt;td>控制 WAL growth cadence&lt;/td>
 &lt;td>觀察 WAL size 與 checkpoint duration&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這張表的重點是把設定與 evidence 綁在一起。若某個 PRAGMA 缺少成功訊號與失敗訊號，就先維持保守預設；盲目追求「最快」通常會把風險推到 power loss、restore 或長尾 latency。&lt;/p>
&lt;h2 id="journal_mode-與-wal-boundary">&lt;code>journal_mode&lt;/code> 與 WAL boundary&lt;/h2>
&lt;p>&lt;code>journal_mode&lt;/code> 的核心責任是決定 transaction 如何保護原始資料。SQLite 預設 rollback journal 對簡單場景合理；WAL mode 則讓 reader 可以在 writer append WAL 時保有 snapshot，適合多 reader、短寫入、互動式 workload。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>模式&lt;/th>
 &lt;th>適合情境&lt;/th>
 &lt;th>注意事項&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>DELETE&lt;/code>&lt;/td>
 &lt;td>最簡單、低併發、短生命週期檔案&lt;/td>
 &lt;td>write / read 衝突較明顯&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>WAL&lt;/code>&lt;/td>
 &lt;td>read-heavy、local app、小型 API&lt;/td>
 &lt;td>需要治理 &lt;code>-wal&lt;/code>、&lt;code>-shm&lt;/code>、checkpoint&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>MEMORY&lt;/code>&lt;/td>
 &lt;td>暫存測試、可丟資料&lt;/td>
 &lt;td>crash 後 recovery 風險高&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>OFF&lt;/code>&lt;/td>
 &lt;td>可重建資料、一次性 bulk load&lt;/td>
 &lt;td>production formal state 應避開&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>WAL mode 是多數 production-like SQLite 的 baseline，但它也引入 sidecar file 與 checkpoint 責任。完整判讀見 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/wal-concurrency-locking/" data-link-title="SQLite WAL Concurrency and Locking" data-link-desc="SQLite WAL mode 如何降低 reader / writer 衝突、保留 single writer boundary，並用 SQLITE_BUSY、WAL growth、checkpoint 訊號判斷 production 上限">WAL concurrency / locking&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/sqlite/" data-link-title="SQLite" data-link-desc="embedded、單檔案、test / CLI / edge 場景的標準選擇、近年因 Cloudflare D1 / Turso 等服務復興">SQLite</a> overview 的 implementation-layer deep article。Overview 已說明 SQLite 的容量規劃要點；本文聚焦 <em>PRAGMA 設定如何變成 durability、latency、檔案大小與 restore risk 的取捨</em>。</p></blockquote>
<p>SQLite PRAGMA tuning 的核心責任是把單檔資料庫的行為固定成可重複、可觀測、可回退的操作契約。SQLite 的許多重要行為由 connection-level 或 database-level PRAGMA 控制；這些設定看起來像小開關，實際上會影響 crash recovery、commit latency、reader / writer 衝突、檔案大小與測試一致性。</p>
<p>本文的判讀錨點是：PRAGMA 是 durability / latency / maintenance 的顯性取捨，而非效能魔法。Production runbook 要記錄設定值、設定時機、驗證 query 與回退條件，避免不同 process、test runner 或 migration tool 用不同 SQLite 行為。</p>
<h2 id="baseline-pragma">Baseline PRAGMA</h2>
<p>SQLite baseline PRAGMA 的責任是讓 application 每次啟動都進入同一個資料庫模式。對 production-like local store、small backend 或 test fixture，建議把 journal、sync、foreign key、busy timeout 與 checkpoint 明確設定，而非依賴語言 binding 預設值。</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="n">PRAGMA</span><span class="w"> </span><span class="n">journal_mode</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">WAL</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">PRAGMA</span><span class="w"> </span><span class="n">synchronous</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">NORMAL</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">PRAGMA</span><span class="w"> </span><span class="n">foreign_keys</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">ON</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="n">PRAGMA</span><span class="w"> </span><span class="n">busy_timeout</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">5000</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">PRAGMA</span><span class="w"> </span><span class="n">wal_autocheckpoint</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">1000</span><span class="p">;</span></span></span></code></pre></div><table>
  <thead>
      <tr>
          <th>設定</th>
          <th>服務責任</th>
          <th>驗證方式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>journal_mode=WAL</code></td>
          <td>降低 reader / writer 衝突</td>
          <td>回傳值為 <code>wal</code>，觀察 <code>-wal</code> file</td>
      </tr>
      <tr>
          <td><code>synchronous=NORMAL</code></td>
          <td>平衡 fsync cost 與 crash durability</td>
          <td>查 <code>PRAGMA synchronous</code>，跑 restore drill</td>
      </tr>
      <tr>
          <td><code>foreign_keys=ON</code></td>
          <td>啟用 FK enforcement</td>
          <td><code>PRAGMA foreign_key_check</code></td>
      </tr>
      <tr>
          <td><code>busy_timeout</code></td>
          <td>吸收短暫 writer queue</td>
          <td>記錄 busy wait 與 timeout rate</td>
      </tr>
      <tr>
          <td><code>wal_autocheckpoint</code></td>
          <td>控制 WAL growth cadence</td>
          <td>觀察 WAL size 與 checkpoint duration</td>
      </tr>
  </tbody>
</table>
<p>這張表的重點是把設定與 evidence 綁在一起。若某個 PRAGMA 缺少成功訊號與失敗訊號，就先維持保守預設；盲目追求「最快」通常會把風險推到 power loss、restore 或長尾 latency。</p>
<h2 id="journal_mode-與-wal-boundary"><code>journal_mode</code> 與 WAL boundary</h2>
<p><code>journal_mode</code> 的核心責任是決定 transaction 如何保護原始資料。SQLite 預設 rollback journal 對簡單場景合理；WAL mode 則讓 reader 可以在 writer append WAL 時保有 snapshot，適合多 reader、短寫入、互動式 workload。</p>
<table>
  <thead>
      <tr>
          <th>模式</th>
          <th>適合情境</th>
          <th>注意事項</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>DELETE</code></td>
          <td>最簡單、低併發、短生命週期檔案</td>
          <td>write / read 衝突較明顯</td>
      </tr>
      <tr>
          <td><code>WAL</code></td>
          <td>read-heavy、local app、小型 API</td>
          <td>需要治理 <code>-wal</code>、<code>-shm</code>、checkpoint</td>
      </tr>
      <tr>
          <td><code>MEMORY</code></td>
          <td>暫存測試、可丟資料</td>
          <td>crash 後 recovery 風險高</td>
      </tr>
      <tr>
          <td><code>OFF</code></td>
          <td>可重建資料、一次性 bulk load</td>
          <td>production formal state 應避開</td>
      </tr>
  </tbody>
</table>
<p>WAL mode 是多數 production-like SQLite 的 baseline，但它也引入 sidecar file 與 checkpoint 責任。完整判讀見 <a href="/blog/backend/01-database/vendors/sqlite/wal-concurrency-locking/" data-link-title="SQLite WAL Concurrency and Locking" data-link-desc="SQLite WAL mode 如何降低 reader / writer 衝突、保留 single writer boundary，並用 SQLITE_BUSY、WAL growth、checkpoint 訊號判斷 production 上限">WAL concurrency / locking</a>。</p>
<h2 id="synchronouscommit-latency-與資料損失窗口"><code>synchronous</code>：commit latency 與資料損失窗口</h2>
<p><code>synchronous</code> 的核心責任是控制 SQLite 在關鍵時刻要求 storage flush 的強度。官方 PRAGMA 文件說明 WAL mode 下 <code>NORMAL</code> 會把 sync 主要放在 checkpoint 路徑；這通常讓 commit 更快，但 crash durability 的語意要由 service owner 接受。</p>
<table>
  <thead>
      <tr>
          <th>設定</th>
          <th>服務語意</th>
          <th>適合情境</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>FULL</code></td>
          <td>更保守的 durability</td>
          <td>金錢、ledger、不可重建 local state</td>
      </tr>
      <tr>
          <td><code>NORMAL</code></td>
          <td>多數 WAL production-like baseline</td>
          <td>local app、小型服務、可接受極小 crash window</td>
      </tr>
      <tr>
          <td><code>OFF</code></td>
          <td>追求速度，放棄重要 durability</td>
          <td>scratch DB、可重建 cache、bulk import staging</td>
      </tr>
  </tbody>
</table>
<p><code>synchronous=OFF</code> 要被視為明確風險接受。若資料是 <a href="/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">source of truth</a>，設定檔、runbook 與 review 都應避免把 staging 的快速設定帶進 production。</p>
<h2 id="cachemmap-與-memory-pressure">Cache、mmap 與 memory pressure</h2>
<p>SQLite memory tuning 的核心責任是降低 read path I/O，同時避免把 device / container memory 壓到不可控。<code>cache_size</code> 控制 SQLite page cache；<code>mmap_size</code> 讓讀取可透過 memory-mapped I/O 加速，但仍受平台、檔案大小與 memory budget 影響。</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="n">PRAGMA</span><span class="w"> </span><span class="n">cache_size</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="o">-</span><span class="mi">64000</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">PRAGMA</span><span class="w"> </span><span class="n">mmap_size</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">268435456</span><span class="p">;</span></span></span></code></pre></div><table>
  <thead>
      <tr>
          <th>設定</th>
          <th>改善目標</th>
          <th>觀測訊號</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>cache_size</code></td>
          <td>減少重複 page read</td>
          <td>query latency、disk read、memory usage</td>
      </tr>
      <tr>
          <td><code>mmap_size</code></td>
          <td>降低 read syscall cost</td>
          <td>p95 / p99 read latency、address space</td>
      </tr>
      <tr>
          <td><code>temp_store</code></td>
          <td>控制 temp table 位置</td>
          <td>sort / join query latency、memory pressure</td>
      </tr>
  </tbody>
</table>
<p>Memory 設定要和 workload size 一起看。Desktop app、mobile app、edge worker、container service 的 memory ceiling 不同；把 server 上的設定複製到 mobile 或 edge runtime 會讓風險轉移到 OOM 或 OS reclaim。</p>
<h2 id="vacuum-與檔案大小治理">Vacuum 與檔案大小治理</h2>
<p>Vacuum 設定的核心責任是控制 delete 後的空間回收。SQLite delete row 後，database file 不會自然縮小；<code>auto_vacuum</code> 要在 database 建立早期決定，後續切換通常需要 <code>VACUUM</code> 重整整個 database。</p>
<table>
  <thead>
      <tr>
          <th>設定 / 操作</th>
          <th>適合情境</th>
          <th>風險 / 成本</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>auto_vacuum=NONE</code></td>
          <td>資料量穩定、delete 少</td>
          <td>檔案可能長期保持高水位</td>
      </tr>
      <tr>
          <td><code>auto_vacuum=INCREMENTAL</code></td>
          <td>需要逐步回收空間</td>
          <td>需要排程 <code>incremental_vacuum</code></td>
      </tr>
      <tr>
          <td><code>VACUUM</code></td>
          <td>maintenance window、重整資料庫</td>
          <td>需要額外空間與 I/O，可能影響服務</td>
      </tr>
      <tr>
          <td><code>VACUUM INTO</code></td>
          <td>compact copy / backup</td>
          <td>產出新檔，適合 restore drill 或 export</td>
      </tr>
  </tbody>
</table>
<p>檔案大小治理要接到 backup 成本。Database file 長期膨脹會放大備份時間、restore 時間與 edge deploy artifact size；若服務有大量 delete / churn，vacuum policy 要被寫進 runbook。</p>
<h2 id="production-踩雷">Production 踩雷</h2>
<h3 id="case-1pragma-只在某個-connection-設定">Case 1：PRAGMA 只在某個 connection 設定</h3>
<p>Connection-level PRAGMA 的核心風險是不同程式路徑行為不一致。Application 啟動時設了 <code>foreign_keys=ON</code>，migration tool 或 test runner 沒設，就會出現 production / migration / test 三種語意。</p>
<p>修正方向是把 baseline PRAGMA 放進 shared DB open path，並在 startup health check 印出設定值。Migration CLI、background worker、test fixture 都要共用同一份 connection initialization。</p>
<h3 id="case-2synchronousoff-從測試環境流到正式資料">Case 2：<code>synchronous=OFF</code> 從測試環境流到正式資料</h3>
<p>快速測試設定外流的核心風險是資料損失只在 crash 後出現。平常 query 都正常，直到 power loss、container kill 或 host crash 後，資料庫出現落差。</p>
<p>修正方向是設定分層。Test / benchmark 可以用 faster profile；formal state profile 要用 <code>NORMAL</code> 或 <code>FULL</code>，並要求 restore drill。</p>
<h3 id="case-3wal-growth-被誤判成資料成長">Case 3：WAL growth 被誤判成資料成長</h3>
<p>WAL growth 的核心風險是 checkpoint 問題被當成容量問題。Disk alert 看到 <code>db-wal</code> 變大，若只擴 disk，長 reader 或 checkpoint starvation 仍會持續。</p>
<p>修正方向是把 WAL size、checkpoint return 與 long reader 一起看。先找 reader lifecycle，再調 checkpoint cadence。</p>
<h3 id="case-4vacuum-在高峰期執行">Case 4：Vacuum 在高峰期執行</h3>
<p>Vacuum 的核心風險是把 maintenance I/O 放到使用者路徑。檔案縮小是好事，但 full vacuum 會消耗 I/O 與時間，對 mobile / desktop / small backend 都可能造成卡頓。</p>
<p>修正方向是把 vacuum 當 maintenance job。大檔案用 <code>incremental_vacuum</code> 或低流量窗口；備份前的 compact copy 可考慮 <code>VACUUM INTO</code>。</p>
<h2 id="操作檢查清單">操作檢查清單</h2>
<p>SQLite PRAGMA runbook 至少要記錄：</p>
<ol>
<li>所有 connection 初始化時執行的 baseline PRAGMA。</li>
<li><code>journal_mode</code> 實際回傳值與 sidecar file 位置。</li>
<li><code>synchronous</code> profile 與資料風險接受者。</li>
<li><code>busy_timeout</code> 值、busy wait metric、timeout threshold。</li>
<li><code>wal_autocheckpoint</code>、manual checkpoint cadence 與 WAL size alert。</li>
<li><code>cache_size</code> / <code>mmap_size</code> 對 memory budget 的影響。</li>
<li><code>auto_vacuum</code> / <code>VACUUM</code> / <code>VACUUM INTO</code> 的 maintenance window。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>上游：<a href="/blog/backend/01-database/vendors/sqlite/" data-link-title="SQLite" data-link-desc="embedded、單檔案、test / CLI / edge 場景的標準選擇、近年因 Cloudflare D1 / Turso 等服務復興">SQLite overview</a></li>
<li>前置：<a href="/blog/backend/01-database/vendors/sqlite/wal-concurrency-locking/" data-link-title="SQLite WAL Concurrency and Locking" data-link-desc="SQLite WAL mode 如何降低 reader / writer 衝突、保留 single writer boundary，並用 SQLITE_BUSY、WAL growth、checkpoint 訊號判斷 production 上限">WAL concurrency / locking</a></li>
<li>操作：<a href="/blog/backend/01-database/vendors/sqlite/hands-on/" data-link-title="SQLite Hands-on 操作路線" data-link-desc="SQLite local file lab、backup / restore drill、WAL busy reproduction、migration fixture、D1 / Turso preview 的操作型章節設計">SQLite Hands-on</a></li>
<li>平行：<a href="/blog/backend/01-database/vendors/sqlite/observability-runbook/" data-link-title="SQLite Observability and Runbook" data-link-desc="SQLite production runbook、backup evidence、WAL growth、busy errors、disk usage、restore drill 與 incident route">Observability / runbook</a></li>
<li>官方：<a href="https://www.sqlite.org/pragma.html">SQLite PRAGMA</a>、<a href="https://www.sqlite.org/lang_vacuum.html">SQLite VACUUM</a>、<a href="https://www.sqlite.org/wal.html">SQLite WAL</a></li>
</ul>
]]></content:encoded></item><item><title>SQLite Schema Migration and Versioning</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/schema-migration-versioning/</link><pubDate>Thu, 21 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/schema-migration-versioning/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/" data-link-title="SQLite" data-link-desc="embedded、單檔案、test / CLI / edge 場景的標準選擇、近年因 Cloudflare D1 / Turso 等服務復興">SQLite&lt;/a> overview 的 implementation-layer deep article。Overview 已說明 SQLite 的 embedded / single-file 定位；本文聚焦 &lt;em>schema version、ALTER TABLE boundary、table rebuild migration 與 application release compatibility&lt;/em>。&lt;/p>&lt;/blockquote>
&lt;p>SQLite schema migration 的核心責任是讓單檔資料庫隨 application release 安全演進。SQLite 沒有獨立 database server，也沒有 DBA 在 server 端統一套 migration；migration 常在 application startup、CLI command、mobile app upgrade 或 desktop app launch 時發生，因此 schema version、binary compatibility、backup 與 rollback 要放在同一個 release contract 中設計。&lt;/p>
&lt;p>本文的判讀錨點是：SQLite migration 同時改資料庫檔案與 application 能讀的資料格式。只要使用者或服務可能拿舊 binary 打開新 database，或新 binary 打開舊 database，migration 就要處理 forward / backward compatibility，而不只是 SQL 成功執行。&lt;/p>
&lt;h2 id="version-model">Version model&lt;/h2>
&lt;p>SQLite schema versioning 的服務責任是讓 application 能判斷 database file 目前處於哪個契約。SQLite 提供 &lt;code>PRAGMA user_version&lt;/code> 作為 application-controlled integer；更複雜的服務也可以用 migration table 記錄多步驟版本、checksum 與執行時間。&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="n">PRAGMA&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">user_version&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">PRAGMA&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">user_version&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">2026052101&lt;/span>&lt;span class="p">;&lt;/span>&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;th>優點&lt;/th>
 &lt;th>邊界&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>user_version&lt;/code>&lt;/td>
 &lt;td>mobile / desktop / CLI single file&lt;/td>
 &lt;td>簡單、內建、開檔即可讀&lt;/td>
 &lt;td>只能存一個整數，缺 migration history&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>migration table&lt;/td>
 &lt;td>small backend、多人維護 schema&lt;/td>
 &lt;td>可記錄每步 migration 與 owner&lt;/td>
 &lt;td>需要先建立 table 與初始化流程&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>external manifest&lt;/td>
 &lt;td>fixture、artifact、read-only DB&lt;/td>
 &lt;td>可和 release artifact 綁定&lt;/td>
 &lt;td>DB file 本身不含完整 history&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Version model 要在第一版就定義。沒有版本欄位的 SQLite file 仍可 migration，但 application 只能靠 introspection 猜 schema，會讓 upgrade / downgrade runbook 複雜化。&lt;/p>
&lt;h2 id="alter-table-boundary">ALTER TABLE boundary&lt;/h2>
&lt;p>SQLite ALTER TABLE 的核心責任是處理有限集合的 schema 變更。官方文件說明 SQLite 支援 rename table、rename column、add column、drop column；更複雜的變更要走 table rebuild pattern。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>變更類型&lt;/th>
 &lt;th>SQLite 支援形態&lt;/th>
 &lt;th>操作判讀&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Rename table / column&lt;/td>
 &lt;td>直接 ALTER，版本差異影響 trigger / view&lt;/td>
 &lt;td>需要測 trigger、view、FK reference&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Add column&lt;/td>
 &lt;td>多數情境很快，受 default / constraint 限制&lt;/td>
 &lt;td>適合 expand migration&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Drop column&lt;/td>
 &lt;td>需要檢查 index、constraint、trigger、view&lt;/td>
 &lt;td>可能掃資料，需 maintenance window&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Change type / constraint&lt;/td>
 &lt;td>通常走 table rebuild&lt;/td>
 &lt;td>需要完整 copy、foreign key check、validation&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>SQLite schema 存在 &lt;code>sqlite_schema&lt;/code> 的 SQL text 中；這讓檔案格式簡潔，但也讓 ALTER TABLE 的安全條件和 server SQL 不同。Production migration 應優先用官方建議的 rebuild procedure，而非直接修改 &lt;code>sqlite_schema&lt;/code>。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/sqlite/" data-link-title="SQLite" data-link-desc="embedded、單檔案、test / CLI / edge 場景的標準選擇、近年因 Cloudflare D1 / Turso 等服務復興">SQLite</a> overview 的 implementation-layer deep article。Overview 已說明 SQLite 的 embedded / single-file 定位；本文聚焦 <em>schema version、ALTER TABLE boundary、table rebuild migration 與 application release compatibility</em>。</p></blockquote>
<p>SQLite schema migration 的核心責任是讓單檔資料庫隨 application release 安全演進。SQLite 沒有獨立 database server，也沒有 DBA 在 server 端統一套 migration；migration 常在 application startup、CLI command、mobile app upgrade 或 desktop app launch 時發生，因此 schema version、binary compatibility、backup 與 rollback 要放在同一個 release contract 中設計。</p>
<p>本文的判讀錨點是：SQLite migration 同時改資料庫檔案與 application 能讀的資料格式。只要使用者或服務可能拿舊 binary 打開新 database，或新 binary 打開舊 database，migration 就要處理 forward / backward compatibility，而不只是 SQL 成功執行。</p>
<h2 id="version-model">Version model</h2>
<p>SQLite schema versioning 的服務責任是讓 application 能判斷 database file 目前處於哪個契約。SQLite 提供 <code>PRAGMA user_version</code> 作為 application-controlled integer；更複雜的服務也可以用 migration table 記錄多步驟版本、checksum 與執行時間。</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="n">PRAGMA</span><span class="w"> </span><span class="n">user_version</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">PRAGMA</span><span class="w"> </span><span class="n">user_version</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">2026052101</span><span class="p">;</span></span></span></code></pre></div><table>
  <thead>
      <tr>
          <th>方式</th>
          <th>適合情境</th>
          <th>優點</th>
          <th>邊界</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>user_version</code></td>
          <td>mobile / desktop / CLI single file</td>
          <td>簡單、內建、開檔即可讀</td>
          <td>只能存一個整數，缺 migration history</td>
      </tr>
      <tr>
          <td>migration table</td>
          <td>small backend、多人維護 schema</td>
          <td>可記錄每步 migration 與 owner</td>
          <td>需要先建立 table 與初始化流程</td>
      </tr>
      <tr>
          <td>external manifest</td>
          <td>fixture、artifact、read-only DB</td>
          <td>可和 release artifact 綁定</td>
          <td>DB file 本身不含完整 history</td>
      </tr>
  </tbody>
</table>
<p>Version model 要在第一版就定義。沒有版本欄位的 SQLite file 仍可 migration，但 application 只能靠 introspection 猜 schema，會讓 upgrade / downgrade runbook 複雜化。</p>
<h2 id="alter-table-boundary">ALTER TABLE boundary</h2>
<p>SQLite ALTER TABLE 的核心責任是處理有限集合的 schema 變更。官方文件說明 SQLite 支援 rename table、rename column、add column、drop column；更複雜的變更要走 table rebuild pattern。</p>
<table>
  <thead>
      <tr>
          <th>變更類型</th>
          <th>SQLite 支援形態</th>
          <th>操作判讀</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Rename table / column</td>
          <td>直接 ALTER，版本差異影響 trigger / view</td>
          <td>需要測 trigger、view、FK reference</td>
      </tr>
      <tr>
          <td>Add column</td>
          <td>多數情境很快，受 default / constraint 限制</td>
          <td>適合 expand migration</td>
      </tr>
      <tr>
          <td>Drop column</td>
          <td>需要檢查 index、constraint、trigger、view</td>
          <td>可能掃資料，需 maintenance window</td>
      </tr>
      <tr>
          <td>Change type / constraint</td>
          <td>通常走 table rebuild</td>
          <td>需要完整 copy、foreign key check、validation</td>
      </tr>
  </tbody>
</table>
<p>SQLite schema 存在 <code>sqlite_schema</code> 的 SQL text 中；這讓檔案格式簡潔，但也讓 ALTER TABLE 的安全條件和 server SQL 不同。Production migration 應優先用官方建議的 rebuild procedure，而非直接修改 <code>sqlite_schema</code>。</p>
<h2 id="table-rebuild-migration">Table rebuild migration</h2>
<p>Table rebuild migration 的服務責任是安全完成 SQLite 直接 ALTER 難以表達的變更。官方 ALTER TABLE 文件建議的 generalized procedure 是建立新 table、copy data、drop old、rename new、重建 index / trigger / view、跑 foreign key check、commit。</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">BEGIN</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">PRAGMA</span><span class="w"> </span><span class="n">foreign_keys</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">OFF</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></span><span class="line"><span class="ln"> 4</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">new_orders</span><span class="w"> </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">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="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">status</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">paid_at</span><span class="w"> </span><span class="nb">TEXT</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w"></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></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="n">new_orders</span><span class="w"> </span><span class="p">(</span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="n">status</span><span class="p">,</span><span class="w"> </span><span class="n">paid_at</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="k">SELECT</span><span class="w"> </span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="n">status</span><span class="p">,</span><span class="w"> </span><span class="n">paid_at</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="n">orders</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></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w"></span><span class="k">DROP</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">orders</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">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">new_orders</span><span class="w"> </span><span class="k">RENAME</span><span class="w"> </span><span class="k">TO</span><span class="w"> </span><span class="n">orders</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></span><span class="line"><span class="ln">17</span><span class="cl"><span class="w"></span><span class="n">PRAGMA</span><span class="w"> </span><span class="n">foreign_key_check</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">PRAGMA</span><span class="w"> </span><span class="n">user_version</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">2026052101</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">COMMIT</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 class="n">PRAGMA</span><span class="w"> </span><span class="n">foreign_keys</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">ON</span><span class="p">;</span></span></span></code></pre></div><p>這段範例是教學骨架，而非可直接複製到所有 schema 的萬用腳本。真實 migration 要先保存 index、trigger、view 與 FK reference，再依 schema 重建；有資料量時還要考慮 copy duration、disk 空間與 rollback snapshot。</p>
<h2 id="app-release-compatibility">App release compatibility</h2>
<p>SQLite migration 的 application compatibility 來自 binary 與 DB file 的同步問題。Server SQL migration 通常有 central deploy order；SQLite file 可能跟著使用者裝置、desktop profile、CLI artifact 或 edge deploy 留在不同版本。</p>
<table>
  <thead>
      <tr>
          <th>相容性問題</th>
          <th>真實情境</th>
          <th>設計策略</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>新 app 打開舊 DB</td>
          <td>使用者升級 app</td>
          <td>startup migration、read compatibility</td>
      </tr>
      <tr>
          <td>舊 app 打開新 DB</td>
          <td>使用者 downgrade、同步舊 binary</td>
          <td>保留 backward-compatible column、feature gate</td>
      </tr>
      <tr>
          <td>多裝置不同版本</td>
          <td>local-first / sync app</td>
          <td>sync protocol version、server authority</td>
      </tr>
      <tr>
          <td>fixture 與 production drift</td>
          <td>test fixture 沒更新</td>
          <td>fixture version、contract test、migration smoke</td>
      </tr>
  </tbody>
</table>
<p>Compatibility 的核心是先決定支援範圍。Mobile app 常要支援舊版資料庫升級；internal CLI 可能只支援最新版本；test fixture 則需要每次 migration 後重新產生。</p>
<h2 id="migration-evidence">Migration evidence</h2>
<p>Migration evidence 的責任是證明 schema 變更已完成且資料仍可用。SQLite migration evidence 比 server DB 簡單，但更依賴 application-level validation。</p>
<table>
  <thead>
      <tr>
          <th>Evidence</th>
          <th>目的</th>
          <th>範例</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>schema version</td>
          <td>確認 DB file 契約</td>
          <td><code>PRAGMA user_version</code></td>
      </tr>
      <tr>
          <td>row count</td>
          <td>確認 copy / rebuild 無漏資料</td>
          <td><code>SELECT COUNT(*) FROM orders</code></td>
      </tr>
      <tr>
          <td>domain query</td>
          <td>確認重要 business invariant</td>
          <td>unpaid / paid 狀態數量</td>
      </tr>
      <tr>
          <td>foreign key check</td>
          <td>確認 reference integrity</td>
          <td><code>PRAGMA foreign_key_check</code></td>
      </tr>
      <tr>
          <td>integrity check</td>
          <td>檢查 DB 結構</td>
          <td><code>PRAGMA integrity_check</code></td>
      </tr>
      <tr>
          <td>backup marker</td>
          <td>回退點</td>
          <td>pre-migration <code>.backup</code> file</td>
      </tr>
  </tbody>
</table>
<p>這些 evidence 應接到 <a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">Observability Evidence Package</a> 或 release note。SQLite migration 失敗時，最清楚的 rollback 通常是回到 migration 前 snapshot，而非在同一檔案上繼續試錯。</p>
<h2 id="production-踩雷">Production 踩雷</h2>
<h3 id="case-1startup-migration-讓-app-啟動卡住">Case 1：startup migration 讓 app 啟動卡住</h3>
<p>Startup migration 的核心風險是把長時間 table rebuild 放在使用者啟動路徑。小表新增 column 可能很快；大表 rebuild、index 重建或 vacuum 類操作會讓 app 啟動、CLI command 或 API cold start 變慢。</p>
<p>修正方向是先估資料量。短 migration 可在 startup；長 migration 要有 explicit command、progress、backup 與 rollback route。</p>
<h3 id="case-2fixture-schema-升級漏掉-production-gap">Case 2：fixture schema 升級漏掉 production gap</h3>
<p>Fixture schema drift 的核心風險是測試 DB 和 production DB 的 dialect / constraint 不一致。SQLite fixture 很快，但 production 若是 PostgreSQL / MySQL，type、date、NULL、constraint 與 transaction 行為都可能不同。</p>
<p>修正方向是把 SQLite fixture 明確標成 contract test 層。Repository error mapping、domain invariant 可以用 SQLite；production-specific SQL 要用 production database container 驗證。</p>
<h3 id="case-3直接改-sqlite_schema">Case 3：直接改 <code>sqlite_schema</code></h3>
<p>直接改 <code>sqlite_schema</code> 的核心風險是產生語法正確但語意破壞的 database file。SQLite 官方文件提供 writable schema route，但同時強調錯誤修改可能讓 database corrupt / unreadable。</p>
<p>修正方向是讓 writable schema 成為最後手段。一般 migration 優先用 ALTER TABLE 或 table rebuild；需要特殊修復時先複製原檔，在副本驗證。</p>
<h2 id="操作檢查清單">操作檢查清單</h2>
<p>SQLite migration runbook 至少要記錄：</p>
<ol>
<li>DB file 目前 <code>user_version</code> 與 application release version。</li>
<li>Migration 是否可重入、是否可中斷後恢復。</li>
<li>Migration 前 backup / snapshot 位置。</li>
<li>需要 table rebuild 的 table、資料量、index / trigger / view 清單。</li>
<li>Validation query、row count、foreign key check、integrity check。</li>
<li>舊 binary / 新 binary 的相容策略。</li>
<li>Fixture DB 是否已重新產生並被 contract test 使用。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>上游：<a href="/blog/backend/01-database/vendors/sqlite/" data-link-title="SQLite" data-link-desc="embedded、單檔案、test / CLI / edge 場景的標準選擇、近年因 Cloudflare D1 / Turso 等服務復興">SQLite overview</a></li>
<li>操作：<a href="/blog/backend/01-database/vendors/sqlite/hands-on/migration-fixture-lab/" data-link-title="SQLite Migration Fixture Lab" data-link-desc="SQLite user_version、table rebuild migration、fixture snapshot、rollback note 與 CI evidence 的操作說明">Migration fixture lab</a></li>
<li>平行：<a href="/blog/backend/01-database/vendors/sqlite/test-fixture-best-practice/" data-link-title="SQLite Test Fixture Best Practice" data-link-desc="SQLite 作為 test fixture、repository contract test、production dialect gap、seed data、fixture snapshot 與 CI evidence 的操作判準">Test Fixture Best Practice</a></li>
<li>遷移：<a href="/blog/backend/01-database/vendors/sqlite/migrate-to-postgresql/" data-link-title="SQLite to PostgreSQL Migration" data-link-desc="SQLite 升級到 PostgreSQL 的 driver、schema diff、data copy、dual run、cutover、rollback 與 cleanup">SQLite to PostgreSQL</a></li>
<li>官方：<a href="https://www.sqlite.org/lang_altertable.html">SQLite ALTER TABLE</a>、<a href="https://www.sqlite.org/pragma.html">SQLite PRAGMA</a></li>
</ul>
]]></content:encoded></item><item><title>SQLite Test Fixture Best Practice</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/test-fixture-best-practice/</link><pubDate>Thu, 21 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/test-fixture-best-practice/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/" data-link-title="SQLite" data-link-desc="embedded、單檔案、test / CLI / edge 場景的標準選擇、近年因 Cloudflare D1 / Turso 等服務復興">SQLite&lt;/a> overview 的 implementation-layer deep article。Overview 已說明 SQLite 適合作為 test fixture；本文聚焦 &lt;em>如何用 SQLite 加速測試，同時保留 production database 的語意邊界&lt;/em>。&lt;/p>&lt;/blockquote>
&lt;p>SQLite test fixture 的核心責任是讓 repository / adapter 測試快速、可重複、可攜帶。SQLite 的單檔特性讓 CI 可以快速建立 DB、載入 seed、跑 contract test；但它的 type affinity、SQL dialect、locking 與 constraint behavior 和 PostgreSQL / MySQL 不完全相同，因此 fixture 要被定位為一層測試工具，而非 production equivalence。&lt;/p>
&lt;p>本文的判讀錨點是：SQLite fixture 適合驗證 application contract，不適合取代 production database compatibility test。若測試目標是 repository error mapping、domain invariant、migration fixture 或 deterministic seed，SQLite 很划算；若測試目標是 PostgreSQL extension、MySQL lock、query planner 或 SQL dialect，應使用 production-like container。&lt;/p>
&lt;h2 id="test-fixture-的位置">Test fixture 的位置&lt;/h2>
&lt;p>SQLite fixture 的服務責任是提供快、穩定、可重建的本地資料狀態。它通常位於 unit test 與 full integration test 之間，承擔 repository adapter 的 contract test。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>測試層級&lt;/th>
 &lt;th>SQLite 適合度&lt;/th>
 &lt;th>判讀&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Pure unit test&lt;/td>
 &lt;td>低&lt;/td>
 &lt;td>fake / in-memory object 通常更快&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Repository contract&lt;/td>
 &lt;td>高&lt;/td>
 &lt;td>驗證 CRUD、constraint mapping、transaction behavior&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Service integration&lt;/td>
 &lt;td>中&lt;/td>
 &lt;td>適合簡單流程，不覆蓋 production-specific SQL&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Production compatibility&lt;/td>
 &lt;td>低&lt;/td>
 &lt;td>用 PostgreSQL / MySQL container 或 staging DB&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Migration smoke&lt;/td>
 &lt;td>中&lt;/td>
 &lt;td>適合 fixture migration，不代表 production DDL&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這張表的重點是把測試目的說清楚。SQLite fixture 讓語言教材與 backend 教材接起來；語言端測 interface / adapter，backend 端保留 production database 的深度文章與 migration playbook。&lt;/p>
&lt;h2 id="fixture-lifecycle">Fixture lifecycle&lt;/h2>
&lt;p>Fixture lifecycle 的核心責任是讓每次測試拿到已知資料狀態。常見策略有三種：每 test 建新 in-memory DB、每 suite 複製 template file、每 CI job 產生 versioned fixture。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>策略&lt;/th>
 &lt;th>適合情境&lt;/th>
 &lt;th>優點&lt;/th>
 &lt;th>邊界&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>:memory:&lt;/code> per test&lt;/td>
 &lt;td>小 schema、快速 unit-like contract&lt;/td>
 &lt;td>隔離最好、清理簡單&lt;/td>
 &lt;td>跨 connection / WAL 行為不同&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>template file copy&lt;/td>
 &lt;td>中等 seed、需要真實檔案行為&lt;/td>
 &lt;td>快速、可測 file lifecycle&lt;/td>
 &lt;td>要避免多 test 共用同一檔案&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>generated fixture&lt;/td>
 &lt;td>migration / seed 驗證&lt;/td>
 &lt;td>和 migration 同步&lt;/td>
 &lt;td>CI 時間較長&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>read-only fixture&lt;/td>
 &lt;td>查詢 / report 測試&lt;/td>
 &lt;td>避免 writer collision&lt;/td>
 &lt;td>不測 mutation&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Fixture file 應和 schema version 綁定。檔名、metadata 或 &lt;code>user_version&lt;/code> 要能回答「這個 fixture 對應哪個 migration 版本」，避免測試資料在多次 schema 變更後變成隱性技術債。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/sqlite/" data-link-title="SQLite" data-link-desc="embedded、單檔案、test / CLI / edge 場景的標準選擇、近年因 Cloudflare D1 / Turso 等服務復興">SQLite</a> overview 的 implementation-layer deep article。Overview 已說明 SQLite 適合作為 test fixture；本文聚焦 <em>如何用 SQLite 加速測試，同時保留 production database 的語意邊界</em>。</p></blockquote>
<p>SQLite test fixture 的核心責任是讓 repository / adapter 測試快速、可重複、可攜帶。SQLite 的單檔特性讓 CI 可以快速建立 DB、載入 seed、跑 contract test；但它的 type affinity、SQL dialect、locking 與 constraint behavior 和 PostgreSQL / MySQL 不完全相同，因此 fixture 要被定位為一層測試工具，而非 production equivalence。</p>
<p>本文的判讀錨點是：SQLite fixture 適合驗證 application contract，不適合取代 production database compatibility test。若測試目標是 repository error mapping、domain invariant、migration fixture 或 deterministic seed，SQLite 很划算；若測試目標是 PostgreSQL extension、MySQL lock、query planner 或 SQL dialect，應使用 production-like container。</p>
<h2 id="test-fixture-的位置">Test fixture 的位置</h2>
<p>SQLite fixture 的服務責任是提供快、穩定、可重建的本地資料狀態。它通常位於 unit test 與 full integration test 之間，承擔 repository adapter 的 contract test。</p>
<table>
  <thead>
      <tr>
          <th>測試層級</th>
          <th>SQLite 適合度</th>
          <th>判讀</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Pure unit test</td>
          <td>低</td>
          <td>fake / in-memory object 通常更快</td>
      </tr>
      <tr>
          <td>Repository contract</td>
          <td>高</td>
          <td>驗證 CRUD、constraint mapping、transaction behavior</td>
      </tr>
      <tr>
          <td>Service integration</td>
          <td>中</td>
          <td>適合簡單流程，不覆蓋 production-specific SQL</td>
      </tr>
      <tr>
          <td>Production compatibility</td>
          <td>低</td>
          <td>用 PostgreSQL / MySQL container 或 staging DB</td>
      </tr>
      <tr>
          <td>Migration smoke</td>
          <td>中</td>
          <td>適合 fixture migration，不代表 production DDL</td>
      </tr>
  </tbody>
</table>
<p>這張表的重點是把測試目的說清楚。SQLite fixture 讓語言教材與 backend 教材接起來；語言端測 interface / adapter，backend 端保留 production database 的深度文章與 migration playbook。</p>
<h2 id="fixture-lifecycle">Fixture lifecycle</h2>
<p>Fixture lifecycle 的核心責任是讓每次測試拿到已知資料狀態。常見策略有三種：每 test 建新 in-memory DB、每 suite 複製 template file、每 CI job 產生 versioned fixture。</p>
<table>
  <thead>
      <tr>
          <th>策略</th>
          <th>適合情境</th>
          <th>優點</th>
          <th>邊界</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>:memory:</code> per test</td>
          <td>小 schema、快速 unit-like contract</td>
          <td>隔離最好、清理簡單</td>
          <td>跨 connection / WAL 行為不同</td>
      </tr>
      <tr>
          <td>template file copy</td>
          <td>中等 seed、需要真實檔案行為</td>
          <td>快速、可測 file lifecycle</td>
          <td>要避免多 test 共用同一檔案</td>
      </tr>
      <tr>
          <td>generated fixture</td>
          <td>migration / seed 驗證</td>
          <td>和 migration 同步</td>
          <td>CI 時間較長</td>
      </tr>
      <tr>
          <td>read-only fixture</td>
          <td>查詢 / report 測試</td>
          <td>避免 writer collision</td>
          <td>不測 mutation</td>
      </tr>
  </tbody>
</table>
<p>Fixture file 應和 schema version 綁定。檔名、metadata 或 <code>user_version</code> 要能回答「這個 fixture 對應哪個 migration 版本」，避免測試資料在多次 schema 變更後變成隱性技術債。</p>
<h2 id="production-dialect-gap">Production dialect gap</h2>
<p>Production dialect gap 的核心責任是避免 SQLite 測試通過後，PostgreSQL / MySQL production 出現不同語意。SQLite 的 dynamic typing、date / time representation、foreign key pragma、ALTER TABLE 支援與 lock model 都會影響測試可信度。</p>
<table>
  <thead>
      <tr>
          <th>Gap 類型</th>
          <th>SQLite 行為</th>
          <th>Production 風險</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Type affinity</td>
          <td>欄位有 affinity，值本身仍有 storage class</td>
          <td>PostgreSQL / MySQL type error 沒被測到</td>
      </tr>
      <tr>
          <td>Date / time</td>
          <td>常以 TEXT / REAL / INTEGER 表示</td>
          <td>timezone、precision、function 差異</td>
      </tr>
      <tr>
          <td>Foreign key</td>
          <td>需要 <code>PRAGMA foreign_keys=ON</code></td>
          <td>fixture 忘記開 FK，constraint bug 漏掉</td>
      </tr>
      <tr>
          <td>ALTER TABLE</td>
          <td>支援 subset，複雜變更需 rebuild</td>
          <td>production migration 工具行為不同</td>
      </tr>
      <tr>
          <td>Locking</td>
          <td>single-file lock / single writer</td>
          <td>server DB connection / lock model 不同</td>
      </tr>
      <tr>
          <td>SQL feature</td>
          <td>extension / JSON / index 差異</td>
          <td>vendor-specific query 需要 production evidence</td>
      </tr>
  </tbody>
</table>
<p>這張表的用法是決定哪些測試留在 SQLite，哪些要升級到 production-like DB。Repository contract 可用 SQLite；query optimization、vendor SQL、online schema change、CDC、replication、pooling 都應回到 PostgreSQL / MySQL 章節。</p>
<h2 id="contract-test-設計">Contract test 設計</h2>
<p>Contract test 的核心責任是讓不同 DB adapter 對 application 呈現同一組語意。SQLite fixture 測的是 application port 的行為，例如 duplicate key、not found、transaction rollback、pagination、domain invariant，而非底層 engine 的所有細節。</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">Repository contract
</span></span><span class="line"><span class="ln">2</span><span class="cl">├── Create / read / update / delete
</span></span><span class="line"><span class="ln">3</span><span class="cl">├── Unique conflict → ErrAlreadyExists
</span></span><span class="line"><span class="ln">4</span><span class="cl">├── Missing row → ErrNotFound
</span></span><span class="line"><span class="ln">5</span><span class="cl">├── Transaction rollback restores domain invariant
</span></span><span class="line"><span class="ln">6</span><span class="cl">├── Pagination order stable
</span></span><span class="line"><span class="ln">7</span><span class="cl">└── Migration version matches fixture</span></span></code></pre></div><p>如果 production adapter 是 PostgreSQL / MySQL，contract test 應至少在 nightly 或 CI matrix 裡跑一輪 production-like database。SQLite 提供快速回饋，production-like test 提供 dialect confidence。</p>
<h2 id="ci-evidence">CI evidence</h2>
<p>SQLite fixture 的 CI evidence 要證明資料狀態和 schema version 一致。測試失敗時，讀者要能知道是 application contract 失效、fixture 過期、migration 漏跑，還是 SQLite / production dialect gap。</p>
<table>
  <thead>
      <tr>
          <th>Evidence</th>
          <th>目的</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>fixture version</td>
          <td>對齊 migration / app release</td>
      </tr>
      <tr>
          <td>seed checksum</td>
          <td>確認測試資料穩定</td>
      </tr>
      <tr>
          <td>migration log</td>
          <td>確認 fixture 可由 migration 重建</td>
      </tr>
      <tr>
          <td>contract test output</td>
          <td>確認 repository behavior</td>
      </tr>
      <tr>
          <td>dialect gap note</td>
          <td>標示未覆蓋 production behavior</td>
      </tr>
  </tbody>
</table>
<p>CI 產物不一定要很複雜，但要能被下一個維護者重建。SQLite fixture 的優勢是可攜帶；若 fixture 只能靠某個人的本機狀態生成，就失去教學與維護價值。</p>
<h2 id="production-踩雷">Production 踩雷</h2>
<h3 id="case-1共用同一個-db-檔跑平行測試">Case 1：共用同一個 <code>.db</code> 檔跑平行測試</h3>
<p>平行測試共用檔案的核心風險是 test runner 製造和 production 不同的 writer collision。測試偶發 <code>SQLITE_BUSY</code>，團隊可能以為 application 有 race；實際上是測試隔離不足。</p>
<p>修正方向是 per-test temp DB 或 read-only template copy。需要測 WAL / busy 行為時，用專門 hands-on lab，讓一般 contract test 專注在 repository contract。</p>
<h3 id="case-2忘記開-foreign-keys">Case 2：忘記開 foreign keys</h3>
<p>Foreign key pragma 漏開的核心風險是 constraint bug 被 fixture 隱藏。SQLite foreign key enforcement 需要明確啟用；若 production DB 一定 enforce FK，fixture 也要在 connection initialization 中開啟。</p>
<p>修正方向是 baseline PRAGMA 和 startup assertion。每個 test DB open 後都跑 <code>PRAGMA foreign_keys</code> 並驗證結果。</p>
<h3 id="case-3sqlite-fixture-掩蓋-vendor-specific-sql">Case 3：SQLite fixture 掩蓋 vendor-specific SQL</h3>
<p>Vendor-specific SQL 被 SQLite 掩蓋的核心風險是 query 到 production 才失敗。例如 PostgreSQL JSONB、partial index、full-text search 或 MySQL generated column、optimizer hint 都應在 vendor DB 測。</p>
<p>修正方向是把 SQL 分層。Portable repository contract 可以用 SQLite；vendor-specific query 要有 PostgreSQL / MySQL test container。</p>
<h2 id="操作檢查清單">操作檢查清單</h2>
<p>SQLite fixture 設計前要回答：</p>
<ol>
<li>這個測試驗證 application contract 還是 production dialect。</li>
<li>Fixture 是 in-memory、template copy、generated file 還是 read-only。</li>
<li><code>PRAGMA foreign_keys</code>、<code>journal_mode</code>、<code>busy_timeout</code> 是否固定。</li>
<li>Fixture version 如何對齊 migration version。</li>
<li>Parallel test 是否每個 worker 有獨立 DB file。</li>
<li>哪些 query 必須在 PostgreSQL / MySQL container 再跑。</li>
<li>CI artifact 是否保留 migration log 與 dialect gap note。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>上游：<a href="/blog/backend/01-database/repository-adapter/" data-link-title="1.4 Repository Adapter 實作" data-link-desc="Port / Adapter 邊界、row mapping、error translation、ORM vs query builder 選型、contract test 設計">Repository Adapter</a></li>
<li>Sibling：<a href="/blog/backend/01-database/vendors/sqlite/schema-migration-versioning/" data-link-title="SQLite Schema Migration and Versioning" data-link-desc="SQLite schema migration、user_version、table rebuild、ALTER TABLE 限制、app release compatibility 與 migration evidence">Schema Migration / Versioning</a>、<a href="/blog/backend/01-database/vendors/sqlite/sql-dialect-index-limits/" data-link-title="SQLite SQL Dialect and Index Limits" data-link-desc="SQLite type affinity、NULL / date handling、constraint、index、query planner 與 PostgreSQL / MySQL 差異">SQL Dialect and Index Limits</a></li>
<li>操作：<a href="/blog/backend/01-database/vendors/sqlite/hands-on/migration-fixture-lab/" data-link-title="SQLite Migration Fixture Lab" data-link-desc="SQLite user_version、table rebuild migration、fixture snapshot、rollback note 與 CI evidence 的操作說明">Migration Fixture Lab</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</a>、<a href="/blog/backend/01-database/vendors/mysql/" data-link-title="MySQL" data-link-desc="高併發網路服務常用關聯式資料庫、Vitess / PlanetScale 分片生態、GitHub / Shopify / Facebook 規模驗證">MySQL</a></li>
<li>官方：<a href="https://www.sqlite.org/datatype3.html">SQLite Datatypes</a>、<a href="https://www.sqlite.org/stricttables.html">SQLite STRICT Tables</a>、<a href="https://www.sqlite.org/pragma.html">SQLite PRAGMA</a></li>
</ul>
]]></content:encoded></item><item><title>SQLite WAL Concurrency and Locking</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/wal-concurrency-locking/</link><pubDate>Thu, 21 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/wal-concurrency-locking/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/" data-link-title="SQLite" data-link-desc="embedded、單檔案、test / CLI / edge 場景的標準選擇、近年因 Cloudflare D1 / Turso 等服務復興">SQLite&lt;/a> overview 的 implementation-layer deep article。Overview 已說明 SQLite 的 single-file / embedded 定位；本文聚焦 &lt;em>WAL concurrency、single writer boundary、&lt;code>SQLITE_BUSY&lt;/code> 與 checkpoint strategy&lt;/em>。&lt;/p>&lt;/blockquote>
&lt;p>SQLite WAL concurrency 的核心責任是讓 reader / writer 衝突下降，同時保留單檔案資料庫的寫入邊界。&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/write-ahead-log/" data-link-title="Write-Ahead Log" data-link-desc="說明資料庫如何先寫入 log 再合併回主資料，以提供持久性與崩潰復原">WAL mode&lt;/a> 把寫入 append 到 &lt;code>-wal&lt;/code> sidecar file，reader 可以從 main database file 加 WAL snapshot 讀取一致視圖；這讓 read-heavy workload 能比 rollback journal mode 更順。但 SQLite 仍遵循 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/single-writer-model/" data-link-title="Single Writer Model" data-link-desc="說明單寫者模型如何序列化寫入，並成為系統的容量邊界">single writer model&lt;/a>、只有一條 writer path，長交易、背景 migration、慢 disk 或多 process 寫入都會在這條 path 上排隊。&lt;/p>
&lt;p>本文的判讀錨點是：WAL 提升的是 reader concurrency，治理的是 writer queue。當服務看到 &lt;code>SQLITE_BUSY&lt;/code>、WAL file 持續變大、checkpoint duration 變長或偶發 commit latency spike，問題通常在 transaction duration、checkpoint cadence、filesystem lock 或 process ownership，而非單純「資料庫太小」。&lt;/p>
&lt;h2 id="wal-mode-的服務責任">WAL mode 的服務責任&lt;/h2>
&lt;p>WAL mode 的服務責任是把「寫入直接改 main database file」改成「寫入先 append 到 WAL，再由 checkpoint 合併回 main database」。SQLite 官方文件把 WAL 模型拆成 reading、writing、checkpointing 三個 primitive；這個 framing 對 production runbook 很重要，因為 checkpoint 會變成獨立的操作訊號。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>模式&lt;/th>
 &lt;th>寫入路徑&lt;/th>
 &lt;th>Reader 影響&lt;/th>
 &lt;th>Production 判讀&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Rollback journal&lt;/td>
 &lt;td>寫入前保存原始 page，再修改 main file&lt;/td>
 &lt;td>write 期間更容易和 reader 互相等待&lt;/td>
 &lt;td>適合簡單、低並發、短交易路徑&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>WAL&lt;/td>
 &lt;td>寫入 append 到 &lt;code>-wal&lt;/code>，checkpoint 後合併&lt;/td>
 &lt;td>reader 可看自己的 WAL snapshot&lt;/td>
 &lt;td>適合 read-heavy、互動式、短寫交易 workload&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這張表的讀法是先看服務是否主要受 read / write 衝突影響。Read-heavy CLI、desktop、mobile、edge-local API 或 small backend 往往能從 WAL mode 受益；write-heavy queue consumer、batch import、multi-process writer 或 high-concurrency OLTP 則會先撞到 single writer boundary。&lt;/p>
&lt;h2 id="locking-model多-reader-與單-writer-是同時成立的">Locking model：多 reader 與單 writer 是同時成立的&lt;/h2>
&lt;p>SQLite locking model 的核心責任是保護單一 database file 的 ACID 邊界。Rollback journal mode 的官方 locking 文件描述了 SHARED、RESERVED、PENDING、EXCLUSIVE 等狀態；WAL mode 的細節另由 WAL 文件說明，但服務判讀上仍要記住同一件事：跨 connection / process 的寫入要被序列化。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/sqlite/" data-link-title="SQLite" data-link-desc="embedded、單檔案、test / CLI / edge 場景的標準選擇、近年因 Cloudflare D1 / Turso 等服務復興">SQLite</a> overview 的 implementation-layer deep article。Overview 已說明 SQLite 的 single-file / embedded 定位；本文聚焦 <em>WAL concurrency、single writer boundary、<code>SQLITE_BUSY</code> 與 checkpoint strategy</em>。</p></blockquote>
<p>SQLite WAL concurrency 的核心責任是讓 reader / writer 衝突下降，同時保留單檔案資料庫的寫入邊界。<a href="/blog/backend/knowledge-cards/write-ahead-log/" data-link-title="Write-Ahead Log" data-link-desc="說明資料庫如何先寫入 log 再合併回主資料，以提供持久性與崩潰復原">WAL mode</a> 把寫入 append 到 <code>-wal</code> sidecar file，reader 可以從 main database file 加 WAL snapshot 讀取一致視圖；這讓 read-heavy workload 能比 rollback journal mode 更順。但 SQLite 仍遵循 <a href="/blog/backend/knowledge-cards/single-writer-model/" data-link-title="Single Writer Model" data-link-desc="說明單寫者模型如何序列化寫入，並成為系統的容量邊界">single writer model</a>、只有一條 writer path，長交易、背景 migration、慢 disk 或多 process 寫入都會在這條 path 上排隊。</p>
<p>本文的判讀錨點是：WAL 提升的是 reader concurrency，治理的是 writer queue。當服務看到 <code>SQLITE_BUSY</code>、WAL file 持續變大、checkpoint duration 變長或偶發 commit latency spike，問題通常在 transaction duration、checkpoint cadence、filesystem lock 或 process ownership，而非單純「資料庫太小」。</p>
<h2 id="wal-mode-的服務責任">WAL mode 的服務責任</h2>
<p>WAL mode 的服務責任是把「寫入直接改 main database file」改成「寫入先 append 到 WAL，再由 checkpoint 合併回 main database」。SQLite 官方文件把 WAL 模型拆成 reading、writing、checkpointing 三個 primitive；這個 framing 對 production runbook 很重要，因為 checkpoint 會變成獨立的操作訊號。</p>
<table>
  <thead>
      <tr>
          <th>模式</th>
          <th>寫入路徑</th>
          <th>Reader 影響</th>
          <th>Production 判讀</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Rollback journal</td>
          <td>寫入前保存原始 page，再修改 main file</td>
          <td>write 期間更容易和 reader 互相等待</td>
          <td>適合簡單、低並發、短交易路徑</td>
      </tr>
      <tr>
          <td>WAL</td>
          <td>寫入 append 到 <code>-wal</code>，checkpoint 後合併</td>
          <td>reader 可看自己的 WAL snapshot</td>
          <td>適合 read-heavy、互動式、短寫交易 workload</td>
      </tr>
  </tbody>
</table>
<p>這張表的讀法是先看服務是否主要受 read / write 衝突影響。Read-heavy CLI、desktop、mobile、edge-local API 或 small backend 往往能從 WAL mode 受益；write-heavy queue consumer、batch import、multi-process writer 或 high-concurrency OLTP 則會先撞到 single writer boundary。</p>
<h2 id="locking-model多-reader-與單-writer-是同時成立的">Locking model：多 reader 與單 writer 是同時成立的</h2>
<p>SQLite locking model 的核心責任是保護單一 database file 的 ACID 邊界。Rollback journal mode 的官方 locking 文件描述了 SHARED、RESERVED、PENDING、EXCLUSIVE 等狀態；WAL mode 的細節另由 WAL 文件說明，但服務判讀上仍要記住同一件事：跨 connection / process 的寫入要被序列化。</p>
<table>
  <thead>
      <tr>
          <th>角色</th>
          <th>WAL mode 下的責任</th>
          <th>常見失效訊號</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Reader</td>
          <td>讀取開始時固定自己的 snapshot end mark</td>
          <td>長讀取讓 checkpoint 停在舊 snapshot，WAL file 持續變大</td>
      </tr>
      <tr>
          <td>Writer</td>
          <td>append 新 transaction 到同一個 WAL file</td>
          <td>其他 writer 看到 <code>SQLITE_BUSY</code> 或 write latency spike</td>
      </tr>
      <tr>
          <td>Checkpoint</td>
          <td>把 WAL frame 合併回 main database file</td>
          <td>checkpoint duration 拉長、commit 偶發變慢</td>
      </tr>
      <tr>
          <td>Filesystem</td>
          <td>提供可靠 file lock 與 shared-memory 支援</td>
          <td>network filesystem、container mount 或權限造成異常</td>
      </tr>
  </tbody>
</table>
<p>多 reader 與單 writer 的組合是 SQLite 的正常設計。讀者在查問題時，要避免把 <code>SQLITE_BUSY</code> 直接解讀成資料毀損；它多半代表某個 connection 正在持有 writer 所需的 lock，或 checkpoint / transaction 正在等待可前進的窗口。</p>
<h2 id="sqlite_busy-的第一輪排查"><code>SQLITE_BUSY</code> 的第一輪排查</h2>
<p><code>SQLITE_BUSY</code> 的核心意義是某個 connection 當下拿不到需要的 lock。SQLite 提供 <code>busy_timeout</code> 讓 connection 等待一段時間；這能吸收短暫 writer queue，但它只是等待策略，single writer boundary 仍然存在。</p>
<table>
  <thead>
      <tr>
          <th>觀察訊號</th>
          <th>可能原因</th>
          <th>第一輪處理</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>短暫 <code>SQLITE_BUSY</code></td>
          <td>多個短寫入撞在一起</td>
          <td>設定 bounded busy timeout，縮短 transaction duration</td>
      </tr>
      <tr>
          <td>持續 <code>SQLITE_BUSY</code></td>
          <td>長交易、migration、batch import</td>
          <td>找出持鎖 connection，拆小 transaction 或移到 maintenance window</td>
      </tr>
      <tr>
          <td>commit latency 偶發變慢</td>
          <td>auto-checkpoint 在 commit path 上</td>
          <td>調整 auto-checkpoint，改由 background checkpoint</td>
      </tr>
      <tr>
          <td>read query 讓 WAL 變大</td>
          <td>long reader 卡住 checkpoint</td>
          <td>限制長查詢、拆 reporting query、設定 reader timeout</td>
      </tr>
      <tr>
          <td>部署後 busy rate 上升</td>
          <td>instance 數增加、multi-process write</td>
          <td>重新檢查 writer ownership，必要時升級 server SQL</td>
      </tr>
  </tbody>
</table>
<p>這張表的重點是先找「誰持有 writer path」。如果問題來自單一長 transaction，修 transaction boundary；如果問題來自多個 process 同時寫同檔，修 process ownership；如果問題來自真實高寫入吞吐，SQLite 已經接近服務邊界。</p>
<h2 id="busy-timeout-是緩衝器容量邊界仍在-writer-path">Busy timeout 是緩衝器，容量邊界仍在 writer path</h2>
<p>Busy timeout 的服務責任是吸收短時間 lock collision。它適合 desktop app autosave、mobile local store、短 API write、測試 fixture 或偶發 background 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="n">PRAGMA</span><span class="w"> </span><span class="n">busy_timeout</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">5000</span><span class="p">;</span></span></span></code></pre></div><p>這個設定代表 connection 最多等待 5000 ms。Production runbook 要同時記錄三個訊號：busy 次數、等待時間分布、等待後成功率。若等待後成功率高且 p99 可接受，代表 writer queue 仍在服務邊界內；若等待常超時，代表 transaction duration 或 writer 並發已經超出單檔模型。</p>
<h2 id="checkpoint-strategywal-growth-是操作訊號">Checkpoint strategy：WAL growth 是操作訊號</h2>
<p>Checkpoint 的核心責任是把 WAL 中的 committed frames 合併回 main database file。SQLite 預設會在 WAL file 達到約 1000 pages 後自動 checkpoint；這個預設適合多數小型場景，但 production 服務要把 checkpoint 視為獨立操作。</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="n">PRAGMA</span><span class="w"> </span><span class="n">wal_checkpoint</span><span class="p">(</span><span class="n">PASSIVE</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">PRAGMA</span><span class="w"> </span><span class="n">wal_checkpoint</span><span class="p">(</span><span class="k">FULL</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">PRAGMA</span><span class="w"> </span><span class="n">wal_checkpoint</span><span class="p">(</span><span class="k">RESTART</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="n">PRAGMA</span><span class="w"> </span><span class="n">wal_checkpoint</span><span class="p">(</span><span class="k">TRUNCATE</span><span class="p">);</span></span></span></code></pre></div><table>
  <thead>
      <tr>
          <th>Checkpoint 型態</th>
          <th>操作語意</th>
          <th>適合場景</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>PASSIVE</td>
          <td>盡量前進，避免主動阻塞 reader / writer</td>
          <td>日常觀測、低風險背景 checkpoint</td>
      </tr>
      <tr>
          <td>FULL</td>
          <td>等待 writer，嘗試完成更多 checkpoint</td>
          <td>maintenance window、WAL growth 需要收斂</td>
      </tr>
      <tr>
          <td>RESTART</td>
          <td>完成後讓後續 writer 可重新使用 WAL</td>
          <td>想降低 WAL 持續膨脹，能接受等待</td>
      </tr>
      <tr>
          <td>TRUNCATE</td>
          <td>完成後截斷 WAL file</td>
          <td>低流量窗口、需要回收檔案空間</td>
      </tr>
  </tbody>
</table>
<p>Checkpoint 策略的判讀要看 workload cadence。互動式服務通常保留 auto-checkpoint，再加上低流量時段的 background checkpoint；長查詢或 reporting workload 需要避免讓 long reader 長期佔住 snapshot；batch import 則要把 transaction 切小，避免 WAL file 在單一交易期間快速膨脹。</p>
<h2 id="checkpoint-starvation長-reader-會讓-wal-持續長大">Checkpoint starvation：長 reader 會讓 WAL 持續長大</h2>
<p>Checkpoint starvation 的核心概念是：只要總有 reader 還在使用舊 snapshot，checkpoint 就可能停在 reset 之前。SQLite 官方 WAL 文件明確指出，checkpoint 可以和 reader 並行，但遇到仍被 reader 使用的 WAL 位置時要停下來；如果長時間沒有 reader gap，WAL file 會持續成長。</p>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>真實服務長相</th>
          <th>修正方向</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Desktop app 開著長報表</td>
          <td>使用者查詢大列表，背景寫入持續發生</td>
          <td>報表分頁、限制 read transaction duration</td>
      </tr>
      <tr>
          <td>API handler 把 cursor 留太久</td>
          <td>streaming response 邊讀邊回，交易未結束</td>
          <td>先 materialize 結果、縮短 DB read transaction</td>
      </tr>
      <tr>
          <td>Background sync 長讀取</td>
          <td>sync worker 掃全表，UI 仍在寫資料</td>
          <td>分批讀取、讀寫排程、低流量 checkpoint</td>
      </tr>
      <tr>
          <td>Test suite 平行讀寫 fixture</td>
          <td>測試共用同一 <code>.db</code>，多 worker 交錯</td>
          <td>per-test DB、read-only fixture、獨立 temp file</td>
      </tr>
  </tbody>
</table>
<p>這些情境的共同點是 reader lifecycle 沒有被 application 控制。SQLite 的 concurrency 問題常發生在 application boundary，而非 database engine 本身；修法也應回到 handler、worker、test runner 或 UI lifecycle。</p>
<h2 id="filesystem-與-deployment-boundary">Filesystem 與 deployment boundary</h2>
<p>SQLite WAL 的 deployment boundary 是 local filesystem 與可靠 shared-memory / file-locking primitive。官方 WAL 文件指出 wal-index 使用 shared memory，所有 reader 要位於同一台機器；這也是 WAL mode 不適合放在一般 network filesystem 上的主要原因。</p>
<table>
  <thead>
      <tr>
          <th>部署方式</th>
          <th>判讀</th>
          <th>建議路由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>單 process / 單機 local disk</td>
          <td>SQLite 最自然的部署形狀</td>
          <td>WAL + backup / restore runbook</td>
      </tr>
      <tr>
          <td>多 process / 同機 local disk</td>
          <td>可行，但要清楚 writer ownership 與 timeout</td>
          <td>WAL + busy timeout + checkpoint evidence</td>
      </tr>
      <tr>
          <td>多 instance / shared volume</td>
          <td>lock 與 writer ownership 風險上升</td>
          <td>升級 PostgreSQL / MySQL，或改用明確 primary pattern</td>
      </tr>
      <tr>
          <td>network filesystem</td>
          <td>WAL shared-memory 與 file lock 語意風險高</td>
          <td>改 local disk + replication，或 server database</td>
      </tr>
      <tr>
          <td>container ephemeral disk</td>
          <td>durability 與 restore 路徑要重新設計</td>
          <td>persistent volume、backup drill、restore evidence</td>
      </tr>
  </tbody>
</table>
<p>Deployment review 要問的第一個問題是「同一時間誰會寫這個檔案」。如果答案是多個 instance、跨機器 process 或不受控 job，SQLite 的服務邊界已經需要重新評估。</p>
<h2 id="production-踩雷">Production 踩雷</h2>
<h3 id="case-1多個-worker-同時寫同一個-sqlite-檔">Case 1：多個 worker 同時寫同一個 SQLite 檔</h3>
<p>多 worker 寫入同一個 SQLite 檔的核心風險是 writer ownership 消失。常見情境是小型服務從單 instance 擴到多 instance，但仍把 database file 放在 shared volume；早期看起來可運作，流量上升後開始出現 busy timeout、WAL growth 與偶發資料修復壓力。</p>
<p>修正方向是重新定義 writer。若服務仍是 small backend，可以收斂到單 writer process + queue；若 multi-instance 是長期需求，應遷移到 <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> 或 <a href="/blog/backend/01-database/vendors/mysql/" data-link-title="MySQL" data-link-desc="高併發網路服務常用關聯式資料庫、Vitess / PlanetScale 分片生態、GitHub / Shopify / Facebook 規模驗證">MySQL</a>。</p>
<h3 id="case-2長讀取卡住-checkpoint磁碟被-wal-吃滿">Case 2：長讀取卡住 checkpoint，磁碟被 WAL 吃滿</h3>
<p>長讀取卡 checkpoint 的核心風險是 WAL file 成為隱性容量消耗。讀者可能只看到 disk usage 增長，誤以為是資料量變大；實際上 main database file 沒有明顯增長，<code>-wal</code> sidecar 持續膨脹。</p>
<p>修正方向是先找到長 reader，再調整 query lifecycle。Reporting query、background sync、streaming response、互動式 UI 大列表都要有 pagination、timeout 或低流量窗口；checkpoint 只負責收斂 WAL，application 仍要主動結束長讀取。</p>
<h3 id="case-3把-busy-timeout-當成擴容策略">Case 3：把 busy timeout 當成擴容策略</h3>
<p>Busy timeout 被當成擴容策略的核心風險是延遲被隱藏到使用者路徑。短暫 lock collision 可以等待；長期 write queue 則會把 API p99、UI freeze 或 worker backlog 拉高。</p>
<p>修正方向是把 busy wait 當 metric。設定 timeout 後要記錄等待時間與超時率；當 busy wait 成為常態，下一步是拆交易、調整 writer process、移走 batch job，或升級到 server database。</p>
<h3 id="case-4checkpoint-放在高流量-commit-path">Case 4：checkpoint 放在高流量 commit path</h3>
<p>Checkpoint 放在高流量 commit path 的核心風險是少數 commit 變得很慢。SQLite 預設 auto-checkpoint 對多數場景合理，但互動式服務可能看到偶發 latency spike；這時可以把 checkpoint 移到背景 thread / process 或低流量窗口。</p>
<p>修正方向是把 checkpoint duration 變成 evidence。觀察 WAL size、checkpoint return、commit latency 與 disk sync；若尖峰可接受，維持預設；若尖峰影響 UX，調整 checkpoint cadence。</p>
<h3 id="case-5wal-mode-版本與部署條件未納入維護">Case 5：WAL mode 版本與部署條件未納入維護</h3>
<p>WAL mode 的維護責任包含 SQLite runtime version、filesystem、sidecar file 與 release notes。SQLite 官方 WAL 文件記錄 2026-03 修正過罕見 WAL-reset bug；雖然觸發條件很窄，production runbook 仍應記錄 SQLite version、runtime package 與更新策略。</p>
<p>修正方向是把 SQLite runtime 當成 dependency。Mobile、desktop、embedded、language binding、OS bundled SQLite 可能各自帶不同版本；需要在 support matrix 中標明版本來源、WAL mode 行為與升級路徑。</p>
<h2 id="操作檢查清單">操作檢查清單</h2>
<p>SQLite WAL / locking runbook 至少要能回答下列問題：</p>
<ol>
<li>Database file、<code>-wal</code>、<code>-shm</code> 是否位於 local durable filesystem。</li>
<li>同一時間哪些 process / thread 會寫入 database file。</li>
<li><code>PRAGMA journal_mode</code>、<code>busy_timeout</code>、<code>wal_autocheckpoint</code> 如何設定。</li>
<li><code>SQLITE_BUSY</code> 次數、等待時間、超時率是否被記錄。</li>
<li>WAL file size、checkpoint duration、disk usage 是否被觀測。</li>
<li>長 read transaction 的來源與 timeout 如何治理。</li>
<li>Batch import、migration、background sync 是否避開互動式高峰。</li>
<li>SQLite runtime version 與 WAL 相關 release notes 如何追蹤。</li>
</ol>
<p>這份清單要接到 <a href="/blog/backend/01-database/vendors/sqlite/observability-runbook/" data-link-title="SQLite Observability and Runbook" data-link-desc="SQLite production runbook、backup evidence、WAL growth、busy errors、disk usage、restore drill 與 incident route">Observability / runbook</a> 與 <a href="/blog/backend/01-database/vendors/sqlite/hands-on/" data-link-title="SQLite Hands-on 操作路線" data-link-desc="SQLite local file lab、backup / restore drill、WAL busy reproduction、migration fixture、D1 / Turso preview 的操作型章節設計">SQLite Hands-on</a>；正文教判讀，hands-on 負責讓讀者重現 <code>SQLITE_BUSY</code>、WAL growth 與 checkpoint 行為。</p>
<h2 id="何時維持-sqlite何時升級">何時維持 SQLite，何時升級</h2>
<p>SQLite WAL mode 適合單機、短交易、read-heavy、writer ownership 清楚的服務。只要 busy wait 可控、checkpoint 能完成、backup / restore drill 成立，SQLite 可以承擔正式狀態。</p>
<p>升級訊號來自 writer boundary 外溢。多 instance write、多 region write、high-write OLTP、集中權限治理、read replica、PITR、DB account / role 與 audit requirement 都會把服務推向 server SQL、edge SQLite product 或 distributed SQL。</p>
<table>
  <thead>
      <tr>
          <th>壓力</th>
          <th>SQLite 內修正</th>
          <th>升級路由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>偶發 <code>SQLITE_BUSY</code></td>
          <td>busy timeout、縮短 transaction</td>
          <td>維持 SQLite</td>
      </tr>
      <tr>
          <td>WAL growth</td>
          <td>找長 reader、manual checkpoint</td>
          <td>維持 SQLite，補 observability</td>
      </tr>
      <tr>
          <td>多 worker 寫入</td>
          <td>收斂單 writer、queue 化</td>
          <td>PostgreSQL / MySQL</td>
      </tr>
      <tr>
          <td>Edge locality</td>
          <td>D1 / Turso compatibility audit</td>
          <td><a href="/blog/backend/01-database/vendors/sqlite/d1-turso-libsql-comparison/" data-link-title="SQLite D1 / Turso / libSQL Comparison" data-link-desc="Cloudflare D1、Turso、libSQL 與 local SQLite 在 edge、replication、consistency、migration 與 vendor boundary 的比較">D1 / Turso route</a></td>
      </tr>
      <tr>
          <td>HA / PITR / audit governance</td>
          <td>file backup 已經難以治理</td>
          <td><a href="/blog/backend/01-database/vendors/sqlite/migrate-to-postgresql/" data-link-title="SQLite to PostgreSQL Migration" data-link-desc="SQLite 升級到 PostgreSQL 的 driver、schema diff、data copy、dual run、cutover、rollback 與 cleanup">SQLite to PostgreSQL</a></td>
      </tr>
  </tbody>
</table>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>上游：<a href="/blog/backend/01-database/vendors/sqlite/" data-link-title="SQLite" data-link-desc="embedded、單檔案、test / CLI / edge 場景的標準選擇、近年因 Cloudflare D1 / Turso 等服務復興">SQLite overview</a></li>
<li>前置：<a href="/blog/backend/01-database/vendors/sqlite/file-lifecycle-backup-boundary/" data-link-title="SQLite file lifecycle 與 backup boundary" data-link-desc="把 SQLite 單檔案正式狀態拆成 WAL、backup API、restore drill、corruption recovery 與操作責任邊界">File lifecycle / backup boundary</a></li>
<li>操作：<a href="/blog/backend/01-database/vendors/sqlite/hands-on/" data-link-title="SQLite Hands-on 操作路線" data-link-desc="SQLite local file lab、backup / restore drill、WAL busy reproduction、migration fixture、D1 / Turso preview 的操作型章節設計">SQLite Hands-on</a> 與 <a href="/blog/backend/01-database/vendors/sqlite/hands-on/wal-busy-reproduction/" data-link-title="SQLite WAL Busy Reproduction" data-link-desc="SQLite long transaction、SQLITE_BUSY、busy_timeout、checkpoint growth 與 writer queue 的操作說明">WAL busy reproduction</a></li>
<li>平行：<a href="/blog/backend/01-database/vendors/sqlite/pragma-tuning-performance/" data-link-title="SQLite PRAGMA Tuning and Performance" data-link-desc="SQLite journal_mode、synchronous、busy_timeout、wal_autocheckpoint、cache_size、mmap_size、auto_vacuum 與 performance evidence 的操作判準">PRAGMA tuning / performance</a>、<a href="/blog/backend/01-database/vendors/sqlite/observability-runbook/" data-link-title="SQLite Observability and Runbook" data-link-desc="SQLite production runbook、backup evidence、WAL growth、busy errors、disk usage、restore drill 與 incident route">Observability / runbook</a></li>
<li>遷移：<a href="/blog/backend/01-database/vendors/sqlite/migrate-to-postgresql/" data-link-title="SQLite to PostgreSQL Migration" data-link-desc="SQLite 升級到 PostgreSQL 的 driver、schema diff、data copy、dual run、cutover、rollback 與 cleanup">SQLite to PostgreSQL</a></li>
<li>官方：<a href="https://www.sqlite.org/wal.html">SQLite Write-Ahead Logging</a>、<a href="https://www.sqlite.org/lockingv3.html">SQLite File Locking</a>、<a href="https://www.sqlite.org/isolation.html">SQLite Isolation</a>、<a href="https://www.sqlite.org/pragma.html">SQLite PRAGMA</a></li>
</ul>
]]></content:encoded></item></channel></rss>