<?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>Evolution on Tarragon</title><link>https://tarrragon.github.io/blog/tags/evolution/</link><description>Recent content in Evolution 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/evolution/index.xml" rel="self" type="application/rss+xml"/><item><title>10.1 服務拆分與邊界判讀</title><link>https://tarrragon.github.io/blog/backend/10-system-evolution/service-decomposition-boundaries/</link><pubDate>Wed, 27 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/10-system-evolution/service-decomposition-boundaries/</guid><description>&lt;p>Monolith 與 microservice 是兩種耦合策略、各自承擔代價：monolith 用單一程式碼庫換低協作成本、microservice 用獨立邊界換團隊與部署彈性。本章處理「演進速度跟組織能力對齊」這個決策邊界 — 起點是辨識當下壓力來源、再選擇拆分軸、流行度與堅持習慣都是次要訊號。&lt;/p>
&lt;h2 id="monolith-與-microservice-的責任差異">Monolith 與 Microservice 的責任差異&lt;/h2>
&lt;p>Monolith 用「同一個程式碼庫、同一個部署單位、同一個資料庫」換取低協作成本與簡單事務語意。Microservice 用「獨立程式碼庫、獨立部署、獨立資料邊界」換取團隊獨立性、技術選型彈性與局部故障隔離。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>維度&lt;/th>
 &lt;th>Monolith&lt;/th>
 &lt;th>Microservice&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>變更速度&lt;/td>
 &lt;td>單庫改完直接上線&lt;/td>
 &lt;td>跨服務協調，需要契約對齊&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>事務一致性&lt;/td>
 &lt;td>本地 transaction 就解決&lt;/td>
 &lt;td>跨服務需要 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/saga/" data-link-title="Saga" data-link-desc="處理跨服務分散事務的補償型 transaction 序列、用最終一致換 ACID atomic">saga&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/outbox-pattern/" data-link-title="Outbox Pattern" data-link-desc="說明資料庫狀態變更與事件發布如何透過 outbox 維持一致">outbox&lt;/a> 或最終一致性&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>故障隔離&lt;/td>
 &lt;td>單點失敗會整個服務掛掉&lt;/td>
 &lt;td>一個服務掛了，其他可能還能服務&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>部署單位&lt;/td>
 &lt;td>整個應用一次部署&lt;/td>
 &lt;td>各服務獨立部署，發布節奏不互相阻擋&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>運維複雜度&lt;/td>
 &lt;td>一組基礎設施&lt;/td>
 &lt;td>N 組基礎設施 + 服務間通訊監控&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Debug 路徑&lt;/td>
 &lt;td>同一個 stack trace 看到底&lt;/td>
 &lt;td>跨服務 trace context、log 聚合不可省&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>適合規模&lt;/td>
 &lt;td>早期、單一團隊、業務尚未分化&lt;/td>
 &lt;td>多團隊、業務已分化、可獨立演進&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>讀者要從這張表反推自己的真實壓力來源。如果痛點是「部署互相卡住、發布頻率被別人拖慢」，拆分能解決；如果痛點是「程式碼太亂、新人看不懂」，拆服務只會把亂的範圍擴大成跨服務契約混亂。&lt;/p>
&lt;p>這張表是兩端對比、實際系統常落在中間。常見折衷形態：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/modular-monolith/" data-link-title="Modular Monolith" data-link-desc="單一部署單位 &amp;#43; 模組化內部邊界的架構、是 monolith 跟 microservice 之間的折衷形態">Modular monolith&lt;/a>&lt;/strong>（單一部署 + 模組化邊界）：保留 monolith 的部署簡單、用模組邊界防止程式碼互相穿透。Shopify、Basecamp、Stack Overflow 是大規模長期維持的代表 — monolith 不是進化中段、是 valid endgame。&lt;/li>
&lt;li>&lt;strong>Macro-services&lt;/strong>（少量大服務、5-15 個）：避免 microservice 的極端碎片化、保留拆分帶來的部署獨立性。是多數中型團隊的實際終點、不是過渡形態。&lt;/li>
&lt;li>&lt;strong>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/cell-based-architecture/" data-link-title="Cell-Based Architecture" data-link-desc="把系統拆成多個 isolated cell、控制 blast radius、跨 cell 共用標準介面">Cell-based architecture&lt;/a>&lt;/strong>（多 cell 各自獨立、跨 cell 共用標準介面）：AWS、Slack、DoorDash 用來控制 blast radius — 把整個系統複製成多個 isolated cell、每個 cell 內可以是 monolith 或 microservice。&lt;/li>
&lt;/ul>
&lt;p>拆分不是進化方向、是壓力應對工具。維持 monolith 在某些情境（極小團隊、PMF 前期、無 DevOps 能力）是更負責任的選擇。&lt;/p>
&lt;h2 id="拆分軸的判讀">拆分軸的判讀&lt;/h2>
&lt;p>服務邊界不只一條軸。常見的四條軸對應不同的壓力來源，正確的拆法是「壓力在哪裡、就沿那條軸拆」，不是同時動四條軸。&lt;/p>
&lt;h3 id="資料邊界">資料邊界&lt;/h3>
&lt;p>當兩塊業務的資料&lt;strong>生命週期不同、一致性需求不同、查詢模式不同&lt;/strong>時，資料邊界已經形成。例如訂單資料需要強一致性與長期保留，瀏覽紀錄可以最終一致性、定期清理。把這兩類資料放同一個 schema 會讓 backup、migration、index 策略互相干擾。&lt;/p>
&lt;p>判讀訊號：同一張表上不同欄位的 read/write QPS 差三個數量級、同一個 transaction 同時寫入多種業務概念、schema migration 一動就要鎖住整個業務的寫入。&lt;/p>
&lt;h3 id="團隊邊界">團隊邊界&lt;/h3>
&lt;p>當兩塊業務由不同團隊維護、發布節奏不同、技術棧偏好不同時，團隊邊界已經形成。Conway&amp;rsquo;s Law 反過來操作：用服務邊界保護團隊邊界，避免一隊改動觸發另一隊重 review。&lt;/p>
&lt;p>判讀訊號：PR review 跨團隊比例過半、發版需要協調多個團隊、技術升級（語言版本、framework 升級）因為其他團隊未準備好而被擋住。&lt;/p>
&lt;h3 id="部署邊界">部署邊界&lt;/h3>
&lt;p>當部分功能需要&lt;strong>獨立的部署節奏、獨立的擴展策略、獨立的可用性等級&lt;/strong>時，部署邊界已經形成。背景批次工作要按小時排程、API 服務要 7×24 線上、報表服務只在工作日運行，三者放同一個部署單位會讓最嚴格的可用性要求拖累其他。&lt;/p>
&lt;p>判讀訊號：高峰時某個功能擴展速度跟不上、低峰時某個功能浪費資源、單一發版策略覆蓋不了所有功能的風險等級。&lt;/p>
&lt;h3 id="流量邊界">流量邊界&lt;/h3>
&lt;p>當不同功能的&lt;strong>流量形狀、失敗代價、SLO 等級不同&lt;/strong>時，流量邊界已經形成。付款 API 一秒 100 個請求、商品搜尋一秒 10000 個請求、後台報表一天 100 個請求，三者放同一個服務會讓彼此爭資源，付款被搜尋擠掉是業務災難。&lt;/p>
&lt;p>判讀訊號：高頻 endpoint 壓爆低頻 endpoint 共用的連線池、不同 endpoint 的 latency 分布同時惡化、無法針對核心交易設定獨立的 SLO 跟 alert。&lt;/p>
&lt;h3 id="其他常見拆分軸">其他常見拆分軸&lt;/h3>
&lt;p>上面四條是技術驅動的主要拆分軸。實務上還有其他軸常成為真實驅動力、要一併納入判讀：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>失敗代價 / blast radius 軸&lt;/strong>：核心交易（掛了會有業務災難）跟邊緣推薦（掛了沒人在意）的可用性等級差距大、適合拆開降低 blast radius。跟 SLO 軸高相關但不同 — 重點在「失敗時誰受影響」的範圍隔離。&lt;/li>
&lt;li>&lt;strong>變更頻率 / 風險軸&lt;/strong>：high-velocity 實驗功能跟 stable 核心應拆開、降低實驗對核心穩定性的牽連。跟團隊軸高相關但獨立 — 同一團隊也可能維持兩種變更頻率的程式碼。&lt;/li>
&lt;li>&lt;strong>資料敏感度 / 合規邊界&lt;/strong>：PCI / PII / 醫療資料的隔離常是合規硬要求（GDPR data residency 強制資料拆境），不是技術選擇。這類軸跟資料邊界相關但服從不同壓力。&lt;/li>
&lt;li>&lt;strong>組織非技術約束&lt;/strong>：併購整合、外部合規節奏、團隊 reorg、預算切分都會強制拆分 — 比 metric 訊號更早觸發、技術上不一定最佳但無法繞過。&lt;/li>
&lt;/ul>
&lt;p>這些軸跟前四條可以同時生效、也可能彼此衝突（合規逼資料拆境、但流量軸建議聚合）。處理衝突時優先順序通常是「合規 &amp;gt; 失敗代價 &amp;gt; 部署 / 流量 &amp;gt; 團隊 &amp;gt; 資料 &amp;gt; 變更頻率」、但每個組織會有自己的權重。&lt;/p></description><content:encoded><![CDATA[<p>Monolith 與 microservice 是兩種耦合策略、各自承擔代價：monolith 用單一程式碼庫換低協作成本、microservice 用獨立邊界換團隊與部署彈性。本章處理「演進速度跟組織能力對齊」這個決策邊界 — 起點是辨識當下壓力來源、再選擇拆分軸、流行度與堅持習慣都是次要訊號。</p>
<h2 id="monolith-與-microservice-的責任差異">Monolith 與 Microservice 的責任差異</h2>
<p>Monolith 用「同一個程式碼庫、同一個部署單位、同一個資料庫」換取低協作成本與簡單事務語意。Microservice 用「獨立程式碼庫、獨立部署、獨立資料邊界」換取團隊獨立性、技術選型彈性與局部故障隔離。</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>Monolith</th>
          <th>Microservice</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>變更速度</td>
          <td>單庫改完直接上線</td>
          <td>跨服務協調，需要契約對齊</td>
      </tr>
      <tr>
          <td>事務一致性</td>
          <td>本地 transaction 就解決</td>
          <td>跨服務需要 <a href="/blog/backend/knowledge-cards/saga/" data-link-title="Saga" data-link-desc="處理跨服務分散事務的補償型 transaction 序列、用最終一致換 ACID atomic">saga</a>、<a href="/blog/backend/knowledge-cards/outbox-pattern/" data-link-title="Outbox Pattern" data-link-desc="說明資料庫狀態變更與事件發布如何透過 outbox 維持一致">outbox</a> 或最終一致性</td>
      </tr>
      <tr>
          <td>故障隔離</td>
          <td>單點失敗會整個服務掛掉</td>
          <td>一個服務掛了，其他可能還能服務</td>
      </tr>
      <tr>
          <td>部署單位</td>
          <td>整個應用一次部署</td>
          <td>各服務獨立部署，發布節奏不互相阻擋</td>
      </tr>
      <tr>
          <td>運維複雜度</td>
          <td>一組基礎設施</td>
          <td>N 組基礎設施 + 服務間通訊監控</td>
      </tr>
      <tr>
          <td>Debug 路徑</td>
          <td>同一個 stack trace 看到底</td>
          <td>跨服務 trace context、log 聚合不可省</td>
      </tr>
      <tr>
          <td>適合規模</td>
          <td>早期、單一團隊、業務尚未分化</td>
          <td>多團隊、業務已分化、可獨立演進</td>
      </tr>
  </tbody>
</table>
<p>讀者要從這張表反推自己的真實壓力來源。如果痛點是「部署互相卡住、發布頻率被別人拖慢」，拆分能解決；如果痛點是「程式碼太亂、新人看不懂」，拆服務只會把亂的範圍擴大成跨服務契約混亂。</p>
<p>這張表是兩端對比、實際系統常落在中間。常見折衷形態：</p>
<ul>
<li><strong><a href="/blog/backend/knowledge-cards/modular-monolith/" data-link-title="Modular Monolith" data-link-desc="單一部署單位 &#43; 模組化內部邊界的架構、是 monolith 跟 microservice 之間的折衷形態">Modular monolith</a></strong>（單一部署 + 模組化邊界）：保留 monolith 的部署簡單、用模組邊界防止程式碼互相穿透。Shopify、Basecamp、Stack Overflow 是大規模長期維持的代表 — monolith 不是進化中段、是 valid endgame。</li>
<li><strong>Macro-services</strong>（少量大服務、5-15 個）：避免 microservice 的極端碎片化、保留拆分帶來的部署獨立性。是多數中型團隊的實際終點、不是過渡形態。</li>
<li><strong><a href="/blog/backend/knowledge-cards/cell-based-architecture/" data-link-title="Cell-Based Architecture" data-link-desc="把系統拆成多個 isolated cell、控制 blast radius、跨 cell 共用標準介面">Cell-based architecture</a></strong>（多 cell 各自獨立、跨 cell 共用標準介面）：AWS、Slack、DoorDash 用來控制 blast radius — 把整個系統複製成多個 isolated cell、每個 cell 內可以是 monolith 或 microservice。</li>
</ul>
<p>拆分不是進化方向、是壓力應對工具。維持 monolith 在某些情境（極小團隊、PMF 前期、無 DevOps 能力）是更負責任的選擇。</p>
<h2 id="拆分軸的判讀">拆分軸的判讀</h2>
<p>服務邊界不只一條軸。常見的四條軸對應不同的壓力來源，正確的拆法是「壓力在哪裡、就沿那條軸拆」，不是同時動四條軸。</p>
<h3 id="資料邊界">資料邊界</h3>
<p>當兩塊業務的資料<strong>生命週期不同、一致性需求不同、查詢模式不同</strong>時，資料邊界已經形成。例如訂單資料需要強一致性與長期保留，瀏覽紀錄可以最終一致性、定期清理。把這兩類資料放同一個 schema 會讓 backup、migration、index 策略互相干擾。</p>
<p>判讀訊號：同一張表上不同欄位的 read/write QPS 差三個數量級、同一個 transaction 同時寫入多種業務概念、schema migration 一動就要鎖住整個業務的寫入。</p>
<h3 id="團隊邊界">團隊邊界</h3>
<p>當兩塊業務由不同團隊維護、發布節奏不同、技術棧偏好不同時，團隊邊界已經形成。Conway&rsquo;s Law 反過來操作：用服務邊界保護團隊邊界，避免一隊改動觸發另一隊重 review。</p>
<p>判讀訊號：PR review 跨團隊比例過半、發版需要協調多個團隊、技術升級（語言版本、framework 升級）因為其他團隊未準備好而被擋住。</p>
<h3 id="部署邊界">部署邊界</h3>
<p>當部分功能需要<strong>獨立的部署節奏、獨立的擴展策略、獨立的可用性等級</strong>時，部署邊界已經形成。背景批次工作要按小時排程、API 服務要 7×24 線上、報表服務只在工作日運行，三者放同一個部署單位會讓最嚴格的可用性要求拖累其他。</p>
<p>判讀訊號：高峰時某個功能擴展速度跟不上、低峰時某個功能浪費資源、單一發版策略覆蓋不了所有功能的風險等級。</p>
<h3 id="流量邊界">流量邊界</h3>
<p>當不同功能的<strong>流量形狀、失敗代價、SLO 等級不同</strong>時，流量邊界已經形成。付款 API 一秒 100 個請求、商品搜尋一秒 10000 個請求、後台報表一天 100 個請求，三者放同一個服務會讓彼此爭資源，付款被搜尋擠掉是業務災難。</p>
<p>判讀訊號：高頻 endpoint 壓爆低頻 endpoint 共用的連線池、不同 endpoint 的 latency 分布同時惡化、無法針對核心交易設定獨立的 SLO 跟 alert。</p>
<h3 id="其他常見拆分軸">其他常見拆分軸</h3>
<p>上面四條是技術驅動的主要拆分軸。實務上還有其他軸常成為真實驅動力、要一併納入判讀：</p>
<ul>
<li><strong>失敗代價 / blast radius 軸</strong>：核心交易（掛了會有業務災難）跟邊緣推薦（掛了沒人在意）的可用性等級差距大、適合拆開降低 blast radius。跟 SLO 軸高相關但不同 — 重點在「失敗時誰受影響」的範圍隔離。</li>
<li><strong>變更頻率 / 風險軸</strong>：high-velocity 實驗功能跟 stable 核心應拆開、降低實驗對核心穩定性的牽連。跟團隊軸高相關但獨立 — 同一團隊也可能維持兩種變更頻率的程式碼。</li>
<li><strong>資料敏感度 / 合規邊界</strong>：PCI / PII / 醫療資料的隔離常是合規硬要求（GDPR data residency 強制資料拆境），不是技術選擇。這類軸跟資料邊界相關但服從不同壓力。</li>
<li><strong>組織非技術約束</strong>：併購整合、外部合規節奏、團隊 reorg、預算切分都會強制拆分 — 比 metric 訊號更早觸發、技術上不一定最佳但無法繞過。</li>
</ul>
<p>這些軸跟前四條可以同時生效、也可能彼此衝突（合規逼資料拆境、但流量軸建議聚合）。處理衝突時優先順序通常是「合規 &gt; 失敗代價 &gt; 部署 / 流量 &gt; 團隊 &gt; 資料 &gt; 變更頻率」、但每個組織會有自己的權重。</p>
<h2 id="拆分時機的判讀">拆分時機的判讀</h2>
<p>拆分時機不能等到「已經痛到動不了」才開始，那時候拆分要付的代價最高。也不能在「還沒長出邊界」時提早拆，那會用 microservice 的協調成本懲罰一個還沒到規模的系統。</p>
<p>提早訊號（可以開始準備但不一定立刻動手）：</p>
<ul>
<li>程式碼裡同一份邏輯被三個 PR 同時修改、merge conflict 增加</li>
<li>同一個 service 的不同功能開始有不同的擴展需求</li>
<li>不同團隊對同一個發版視窗的需求開始衝突</li>
</ul>
<p>該動手訊號（再拖就要付高昂代價）：</p>
<ul>
<li>任何一個功能改動需要 freeze 整個服務發版</li>
<li>局部高峰擴展時整個服務一起擴展，成本翻倍</li>
<li>一個團隊的事故會直接影響另一個團隊的營運指標</li>
<li>跨團隊 deadlock：A 等 B 改完才能上、B 等 A 改完才能上</li>
</ul>
<p>過晚訊號（拆分要付遷移代價）：</p>
<ul>
<li>已經出現過跨團隊事故、且復盤結論是「無法分離責任」</li>
<li>DB 連線池在多個業務間爭搶、無法用 connection pool 隔離解決</li>
<li>部署平台跑不動：CI 太慢、build 太大、本地開發無法啟動完整環境</li>
</ul>
<h2 id="拆分代價與回退路徑">拆分代價與回退路徑</h2>
<p>拆分不是免費操作。每多一個服務，就多一份運維成本、跨服務 trace 成本、契約治理成本。讀者要在拆分前認知這些代價，而不是事後才發現。</p>
<table>
  <thead>
      <tr>
          <th>代價類型</th>
          <th>具體表現</th>
          <th>緩解方向</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>分散事務</td>
          <td>一筆業務動作跨多個服務、需要 saga 或最終一致性</td>
          <td><a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">03 message queue</a> 的 outbox、<a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a></td>
      </tr>
      <tr>
          <td>運維複雜度</td>
          <td>N 個服務 × M 個環境 × K 個版本，組合爆炸</td>
          <td>收斂部署平台、用 <a href="/blog/backend/05-deployment-platform/kubernetes-deployment/" data-link-title="5.2 Kubernetes 部署策略" data-link-desc="整理 deployment、probe 與 rolling update">5.2 K8s 部署策略</a> 統一管理</td>
      </tr>
      <tr>
          <td>跨服務 debug</td>
          <td>一個請求跨多個服務、不知道在哪一段失敗</td>
          <td><a href="/blog/backend/knowledge-cards/trace-context/" data-link-title="Trace Context" data-link-desc="說明跨服務 request 如何用 trace context 串起路徑與耗時">04 trace context</a>、結構化 log 聚合</td>
      </tr>
      <tr>
          <td>契約治理</td>
          <td>服務 A 的 API 改動會影響服務 B、C、D</td>
          <td><a href="/blog/backend/knowledge-cards/contract/" data-link-title="Boundary Contract" data-link-desc="說明跨邊界約定如何維持相容與可驗證">contract test</a>、版本化 API</td>
      </tr>
      <tr>
          <td>資料一致性</td>
          <td>各服務 DB 獨立，跨服務查詢需要 join 或 read model</td>
          <td>CQRS、event-driven projection、reconciliation</td>
      </tr>
  </tbody>
</table>
<p>拆分失敗的回退路徑要在拆分前設計好。常見回退策略：保留原 monolith 程式碼一段時間（雙寫期），新服務出問題可以切回；先拆<strong>讀路徑</strong>驗證流量，再拆寫路徑；用 feature flag 控制是否走新服務。沒有回退路徑的拆分一旦撞牆，會比不拆更難收拾。</p>
<h3 id="拆分後的通訊優先級事件--同步-rpc">拆分後的通訊優先級：事件 &gt; 同步 RPC</h3>
<p>拆完後跨服務通訊有兩條路：同步 RPC（gRPC、REST）跟異步事件（<a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">message queue</a>、event bus）。預設應該選事件、保留 RPC 給「真的需要同步回應的查詢」。</p>
<p>理由：</p>
<ul>
<li><strong>失敗代價隔離</strong>：服務 A 發事件給 B、B 掛了不影響 A — 事件留在 queue 等。同步 RPC 下、B 掛了 A 也跟著掛</li>
<li><strong>流量解耦</strong>：事件本身就是 buffer、能吸收 burst。同步 RPC 是 throughput 的硬上限、A 的尖峰 = B 的尖峰</li>
<li><strong>可重放</strong>：事件可以重放（replay）做資料修補、debug、新服務 backfill。同步 RPC 過了就過了</li>
<li><strong>服務獨立演進</strong>：事件 schema 可以加欄位向下相容、consumer 慢慢 adapt。RPC interface 改動是 breaking change</li>
</ul>
<p>該用同步 RPC 的少數場景：使用者請求路徑需要立即回應（「使用者按下查詢、顯示結果」）、且兩個服務都在同一個 latency budget 內。其他都優先事件。</p>
<p>詳見 <a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">03 模組訊息佇列</a> 跟 <a href="/blog/backend/00-service-selection/async-delivery-selection/" data-link-title="0.3 非同步與事件傳遞選型" data-link-desc="區分背景工作、durable queue、stream、pub/sub 與 outbox 的選型邊界">0.3 非同步與事件傳遞選型</a>。</p>
<h2 id="反例拆分過度的收回">反例：拆分過度的收回</h2>
<p>服務拆分的反向動作是合併。當拆分後發現「服務間呼叫太頻繁、近乎同步、跨服務事務太多」時，代表這條邊界拆錯了。處理方式是把這兩個服務合回去，繼續增加跨服務工具只會堆疊複雜度。</p>
<p>判讀「該合併」的訊號：服務 A 與 B 之間每秒幾百次同步呼叫且失敗會連鎖、A 改動必定觸發 B 改動且兩者由同一團隊維護、跨服務事務佔總業務動作比例過高、跨服務 latency 是 SLO 主要消耗者。</p>
<p>合併不是失敗。它代表團隊已經理解這條邊界不該存在，及時收回比硬撐更負責任。Modular monolith（單一部署、模組化邊界）是常見的折衷形態：保留模組邊界、避免分散事務代價、未來壓力出現時再分拆。</p>
<h2 id="判讀訊號">判讀訊號</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>判讀重點</th>
          <th>對應動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>多團隊發版互相阻擋</td>
          <td>部署邊界已形成、但服務仍綁在一起</td>
          <td>從 CI/部署單位開始拆，先讓發布獨立</td>
      </tr>
      <tr>
          <td>同一服務不同功能擴展需求差距大</td>
          <td>流量邊界已形成</td>
          <td>沿流量軸拆，高頻 endpoint 獨立服務 + 獨立 auto scaling</td>
      </tr>
      <tr>
          <td>DB 寫入鎖跨業務互相影響</td>
          <td>資料邊界已形成</td>
          <td>沿資料軸拆，獨立 schema 與獨立 DB instance</td>
      </tr>
      <tr>
          <td>拆分後跨服務同步呼叫激增</td>
          <td>邊界拆錯、實際耦合並未被服務界線解開</td>
          <td>評估合併、或改用事件驅動把同步呼叫變成非同步交接</td>
      </tr>
      <tr>
          <td>拆分後事故 MTTR 拉長</td>
          <td>跨服務觀測能力跟不上</td>
          <td>補 <a href="/blog/backend/knowledge-cards/trace-context/" data-link-title="Trace Context" data-link-desc="說明跨服務 request 如何用 trace context 串起路徑與耗時">04 trace context</a> 與 service topology</td>
      </tr>
      <tr>
          <td>拆分後 dev velocity 反而下降</td>
          <td>契約治理跟跨服務協作成本超過拆分收益</td>
          <td>評估合併或建立 shared kernel</td>
      </tr>
  </tbody>
</table>
<h2 id="常見誤區">常見誤區</h2>
<p>把「technical debt」當成拆分理由。Monolith 程式碼髒亂的解法是重構，不是拆服務。拆服務只是把髒亂從單庫變成跨服務契約混亂，問題並沒有消失。</p>
<p>把「跟風 microservice」當成決策。沒有業務壓力、團隊規模不到位、運維能力不夠的情況下拆服務，新的協作成本會壓垮整個團隊，這比 monolith 的痛苦更大。</p>
<p>把拆分當成單向操作。沒有設計回退路徑、沒有保留合併選項，拆錯了就只能硬撐。成熟的服務演進策略要把「拆」跟「合」當成雙向可逆操作。</p>
<h2 id="定位邊界">定位邊界</h2>
<p>本章專注「該不該拆、沿哪條軸拆、拆完怎麼收尾」。當問題進入具體拆分後的部署、流量、觀測責任，分別交給以下模組：</p>
<ul>
<li>服務獨立部署 → <a href="/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">05 deployment platform</a></li>
<li>跨服務交接與事件 → <a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">03 message queue</a></li>
<li>跨服務觀測與 trace → <a href="/blog/backend/04-observability/" data-link-title="模組四：可觀測性平台" data-link-desc="整理 log、metric、trace、dashboard 與 alert 的後端操作實務">04 observability</a></li>
<li>跨服務一致性與冪等性 → <a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">03 idempotency-replay</a> + outbox pattern</li>
</ul>
<h2 id="案例回寫">案例回寫</h2>
<p>服務拆分判讀可用以下案例回寫：</p>
<ul>
<li><a href="/blog/backend/09-performance-capacity/cases/netflix-aurora-consolidation/" data-link-title="9.C23 Netflix：把關聯式 DB 統一到 Aurora、效能 &#43;75%、成本 -28%" data-link-desc="Netflix 把多套關聯式 DB 統一到 Aurora、效能提升 75%、成本下降 28%、串流數十億小時">9.C23 Netflix：把關聯式 DB 統一到 Aurora、效能 +75%、成本 -28%</a> — 反例方向：原本各 microservice 各自 DB 造成運維碎片化、最後做 consolidation；對照本章「拆分過度的收回」段。</li>
<li><a href="/blog/backend/05-deployment-platform/cases/conde-nast-platform-modernization-eks/" data-link-title="5.C2 Condé Nast：EKS 平台整併與標準化" data-link-desc="多地區異質 Kubernetes 平台整併為統一控制面的案例。">5.C2 Condé Nast：EKS 平台整併與標準化</a> — Condé Nast 把多 brand 各自的 K8s cluster 整併到統一 EKS 控制面、降低跨團隊運維分歧。對照本章「拆分代價 / 運維複雜度」段：拆出去快、合回來慢、設計時就要評估這種非對稱性。</li>
<li><a href="/blog/backend/09-performance-capacity/cases/riot-games-eks-multi-cluster/" data-link-title="9.C12 Riot Games：246 個 EKS cluster 的多遊戲多地區治理" data-link-desc="Riot Games 從 Mesos 遷移到 EKS、用 246 個 cluster 跨遊戲跨地區治理、年省 1000 萬美金">9.C12 Riot Games：246 個 EKS cluster 的多遊戲多地區治理</a> — Riot 的拆分軸是「遊戲 × 地區 × 環境」三維交集、246 個 cluster 是這三軸的笛卡兒積取一個 subset。對照本章「拆分軸 / 部署邊界」段：實務上的拆分常常是多軸交集、不是單軸推進。</li>
</ul>
<p>Netflix Aurora consolidation 是反例最有教學價值的一筆 — 它證明「拆 microservice 各自 DB → consolidation 回 Aurora」是 valid endgame、拆服務不是單向操作。Condé Nast 跟 Riot Games 補充另兩條維度：碎片化的運維代價、多軸交集的設計複雜度。把這三筆放回「拆分時機判讀」框架的不同節點上、能看出拆分決策的本質是「沿哪幾條軸 + 接受哪些代價」的組合。</p>
<h2 id="跨模組路由">跨模組路由</h2>
<ol>
<li>與 <a href="/blog/backend/00-service-selection/service-capability-map/" data-link-title="0.1 後端服務能力地圖" data-link-desc="用需求類型判斷應先評估資料庫、快取、訊息佇列、觀測平台或部署平台">0.1 後端服務能力地圖</a> 的交接：拆分前要先理解每塊責任屬於哪種能力分類，避免拆出語意混亂的服務。</li>
<li>與 <a href="/blog/backend/00-service-selection/traffic-data-scale/" data-link-title="0.5 流量與資料量評估" data-link-desc="用流量形狀、資料成長、hot key、保留期限與尖峰模式評估後端需求規模">0.5 流量與資料量評估</a> 的交接：流量軸拆分要先有流量基線。</li>
<li>與 <a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">03 message queue</a> 的交接：拆分後跨服務通訊優先用事件、不是同步 RPC。</li>
<li>與 <a href="/blog/backend/09-performance-capacity/scaling-axes/" data-link-title="9.13 擴展軸與 Stateless 前提" data-link-desc="整理垂直 / 水平擴展取捨、stateless vs stateful 前提、auto scaling 操作模型與兩種擴展的 hidden cost">9.13 擴展軸</a> 的交接：拆分常常是水平擴展的前提（無狀態服務拆分後才能獨立水平擴展）。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<p><strong>規模成長路線下一站 → <a href="/blog/backend/09-performance-capacity/scaling-axes/" data-link-title="9.13 擴展軸與 Stateless 前提" data-link-desc="整理垂直 / 水平擴展取捨、stateless vs stateful 前提、auto scaling 操作模型與兩種擴展的 hidden cost">9.13 擴展軸與 Stateless 前提</a></strong>：拆分後接著要為每個服務選擇擴展軸。</p>
<p>其他延伸方向：</p>
<ul>
<li>實作層：服務如何獨立部署 → <a href="/blog/backend/05-deployment-platform/kubernetes-deployment/" data-link-title="5.2 Kubernetes 部署策略" data-link-desc="整理 deployment、probe 與 rolling update">5.2 Kubernetes 部署策略</a></li>
<li>事件層：拆分後跨服務通訊設計 → <a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">03 模組訊息佇列</a></li>
</ul>
]]></content:encoded></item><item><title>10.2 服務拆分執行 Runbook（Strangler Fig / 雙寫期 / 切流 / 回退）</title><link>https://tarrragon.github.io/blog/backend/10-system-evolution/service-decomposition-execution-runbook/</link><pubDate>Wed, 27 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/10-system-evolution/service-decomposition-execution-runbook/</guid><description>&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/10-system-evolution/service-decomposition-boundaries/" data-link-title="10.1 服務拆分與邊界判讀" data-link-desc="整理 monolith vs microservice 取捨、服務邊界判讀訊號、拆分時機與回退路徑">10.1 服務拆分與邊界判讀&lt;/a> 處理「該不該拆」、本章處理「決定拆之後實際怎麼動手」。拆服務是漸進演進的過程、一次性大爆炸（big bang）的成功率極低。&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/strangler-fig/" data-link-title="Strangler Fig Pattern" data-link-desc="服務拆分 / 系統替換的漸進演進模式、用『新舊共存 &amp;#43; 逐步遷移 &amp;#43; 最終下架』取代 big bang 重寫">Strangler Fig pattern&lt;/a> 是這層的工程基底 — 用「新功能在新服務、舊功能慢慢搬」的方式、把整個 monolith 包圍、逐步替換。&lt;/p>
&lt;h2 id="strangler-fig-pattern-的工程含義">Strangler Fig Pattern 的工程含義&lt;/h2>
&lt;p>Strangler Fig（絞殺榕）是 Martin Fowler 對漸進拆分的命名比喻：榕樹依附在宿主樹上、慢慢長大、最終取代宿主。應用到服務拆分：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>舊系統繼續運作&lt;/strong>：拆分過程中、monolith 仍是 source of truth、新服務從旁長出&lt;/li>
&lt;li>&lt;strong>流量逐步遷移&lt;/strong>：用 routing layer（API gateway、proxy、feature flag）控制哪些 request 走新服務、哪些走舊&lt;/li>
&lt;li>&lt;strong>驗證 → 擴大&lt;/strong>：每個遷移的功能先小流量驗證、確認新舊一致後再加流量比例&lt;/li>
&lt;li>&lt;strong>舊系統最終下架&lt;/strong>：當所有功能都遷出後、monolith 才被退役&lt;/li>
&lt;/ul>
&lt;p>Strangler Fig 跟 big bang 拆分的本質差異是「失敗代價可控」— 大爆炸拆分失敗就整個服務掛、Strangler 拆分失敗只影響該功能、且可即時切回 monolith。&lt;/p>
&lt;h2 id="拆分執行階段">拆分執行階段&lt;/h2>
&lt;p>把 Strangler 細化成可操作的四階段：&lt;/p>
&lt;h3 id="階段-1邊界冷凍--adapter-抽出">階段 1：邊界冷凍 + Adapter 抽出&lt;/h3>
&lt;p>動手拆之前、先在 monolith 內部把「將要拆出去」的功能用 adapter / interface 封起來。所有外部呼叫該功能都走 adapter、不直接呼叫實作。&lt;/p>
&lt;p>這層動作的責任：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>強制 dependency 清楚&lt;/strong>：哪些功能依賴它、哪些功能被它依賴、必須變成顯式 interface 而非分散在 codebase&lt;/li>
&lt;li>&lt;strong>資料邊界明示&lt;/strong>：該功能用到哪些 table / column、用 repository / DAO 封裝、不讓其他功能直接 access&lt;/li>
&lt;li>&lt;strong>變更頻率冷凍&lt;/strong>：拆分期間原則上不接受該功能的新需求、避免「拆到一半新需求又進來」&lt;/li>
&lt;/ul>
&lt;p>階段 1 在 monolith 內完成、不動部署、不動資料。完成後、拆分的「邊界」已經在 codebase 顯現、是 prerequisite。&lt;/p>
&lt;h3 id="階段-2新服務--雙寫期">階段 2：新服務 + 雙寫期&lt;/h3>
&lt;p>新服務 spin up、實作 adapter 同樣的介面。寫入路徑進入「雙寫期」：所有寫入同時寫 monolith 跟新服務、讀取仍從 monolith 取。&lt;/p>
&lt;p>雙寫期的設計關鍵：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>寫入順序&lt;/strong>：先寫 monolith 還是先寫新服務？通常先寫 monolith（保持 source of truth 一致性）、新服務寫失敗時記 error 但不影響業務&lt;/li>
&lt;li>&lt;strong>跨服務一致性&lt;/strong>：兩邊寫入用 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/outbox-pattern/" data-link-title="Outbox Pattern" data-link-desc="說明資料庫狀態變更與事件發布如何透過 outbox 維持一致">outbox pattern&lt;/a> 或 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/saga/" data-link-title="Saga" data-link-desc="處理跨服務分散事務的補償型 transaction 序列、用最終一致換 ACID atomic">saga&lt;/a> 保證最終一致、不能容忍長期不一致&lt;/li>
&lt;li>&lt;strong>資料對賬機制&lt;/strong>：每天 / 每小時跑對賬 job、找出兩邊不一致的 row、修正 + 統計差異率&lt;/li>
&lt;li>&lt;strong>雙寫期長度&lt;/strong>：通常 1-4 週、視差異率收斂速度決定。差異率穩定在 0.01% 以下、可進階段 3&lt;/li>
&lt;/ul>
&lt;p>雙寫期的失敗訊號：差異率持續高於 1%、代表資料模型對應有 gap、不該進切流階段。&lt;/p>
&lt;h3 id="階段-3切流讀路徑遷移">階段 3：切流（讀路徑遷移）&lt;/h3>
&lt;p>雙寫期穩定後、讀路徑開始從 monolith 切到新服務。切流策略選擇：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>按 user / tenant ID hash 分流&lt;/strong>：取 user_id mod 100、x% 走新服務、其餘走 monolith。漸進 ramp up（1% → 5% → 25% → 100%）&lt;/li>
&lt;li>&lt;strong>按 endpoint 分流&lt;/strong>：read endpoint A 全切、endpoint B 跟 C 還在 monolith。適合「不同 endpoint 風險不同」的場景&lt;/li>
&lt;li>&lt;strong>Dark launch&lt;/strong>：每個 request 同時打兩邊、用 monolith 結果回應、log 兩邊差異。是 shadow read、不是真實切流、但能在切流前找出 edge case&lt;/li>
&lt;/ul>
&lt;p>切流期間的觀測重點：&lt;/p></description><content:encoded><![CDATA[<p><a href="/blog/backend/10-system-evolution/service-decomposition-boundaries/" data-link-title="10.1 服務拆分與邊界判讀" data-link-desc="整理 monolith vs microservice 取捨、服務邊界判讀訊號、拆分時機與回退路徑">10.1 服務拆分與邊界判讀</a> 處理「該不該拆」、本章處理「決定拆之後實際怎麼動手」。拆服務是漸進演進的過程、一次性大爆炸（big bang）的成功率極低。<a href="/blog/backend/knowledge-cards/strangler-fig/" data-link-title="Strangler Fig Pattern" data-link-desc="服務拆分 / 系統替換的漸進演進模式、用『新舊共存 &#43; 逐步遷移 &#43; 最終下架』取代 big bang 重寫">Strangler Fig pattern</a> 是這層的工程基底 — 用「新功能在新服務、舊功能慢慢搬」的方式、把整個 monolith 包圍、逐步替換。</p>
<h2 id="strangler-fig-pattern-的工程含義">Strangler Fig Pattern 的工程含義</h2>
<p>Strangler Fig（絞殺榕）是 Martin Fowler 對漸進拆分的命名比喻：榕樹依附在宿主樹上、慢慢長大、最終取代宿主。應用到服務拆分：</p>
<ul>
<li><strong>舊系統繼續運作</strong>：拆分過程中、monolith 仍是 source of truth、新服務從旁長出</li>
<li><strong>流量逐步遷移</strong>：用 routing layer（API gateway、proxy、feature flag）控制哪些 request 走新服務、哪些走舊</li>
<li><strong>驗證 → 擴大</strong>：每個遷移的功能先小流量驗證、確認新舊一致後再加流量比例</li>
<li><strong>舊系統最終下架</strong>：當所有功能都遷出後、monolith 才被退役</li>
</ul>
<p>Strangler Fig 跟 big bang 拆分的本質差異是「失敗代價可控」— 大爆炸拆分失敗就整個服務掛、Strangler 拆分失敗只影響該功能、且可即時切回 monolith。</p>
<h2 id="拆分執行階段">拆分執行階段</h2>
<p>把 Strangler 細化成可操作的四階段：</p>
<h3 id="階段-1邊界冷凍--adapter-抽出">階段 1：邊界冷凍 + Adapter 抽出</h3>
<p>動手拆之前、先在 monolith 內部把「將要拆出去」的功能用 adapter / interface 封起來。所有外部呼叫該功能都走 adapter、不直接呼叫實作。</p>
<p>這層動作的責任：</p>
<ul>
<li><strong>強制 dependency 清楚</strong>：哪些功能依賴它、哪些功能被它依賴、必須變成顯式 interface 而非分散在 codebase</li>
<li><strong>資料邊界明示</strong>：該功能用到哪些 table / column、用 repository / DAO 封裝、不讓其他功能直接 access</li>
<li><strong>變更頻率冷凍</strong>：拆分期間原則上不接受該功能的新需求、避免「拆到一半新需求又進來」</li>
</ul>
<p>階段 1 在 monolith 內完成、不動部署、不動資料。完成後、拆分的「邊界」已經在 codebase 顯現、是 prerequisite。</p>
<h3 id="階段-2新服務--雙寫期">階段 2：新服務 + 雙寫期</h3>
<p>新服務 spin up、實作 adapter 同樣的介面。寫入路徑進入「雙寫期」：所有寫入同時寫 monolith 跟新服務、讀取仍從 monolith 取。</p>
<p>雙寫期的設計關鍵：</p>
<ul>
<li><strong>寫入順序</strong>：先寫 monolith 還是先寫新服務？通常先寫 monolith（保持 source of truth 一致性）、新服務寫失敗時記 error 但不影響業務</li>
<li><strong>跨服務一致性</strong>：兩邊寫入用 <a href="/blog/backend/knowledge-cards/outbox-pattern/" data-link-title="Outbox Pattern" data-link-desc="說明資料庫狀態變更與事件發布如何透過 outbox 維持一致">outbox pattern</a> 或 <a href="/blog/backend/knowledge-cards/saga/" data-link-title="Saga" data-link-desc="處理跨服務分散事務的補償型 transaction 序列、用最終一致換 ACID atomic">saga</a> 保證最終一致、不能容忍長期不一致</li>
<li><strong>資料對賬機制</strong>：每天 / 每小時跑對賬 job、找出兩邊不一致的 row、修正 + 統計差異率</li>
<li><strong>雙寫期長度</strong>：通常 1-4 週、視差異率收斂速度決定。差異率穩定在 0.01% 以下、可進階段 3</li>
</ul>
<p>雙寫期的失敗訊號：差異率持續高於 1%、代表資料模型對應有 gap、不該進切流階段。</p>
<h3 id="階段-3切流讀路徑遷移">階段 3：切流（讀路徑遷移）</h3>
<p>雙寫期穩定後、讀路徑開始從 monolith 切到新服務。切流策略選擇：</p>
<ul>
<li><strong>按 user / tenant ID hash 分流</strong>：取 user_id mod 100、x% 走新服務、其餘走 monolith。漸進 ramp up（1% → 5% → 25% → 100%）</li>
<li><strong>按 endpoint 分流</strong>：read endpoint A 全切、endpoint B 跟 C 還在 monolith。適合「不同 endpoint 風險不同」的場景</li>
<li><strong>Dark launch</strong>：每個 request 同時打兩邊、用 monolith 結果回應、log 兩邊差異。是 shadow read、不是真實切流、但能在切流前找出 edge case</li>
</ul>
<p>切流期間的觀測重點：</p>
<ul>
<li><strong>錯誤率對比</strong>：新服務 vs monolith 同 endpoint 的 5xx / 4xx 比例</li>
<li><strong>延遲分布對比</strong>：P50 / P95 / P99 latency</li>
<li><strong>業務指標對比</strong>：轉換率、跳出率、訂單成功率 — 確認沒有「技術指標看起來正常、業務指標掉」的隱形 regression</li>
</ul>
<p>任一指標惡化、切回 monolith、不繼續推進。</p>
<h3 id="階段-4寫路徑遷移--monolith-退役">階段 4：寫路徑遷移 + Monolith 退役</h3>
<p>讀路徑 100% 切完、且穩定觀察一段時間後（建議至少 2 週）、寫路徑才從「雙寫」變成「只寫新服務」。</p>
<p>寫路徑切換的步驟：</p>
<ol>
<li><strong>雙寫變成「新服務 + 異步 backfill 到 monolith」</strong>：以新服務為主、monolith 變成 standby</li>
<li><strong>觀察期 1-2 週</strong>：確認新服務寫入路徑穩定、無資料遺失或不一致</li>
<li><strong>停止 backfill</strong>：monolith 不再被寫入、變成 read-only</li>
<li><strong>Monolith 該功能下架</strong>：等確認所有 dependency 都已遷移後（通常還要再 1-4 週觀察）、刪掉 monolith 對應 code 跟 table</li>
</ol>
<p>階段 4 是 point of no return — 過了寫路徑切換、回 monolith 的成本變得很高（要把新服務累積的寫入 backfill 回去）。這個 checkpoint 必須有明確的 go/no-go 決策、不是「順勢推進」。</p>
<h2 id="回退路徑設計">回退路徑設計</h2>
<p>回退條件必須在拆分啟動前就定義、不是事故時臨時決策。常見回退路徑：</p>
<table>
  <thead>
      <tr>
          <th>階段</th>
          <th>失敗訊號</th>
          <th>回退動作</th>
          <th>成本</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1</td>
          <td>Adapter 抽出後 monolith 變慢 / 出錯</td>
          <td>revert PR、重新規劃 adapter 邊界</td>
          <td>低</td>
      </tr>
      <tr>
          <td>2</td>
          <td>雙寫期差異率 &gt; 1% 持續</td>
          <td>停雙寫、回 monolith 單寫、修資料模型對應</td>
          <td>中</td>
      </tr>
      <tr>
          <td>3</td>
          <td>切流期間錯誤率 / 延遲 / 業務指標惡化</td>
          <td>切流比例調回 0%、回 monolith 單讀、雙寫繼續</td>
          <td>中</td>
      </tr>
      <tr>
          <td>4</td>
          <td>寫路徑切換後 1 週內出資料遺失</td>
          <td>觸發 backfill from 新服務 → monolith、切回雙寫期</td>
          <td>高</td>
      </tr>
      <tr>
          <td>4+</td>
          <td>Monolith 已下架、新服務出事</td>
          <td>災難級別、需要從備份重建 + 大規模事件公告</td>
          <td>極高</td>
      </tr>
  </tbody>
</table>
<p>階段 4 之後的回退代價是指數成長的。設計時要把 monolith 下架時點延後到「確信不需要回退」、寧可多保留 monolith 1-2 個月。</p>
<h2 id="拆分執行的判讀訊號">拆分執行的判讀訊號</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>判讀重點</th>
          <th>對應動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Adapter 抽出時發現難以封裝（dependency 散落各處）</td>
          <td>邊界其實沒形成、拆分判斷錯了</td>
          <td>回 10.1 重新評估、考慮先重構 monolith 再拆</td>
      </tr>
      <tr>
          <td>雙寫期差異率不收斂</td>
          <td>資料模型對應有 gap、或業務邏輯有 monolith 隱式依賴</td>
          <td>暫停拆分、做 data audit、找出隱式依賴點</td>
      </tr>
      <tr>
          <td>切流比例增加後業務指標掉</td>
          <td>技術等價但業務行為不等價（例如 latency 微升影響轉換）</td>
          <td>切回 monolith、檢查 latency / 業務指標關聯</td>
      </tr>
      <tr>
          <td>階段 4 出現「monolith 還有人在用」</td>
          <td>dependency 沒清乾淨、有隱藏的呼叫者</td>
          <td>延後 monolith 下架、用 access log audit 找出殘留呼叫者</td>
      </tr>
      <tr>
          <td>拆分過程中 dev velocity 大幅下降</td>
          <td>拆分成本超過短期收益、可能拆錯時機</td>
          <td>評估暫停拆分、回到 modular monolith</td>
      </tr>
  </tbody>
</table>
<h2 id="常見誤區">常見誤區</h2>
<p>把拆分當成「直接把功能搬出去」、跳過階段 1 adapter 抽出。沒有 adapter 抽出、新服務跟 monolith 的 dependency 邊界不清楚、雙寫期會出現難以排查的隱式依賴問題。</p>
<p>把雙寫期當成「過渡而已、隨便寫」。雙寫期是拆分的 source of truth verification 階段、差異率沒收斂前不能進切流。隨便寫的結果是切流後出資料一致性事故。</p>
<p>把「monolith 下架」當成拆分成功訊號。Monolith 下架太早是常見事故來源 — 即使流量 100% 切完、可能仍有 batch job / report / 內部 tool 在用 monolith。下架前先用 access log audit 確認真實流量為 0。</p>
<h2 id="定位邊界">定位邊界</h2>
<p>本章專注「Strangler Fig 漸進拆分的執行流程」。當問題進入「該不該拆」的判讀、回 <a href="/blog/backend/10-system-evolution/service-decomposition-boundaries/" data-link-title="10.1 服務拆分與邊界判讀" data-link-desc="整理 monolith vs microservice 取捨、服務邊界判讀訊號、拆分時機與回退路徑">10.1 服務拆分與邊界判讀</a>；進入跨服務通訊設計（同步 vs 異步、event-driven）、進 <a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">03 message queue</a>；進入部署層的切流機制（feature flag、canary、blue/green）、進 <a href="/blog/backend/05-deployment-platform/deployment-rollout-drain-rollback/" data-link-title="5.8 Deployment Rollout with Drain and Rollback（實作示範）" data-link-desc="以 checkout service 示範部署切換如何交付 canary evidence、drain signal、release gate 與 incident decision log。">5.8 deployment rollout</a>；進入資料庫遷移層的具體技術（dual write、shadow read、cutover），進 <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>09 / 05 案例庫中、Strangler 拆分案例不算多（多數案例是已拆完的狀態描述、而非拆分過程紀錄）。可用以下案例反向追問：</p>
<ul>
<li><a href="/blog/backend/09-performance-capacity/cases/netflix-aurora-consolidation/" data-link-title="9.C23 Netflix：把關聯式 DB 統一到 Aurora、效能 &#43;75%、成本 -28%" data-link-desc="Netflix 把多套關聯式 DB 統一到 Aurora、效能提升 75%、成本下降 28%、串流數十億小時">9.C23 Netflix Aurora consolidation</a> — Netflix 的故事是「拆完合回去」、隱含 strangler 反向。對照本章可問：合併過程是否也走了類似四階段、只是方向相反（雙寫期把多 DB 合到 Aurora、再切讀路徑、最後下架原 DB）？</li>
<li><a href="/blog/backend/05-deployment-platform/cases/conde-nast-platform-modernization-eks/" data-link-title="5.C2 Condé Nast：EKS 平台整併與標準化" data-link-desc="多地區異質 Kubernetes 平台整併為統一控制面的案例。">5.C2 Condé Nast：EKS 平台整併</a> — 平台層整併。本章在「服務層」、整併在「平台層」、邏輯類似但 surface 不同。</li>
</ul>
<h2 id="跨模組路由">跨模組路由</h2>
<ol>
<li>與 <a href="/blog/backend/10-system-evolution/service-decomposition-boundaries/" data-link-title="10.1 服務拆分與邊界判讀" data-link-desc="整理 monolith vs microservice 取捨、服務邊界判讀訊號、拆分時機與回退路徑">10.1 服務拆分判讀</a> 的交接：10.1 給「該拆」的判讀、本章給「怎麼拆」的執行。</li>
<li>與 <a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">03 message queue + outbox</a> 的交接：雙寫期跟拆分後跨服務通訊都依賴 outbox / saga 保證一致性。</li>
<li>與 <a href="/blog/backend/05-deployment-platform/deployment-rollout-drain-rollback/" data-link-title="5.8 Deployment Rollout with Drain and Rollback（實作示範）" data-link-desc="以 checkout service 示範部署切換如何交付 canary evidence、drain signal、release gate 與 incident decision log。">5.8 deployment rollout</a> 的交接：階段 3 切流的技術機制（feature flag、canary）跟部署層的 rollout 同源。</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 資料庫轉換實作</a> 的交接：階段 2 雙寫期跟資料庫遷移的雙寫期是同一套機制、只是 surface 不同。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<p>要看拆分判讀（該不該拆）、回 <a href="/blog/backend/10-system-evolution/service-decomposition-boundaries/" data-link-title="10.1 服務拆分與邊界判讀" data-link-desc="整理 monolith vs microservice 取捨、服務邊界判讀訊號、拆分時機與回退路徑">10.1 服務拆分與邊界判讀</a>。要看拆分後跨服務通訊設計、進 <a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">03 模組訊息佇列</a>。要看部署層的切流技術細節、進 <a href="/blog/backend/05-deployment-platform/deployment-rollout-drain-rollback/" data-link-title="5.8 Deployment Rollout with Drain and Rollback（實作示範）" data-link-desc="以 checkout service 示範部署切換如何交付 canary evidence、drain signal、release gate 與 incident decision log。">5.8 Deployment Rollout</a>。</p>
]]></content:encoded></item><item><title>10.3 託管形態遷出：資產線盤點與並行期執行</title><link>https://tarrragon.github.io/blog/backend/10-system-evolution/managed-platform-exit/</link><pubDate>Thu, 11 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/10-system-evolution/managed-platform-exit/</guid><description>&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/00-service-selection/delivery-mode-selection/" data-link-title="0.21 交付形態選型：從全託管到自建的光譜與邊界" data-link-desc="在進入資料庫、快取與部署選型之前、先判斷服務該用託管平台（Wix / Shopify / Google Sites）、辦公生態自動化（Apps Script）、BaaS（Firebase）、半託管 CMS（WordPress）還是自建、並為日後遷往自建保留可遷出路徑">0.21 交付形態選型&lt;/a> 的升級自建 tripwire 回答「何時該重新評估」、評估成立後、本章接手回答「按下遷出鍵之後的工程」。讀者情境：產品跑在 Wix / Shopify / Firebase / WordPress 這類託管形態上、tripwire 已命中、目標是自建或半託管。遷出的核心原則是把「搬家」拆成多條資產線各自的受控 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/migration/" data-link-title="Migration" data-link-desc="說明系統如何把資料、流量或結構從舊狀態移到新狀態">migration&lt;/a>：資料、身分、流量、整合的可攜性差異極大、斷點位置不同、可以分開 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/cutover-window/" data-link-title="Cutover Window" data-link-desc="說明正式切換發生的觀察窗口、停止條件與回退判讀範圍">cutover&lt;/a> — 把它們綁成同一天的大爆炸切換（big bang cutover）、等於把可攜性最差的那條線的風險強加給其他所有線。&lt;/p>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/vendor-lock-in/" data-link-title="Vendor Lock-In" data-link-desc="說明採用供應商產品後，其 API 與格式滲入程式碼造成的退出成本">Vendor lock-in&lt;/a> 在遷出日的具體形狀就是這幾條資產線的斷點。0.21 的可遷出保險清單（自有網域、資料定期匯出、客戶聯絡管道自有、金流可攜性、密碼不可攜的預案、業務邏輯文件化）是進場時買的保險、本章是理賠流程 — 保險有買、每條線的斷點都有現成出口；保險沒買、本章每一節都會多一段「先補保險再動手」的前置工。&lt;/p>
&lt;h2 id="資產線盤點">資產線盤點&lt;/h2>
&lt;p>動手前先盤點：這個產品在平台上累積了哪些資產、每項資產走哪條線、可攜性如何。盤點的產出是一張「資產 → 線 → 出口 → 斷點」對照、它決定後面所有階段的順序與凍結窗口長度。&lt;/p>
&lt;h3 id="資料線">資料線&lt;/h3>
&lt;p>資料線問兩個問題：拿得出來嗎、拿出來之後能直接用嗎。多數平台對第一個問題的答案是肯定的 — Shopify 的商品與訂單歷史有官方 CSV / API 匯出、WordPress 的文章與媒體是最成熟的匯出路徑、Firebase 的 Firestore 有官方 export。真正的工程量在第二個問題：匯出格式是平台資料模型的快照、不是自建系統的 schema。&lt;/p>
&lt;p>兩個典型情境。第一、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/baas/" data-link-title="BaaS（Backend as a Service）" data-link-desc="說明把認證、資料庫、檔案儲存、推播打包成現成模組、由前端 SDK 直連的後端交付形態">BaaS&lt;/a> 的反正規化結構：Firestore 的文件沿查詢需求生長、同一份事實散在多個 collection、而目標端的關聯式 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/database/" data-link-title="Database" data-link-desc="說明 database 在後端系統中如何承擔正式狀態、查詢與一致性責任">database&lt;/a> 要求單一事實單一位置 — 執行順序是先設計目標 schema、再寫轉換管線、而不是把 export 原樣灌進去。第二、半託管 CMS 的外掛私有表：WordPress 官方匯出涵蓋文章與媒體、外掛各自的私有表（會員等級、預約規則、客製欄位）在匯出範圍之外 — 每個外掛要單獨確認資料位置與匯出手段。盤點階段把這兩類「拿得出來但不能直接用」的資產標出來、它們是資料線時程的主要變數。&lt;/p>
&lt;p>歷史資料搬完之後、增量是另一個問題：平台在並行期仍持續產生新訂單、新會員、新內容、需要一條增量同步管道（webhook、API 輪詢、排程匯出）把變更餵進新系統 — 角色等同自建世界的 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/change-data-capture/" data-link-title="Change Data Capture" data-link-desc="說明資料變更如何被捕捉並傳送到其他系統">change data capture&lt;/a>、只是來源是平台 API 而不是資料庫 log。&lt;/p>
&lt;p>資料線還有一類「可放棄、但要快照」的資產：平台內建報表與分析歷史。這類數據多數沒有匯出路徑、平台降級或關站後即消失 — 歷史明細可以放棄、但 cutover 後的健康判讀需要遷移前的基線（自然流量、轉換率、客單價）。盤點階段把基線指標匯出存檔、觀察期的「下滑超過預估」才有對照對象。&lt;/p>
&lt;h3 id="身分線">身分線&lt;/h3>
&lt;p>身分線的可攜性在所有資產線中分布最極端。會員的 email 與基本資料幾乎都可匯出；密碼雜湊多數平台拒絕交出 — Firebase Auth 是少數友善案例、官方工具可匯出密碼雜湊、演算法參數從主控台另行取得、自建認證系統照參數驗證即可無感銜接。多數平台（電商會員、網站會員系統）把雜湊留在自己手上、這條線的執行形態於是變成全體重設密碼。&lt;/p>
&lt;p>重設密碼遷移要當成產品功能設計、而不是遷移日的告示：分批寄送重設邀請、首次登入時引導重設、保留舊 email 驗證鏈路、把重設高峰排開行銷活動。0.21 可遷出保險裡「密碼不可攜的預案」指的就是這套體驗、執行階段它從預案變成排程上的工作項。&lt;/p>
&lt;p>Session 綁定在平台端、cutover 當天全體使用者重新登入是預設行為、要納入切換日的客服與監控預期。第三方登入（Google / Apple 登入）的識別碼可攜性介於兩者之間：識別碼存在 provider 端、但可能綁定在 OAuth client 或開發者帳號的範圍上 — Apple 的 user identifier 以開發者團隊為界、換團隊後同一使用者拿到不同識別碼。遷移前先用測試帳號驗證新舊系統拿到的識別碼一致、再決定第三方登入使用者要走無感銜接還是重新綁定。&lt;/p>
&lt;p>身分線的盤點對象除了終端使用者、還有操作者與機器：員工帳號、角色權限、API key 與第三方服務的 OAuth 授權都要在新系統重新佈建、並納入 cutover 演練 — 切換日客服登不進新後台、是這條線最常見的自傷事故。&lt;/p>
&lt;h3 id="流量線">流量線&lt;/h3>
&lt;p>流量線的前提是自有網域 — 0.21 可遷出保險清單裡的保險項。網域在自己名下、DNS 自己控制、流量切換就是一次 DNS 變更加一套轉址規則；流量活在平台贈送的子網域上、遷出等於換址、SEO 與既有連結歸零、這條線要先補保險（買網域、在平台上綁定、讓搜尋引擎與外部連結先收斂到自有網域）再談切換。&lt;/p>
&lt;p>執行面的關鍵是斷裂面管理。平台的 URL 結構（&lt;code>/products/handle&lt;/code>、&lt;code>/blogs/news/slug&lt;/code>）跟自建系統的路由幾乎必然不同、而離開平台後、舊 URL 的轉址規則沒有地方住 — 平台停用後它連 404 都不會回、是 DNS 直接指向新系統。所以轉址表（舊 URL 樣式 → 新 URL）要建在新系統自己身上：cutover 後由新系統對舊樣式回 301、搜尋引擎與外部連結沿轉址收斂。配套動作：cutover 前把 DNS TTL 調低、cutover 後重交 sitemap、用搜尋主控台觀察索引替換進度。SEO 累積是按月計的資產、轉址表缺漏的代價以自然流量下滑直接體現。&lt;/p>
&lt;h3 id="整合線">整合線&lt;/h3>
&lt;p>整合線收所有由平台出面跟外部世界握手的合約、其中金流是最硬的斷點 — 它在本章盤點順序排最後、執行確認要排最早、答案會改變整場遷移的形狀。一次性收款的遷移成本低 — 換金流串接、新訂單走新管道。訂閱制是另一回事：扣款授權 token 存在金流商的 vault 裡、且常綁定在平台名下的金流帳戶上。遷出時先問金流商「授權能否轉移到商家自有的金流帳戶」— 部分金流商支援處理商之間的卡號資料轉移、談得下來就是一次後台作業；談不下來、全體訂閱者重新授權、流失率直接換算成訂閱營收缺口。執行手段跟重設密碼同構：分批通知、寬限期、必要時用優惠補償授權摩擦。&lt;/p>
&lt;p>金流之外、整合線還包括：平台外掛 / app 生態承擔的業務邏輯（Shopify app 做的折扣規則、WordPress 外掛做的預約流程）要逐個盤點、決定重寫進自建系統、換成獨立 SaaS、或趁機放棄；對外 webhook（ERP、出貨、會計系統）的端點切換要跟對方排時程；行銷 email 的寄送信譽綁在平台的寄件網域上、換到自有寄件網域要重建 SPF / DKIM 並逐步暖機、避免遷移週的通知信全進垃圾箱。&lt;/p></description><content:encoded><![CDATA[<p><a href="/blog/backend/00-service-selection/delivery-mode-selection/" data-link-title="0.21 交付形態選型：從全託管到自建的光譜與邊界" data-link-desc="在進入資料庫、快取與部署選型之前、先判斷服務該用託管平台（Wix / Shopify / Google Sites）、辦公生態自動化（Apps Script）、BaaS（Firebase）、半託管 CMS（WordPress）還是自建、並為日後遷往自建保留可遷出路徑">0.21 交付形態選型</a> 的升級自建 tripwire 回答「何時該重新評估」、評估成立後、本章接手回答「按下遷出鍵之後的工程」。讀者情境：產品跑在 Wix / Shopify / Firebase / WordPress 這類託管形態上、tripwire 已命中、目標是自建或半託管。遷出的核心原則是把「搬家」拆成多條資產線各自的受控 <a href="/blog/backend/knowledge-cards/migration/" data-link-title="Migration" data-link-desc="說明系統如何把資料、流量或結構從舊狀態移到新狀態">migration</a>：資料、身分、流量、整合的可攜性差異極大、斷點位置不同、可以分開 <a href="/blog/backend/knowledge-cards/cutover-window/" data-link-title="Cutover Window" data-link-desc="說明正式切換發生的觀察窗口、停止條件與回退判讀範圍">cutover</a> — 把它們綁成同一天的大爆炸切換（big bang cutover）、等於把可攜性最差的那條線的風險強加給其他所有線。</p>
<p><a href="/blog/backend/knowledge-cards/vendor-lock-in/" data-link-title="Vendor Lock-In" data-link-desc="說明採用供應商產品後，其 API 與格式滲入程式碼造成的退出成本">Vendor lock-in</a> 在遷出日的具體形狀就是這幾條資產線的斷點。0.21 的可遷出保險清單（自有網域、資料定期匯出、客戶聯絡管道自有、金流可攜性、密碼不可攜的預案、業務邏輯文件化）是進場時買的保險、本章是理賠流程 — 保險有買、每條線的斷點都有現成出口；保險沒買、本章每一節都會多一段「先補保險再動手」的前置工。</p>
<h2 id="資產線盤點">資產線盤點</h2>
<p>動手前先盤點：這個產品在平台上累積了哪些資產、每項資產走哪條線、可攜性如何。盤點的產出是一張「資產 → 線 → 出口 → 斷點」對照、它決定後面所有階段的順序與凍結窗口長度。</p>
<h3 id="資料線">資料線</h3>
<p>資料線問兩個問題：拿得出來嗎、拿出來之後能直接用嗎。多數平台對第一個問題的答案是肯定的 — Shopify 的商品與訂單歷史有官方 CSV / API 匯出、WordPress 的文章與媒體是最成熟的匯出路徑、Firebase 的 Firestore 有官方 export。真正的工程量在第二個問題：匯出格式是平台資料模型的快照、不是自建系統的 schema。</p>
<p>兩個典型情境。第一、<a href="/blog/backend/knowledge-cards/baas/" data-link-title="BaaS（Backend as a Service）" data-link-desc="說明把認證、資料庫、檔案儲存、推播打包成現成模組、由前端 SDK 直連的後端交付形態">BaaS</a> 的反正規化結構：Firestore 的文件沿查詢需求生長、同一份事實散在多個 collection、而目標端的關聯式 <a href="/blog/backend/knowledge-cards/database/" data-link-title="Database" data-link-desc="說明 database 在後端系統中如何承擔正式狀態、查詢與一致性責任">database</a> 要求單一事實單一位置 — 執行順序是先設計目標 schema、再寫轉換管線、而不是把 export 原樣灌進去。第二、半託管 CMS 的外掛私有表：WordPress 官方匯出涵蓋文章與媒體、外掛各自的私有表（會員等級、預約規則、客製欄位）在匯出範圍之外 — 每個外掛要單獨確認資料位置與匯出手段。盤點階段把這兩類「拿得出來但不能直接用」的資產標出來、它們是資料線時程的主要變數。</p>
<p>歷史資料搬完之後、增量是另一個問題：平台在並行期仍持續產生新訂單、新會員、新內容、需要一條增量同步管道（webhook、API 輪詢、排程匯出）把變更餵進新系統 — 角色等同自建世界的 <a href="/blog/backend/knowledge-cards/change-data-capture/" data-link-title="Change Data Capture" data-link-desc="說明資料變更如何被捕捉並傳送到其他系統">change data capture</a>、只是來源是平台 API 而不是資料庫 log。</p>
<p>資料線還有一類「可放棄、但要快照」的資產：平台內建報表與分析歷史。這類數據多數沒有匯出路徑、平台降級或關站後即消失 — 歷史明細可以放棄、但 cutover 後的健康判讀需要遷移前的基線（自然流量、轉換率、客單價）。盤點階段把基線指標匯出存檔、觀察期的「下滑超過預估」才有對照對象。</p>
<h3 id="身分線">身分線</h3>
<p>身分線的可攜性在所有資產線中分布最極端。會員的 email 與基本資料幾乎都可匯出；密碼雜湊多數平台拒絕交出 — Firebase Auth 是少數友善案例、官方工具可匯出密碼雜湊、演算法參數從主控台另行取得、自建認證系統照參數驗證即可無感銜接。多數平台（電商會員、網站會員系統）把雜湊留在自己手上、這條線的執行形態於是變成全體重設密碼。</p>
<p>重設密碼遷移要當成產品功能設計、而不是遷移日的告示：分批寄送重設邀請、首次登入時引導重設、保留舊 email 驗證鏈路、把重設高峰排開行銷活動。0.21 可遷出保險裡「密碼不可攜的預案」指的就是這套體驗、執行階段它從預案變成排程上的工作項。</p>
<p>Session 綁定在平台端、cutover 當天全體使用者重新登入是預設行為、要納入切換日的客服與監控預期。第三方登入（Google / Apple 登入）的識別碼可攜性介於兩者之間：識別碼存在 provider 端、但可能綁定在 OAuth client 或開發者帳號的範圍上 — Apple 的 user identifier 以開發者團隊為界、換團隊後同一使用者拿到不同識別碼。遷移前先用測試帳號驗證新舊系統拿到的識別碼一致、再決定第三方登入使用者要走無感銜接還是重新綁定。</p>
<p>身分線的盤點對象除了終端使用者、還有操作者與機器：員工帳號、角色權限、API key 與第三方服務的 OAuth 授權都要在新系統重新佈建、並納入 cutover 演練 — 切換日客服登不進新後台、是這條線最常見的自傷事故。</p>
<h3 id="流量線">流量線</h3>
<p>流量線的前提是自有網域 — 0.21 可遷出保險清單裡的保險項。網域在自己名下、DNS 自己控制、流量切換就是一次 DNS 變更加一套轉址規則；流量活在平台贈送的子網域上、遷出等於換址、SEO 與既有連結歸零、這條線要先補保險（買網域、在平台上綁定、讓搜尋引擎與外部連結先收斂到自有網域）再談切換。</p>
<p>執行面的關鍵是斷裂面管理。平台的 URL 結構（<code>/products/handle</code>、<code>/blogs/news/slug</code>）跟自建系統的路由幾乎必然不同、而離開平台後、舊 URL 的轉址規則沒有地方住 — 平台停用後它連 404 都不會回、是 DNS 直接指向新系統。所以轉址表（舊 URL 樣式 → 新 URL）要建在新系統自己身上：cutover 後由新系統對舊樣式回 301、搜尋引擎與外部連結沿轉址收斂。配套動作：cutover 前把 DNS TTL 調低、cutover 後重交 sitemap、用搜尋主控台觀察索引替換進度。SEO 累積是按月計的資產、轉址表缺漏的代價以自然流量下滑直接體現。</p>
<h3 id="整合線">整合線</h3>
<p>整合線收所有由平台出面跟外部世界握手的合約、其中金流是最硬的斷點 — 它在本章盤點順序排最後、執行確認要排最早、答案會改變整場遷移的形狀。一次性收款的遷移成本低 — 換金流串接、新訂單走新管道。訂閱制是另一回事：扣款授權 token 存在金流商的 vault 裡、且常綁定在平台名下的金流帳戶上。遷出時先問金流商「授權能否轉移到商家自有的金流帳戶」— 部分金流商支援處理商之間的卡號資料轉移、談得下來就是一次後台作業；談不下來、全體訂閱者重新授權、流失率直接換算成訂閱營收缺口。執行手段跟重設密碼同構：分批通知、寬限期、必要時用優惠補償授權摩擦。</p>
<p>金流之外、整合線還包括：平台外掛 / app 生態承擔的業務邏輯（Shopify app 做的折扣規則、WordPress 外掛做的預約流程）要逐個盤點、決定重寫進自建系統、換成獨立 SaaS、或趁機放棄；對外 webhook（ERP、出貨、會計系統）的端點切換要跟對方排時程；行銷 email 的寄送信譽綁在平台的寄件網域上、換到自有寄件網域要重建 SPF / DKIM 並逐步暖機、避免遷移週的通知信全進垃圾箱。</p>
<h2 id="並行期設計">並行期設計</h2>
<p><a href="/blog/backend/knowledge-cards/parallel-run/" data-link-title="並行期（Parallel Run）" data-link-desc="說明舊系統維持 source of truth、新系統以單向同步加唯讀驗證運轉的遷移共存階段、與雙寫的寫入路徑控制權差異">並行期</a>是舊平台與新系統共存、用真實資料驗證新系統的階段 — 前提是目標系統已依 <a href="/blog/backend/00-service-selection/" data-link-title="模組零：後端服務選型" data-link-desc="從需求類型判斷資料庫、快取、訊息佇列、觀測與部署平台的選型方向">模組零的選型順序</a> 建置完成、本章不重複選型推導。它跟 <a href="/blog/backend/10-system-evolution/service-decomposition-execution-runbook/" data-link-title="10.2 服務拆分執行 Runbook（Strangler Fig / 雙寫期 / 切流 / 回退）" data-link-desc="10.1 決定該拆之後、實際怎麼動手拆 — Strangler Fig pattern、雙寫期管理、切流策略、回退條件設計">10.2 服務拆分執行 Runbook</a> 的雙寫期同源但形狀不同：服務拆分時、寫入路徑在自己的程式碼裡、可以實作 <a href="/blog/backend/knowledge-cards/dual-write/" data-link-title="Dual Write" data-link-desc="說明同一變更同時寫入兩個系統時的一致性風險">dual write</a>；託管平台的寫入發生在平台內部 — 顧客在 Shopify 結帳、會員在平台註冊 — 自建程式碼插不進那條寫入路徑。所以並行期的形態是「平台維持 <a href="/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">source of truth</a>、單向同步、新系統唯讀驗證」：</p>
<ol>
<li>增量同步管道（webhook / API 輪詢 / 排程匯出）持續把平台變更餵進新系統</li>
<li>新系統以唯讀 replica 的角色運轉、對帳 job 定期比對兩邊的訂單數、會員數、金額總和</li>
<li>內部使用者先在新系統上工作（報表、後台查詢）、用真實業務流量驗證資料轉換的正確性</li>
<li>差異率收斂並穩定後、才排 cutover 日</li>
</ol>
<p>Cutover 本身是一段 <a href="/blog/backend/knowledge-cards/cutover-window/" data-link-title="Cutover Window" data-link-desc="說明正式切換發生的觀察窗口、停止條件與回退判讀範圍">cutover window</a>、不是一個按鈕：選低流量時段、短暫凍結平台側變更（電商常用「暫停結帳維護頁」幾十分鐘）、跑最後一輪增量同步、切 DNS、然後密集觀察訂單成功率、登入成功率、金流授權成功率 — 觀察清單來自資產線盤點、每條線各有自己的健康訊號。</p>
<p>回切窗口的設計決定這場遷移的失敗代價。cutover 後保留舊平台訂閱與設定、回切動作是 DNS 切回；代價是新系統在窗口內產生的交易要補回平台 — 平台側通常沒有批次匯入訂單的好路徑、補回多半是手動作業、所以回切窗口內要刻意壓低不可逆變更的累積速度（例如窗口前 48 小時內暫停大型行銷活動）。這跟 10.2 寫路徑切換的 point of no return 是同一個判讀：回退成本隨時間墊高、go/no-go 要當成有明確時點的決策執行、判定條件在進入窗口前排定。</p>
<p>關舊站走降級、而不是直接刪除。觀察期過後、平台帳號先降到最低方案、店面關閉但後台保留 — 退款處理、客服查歷史訂單、會計與稅務稽核都還會用到平台側資料。刪除帳號前的檢查條件：所有歷史資料已完整落地自有儲存並驗證可讀、法規要求的交易紀錄保存年限已由自有系統接手、最後一筆平台側退款 / 爭議單已結案。</p>
<h2 id="部分遷出是常見的中繼形態">部分遷出是常見的中繼形態</h2>
<p>資產線可以獨立 cutover 的另一面、是遷出可以分期：先撤其中幾條線、其餘留在平台。部分遷出是把遷移風險拆期攤還的標準形態、結構上同 <a href="/blog/backend/knowledge-cards/strangler-fig/" data-link-title="Strangler Fig Pattern" data-link-desc="服務拆分 / 系統替換的漸進演進模式、用『新舊共存 &#43; 逐步遷移 &#43; 最終下架』取代 big bang 重寫">Strangler Fig</a>：新系統從旁長出、逐線取代、平台最後才退役。</p>
<p>常見的中繼形態有四種。資料層先撤：增量同步管道建好之後、自有資料庫先成為報表與分析的 source、前台與結帳留在平台 — 0.21 BaaS 段描述的跨集合報表困境、在這個形態下已經解掉、而最高風險的金流與流量線還沒動。前台先撤（headless）：自建前端體驗層、平台降級為後端引擎（結帳 API、內容 API）— 流量線與 SEO 控制權先回手、金流與資料留在平台的成熟路徑上。身分後撤：認證是使用者感知最強的線、Firebase Auth 這類可攜性好的元件常被留到最後 — 資料與流量都搬完、產品穩定後、再做密碼雜湊匯入或重設遷移。金流後撤（或長期留平台）：訂閱授權轉移談不下來時、資料、前台與流量都遷出、訂閱扣款續走平台帳戶 — 它跟前三種不同、可能由中繼轉成長期形態、去留判讀回整合線的金流斷點確認。</p>
<p>中繼形態的判讀標準是「每個階段結束時、撤出的那條線已經完整脫離平台、由新系統持有唯一事實」。模糊狀態（一半訂單在平台、一半在自建、靠人腦記得哪邊查）是部分遷出最常見的事故源 — 每條線在任一時刻都要有唯一的 source of truth。</p>
<h2 id="判讀訊號">判讀訊號</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>判讀重點</th>
          <th>對應動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>盤點時發現業務邏輯只存在平台 UI 設定裡</td>
          <td>0.21 可遷出保險「業務邏輯文件化」缺項</td>
          <td>先文件化再動手、規則重建期計入時程</td>
      </tr>
      <tr>
          <td>並行期對帳差異率不收斂</td>
          <td>資料轉換有 gap、或增量同步管道漏事件</td>
          <td>暫停 cutover 排程、audit 轉換管線與 webhook</td>
      </tr>
      <tr>
          <td>金流商拒絕授權轉移</td>
          <td>訂閱線變成全體重新授權、流失進入營收預估</td>
          <td>重算遷移 ROI、評估訂閱線單獨延後</td>
      </tr>
      <tr>
          <td>Cutover 後自然流量持續下滑超過觀察期預估</td>
          <td>轉址表缺漏、或索引替換異常</td>
          <td>比對搜尋主控台的 404 清單、補轉址規則</td>
      </tr>
      <tr>
          <td>回切窗口內手動補單量超出客服消化能力</td>
          <td>不可逆變更累積速度超過回切設計</td>
          <td>縮短決策週期、提前 go/no-go 判定</td>
      </tr>
      <tr>
          <td>並行期超過原定窗口仍未排 cutover</td>
          <td>並行不是穩態、雙系統維運與平台月費在吃遷移 ROI</td>
          <td>重訂 cutover 條件、或承認部分遷出為長期形態</td>
      </tr>
      <tr>
          <td>新舊系統各管一部分同類資料超過一個階段</td>
          <td>部分遷出停在模糊狀態、source of truth 分裂</td>
          <td>強制收斂該資產線、明確指定唯一 source of truth</td>
      </tr>
  </tbody>
</table>
<p>業務邏輯那一列值得展開：平台設定裡長出來的折扣邏輯、會員等級、運費規則、是盤點階段最容易漏的資產 — 它們沒有檔案形態、不會出現在任何匯出工具裡。0.21 可遷出保險清單把「業務邏輯文件化」列為進場保險、沒買這項保險的遷移、第一個階段是考古：對著平台後台逐頁截圖、把規則寫成文件、再評估哪些重寫、哪些放棄。</p>
<p>金流那一列是整場遷移裡少數「工程努力無法繞過」的斷點 — 授權轉移的決定權在金流商與平台的合約上、不在工程團隊手上。所以它在盤點階段就要最先確認：答案直接改變遷移的營收影響模型、甚至可能讓「訂閱線留在平台、其餘遷出」成為長期形態。</p>
<h2 id="定位邊界">定位邊界</h2>
<p>本章專注「託管形態 → 自建 / 半託管」的遷出執行。當問題回到「該不該遷、何時該重新評估」、回 <a href="/blog/backend/00-service-selection/delivery-mode-selection/" data-link-title="0.21 交付形態選型：從全託管到自建的光譜與邊界" data-link-desc="在進入資料庫、快取與部署選型之前、先判斷服務該用託管平台（Wix / Shopify / Google Sites）、辦公生態自動化（Apps Script）、BaaS（Firebase）、半託管 CMS（WordPress）還是自建、並為日後遷往自建保留可遷出路徑">0.21 交付形態選型</a> 的升級自建 tripwire 表；遷移目標的自建選型（資料庫、部署、金流接法）走 <a href="/blog/backend/00-service-selection/" data-link-title="模組零：後端服務選型" data-link-desc="從需求類型判斷資料庫、快取、訊息佇列、觀測與部署平台的選型方向">模組零的選型順序</a>；自建系統之間的資料庫搬遷技術細節（雙寫、shadow read、切換）進 <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>；服務層的漸進替換紀律進 <a href="/blog/backend/10-system-evolution/service-decomposition-execution-runbook/" data-link-title="10.2 服務拆分執行 Runbook（Strangler Fig / 雙寫期 / 切流 / 回退）" data-link-desc="10.1 決定該拆之後、實際怎麼動手拆 — Strangler Fig pattern、雙寫期管理、切流策略、回退條件設計">10.2 服務拆分執行 Runbook</a>。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>要回頭確認遷移時機與保險、見 <a href="/blog/backend/00-service-selection/delivery-mode-selection/" data-link-title="0.21 交付形態選型：從全託管到自建的光譜與邊界" data-link-desc="在進入資料庫、快取與部署選型之前、先判斷服務該用託管平台（Wix / Shopify / Google Sites）、辦公生態自動化（Apps Script）、BaaS（Firebase）、半託管 CMS（WordPress）還是自建、並為日後遷往自建保留可遷出路徑">0.21 交付形態選型</a>。要看並行期同源的雙寫與切流紀律、見 <a href="/blog/backend/10-system-evolution/service-decomposition-execution-runbook/" data-link-title="10.2 服務拆分執行 Runbook（Strangler Fig / 雙寫期 / 切流 / 回退）" data-link-desc="10.1 決定該拆之後、實際怎麼動手拆 — Strangler Fig pattern、雙寫期管理、切流策略、回退條件設計">10.2 服務拆分執行 Runbook</a>。遷入自建後的第一站、從 <a href="/blog/backend/00-service-selection/backend-demand-taxonomy/" data-link-title="0.0 後端需求分類地圖" data-link-desc="先從需求形狀辨識狀態、讀取、非同步、即時、診斷、交付與可靠性問題">0.0 後端需求分類地圖</a> 開始走選型順序。</p>
]]></content:encoded></item><item><title>規模演進</title><link>https://tarrragon.github.io/blog/monitoring/04-collector/scaling-evolution/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/04-collector/scaling-evolution/</guid><description>&lt;p>Collector 的儲存方案是可插拔 storage backend — 同一個 binary 透過啟動參數選擇不同的 storage implementation。Go 的 interface composition 讓 storage 分成 BasicStorage（所有 backend 共用）和 AnalyticsStorage（PostgreSQL 層新增），內部實作（SQLite / PostgreSQL / 時間序列 DB）分離，切換是 config change 而非重寫程式碼。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">BasicStorage&lt;/span> &lt;span class="kd">interface&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nf">Store&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">event&lt;/span> &lt;span class="nx">Event&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">error&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nf">Query&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">filter&lt;/span> &lt;span class="nx">QueryFilter&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">([]&lt;/span>&lt;span class="nx">Event&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">error&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nf">Close&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="kt">error&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nf">Downsample&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="kt">error&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nf">Purge&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="kt">error&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">AnalyticsStorage&lt;/span> &lt;span class="kd">interface&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="nx">BasicStorage&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="nf">Aggregate&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">spec&lt;/span> &lt;span class="nx">AggregateSpec&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">AggregateResult&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">error&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="nf">Funnel&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">steps&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">timeWindow&lt;/span> &lt;span class="nx">Duration&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">FunnelResult&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">error&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="nf">Cohort&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">groupBy&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">metric&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">CohortResult&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">error&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>SQLite implementation 只實作 &lt;code>BasicStorage&lt;/code>。PostgreSQL implementation 實作 &lt;code>AnalyticsStorage&lt;/code>。Dashboard 用 Go 的 type assertion（&lt;code>if as, ok := storage.(AnalyticsStorage); ok { ... }&lt;/code>）判斷能力 — funnel/cohort 視圖在 SQLite 模式下不顯示入口，而非顯示後報錯。&lt;/p>
&lt;p>選擇哪個 backend 取決於部署場景和查詢需求：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>場景&lt;/th>
 &lt;th>Backend&lt;/th>
 &lt;th>啟動參數&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>自架簡單版（零依賴）&lt;/td>
 &lt;td>SQLite&lt;/td>
 &lt;td>&lt;code>--storage=sqlite&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>需要聚合分析的自用版&lt;/td>
 &lt;td>PostgreSQL&lt;/td>
 &lt;td>&lt;code>--storage=postgres --dsn=...&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>高併發 + 長期保留&lt;/td>
 &lt;td>時間序列 DB&lt;/td>
 &lt;td>&lt;code>--storage=timescale --dsn=...&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="sqlite-backendday-one-預設">SQLite Backend（day-one 預設）&lt;/h2>
&lt;p>SQLite 是嵌入式資料庫，編譯進 collector binary 中，不需要額外 server。Go 用 &lt;code>modernc.org/sqlite&lt;/code>（pure Go、無 CGO 依賴、效能約為 CGO driver mattn/go-sqlite3 的 60-80%，自用規模下足夠），開源使用者 &lt;code>go build &amp;amp;&amp;amp; ./collector&lt;/code> 就能跑，部署步驟為零。WAL mode 允許讀寫並行 — dashboard 的 SELECT 查詢不會被 ingestion 的 INSERT 阻塞，反之亦然。寫入之間的競爭由 busy_timeout 處理。&lt;/p>
&lt;h3 id="能力範圍">能力範圍&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>索引查詢&lt;/strong>：按 type、name、timestamp 建索引，查詢從全表掃描變成索引查找&lt;/li>
&lt;li>&lt;strong>SQL 聚合&lt;/strong>：&lt;code>SELECT name, COUNT(*) FROM events WHERE type='error' GROUP BY name&lt;/code> — 一行 SQL 完成分群計數&lt;/li>
&lt;li>&lt;strong>跨欄位過濾&lt;/strong>：&lt;code>WHERE type='error' AND name LIKE 'terminal.%' AND ts &amp;gt; '2026-06-18'&lt;/code>&lt;/li>
&lt;li>&lt;strong>寫入&lt;/strong>：WAL mode 下每秒數千筆 append 寫入&lt;/li>
&lt;/ul>
&lt;h3 id="events-主表-ddl">Events 主表 DDL&lt;/h3>
&lt;p>Events 表的欄位從 &lt;a href="https://tarrragon.github.io/blog/monitoring/02-log-schema/event-schema-fields/" data-link-title="event.schema.json 完整欄位解說" data-link-desc="監控事件的 JSON Schema 定義 — 每個欄位的語意、必填/選填、資料型別和設計理由">event.schema.json&lt;/a> 的 JSON 結構推導。Source 的 nested object 攤平成獨立 column — 方便 SQL 查詢和索引，不需要每次從 JSON 裡 extract。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">events&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INTEGER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">PRIMARY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">KEY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">AUTOINCREMENT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">v&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INTEGER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DEFAULT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">type&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">name&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">ts&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">source_sdk&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">source_app&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">source_version&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">source_platform&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">source_os&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">session_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">session_started&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">level&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">data&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">error_message&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">error_stack&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">error_type&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">receive_ts&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>source_sdk&lt;/code> 獨立成 column 讓「按 SDK 來源篩選」（&lt;code>WHERE source_sdk = 'python'&lt;/code>）不需要從 JSON extract。&lt;code>data&lt;/code> 用 TEXT 存 JSON。SQLite 沒有原生 JSON 型別，但 3.38+ 支援 &lt;code>json_extract()&lt;/code> 函式做查詢（&lt;code>WHERE json_extract(data, '$.duration_ms') &amp;gt; 1000&lt;/code>）。&lt;code>session_id&lt;/code> 獨立成 column 讓 session 回放的 JOIN 不需要 JSON extract。&lt;code>error_stack&lt;/code> 獨立成 column 讓 error 調查時全文搜尋 stack trace 不需要 JSON extract。&lt;code>receive_ts&lt;/code> 是 collector 收到事件的時間，和 SDK 端的 &lt;code>ts&lt;/code> 對照可估算 clock drift。&lt;/p></description><content:encoded><![CDATA[<p>Collector 的儲存方案是可插拔 storage backend — 同一個 binary 透過啟動參數選擇不同的 storage implementation。Go 的 interface composition 讓 storage 分成 BasicStorage（所有 backend 共用）和 AnalyticsStorage（PostgreSQL 層新增），內部實作（SQLite / PostgreSQL / 時間序列 DB）分離，切換是 config change 而非重寫程式碼。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">BasicStorage</span> <span class="kd">interface</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nf">Store</span><span class="p">(</span><span class="nx">event</span> <span class="nx">Event</span><span class="p">)</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nf">Query</span><span class="p">(</span><span class="nx">filter</span> <span class="nx">QueryFilter</span><span class="p">)</span> <span class="p">([]</span><span class="nx">Event</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nf">Close</span><span class="p">()</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nf">Downsample</span><span class="p">()</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nf">Purge</span><span class="p">()</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="kd">type</span> <span class="nx">AnalyticsStorage</span> <span class="kd">interface</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nx">BasicStorage</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nf">Aggregate</span><span class="p">(</span><span class="nx">spec</span> <span class="nx">AggregateSpec</span><span class="p">)</span> <span class="p">(</span><span class="nx">AggregateResult</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nf">Funnel</span><span class="p">(</span><span class="nx">steps</span> <span class="p">[]</span><span class="kt">string</span><span class="p">,</span> <span class="nx">timeWindow</span> <span class="nx">Duration</span><span class="p">)</span> <span class="p">(</span><span class="nx">FunnelResult</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nf">Cohort</span><span class="p">(</span><span class="nx">groupBy</span> <span class="kt">string</span><span class="p">,</span> <span class="nx">metric</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">CohortResult</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>SQLite implementation 只實作 <code>BasicStorage</code>。PostgreSQL implementation 實作 <code>AnalyticsStorage</code>。Dashboard 用 Go 的 type assertion（<code>if as, ok := storage.(AnalyticsStorage); ok { ... }</code>）判斷能力 — funnel/cohort 視圖在 SQLite 模式下不顯示入口，而非顯示後報錯。</p>
<p>選擇哪個 backend 取決於部署場景和查詢需求：</p>
<table>
  <thead>
      <tr>
          <th>場景</th>
          <th>Backend</th>
          <th>啟動參數</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>自架簡單版（零依賴）</td>
          <td>SQLite</td>
          <td><code>--storage=sqlite</code></td>
      </tr>
      <tr>
          <td>需要聚合分析的自用版</td>
          <td>PostgreSQL</td>
          <td><code>--storage=postgres --dsn=...</code></td>
      </tr>
      <tr>
          <td>高併發 + 長期保留</td>
          <td>時間序列 DB</td>
          <td><code>--storage=timescale --dsn=...</code></td>
      </tr>
  </tbody>
</table>
<h2 id="sqlite-backendday-one-預設">SQLite Backend（day-one 預設）</h2>
<p>SQLite 是嵌入式資料庫，編譯進 collector binary 中，不需要額外 server。Go 用 <code>modernc.org/sqlite</code>（pure Go、無 CGO 依賴、效能約為 CGO driver mattn/go-sqlite3 的 60-80%，自用規模下足夠），開源使用者 <code>go build &amp;&amp; ./collector</code> 就能跑，部署步驟為零。WAL mode 允許讀寫並行 — dashboard 的 SELECT 查詢不會被 ingestion 的 INSERT 阻塞，反之亦然。寫入之間的競爭由 busy_timeout 處理。</p>
<h3 id="能力範圍">能力範圍</h3>
<ul>
<li><strong>索引查詢</strong>：按 type、name、timestamp 建索引，查詢從全表掃描變成索引查找</li>
<li><strong>SQL 聚合</strong>：<code>SELECT name, COUNT(*) FROM events WHERE type='error' GROUP BY name</code> — 一行 SQL 完成分群計數</li>
<li><strong>跨欄位過濾</strong>：<code>WHERE type='error' AND name LIKE 'terminal.%' AND ts &gt; '2026-06-18'</code></li>
<li><strong>寫入</strong>：WAL mode 下每秒數千筆 append 寫入</li>
</ul>
<h3 id="events-主表-ddl">Events 主表 DDL</h3>
<p>Events 表的欄位從 <a href="/blog/monitoring/02-log-schema/event-schema-fields/" data-link-title="event.schema.json 完整欄位解說" data-link-desc="監控事件的 JSON Schema 定義 — 每個欄位的語意、必填/選填、資料型別和設計理由">event.schema.json</a> 的 JSON 結構推導。Source 的 nested object 攤平成獨立 column — 方便 SQL 查詢和索引，不需要每次從 JSON 裡 extract。</p>





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





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





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">-- 摘要表
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">hourly_summary</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">    </span><span class="n">hour</span><span class="w"> </span><span class="nb">TEXT</span><span class="p">,</span><span class="w"> </span><span class="k">type</span><span class="w"> </span><span class="nb">TEXT</span><span class="p">,</span><span class="w"> </span><span class="n">name</span><span class="w"> </span><span class="nb">TEXT</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">    </span><span class="k">count</span><span class="w"> </span><span class="nb">INTEGER</span><span class="p">,</span><span class="w"> </span><span class="n">error_count</span><span class="w"> </span><span class="nb">INTEGER</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">    </span><span class="k">UNIQUE</span><span class="p">(</span><span class="n">hour</span><span class="p">,</span><span class="w"> </span><span class="k">type</span><span class="p">,</span><span class="w"> </span><span class="n">name</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w"></span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">daily_summary</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">    </span><span class="nb">date</span><span class="w"> </span><span class="nb">TEXT</span><span class="p">,</span><span class="w"> </span><span class="k">type</span><span class="w"> </span><span class="nb">TEXT</span><span class="p">,</span><span class="w"> </span><span class="n">name</span><span class="w"> </span><span class="nb">TEXT</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">    </span><span class="k">count</span><span class="w"> </span><span class="nb">INTEGER</span><span class="p">,</span><span class="w"> </span><span class="n">unique_sessions</span><span class="w"> </span><span class="nb">INTEGER</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">    </span><span class="k">UNIQUE</span><span class="p">(</span><span class="nb">date</span><span class="p">,</span><span class="w"> </span><span class="k">type</span><span class="p">,</span><span class="w"> </span><span class="n">name</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w"></span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w"></span><span class="c1">-- 降採樣（Downsample，每小時跑一次，幂等 — 重跑只更新不重複）
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">OR</span><span class="w"> </span><span class="k">REPLACE</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="n">hourly_summary</span><span class="w"> </span><span class="p">(</span><span class="n">hour</span><span class="p">,</span><span class="w"> </span><span class="k">type</span><span class="p">,</span><span class="w"> </span><span class="n">name</span><span class="p">,</span><span class="w"> </span><span class="k">count</span><span class="p">,</span><span class="w"> </span><span class="n">error_count</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">strftime</span><span class="p">(</span><span class="s1">&#39;%Y-%m-%dT%H:00:00&#39;</span><span class="p">,</span><span class="w"> </span><span class="n">ts</span><span class="p">),</span><span class="w"> </span><span class="k">type</span><span class="p">,</span><span class="w"> </span><span class="n">name</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w">       </span><span class="k">COUNT</span><span class="p">(</span><span class="o">*</span><span class="p">),</span><span class="w"> </span><span class="k">SUM</span><span class="p">(</span><span class="k">CASE</span><span class="w"> </span><span class="k">WHEN</span><span class="w"> </span><span class="k">type</span><span class="o">=</span><span class="s1">&#39;error&#39;</span><span class="w"> </span><span class="k">THEN</span><span class="w"> </span><span class="mi">1</span><span class="w"> </span><span class="k">ELSE</span><span class="w"> </span><span class="mi">0</span><span class="w"> </span><span class="k">END</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="n">events</span><span class="w">
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="n">ts</span><span class="w"> </span><span class="o">&gt;=</span><span class="w"> </span><span class="n">datetime</span><span class="p">(</span><span class="s1">&#39;now&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;-1 hour&#39;</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="w"></span><span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w"> </span><span class="mi">2</span><span class="p">,</span><span class="w"> </span><span class="mi">3</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="w"></span><span class="c1">-- 清理（Purge，每天跑一次，分批刪除避免長時間鎖定）
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="c1"></span><span class="k">DELETE</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">events</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">rowid</span><span class="w"> </span><span class="k">IN</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="w">  </span><span class="k">SELECT</span><span class="w"> </span><span class="n">rowid</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">events</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">ts</span><span class="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="n">datetime</span><span class="p">(</span><span class="s1">&#39;now&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;-7 days&#39;</span><span class="p">)</span><span class="w"> </span><span class="k">LIMIT</span><span class="w"> </span><span class="mi">10000</span><span class="w">
</span></span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="w"></span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="w"></span><span class="c1">-- 重複執行直到影響行數為 0
</span></span></span><span class="line"><span class="ln">26</span><span class="cl"><span class="c1"></span><span class="k">DELETE</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">hourly_summary</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">hour</span><span class="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="n">datetime</span><span class="p">(</span><span class="s1">&#39;now&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;-90 days&#39;</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">27</span><span class="cl"><span class="w"></span><span class="k">DELETE</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">daily_summary</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="nb">date</span><span class="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="n">datetime</span><span class="p">(</span><span class="s1">&#39;now&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;-365 days&#39;</span><span class="p">);</span></span></span></code></pre></div><p>保留期限由 collector config 設定，數字的來源是「哪些查詢需要回溯多遠」：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln">1</span><span class="cl"><span class="nt">retention</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w">  </span><span class="nt">raw_events</span><span class="p">:</span><span class="w"> </span><span class="l">7d</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w">  </span><span class="nt">hourly_summary</span><span class="p">:</span><span class="w"> </span><span class="l">90d</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w">  </span><span class="nt">daily_summary</span><span class="p">:</span><span class="w"> </span><span class="l">365d</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w">  </span><span class="nt">monthly_summary</span><span class="p">:</span><span class="w"> </span><span class="l">forever</span></span></span></code></pre></div><p>Storage interface 的 <code>Downsample()</code> 和 <code>Purge()</code> 由 collector 的定時排程觸發（Go 的 <code>time.Ticker</code>）。每個 storage backend 各自實作 — SQLite 用上述 SQL、PostgreSQL 用相同邏輯但可以加 partial index 加速、時間序列 DB 的 continuous aggregate 和 retention policy 原生支援。</p>
<h3 id="為什麼是聚合而非抽樣">為什麼是聚合而非抽樣</h3>
<p>原始事件的保留期到期後，需要決定如何保留歷史統計。降採樣有兩種思路。<strong>抽樣保留</strong>是同事件名稱（<code>name</code> 欄位）同小時保留一筆原始事件、刪除其餘，保留了逐筆查詢能力但喪失準確計數。<strong>聚合摘要</strong>是把一小時內的事件壓成一筆計數記錄，喪失逐筆細節但保留準確統計。</p>
<p>Collector 選擇聚合摘要——捨棄逐筆細節，換取準確計數。降採樣後的資料用途是趨勢圖和長期統計，這些查詢需要「過去 30 天每小時的 error 總數」而非「某一筆原始 error 的 stack trace」。</p>
<p>這意味著原始事件 purge（定期清理過期事件）後，超過保留期的逐筆查詢會回傳空結果。Dashboard 在回溯超過原始事件保留期的時間範圍時，應切換到上方的摘要表（<code>hourly_summary</code>/<code>daily_summary</code>）查詢——顯示趨勢圖而非事件列表。設計方向是查詢 API 的 <code>from</code> 參數超過 <code>retention.raw_events</code> 時自動降級到摘要表，或回傳提示告知 client 該時間範圍只有聚合資料（初版 collector 尚未實作此降級邏輯）。</p>
<h3 id="觸發切換到-postgresql-的訊號">觸發切換到 PostgreSQL 的訊號</h3>
<p><strong>寫入爭搶</strong>：SQLite 是單寫者模型。高併發寫入（多個 SDK 同時 flush、每秒數百筆以上持續發生）會出現 <code>database is locked</code> 錯誤。WAL mode 能緩解但不能根治。</p>
<p><strong>聚合查詢效能不足</strong>：Dashboard 需要的聚合查詢（「過去 30 天每小時的 error 數量趨勢」「funnel 的每步轉換率」）在資料量成長後變慢。SQLite 沒有 parallel query 和 partial index 等進階 OLAP 能力。</p>
<p><strong>跨實例需求</strong>：需要多個 collector 實例共用同一個資料庫時，SQLite 的單檔案模型無法跨主機存取。</p>
<h2 id="postgresql-backend分析觸發">PostgreSQL Backend（分析觸發）</h2>
<p>PostgreSQL 是獨立的資料庫 server，提供多連線並行寫入、進階索引（GIN for JSONB、partial index）和完整的 SQL 分析能力。切換到 PostgreSQL 意味著 collector 從「零依賴單一 binary」變成「binary + 外部 DB」，運維複雜度上升。</p>
<h3 id="觸發條件">觸發條件</h3>
<p>SQLite 的寫入爭搶或聚合效能成為瓶頸時切換。具體訊號：<code>database is locked</code> 錯誤頻率超過每分鐘一次、或 dashboard 的聚合查詢超過 3 秒。</p>
<h3 id="切換方式">切換方式</h3>
<p>切換是 config change：把 <code>--storage=sqlite</code> 改成 <code>--storage=postgres --dsn=postgres://...</code>。資料遷移用匯出 + 匯入完成：</p>
<ol>
<li>從 SQLite 匯出事件為 JSONL（<code>monitor export --format=jsonl</code>）</li>
<li>在 PostgreSQL 建立 events 表（schema 和 SQLite 相同，data 欄位改用 JSONB）</li>
<li>匯入 JSONL 到 PostgreSQL（<code>monitor import --storage=postgres --file=events.jsonl</code>）</li>
<li>切換啟動參數、確認查詢正常後停用 SQLite 檔案</li>
</ol>
<p>Storage interface 保證 collector 的 ingestion、query、rule engine 邏輯不需要改動 — 只有 storage implementation 層切換。</p>
<h3 id="能力增量">能力增量</h3>
<ul>
<li><strong>並行寫入</strong>：多個 SDK 同時 flush 不會 lock</li>
<li><strong>JSONB 索引</strong>：對 data 欄位的特定 key 建索引（<code>CREATE INDEX ON events ((data-&gt;&gt;'name'))</code>）</li>
<li><strong>Window function</strong>：funnel 和 cohort 分析的 SQL 基礎</li>
<li><strong>Read replica</strong>：寫入和查詢分離，dashboard 的查詢不影響 ingestion 效能</li>
</ul>
<h2 id="時間序列-db-backend長期演進">時間序列 DB Backend（長期演進）</h2>
<p>時間序列資料庫（TimescaleDB、InfluxDB、VictoriaMetrics）專門為高頻 append 寫入和時間分桶聚合設計。TimescaleDB 基於 PostgreSQL 擴展，Storage interface 的 PostgreSQL implementation 可以直接複用、加上 hypertable 和 continuous aggregate。</p>
<h3 id="觸發條件-1">觸發條件</h3>
<p>每秒數萬筆以上的持續寫入、或需要自動 downsampling（每分鐘的原始資料保留 7 天、每小時的聚合保留 90 天、每天的聚合永久保留）。多數自用工具和小型團隊不會到達這個規模。</p>
<h3 id="能力增量-1">能力增量</h3>
<ul>
<li><strong>時間分桶原生操作</strong>：<code>time_bucket('1 hour', ts)</code> 替代手動 DATE_TRUNC</li>
<li><strong>Continuous aggregate</strong>：預計算的聚合結果自動更新</li>
<li><strong>壓縮</strong>：歷史資料自動壓縮，TB 級資料可查詢</li>
<li><strong>Retention policy</strong>：按時間自動清理舊資料</li>
</ul>
<h2 id="jsonl-匯出debug-用途">JSONL 匯出（debug 用途）</h2>
<p>JSONL 不作為主要 storage backend，而是作為匯出格式保留人類可讀性和 grep 友好性。<code>monitor export --format=jsonl</code> 把 storage 中的事件匯出為每行一個 JSON 物件的檔案，讓開發者可以用 grep / jq 做臨時查詢或把資料搬到其他工具。</p>
<p>JSONL 匯出也是備份和遷移的中介格式 — SQLite 損壞時從 JSONL 重建、切換到 PostgreSQL 時從 JSONL 匯入。</p>
<p>匯出使用 streaming — 從 storage 逐筆讀取、逐行寫出檔案，記憶體使用和事件總量無關。300 萬筆事件（約 900MB JSONL）的匯出不需要載入全部資料到記憶體。匯出的 JSONL 檔案包含事件明文（已 redaction 的欄位除外），匯出後不受 collector 的存取控制保護，應注意存放位置和存取權限。</p>
<h2 id="演進原則">演進原則</h2>
<p><strong>按觀察到的瓶頸切換</strong>。<code>database is locked</code> 錯誤頻率、聚合查詢延遲、磁碟使用量 — 這些是可觀察的訊號。「未來可能有百萬筆事件」是預測。按訊號行動，不按預測行動。</p>
<p><strong>切換是 config change</strong>。Storage interface 確保切換 backend 時 collector 的其他邏輯（ingestion、query API、rule engine、dashboard）不需要改動。切換的成本是資料遷移，不是程式碼重寫。</p>
<p><strong>SQLite 是安全的起點</strong>。多數開源使用者會停留在 SQLite backend — 單日萬筆以下、索引查詢毫秒級、零依賴部署。只有明確的效能瓶頸才值得引入外部 DB 的運維成本。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>Collector 的整體架構 → <a href="/blog/monitoring/04-collector/architecture/" data-link-title="Collector 架構" data-link-desc="HTTP endpoint → JSON Schema 驗證 → 儲存 → 查詢 → rule engine 的五段式處理鏈路">Collector 架構</a></li>
<li>查詢 API 的設計（跨 backend 統一） → <a href="/blog/monitoring/04-collector/query-api/" data-link-title="查詢 API 設計" data-link-desc="CLI grep 友好的 JSONL 結構 &#43; HTTP 查詢 endpoint — 兩種查詢介面各自的適用場景和設計要點">查詢 API 設計</a></li>
<li>資料庫選型的通用指南 → <a href="/blog/backend/01-database/" data-link-title="模組一：資料庫與持久化" data-link-desc="整理 SQL、transaction、migration 與 repository adapter 的後端實務">backend 01 資料庫</a></li>
<li>效能瓶頸的判讀方法 → <a href="/blog/backend/09-performance-capacity/" data-link-title="模組九：效能工程與容量規劃" data-link-desc="把『目前配置能撐多少、要加多少機器』變成可量化、可驗證、可改進的工程流程">backend 09 效能容量</a></li>
<li>水平擴展的基礎概念 → <a href="/blog/devops/02-horizontal-scaling/" data-link-title="模組二：水平擴展" data-link-desc="一個實例不夠時怎麼加第二個 — stateless 設計、shared storage、session 處理的工程約束">DevOps 水平擴展</a></li>
<li>Error fingerprint 的 DDL 擴充 → <a href="/blog/monitoring/04-collector/error-fingerprint/" data-link-title="Error Fingerprint 與去重分群" data-link-desc="把大量 error 事件歸組成可管理的 issue 列表 — fingerprint 演算法、message normalization、error_groups 表設計、自架方案的務實邊界">Error Fingerprint 與去重分群</a></li>
</ul>
]]></content:encoded></item><item><title>模組十：系統演進與遷移</title><link>https://tarrragon.github.io/blog/backend/10-system-evolution/</link><pubDate>Wed, 27 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/10-system-evolution/</guid><description>&lt;p>系統演進與遷移的核心目標是把高風險的執行變更從設計時的選型判斷分離出來、用獨立的紀律處理。模組零回答「該選哪個服務」、模組十回答「決定要改之後、怎麼安全動手」。兩者的失敗模式不同 — 選型錯了重來成本是「再評估一次」、遷移錯了重來成本可能是「事件、資料損失、回退耗時數週」。&lt;/p>
&lt;h2 id="跟模組零的責任分工">跟模組零的責任分工&lt;/h2>
&lt;p>模組零（服務選型）處理設計階段：辨識需求、比較能力、決定要不要引入某類服務。模組十（系統演進）處理執行階段：拆服務、跨服務重構、schema 大型變更、雲端切換、capacity ramp 的劇本與回退條件。兩者銜接點是「決策完成、執行待動」 — 模組零的結論「應該拆某個服務」進到模組十、變成「怎麼拆、用什麼 pattern、何時切流、回退條件是什麼」。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>階段&lt;/th>
 &lt;th>模組零承擔&lt;/th>
 &lt;th>模組十承擔&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>起點訊號&lt;/td>
 &lt;td>需求分類、流量形狀、團隊能力&lt;/td>
 &lt;td>已決定要動、評估完成&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>主要產物&lt;/td>
 &lt;td>選型表、能力對照、取捨判讀&lt;/td>
 &lt;td>執行劇本、切流策略、回退條件&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>失敗代價&lt;/td>
 &lt;td>選錯方向、回頭再評估&lt;/td>
 &lt;td>切流失敗、資料損失、事件影響使用者&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>工具語言&lt;/td>
 &lt;td>mental model、taxonomy、capability&lt;/td>
 &lt;td>runbook、cutover、rollback&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="跟其他模組的邊界">跟其他模組的邊界&lt;/h2>
&lt;p>模組十收的是「跨服務、跨模組、跨環境的演進劇本」、不是「該技術的小範圍變更」。常見的容易誤判邊界：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>議題&lt;/th>
 &lt;th>留原模組&lt;/th>
 &lt;th>進模組十&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>schema migration 語法、index 設計、rollout&lt;/td>
 &lt;td>01 留&lt;/td>
 &lt;td>—&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>schema 跨多 release 的 zero-downtime 切換&lt;/td>
 &lt;td>—&lt;/td>
 &lt;td>模組十收（未來、Strangler Fig 跨服務替換）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>cache aside / TTL / eviction&lt;/td>
 &lt;td>02 留&lt;/td>
 &lt;td>—&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>cache 大型 vendor 切換（自建 → 雲服務）&lt;/td>
 &lt;td>—&lt;/td>
 &lt;td>模組十收（未來）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>service 拆分判讀&lt;/td>
 &lt;td>—&lt;/td>
 &lt;td>模組十收（10.1）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>service 拆分執行 runbook&lt;/td>
 &lt;td>—&lt;/td>
 &lt;td>模組十收（10.2）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>雲端能力對照（AWS / GCP / Azure）&lt;/td>
 &lt;td>00 留（0.19）&lt;/td>
 &lt;td>—&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>跨雲遷移執行劇本&lt;/td>
 &lt;td>—&lt;/td>
 &lt;td>模組十收（未來）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>9.x 擴展軸、容量規劃&lt;/td>
 &lt;td>09 留&lt;/td>
 &lt;td>—&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>交付形態該不該遷、升級 tripwire 判讀&lt;/td>
 &lt;td>00 留（0.21）&lt;/td>
 &lt;td>—&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>託管形態遷出的執行劇本&lt;/td>
 &lt;td>—&lt;/td>
 &lt;td>模組十收（10.3）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>拆分後造成的容量重平衡 runbook&lt;/td>
 &lt;td>—&lt;/td>
 &lt;td>模組十收（未來）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>判別問題是「這個變更失敗時、回退範圍跨幾個服務 / 模組？」。跨多模組的演進劇本進模組十、單模組內的小範圍變更留原模組。&lt;/p>
&lt;h2 id="章節列表">章節列表&lt;/h2>
&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;a href="https://tarrragon.github.io/blog/backend/10-system-evolution/service-decomposition-boundaries/" data-link-title="10.1 服務拆分與邊界判讀" data-link-desc="整理 monolith vs microservice 取捨、服務邊界判讀訊號、拆分時機與回退路徑">10.1&lt;/a>&lt;/td>
 &lt;td>服務拆分與邊界判讀&lt;/td>
 &lt;td>整理 monolith vs microservice 取捨、服務邊界判讀訊號、拆分時機與回退路徑&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/10-system-evolution/service-decomposition-execution-runbook/" data-link-title="10.2 服務拆分執行 Runbook（Strangler Fig / 雙寫期 / 切流 / 回退）" data-link-desc="10.1 決定該拆之後、實際怎麼動手拆 — Strangler Fig pattern、雙寫期管理、切流策略、回退條件設計">10.2&lt;/a>&lt;/td>
 &lt;td>服務拆分執行 Runbook&lt;/td>
 &lt;td>10.1 決定該拆之後、實際怎麼動手 — &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/strangler-fig/" data-link-title="Strangler Fig Pattern" data-link-desc="服務拆分 / 系統替換的漸進演進模式、用『新舊共存 &amp;#43; 逐步遷移 &amp;#43; 最終下架』取代 big bang 重寫">Strangler Fig&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/dual-write/" data-link-title="Dual Write" data-link-desc="說明同一變更同時寫入兩個系統時的一致性風險">雙寫期&lt;/a> 管理、切流策略、回退條件設計&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/10-system-evolution/managed-platform-exit/" data-link-title="10.3 託管形態遷出：資產線盤點與並行期執行" data-link-desc="0.21 升級自建 tripwire 觸發後的執行劇本 — 把遷出拆成資料、身分、流量、整合各自的可攜性與斷點、設計舊平台與新系統的並行期與回切窗口、用部分遷出作為中繼形態">10.3&lt;/a>&lt;/td>
 &lt;td>託管形態遷出&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/00-service-selection/delivery-mode-selection/" data-link-title="0.21 交付形態選型：從全託管到自建的光譜與邊界" data-link-desc="在進入資料庫、快取與部署選型之前、先判斷服務該用託管平台（Wix / Shopify / Google Sites）、辦公生態自動化（Apps Script）、BaaS（Firebase）、半託管 CMS（WordPress）還是自建、並為日後遷往自建保留可遷出路徑">0.21&lt;/a> 升級自建 tripwire 觸發之後、從託管平台 / BaaS 遷往自建的執行 — 資料 / 身分 / 流量 / 整合的資產線盤點、並行期與回切窗口、部分遷出中繼形態&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="後續擴充方向">後續擴充方向&lt;/h2>
&lt;p>本模組目前收服務拆分與託管形態遷出議題。未來會擴充的演進類議題：&lt;/p></description><content:encoded><![CDATA[<p>系統演進與遷移的核心目標是把高風險的執行變更從設計時的選型判斷分離出來、用獨立的紀律處理。模組零回答「該選哪個服務」、模組十回答「決定要改之後、怎麼安全動手」。兩者的失敗模式不同 — 選型錯了重來成本是「再評估一次」、遷移錯了重來成本可能是「事件、資料損失、回退耗時數週」。</p>
<h2 id="跟模組零的責任分工">跟模組零的責任分工</h2>
<p>模組零（服務選型）處理設計階段：辨識需求、比較能力、決定要不要引入某類服務。模組十（系統演進）處理執行階段：拆服務、跨服務重構、schema 大型變更、雲端切換、capacity ramp 的劇本與回退條件。兩者銜接點是「決策完成、執行待動」 — 模組零的結論「應該拆某個服務」進到模組十、變成「怎麼拆、用什麼 pattern、何時切流、回退條件是什麼」。</p>
<table>
  <thead>
      <tr>
          <th>階段</th>
          <th>模組零承擔</th>
          <th>模組十承擔</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>起點訊號</td>
          <td>需求分類、流量形狀、團隊能力</td>
          <td>已決定要動、評估完成</td>
      </tr>
      <tr>
          <td>主要產物</td>
          <td>選型表、能力對照、取捨判讀</td>
          <td>執行劇本、切流策略、回退條件</td>
      </tr>
      <tr>
          <td>失敗代價</td>
          <td>選錯方向、回頭再評估</td>
          <td>切流失敗、資料損失、事件影響使用者</td>
      </tr>
      <tr>
          <td>工具語言</td>
          <td>mental model、taxonomy、capability</td>
          <td>runbook、cutover、rollback</td>
      </tr>
  </tbody>
</table>
<h2 id="跟其他模組的邊界">跟其他模組的邊界</h2>
<p>模組十收的是「跨服務、跨模組、跨環境的演進劇本」、不是「該技術的小範圍變更」。常見的容易誤判邊界：</p>
<table>
  <thead>
      <tr>
          <th>議題</th>
          <th>留原模組</th>
          <th>進模組十</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>schema migration 語法、index 設計、rollout</td>
          <td>01 留</td>
          <td>—</td>
      </tr>
      <tr>
          <td>schema 跨多 release 的 zero-downtime 切換</td>
          <td>—</td>
          <td>模組十收（未來、Strangler Fig 跨服務替換）</td>
      </tr>
      <tr>
          <td>cache aside / TTL / eviction</td>
          <td>02 留</td>
          <td>—</td>
      </tr>
      <tr>
          <td>cache 大型 vendor 切換（自建 → 雲服務）</td>
          <td>—</td>
          <td>模組十收（未來）</td>
      </tr>
      <tr>
          <td>service 拆分判讀</td>
          <td>—</td>
          <td>模組十收（10.1）</td>
      </tr>
      <tr>
          <td>service 拆分執行 runbook</td>
          <td>—</td>
          <td>模組十收（10.2）</td>
      </tr>
      <tr>
          <td>雲端能力對照（AWS / GCP / Azure）</td>
          <td>00 留（0.19）</td>
          <td>—</td>
      </tr>
      <tr>
          <td>跨雲遷移執行劇本</td>
          <td>—</td>
          <td>模組十收（未來）</td>
      </tr>
      <tr>
          <td>9.x 擴展軸、容量規劃</td>
          <td>09 留</td>
          <td>—</td>
      </tr>
      <tr>
          <td>交付形態該不該遷、升級 tripwire 判讀</td>
          <td>00 留（0.21）</td>
          <td>—</td>
      </tr>
      <tr>
          <td>託管形態遷出的執行劇本</td>
          <td>—</td>
          <td>模組十收（10.3）</td>
      </tr>
      <tr>
          <td>拆分後造成的容量重平衡 runbook</td>
          <td>—</td>
          <td>模組十收（未來）</td>
      </tr>
  </tbody>
</table>
<p>判別問題是「這個變更失敗時、回退範圍跨幾個服務 / 模組？」。跨多模組的演進劇本進模組十、單模組內的小範圍變更留原模組。</p>
<h2 id="章節列表">章節列表</h2>
<table>
  <thead>
      <tr>
          <th>章節</th>
          <th>主題</th>
          <th>關鍵收穫</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/backend/10-system-evolution/service-decomposition-boundaries/" data-link-title="10.1 服務拆分與邊界判讀" data-link-desc="整理 monolith vs microservice 取捨、服務邊界判讀訊號、拆分時機與回退路徑">10.1</a></td>
          <td>服務拆分與邊界判讀</td>
          <td>整理 monolith vs microservice 取捨、服務邊界判讀訊號、拆分時機與回退路徑</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/10-system-evolution/service-decomposition-execution-runbook/" data-link-title="10.2 服務拆分執行 Runbook（Strangler Fig / 雙寫期 / 切流 / 回退）" data-link-desc="10.1 決定該拆之後、實際怎麼動手拆 — Strangler Fig pattern、雙寫期管理、切流策略、回退條件設計">10.2</a></td>
          <td>服務拆分執行 Runbook</td>
          <td>10.1 決定該拆之後、實際怎麼動手 — <a href="/blog/backend/knowledge-cards/strangler-fig/" data-link-title="Strangler Fig Pattern" data-link-desc="服務拆分 / 系統替換的漸進演進模式、用『新舊共存 &#43; 逐步遷移 &#43; 最終下架』取代 big bang 重寫">Strangler Fig</a>、<a href="/blog/backend/knowledge-cards/dual-write/" data-link-title="Dual Write" data-link-desc="說明同一變更同時寫入兩個系統時的一致性風險">雙寫期</a> 管理、切流策略、回退條件設計</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/10-system-evolution/managed-platform-exit/" data-link-title="10.3 託管形態遷出：資產線盤點與並行期執行" data-link-desc="0.21 升級自建 tripwire 觸發後的執行劇本 — 把遷出拆成資料、身分、流量、整合各自的可攜性與斷點、設計舊平台與新系統的並行期與回切窗口、用部分遷出作為中繼形態">10.3</a></td>
          <td>託管形態遷出</td>
          <td><a href="/blog/backend/00-service-selection/delivery-mode-selection/" data-link-title="0.21 交付形態選型：從全託管到自建的光譜與邊界" data-link-desc="在進入資料庫、快取與部署選型之前、先判斷服務該用託管平台（Wix / Shopify / Google Sites）、辦公生態自動化（Apps Script）、BaaS（Firebase）、半託管 CMS（WordPress）還是自建、並為日後遷往自建保留可遷出路徑">0.21</a> 升級自建 tripwire 觸發之後、從託管平台 / BaaS 遷往自建的執行 — 資料 / 身分 / 流量 / 整合的資產線盤點、並行期與回切窗口、部分遷出中繼形態</td>
      </tr>
  </tbody>
</table>
<h2 id="後續擴充方向">後續擴充方向</h2>
<p>本模組目前收服務拆分與託管形態遷出議題。未來會擴充的演進類議題：</p>
<ul>
<li><strong>跨服務 schema 演進</strong>：API contract migration、event schema versioning、跨服務的 backfill 策略</li>
<li><strong>大型雲端遷移</strong>：on-prem → cloud、跨雲遷移的 cutover 劇本、流量切換策略</li>
<li><strong>基礎設施替換</strong>：資料庫引擎切換（如 MySQL → Postgres、自建 → managed）、cache vendor 切換、queue broker 切換的執行紀律</li>
<li><strong>容量重平衡</strong>：拆分後的服務間流量分佈、shard 重分佈、tenant 隔離調整</li>
</ul>
<p>這些議題的共同特徵：跨多個技術模組、失敗代價遠超「該技術的小範圍變更」、需要獨立的執行劇本跟回退條件。</p>
]]></content:encoded></item></channel></rss>