<?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>Schema on Tarragon</title><link>https://tarrragon.github.io/blog/tags/schema/</link><description>Recent content in Schema on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Fri, 19 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/schema/index.xml" rel="self" type="application/rss+xml"/><item><title>模組二：Log Schema 設計</title><link>https://tarrragon.github.io/blog/monitoring/02-log-schema/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/02-log-schema/</guid><description>&lt;p>回答「事件長什麼樣」。schema 是所有 SDK 和 collector 的契約 SOT。&lt;/p>
&lt;h2 id="待寫章節">待寫章節&lt;/h2>
&lt;ul>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> event.schema.json 完整欄位解說&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 欄位設計原則（source 標明來源 / data 自由欄位 / v 版本演進）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Schema 版本演進策略（backward compatible 的增量變更）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 跟 OpenTelemetry 的 schema 差異對照&lt;/li>
&lt;/ul>
&lt;h2 id="跨分類引用">跨分類引用&lt;/h2>
&lt;ul>
&lt;li>SOT repo：&lt;a href="https://github.com/tarrragon/monitor">tarrragon/monitor&lt;/a> 的 &lt;code>schema/event.schema.json&lt;/code>&lt;/li>
&lt;li>← &lt;a href="https://tarrragon.github.io/blog/testing/02-client-observability/" data-link-title="模組二：客戶端可觀測性" data-link-desc="連線生命週期 log、protocol 訊息 log、使用者行為 log — log 設計是功能規格的一部分">testing 模組二&lt;/a>：log 點設計產出的事件需符合本 schema&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/monitoring/07-security-privacy/" data-link-title="模組七：資安與隱私" data-link-desc="SDK redaction / transport 加密 / collector access control / 去識別化 — 蒐集的資料本身就是風險資產">monitoring 模組七 資安&lt;/a>：schema 中哪些欄位需要 redaction&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>回答「事件長什麼樣」。schema 是所有 SDK 和 collector 的契約 SOT。</p>
<h2 id="待寫章節">待寫章節</h2>
<ul>
<li><input checked="" disabled="" type="checkbox"> event.schema.json 完整欄位解說</li>
<li><input checked="" disabled="" type="checkbox"> 欄位設計原則（source 標明來源 / data 自由欄位 / v 版本演進）</li>
<li><input checked="" disabled="" type="checkbox"> Schema 版本演進策略（backward compatible 的增量變更）</li>
<li><input checked="" disabled="" type="checkbox"> 跟 OpenTelemetry 的 schema 差異對照</li>
</ul>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>SOT repo：<a href="https://github.com/tarrragon/monitor">tarrragon/monitor</a> 的 <code>schema/event.schema.json</code></li>
<li>← <a href="/blog/testing/02-client-observability/" data-link-title="模組二：客戶端可觀測性" data-link-desc="連線生命週期 log、protocol 訊息 log、使用者行為 log — log 設計是功能規格的一部分">testing 模組二</a>：log 點設計產出的事件需符合本 schema</li>
<li>→ <a href="/blog/monitoring/07-security-privacy/" data-link-title="模組七：資安與隱私" data-link-desc="SDK redaction / transport 加密 / collector access control / 去識別化 — 蒐集的資料本身就是風險資產">monitoring 模組七 資安</a>：schema 中哪些欄位需要 redaction</li>
</ul>
]]></content:encoded></item><item><title>1.2 Schema Design 與資料建模</title><link>https://tarrragon.github.io/blog/backend/01-database/schema-design/</link><pubDate>Wed, 13 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/schema-design/</guid><description>&lt;p>資料綱要設計（schema design）的核心責任是把業務狀態轉成可維護、可查詢、可演進的資料結構。資料建模做得好、交易邊界、查詢效率、migration 成本與事故修復路徑都會更穩定。&lt;/p>
&lt;p>本章是 01 模組的基礎章節之一、結合 &lt;a href="https://tarrragon.github.io/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&lt;/a>（交易範圍）、&lt;a href="https://tarrragon.github.io/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&lt;/a>（演進證據）與 &lt;a href="https://tarrragon.github.io/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 容量規劃&lt;/a>（partition key 設計）一起讀。讀完後能回答：table 怎麼切、index 怎麼選、什麼時候 denormalize、partition 怎麼設、命名怎麼治理。&lt;/p>
&lt;h2 id="先定義狀態責任">先定義狀態責任&lt;/h2>
&lt;p>資料模型第一步是定義狀態責任：哪些欄位代表正式狀態、哪些欄位是派生值、哪些欄位只為追蹤與審計。這個分層會直接決定 table 邊界與 relation 方向。&lt;/p>
&lt;p>在訂單服務中、訂單主檔、付款狀態、庫存扣減屬於正式狀態；展示排序欄位、快取摘要屬於派生值；版本號、更新時間與來源欄位屬於可追蹤證據。把三類混在同一模型裡、後續查詢與演進成本會持續上升。&lt;/p>
&lt;p>詳見 &lt;a href="https://tarrragon.github.io/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&lt;/a>。&lt;/p>
&lt;h2 id="table-與-relation">Table 與 Relation&lt;/h2>
&lt;p>table 切分要對齊業務聚合邊界。聚合內需要交易一致性的欄位、放在同一交易可控範圍；跨聚合流程透過事件或引用關係接續。relation 的責任是表達資料約束、不是替代流程編排。&lt;/p>
&lt;p>主鍵策略要先回答「如何穩定識別」與「如何支援查詢」。自然鍵可讀性高但變動風險高；代理鍵穩定且易擴展、常搭配業務唯一鍵一起使用。外鍵策略則要平衡完整性與演進自由度：正式核心域可強約束、跨域整合可由應用層保護並保留遷移彈性。&lt;/p>
&lt;p>&lt;strong>主鍵選擇實務&lt;/strong>：&lt;/p>
&lt;p>ID 設計不只是「選個格式」，而是在五個維度做取捨。先理解取捨、再按場景選型。&lt;/p>
&lt;h3 id="id-設計的五個取捨維度">ID 設計的五個取捨維度&lt;/h3>
&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;strong>唯一性&lt;/strong>&lt;/td>
 &lt;td>跨機器、跨時間不碰撞&lt;/td>
 &lt;td>分散式系統的核心需求&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>有序性&lt;/strong>&lt;/td>
 &lt;td>是否可按生成順序排序&lt;/td>
 &lt;td>B-tree 插入效能、時間軸查詢&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>隱私性&lt;/strong>&lt;/td>
 &lt;td>是否洩漏業務資訊（量級、時間、機器）&lt;/td>
 &lt;td>外部可見的 ID 不應洩漏用戶數量&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>儲存成本&lt;/strong>&lt;/td>
 &lt;td>佔多少 byte、index 體積&lt;/td>
 &lt;td>高 TPS 場景每 byte 都乘以百萬筆&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>產生效能&lt;/strong>&lt;/td>
 &lt;td>需要鎖？需要 crypto/rand？需要 network call？&lt;/td>
 &lt;td>熱路徑上的 ID 產生 ns 級差異有影響&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="id-類型選型矩陣">ID 類型選型矩陣&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>ID 類型&lt;/th>
 &lt;th>大小&lt;/th>
 &lt;th>唯一性&lt;/th>
 &lt;th>有序性&lt;/th>
 &lt;th>隱私性&lt;/th>
 &lt;th>產生效能&lt;/th>
 &lt;th>適合場景&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;strong>Bigint sequence&lt;/strong>&lt;/td>
 &lt;td>8 byte&lt;/td>
 &lt;td>單機唯一&lt;/td>
 &lt;td>嚴格有序&lt;/td>
 &lt;td>低（可猜量級）&lt;/td>
 &lt;td>最快（DB 自增）&lt;/td>
 &lt;td>單機、內部 ID&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>UUID v4&lt;/strong>&lt;/td>
 &lt;td>16 byte&lt;/td>
 &lt;td>全域唯一&lt;/td>
 &lt;td>無序&lt;/td>
 &lt;td>高（不可預測）&lt;/td>
 &lt;td>中（crypto/rand）&lt;/td>
 &lt;td>外部可見 ID、隱私敏感&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>UUID v7&lt;/strong>&lt;/td>
 &lt;td>16 byte&lt;/td>
 &lt;td>全域唯一&lt;/td>
 &lt;td>時間有序&lt;/td>
 &lt;td>中（時間可推）&lt;/td>
 &lt;td>中（timestamp + crypto/rand）&lt;/td>
 &lt;td>內部 ID、事件追蹤、DB 主鍵&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>ULID&lt;/strong>&lt;/td>
 &lt;td>16 byte&lt;/td>
 &lt;td>全域唯一&lt;/td>
 &lt;td>時間有序&lt;/td>
 &lt;td>中&lt;/td>
 &lt;td>中&lt;/td>
 &lt;td>類 UUID v7（先於 v7 標準化）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Snowflake&lt;/strong>&lt;/td>
 &lt;td>8 byte&lt;/td>
 &lt;td>需要 machine_id 協調&lt;/td>
 &lt;td>時間有序&lt;/td>
 &lt;td>低（含 machine_id）&lt;/td>
 &lt;td>快（無 crypto）&lt;/td>
 &lt;td>高 TPS + 分散式 + 空間敏感&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>NanoID&lt;/strong>&lt;/td>
 &lt;td>可變（預設 21 字元）&lt;/td>
 &lt;td>依長度&lt;/td>
 &lt;td>無序&lt;/td>
 &lt;td>高&lt;/td>
 &lt;td>快（PRNG 即可）&lt;/td>
 &lt;td>URL-safe 短 ID（用於外部可見的短連結、邀請碼）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="選型決策流程">選型決策流程&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">需要跨機器唯一？
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> └─ 否 → Bigint sequence（最簡單、效能最好）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> └─ 是 → ID 對外部可見？
&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"> └─ 是 → UUID v4（不可預測）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> └─ 否 → UUID v7（有序、DB 友好）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> └─ 否 → 空間敏感（8 byte vs 16 byte）？
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl"> └─ 是 → Snowflake（需要 machine_id 協調）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl"> └─ 否 → UUID v7（簡單、標準）&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="有序-id-的-db-效能影響">有序 ID 的 DB 效能影響&lt;/h3>
&lt;p>B-tree 索引的插入效能和 key 的分布有直接關係。UUID v4 的隨機分布導致每次插入都可能落在 B-tree 的不同 leaf page，造成大量隨機 I/O（page split、cache miss）。UUID v7 的時間戳前綴讓插入集中在 B-tree 的尾端，接近 sequential insert。&lt;/p></description><content:encoded><![CDATA[<p>資料綱要設計（schema design）的核心責任是把業務狀態轉成可維護、可查詢、可演進的資料結構。資料建模做得好、交易邊界、查詢效率、migration 成本與事故修復路徑都會更穩定。</p>
<p>本章是 01 模組的基礎章節之一、結合 <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>（交易範圍）、<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>（演進證據）與 <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 容量規劃</a>（partition key 設計）一起讀。讀完後能回答：table 怎麼切、index 怎麼選、什麼時候 denormalize、partition 怎麼設、命名怎麼治理。</p>
<h2 id="先定義狀態責任">先定義狀態責任</h2>
<p>資料模型第一步是定義狀態責任：哪些欄位代表正式狀態、哪些欄位是派生值、哪些欄位只為追蹤與審計。這個分層會直接決定 table 邊界與 relation 方向。</p>
<p>在訂單服務中、訂單主檔、付款狀態、庫存扣減屬於正式狀態；展示排序欄位、快取摘要屬於派生值；版本號、更新時間與來源欄位屬於可追蹤證據。把三類混在同一模型裡、後續查詢與演進成本會持續上升。</p>
<p>詳見 <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>。</p>
<h2 id="table-與-relation">Table 與 Relation</h2>
<p>table 切分要對齊業務聚合邊界。聚合內需要交易一致性的欄位、放在同一交易可控範圍；跨聚合流程透過事件或引用關係接續。relation 的責任是表達資料約束、不是替代流程編排。</p>
<p>主鍵策略要先回答「如何穩定識別」與「如何支援查詢」。自然鍵可讀性高但變動風險高；代理鍵穩定且易擴展、常搭配業務唯一鍵一起使用。外鍵策略則要平衡完整性與演進自由度：正式核心域可強約束、跨域整合可由應用層保護並保留遷移彈性。</p>
<p><strong>主鍵選擇實務</strong>：</p>
<p>ID 設計不只是「選個格式」，而是在五個維度做取捨。先理解取捨、再按場景選型。</p>
<h3 id="id-設計的五個取捨維度">ID 設計的五個取捨維度</h3>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>說明</th>
          <th>範例</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>唯一性</strong></td>
          <td>跨機器、跨時間不碰撞</td>
          <td>分散式系統的核心需求</td>
      </tr>
      <tr>
          <td><strong>有序性</strong></td>
          <td>是否可按生成順序排序</td>
          <td>B-tree 插入效能、時間軸查詢</td>
      </tr>
      <tr>
          <td><strong>隱私性</strong></td>
          <td>是否洩漏業務資訊（量級、時間、機器）</td>
          <td>外部可見的 ID 不應洩漏用戶數量</td>
      </tr>
      <tr>
          <td><strong>儲存成本</strong></td>
          <td>佔多少 byte、index 體積</td>
          <td>高 TPS 場景每 byte 都乘以百萬筆</td>
      </tr>
      <tr>
          <td><strong>產生效能</strong></td>
          <td>需要鎖？需要 crypto/rand？需要 network call？</td>
          <td>熱路徑上的 ID 產生 ns 級差異有影響</td>
      </tr>
  </tbody>
</table>
<h3 id="id-類型選型矩陣">ID 類型選型矩陣</h3>
<table>
  <thead>
      <tr>
          <th>ID 類型</th>
          <th>大小</th>
          <th>唯一性</th>
          <th>有序性</th>
          <th>隱私性</th>
          <th>產生效能</th>
          <th>適合場景</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>Bigint sequence</strong></td>
          <td>8 byte</td>
          <td>單機唯一</td>
          <td>嚴格有序</td>
          <td>低（可猜量級）</td>
          <td>最快（DB 自增）</td>
          <td>單機、內部 ID</td>
      </tr>
      <tr>
          <td><strong>UUID v4</strong></td>
          <td>16 byte</td>
          <td>全域唯一</td>
          <td>無序</td>
          <td>高（不可預測）</td>
          <td>中（crypto/rand）</td>
          <td>外部可見 ID、隱私敏感</td>
      </tr>
      <tr>
          <td><strong>UUID v7</strong></td>
          <td>16 byte</td>
          <td>全域唯一</td>
          <td>時間有序</td>
          <td>中（時間可推）</td>
          <td>中（timestamp + crypto/rand）</td>
          <td>內部 ID、事件追蹤、DB 主鍵</td>
      </tr>
      <tr>
          <td><strong>ULID</strong></td>
          <td>16 byte</td>
          <td>全域唯一</td>
          <td>時間有序</td>
          <td>中</td>
          <td>中</td>
          <td>類 UUID v7（先於 v7 標準化）</td>
      </tr>
      <tr>
          <td><strong>Snowflake</strong></td>
          <td>8 byte</td>
          <td>需要 machine_id 協調</td>
          <td>時間有序</td>
          <td>低（含 machine_id）</td>
          <td>快（無 crypto）</td>
          <td>高 TPS + 分散式 + 空間敏感</td>
      </tr>
      <tr>
          <td><strong>NanoID</strong></td>
          <td>可變（預設 21 字元）</td>
          <td>依長度</td>
          <td>無序</td>
          <td>高</td>
          <td>快（PRNG 即可）</td>
          <td>URL-safe 短 ID（用於外部可見的短連結、邀請碼）</td>
      </tr>
  </tbody>
</table>
<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">需要跨機器唯一？
</span></span><span class="line"><span class="ln">2</span><span class="cl">  └─ 否 → Bigint sequence（最簡單、效能最好）
</span></span><span class="line"><span class="ln">3</span><span class="cl">  └─ 是 → ID 對外部可見？
</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">                    └─ 是 → UUID v4（不可預測）
</span></span><span class="line"><span class="ln">6</span><span class="cl">                    └─ 否 → UUID v7（有序、DB 友好）
</span></span><span class="line"><span class="ln">7</span><span class="cl">           └─ 否 → 空間敏感（8 byte vs 16 byte）？
</span></span><span class="line"><span class="ln">8</span><span class="cl">                    └─ 是 → Snowflake（需要 machine_id 協調）
</span></span><span class="line"><span class="ln">9</span><span class="cl">                    └─ 否 → UUID v7（簡單、標準）</span></span></code></pre></div><h3 id="有序-id-的-db-效能影響">有序 ID 的 DB 效能影響</h3>
<p>B-tree 索引的插入效能和 key 的分布有直接關係。UUID v4 的隨機分布導致每次插入都可能落在 B-tree 的不同 leaf page，造成大量隨機 I/O（page split、cache miss）。UUID v7 的時間戳前綴讓插入集中在 B-tree 的尾端，接近 sequential insert。</p>
<table>
  <thead>
      <tr>
          <th>測試場景（PostgreSQL、1000 萬筆）</th>
          <th>UUID v4</th>
          <th>UUID v7</th>
          <th>Bigint</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>INSERT 吞吐</td>
          <td>~5,000/sec</td>
          <td>~15,000/sec</td>
          <td>~20,000/sec</td>
      </tr>
      <tr>
          <td>Index 大小</td>
          <td>~400 MB</td>
          <td>~350 MB</td>
          <td>~200 MB</td>
      </tr>
      <tr>
          <td>範圍查詢延遲</td>
          <td>要額外建 timestamp index</td>
          <td>UUID 本身有序</td>
          <td>天然有序</td>
      </tr>
  </tbody>
</table>
<p>上表數字是基於 NVMe SSD 環境的量級估算（源自 UUID v4 的 random page split 成本約為 sequential 的 1/3-1/4 這個 B-tree 特性推導），實際效能依硬體和 workload 而定。核心結論：UUID v7 的插入效能約為 v4 的 3 倍，接近 bigint sequential。</p>
<h3 id="隱私考量v4-vs-v7">隱私考量：v4 vs v7</h3>
<p>UUID v7 的前 48 bit 是 Unix 時間戳（毫秒精度）。攻擊者拿到 UUID v7 可以推算「這個 ID 在幾點幾分產生」。這在不同場景有不同風險：</p>
<table>
  <thead>
      <tr>
          <th>場景</th>
          <th>v7 洩漏的資訊</th>
          <th>風險等級</th>
          <th>建議</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>內部事件追蹤 ID</td>
          <td>事件產生時間</td>
          <td>無風險（log 本身有 timestamp）</td>
          <td>v7</td>
      </tr>
      <tr>
          <td>DB 主鍵（內部）</td>
          <td>資料建立時間</td>
          <td>低風險</td>
          <td>v7</td>
      </tr>
      <tr>
          <td>Session ID（自用工具）</td>
          <td>Session 開始時間</td>
          <td>低風險</td>
          <td>v7</td>
      </tr>
      <tr>
          <td>Session ID（商業產品、有外部使用者）</td>
          <td>使用者活動時間</td>
          <td>中風險（可交叉比對身份）</td>
          <td>v4</td>
      </tr>
      <tr>
          <td>API key / token</td>
          <td>簽發時間</td>
          <td>高風險（可推斷 key 輪換週期）</td>
          <td>v4 或加密</td>
      </tr>
      <tr>
          <td>訂單 ID（外部可見）</td>
          <td>下單時間 + 量級趨勢</td>
          <td>中風險</td>
          <td>v4 或 NanoID</td>
      </tr>
  </tbody>
</table>
<p>經驗法則：<strong>對外暴露給不可信第三方的 ID 用 v4（不可預測），內部 ID 用 v7（有序、效能好）。</strong></p>
<h3 id="各語言的標準庫支援">各語言的標準庫支援</h3>
<table>
  <thead>
      <tr>
          <th>語言</th>
          <th>UUID v4</th>
          <th>UUID v7</th>
          <th>套件</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Python 3.14+</td>
          <td><code>uuid.uuid4()</code></td>
          <td><code>uuid.uuid7()</code></td>
          <td>標準庫</td>
      </tr>
      <tr>
          <td>Python &lt; 3.14</td>
          <td><code>uuid.uuid4()</code></td>
          <td><code>uuid_utils.uuid7()</code></td>
          <td>第三方</td>
      </tr>
      <tr>
          <td>Go</td>
          <td><code>google/uuid</code> v4</td>
          <td><code>google/uuid</code> v7（1.6+）</td>
          <td>事實標準</td>
      </tr>
      <tr>
          <td>TypeScript</td>
          <td><code>crypto.randomUUID()</code></td>
          <td>標準庫無（<code>uuidv7</code> npm）</td>
          <td>第三方</td>
      </tr>
      <tr>
          <td>Dart</td>
          <td><code>uuid</code> package</td>
          <td><code>uuid</code> package v4+（支援 v7）</td>
          <td>pub.dev</td>
      </tr>
      <tr>
          <td>PostgreSQL</td>
          <td><code>gen_random_uuid()</code></td>
          <td><code>uuidv7()</code>（pg_uuidv7 extension）</td>
          <td>擴展</td>
      </tr>
  </tbody>
</table>
<p>Go 的 <code>google/uuid</code> v1.6+ 內建 <code>uuid.NewV7()</code>，效能約 350ns/op（含 crypto/rand），和 JSON 解析（5-10μs）、DB 寫入（200μs）相比不是瓶頸。</p>
<p>對應 KV 案例：<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 partition key</a>、<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 composite key</a> 都是主鍵策略的延伸。</p>
<h2 id="index-設計">Index 設計</h2>
<p>index 設計要從查詢路徑反推、不是從欄位列表前推。每個高頻查詢至少要回答三件事：過濾條件是什麼、排序規則是什麼、回傳範圍有多大。這三件事能否由索引覆蓋、決定了 latency 與成本。</p>
<p><strong>Index 類型對照</strong>：</p>
<table>
  <thead>
      <tr>
          <th>Index 類型</th>
          <th>適用 query</th>
          <th>例子</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>B-tree（預設）</td>
          <td><code>WHERE col = ?</code> / <code>WHERE col &gt; ?</code> / <code>ORDER BY col</code></td>
          <td>多數查詢</td>
      </tr>
      <tr>
          <td>Hash</td>
          <td><code>WHERE col = ?</code>（不支援 range）</td>
          <td>PostgreSQL 限定、少用</td>
      </tr>
      <tr>
          <td>GIN</td>
          <td>JSONB / array / full-text search</td>
          <td><code>WHERE jsonb_data @&gt; ?</code></td>
      </tr>
      <tr>
          <td>GiST</td>
          <td>範圍 / 地理 / 自訂型別</td>
          <td>PostGIS、range type</td>
      </tr>
      <tr>
          <td>BRIN</td>
          <td>大表時序資料、欄位跟物理順序相關</td>
          <td>log table by timestamp</td>
      </tr>
      <tr>
          <td>Partial index</td>
          <td><code>WHERE</code> 條件下才建 index</td>
          <td><code>WHERE status = 'pending'</code></td>
      </tr>
      <tr>
          <td>Covering index</td>
          <td>包含所有查詢欄位、避免 heap lookup</td>
          <td><code>INDEX (a) INCLUDE (b, c)</code></td>
      </tr>
      <tr>
          <td>Compound index</td>
          <td>多欄位、順序敏感</td>
          <td><code>INDEX (a, b)</code> 對 <code>WHERE a=? AND b=?</code></td>
      </tr>
  </tbody>
</table>
<p><strong>常見設計原則</strong>：</p>
<ol>
<li>先保護交易關鍵查詢、再處理報表與後台查詢</li>
<li>複合索引依查詢過濾與排序順序排列、避免僅憑欄位熱門度排列</li>
<li>大表變更前先評估索引建立成本與回退方案、避免在高峰時段同步放大風險</li>
<li>定期 review 未用 index（PostgreSQL <code>pg_stat_user_indexes</code>、MySQL <code>sys.schema_unused_indexes</code>）— 寫入吞吐被舊 index 拖垮</li>
<li>partial index 對 <code>boolean</code> / <code>status</code> column 特別有用 — 只 index 「pending」「failed」等小集合</li>
</ol>
<p><strong>Index 反模式</strong>：</p>
<ul>
<li>每個欄位都建 index：寫入吞吐被拖垮</li>
<li>不看 EXPLAIN 就建 index：可能跟 query planner 不對齊</li>
<li>用 OR 條件依賴單一 index：query planner 不一定能用</li>
<li>大表 ALTER INDEX 不分批：lock 整個表</li>
</ul>
<h2 id="denormalization-模式">Denormalization 模式</h2>
<p>normalize 是 SQL 的預設、但 denormalize 有時是更好的工程選擇。</p>
<p><strong>Precomputed aggregate</strong>：</p>
<ul>
<li>把 COUNT / SUM 結果存在 parent row 而非每次 query 算</li>
<li>例：<code>posts.comment_count</code> 存實際值、不每次 SELECT COUNT</li>
<li>風險：consistency（comment 寫入後 count 沒更新）</li>
<li>對策：用 trigger 或應用層 transaction 確保同步、或定期 reconcile</li>
</ul>
<p><strong>Embedded one-to-many</strong>：</p>
<ul>
<li>小量 1-many 關係可以 embed 成 JSONB / nested column</li>
<li>例：<code>order.line_items</code> JSON column、不另建 line_items table</li>
<li>風險：個別 line item 查詢不便</li>
<li>適合：line items 通常一起讀寫（同 transaction boundary）</li>
</ul>
<p><strong>Materialized view</strong>：</p>
<ul>
<li>預計算 query 結果、定期 refresh</li>
<li>適合：複雜 JOIN / aggregation 重複跑</li>
<li>風險：refresh window 內看到舊資料</li>
</ul>
<p><strong>Read model</strong>（CQRS）：</p>
<ul>
<li>寫入路徑跟讀取路徑用不同 schema</li>
<li>寫入 normalize、讀取 denormalize 成不同 read model</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</a></li>
</ul>
<p><strong>對應案例</strong>：</p>
<ul>
<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">9.C27 Disney+ watch list</a> — denormalize 用戶 metadata、跨裝置查詢方便</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% 可用性的廣告事件量測">9.C5 Amazon Ads</a> — DynamoDB single-table design 是極端 denormalization</li>
</ul>
<h2 id="partition-策略">Partition 策略</h2>
<p>單表 &gt; 1 TB 時、partition 是必要的維運手段。partition 不是「擴 storage」、是「讓 vacuum / index / DROP 可分批跑」。</p>
<p><strong>Partition 類型</strong>：</p>
<ul>
<li><strong>Range partition</strong>：按 timestamp / id 範圍切。<code>orders_2024_q1</code>, <code>orders_2024_q2</code>&hellip;</li>
<li><strong>List partition</strong>：按枚舉值切。<code>orders_us</code>, <code>orders_eu</code>&hellip;</li>
<li><strong>Hash partition</strong>：按 hash 均勻切。適合無自然切分維度的大表</li>
</ul>
<p><strong>Partition 設計要點</strong>：</p>
<ol>
<li>partition key 必須出現在 <em>多數 query 的 WHERE clause</em>（partition pruning 才能生效）</li>
<li>partition 數量 <em>適中</em>（10-100）— 太少 partition 太大、太多 partition metadata 開銷大</li>
<li>老 partition 可以 DROP 或 archive、儲存成本可控</li>
<li><code>cross-partition unique constraint</code> 限制 — 唯一鍵必須含 partition key</li>
</ol>
<p><strong>對應案例</strong>：</p>
<ul>
<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 個獨立 Aurora cluster 是極端 partition by business</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% 可用性的廣告事件量測">9.C5 Amazon Ads</a> — DynamoDB 透明 partition、應用層不必管</li>
</ul>
<h2 id="schema-evolution-友好設計">Schema Evolution 友好設計</h2>
<p>schema 從 day 1 就要為演進設計、不能假設「以後不會改」。</p>
<p><strong>避免 breaking changes</strong>：</p>
<ul>
<li><strong>加欄位</strong>：safe（nullable 或 default）</li>
<li><strong>刪欄位</strong>：unsafe（先讓所有 code 不再讀 → 部署 → 再刪）</li>
<li><strong>改欄位類型</strong>：unsafe（先加新欄位、雙寫、backfill、移除舊欄位）</li>
<li><strong>改欄位名</strong>：unsafe（同上）</li>
<li><strong>加 NOT NULL constraint</strong>：unsafe（先 backfill default、再加 constraint）</li>
</ul>
<p><strong>Evolution-friendly schema 原則</strong>：</p>
<ol>
<li><strong>欄位 nullable by default</strong>：除非業務不允許 null、否則先 nullable、之後再 tighten</li>
<li><strong>避免大表 ALTER TABLE</strong>：用 <a href="/blog/backend/knowledge-cards/expand-contract/" data-link-title="Expand / Contract" data-link-desc="說明先擴充相容面、再收斂舊路徑的遷移做法">Expand / Contract</a> 模式</li>
<li><strong>predict breaking changes</strong>：訂版本、跟 application code 同步演進</li>
<li><strong>schema version column</strong>：每 row 帶 version、應用層按版本處理</li>
<li><strong>migration 工具版本控</strong>：Flyway / Liquibase / Atlas / golang-migrate 必須有</li>
</ol>
<p>詳見 <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> 跟 <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>。</p>
<h2 id="naming-與一致性">Naming 與一致性</h2>
<p>命名規則的責任是維持跨版本可讀性。table、column、index 的命名若沒有一致語意、migration 與故障排查會持續變慢。穩定做法是把命名和業務語意對齊、並保留可辨識版本與作用域。</p>
<p><strong>Naming 慣例</strong>：</p>
<ul>
<li><strong>Table</strong>：複數名詞、<code>snake_case</code>（<code>orders</code>, <code>payment_methods</code>）</li>
<li><strong>Column</strong>：<code>snake_case</code>、明確語意（<code>created_at</code> 不是 <code>ts</code>）</li>
<li><strong>Foreign key</strong>：<code>{referenced_table}_id</code>（<code>user_id</code> 指 <code>users.id</code>）</li>
<li><strong>Boolean</strong>：<code>is_*</code> / <code>has_*</code> / <code>can_*</code>（<code>is_active</code>, <code>has_subscription</code>）</li>
<li><strong>Timestamp</strong>：<code>*_at</code> for events（<code>created_at</code>, <code>paid_at</code>）、<code>*_on</code> for dates（<code>born_on</code>）</li>
<li><strong>Index</strong>：<code>idx_{table}_{cols}</code>（<code>idx_orders_user_id_created_at</code>）</li>
<li><strong>Unique constraint</strong>：<code>uq_{table}_{cols}</code></li>
<li><strong>Foreign key constraint</strong>：<code>fk_{table}_{ref}</code></li>
</ul>
<p><strong>避免的反模式</strong>：</p>
<ul>
<li>縮寫不一致（<code>u_id</code> vs <code>user_id</code>）</li>
<li>隱性意義（<code>status</code> 是 enum、值在哪裡？）</li>
<li>跨表同義不同名（<code>user.name</code> vs <code>customer.full_name</code>）</li>
<li>反向命名（<code>name_first</code> vs 業界 <code>first_name</code>）</li>
</ul>
<p>schema 演進時、命名與結構要一起考慮。欄位重命名、拆欄位、合併欄位都應配合 <a href="/blog/backend/knowledge-cards/expand-contract/" data-link-title="Expand / Contract" data-link-desc="說明先擴充相容面、再收斂舊路徑的遷移做法">Expand / Contract</a> 與 <a href="/blog/backend/knowledge-cards/schema-migration/" data-link-title="Schema Migration" data-link-desc="說明資料庫結構如何隨應用程式版本安全演進">schema migration</a> 策略、讓新舊版本在過渡期可共存。</p>
<h2 id="判讀訊號">判讀訊號</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>判讀重點</th>
          <th>對應動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>同一查詢在資料量成長後延遲快速上升</td>
          <td>索引與查詢模型不對齊</td>
          <td>補複合索引、重寫查詢條件</td>
      </tr>
      <tr>
          <td>migration 後查詢計畫顯著變化</td>
          <td>統計資訊或索引選擇偏移</td>
          <td>重建統計、校正索引與查詢</td>
      </tr>
      <tr>
          <td>交易流程需跨多表同步更新</td>
          <td>table 邊界與業務聚合邊界不一致</td>
          <td>重切聚合邊界、減少跨聚合同步更新</td>
      </tr>
      <tr>
          <td>同義欄位在多表重複存在且語意漂移</td>
          <td>命名與責任邊界失控</td>
          <td>收斂欄位責任、補資料字典與遷移計畫</td>
      </tr>
      <tr>
          <td>修復事故時需要多次手動比對資料</td>
          <td>可追蹤欄位與關聯鍵不足</td>
          <td>補追蹤欄位、設計對帳查詢與修復流程</td>
      </tr>
      <tr>
          <td>單表 &gt; 1 TB 且 vacuum 變慢</td>
          <td>沒 partition、後續維運成本爆</td>
          <td>規劃 partition by range / hash</td>
      </tr>
      <tr>
          <td>大量 unused index</td>
          <td>寫入吞吐被舊 index 拖垮</td>
          <td>review pg_stat_user_indexes、定期 drop</td>
      </tr>
  </tbody>
</table>
<h2 id="常見誤區">常見誤區</h2>
<p>把 schema 設計等同於「先能寫入就好」、會把結構債延後到流量成長與事故時一次爆發。資料模型的工程價值在於可演進性、不在於初版欄位數量最少。</p>
<p>把索引當成效能補丁、忽略查詢模型與資料責任、也會讓後續維護成本持續疊加。索引與查詢要一起設計、才能在演進中保持穩定。</p>
<p>把 normalize 當成 <em>絕對守則</em>、忽略 denormalize 的工程效益。1NF / 2NF / 3NF 是理論起點、不是 <em>production 必須</em>。</p>
<h2 id="案例對照">案例對照</h2>
<table>
  <thead>
      <tr>
          <th>案例</th>
          <th>Schema 設計重點</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><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></td>
          <td>DynamoDB single-table design、極端 denormalize</td>
      </tr>
      <tr>
          <td><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></td>
          <td>Composite partition key、event_id × user_id_hash</td>
      </tr>
      <tr>
          <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、按業務切 partition</td>
      </tr>
      <tr>
          <td><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">9.C27 Disney+</a></td>
          <td>watch list embedded design、跨裝置同步</td>
      </tr>
      <tr>
          <td><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></td>
          <td>Cosmos DB synthetic partition key 強制分散</td>
      </tr>
  </tbody>
</table>
<h2 id="案例回寫">案例回寫</h2>
<p>資料建模議題可以用 <a href="/blog/backend/08-incident-response/cases/github/2018-oct21-mysql-topology-incident/" data-link-title="GitHub 2018 Oct21 MySQL Topology Incident" data-link-desc="2018-10-21 GitHub 因 network partition 觸發跨區資料庫拓撲異常的事故解析：資料一致性優先、fail-forward 決策與長時間恢復。">GitHub 2018 Oct21 MySQL Topology Incident</a> 做回寫練習。讀這個事件時、先看跨區拓樸切換如何影響資料一致性、再回到本章檢查三件事：聚合邊界是否清晰、交易查詢與對帳查詢是否分層、修復時是否有可追蹤欄位與對帳鍵。</p>
<p>這個案例主要支撐的是「查詢與資料模型邊界」判讀、不直接支撐 transaction retry 或 queue replay 調校；若問題是重試放大、應轉到 1.3 或 3.x 章節處理。</p>
<p>當事件呈現長時間人工比對或查詢語意漂移時、先修正本章的 query boundary 與 naming 一致性、再補 <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 資料庫轉換實作</a> 的驗證與回退路徑。</p>
<h2 id="跨模組路由">跨模組路由</h2>
<p>schema 設計會直接影響後續可靠性與事故處理。</p>
<ol>
<li>與 1.3 的交接：交易一致性邊界落在 <a href="/blog/backend/01-database/transaction-boundary/" data-link-title="1.3 Transaction 與一致性邊界" data-link-desc="交易邊界、isolation level、retry 策略、distributed transaction（2PC、Saga）與跨 region 強一致取捨">transaction boundary</a>。</li>
<li>與 1.6 的交接：演進策略落在 <a href="/blog/backend/01-database/database-migration-playbook/" data-link-title="1.6 資料庫轉換實作：雙寫、回填、切流與回滾" data-link-desc="同 DB 內 schema 演進與資料變更的可分段驗證流程、跟 1.12 cross-DB migration 分工">資料庫轉換實作</a>。</li>
<li>與 1.7 的交接：欄位責任進入 production rollout 時、讀 <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 證據實作示範</a>。</li>
<li>與 1.8 的交接：state ownership 跟 query boundary 設計落在 <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 邊界">State Ownership</a>。</li>
<li>與 1.10 的交接：KV / Document 的 partition key 設計落在 <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 選擇">KV / Document 容量規劃</a>。</li>
<li>與 4.20 的交接：查詢與資料驗證證據進入 <a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">Observability Evidence Package</a>。</li>
<li>與 6.11 的交接：高風險 schema 變更進入 <a href="/blog/backend/06-reliability/migration-safety/" data-link-title="6.11 Migration Safety 與 DB Rollout" data-link-desc="把 schema migration 從一次性事件變成可逆、可漸進的 rollout 流程">Migration Safety</a>。</li>
<li>與 8.19 的交接：資料修復與回退決策記錄進入 <a href="/blog/backend/08-incident-response/incident-decision-log/" data-link-title="8.19 Incident Decision Log" data-link-desc="把事中假設、決策、證據、回退條件與責任人留下可復盤紀錄">Incident Decision Log</a>。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<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>、<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</a></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> / <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> / <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 容量規劃</a></li>
<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 index 設計</a>、<a href="/blog/backend/01-database/vendors/mysql/" data-link-title="MySQL" data-link-desc="高併發網路服務常用關聯式資料庫、Vitess / PlanetScale 分片生態、GitHub / Shopify / Facebook 規模驗證">MySQL InnoDB clustered index</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 single-table design</a></li>
<li>DynamoDB schema 深入：<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</a> / <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> / <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 設計</a></li>
<li>MongoDB schema 深入：<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> / <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 選型</a></li>
<li>Cosmos DB schema 深入：<a href="/blog/backend/01-database/vendors/cosmosdb/partition-key-design/" data-link-title="Cosmos DB Partition Key Design：synthetic / composite / hierarchical &#43; 不可逆性硬約束" data-link-desc="Cosmos DB logical partition 10000 RU/s 上限、partition key 不可改、三種設計模式（synthetic / composite / hierarchical）、跟 DynamoDB / MongoDB 可逆性對比、latency budget 拆解 — 從 Minecraft Earth &#43; ASOS 切入">partition key 設計</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>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></channel></rss>