<?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>Implementation on Tarragon</title><link>https://tarrragon.github.io/blog/tags/implementation/</link><description>Recent content in Implementation on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Mon, 11 May 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/implementation/index.xml" rel="self" type="application/rss+xml"/><item><title>1.7 Schema Migration Rollout 證據（Schema Migration Rollout Evidence）實作示範</title><link>https://tarrragon.github.io/blog/backend/01-database/schema-migration-rollout-evidence/</link><pubDate>Mon, 11 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/schema-migration-rollout-evidence/</guid><description>&lt;p>Schema migration rollout 證據（Schema Migration Rollout Evidence）的核心責任是把正式狀態的演進拆成可觀測、可放行、可停止與可回寫的服務路徑。這篇以訂單資料表的付款狀態欄位演進為例，示範資料庫變更如何從 schema design、backfill、cutover 交接到 evidence package、release gate 與 incident decision log。&lt;/p>
&lt;h2 id="服務路徑與狀態責任">服務路徑與狀態責任&lt;/h2>
&lt;p>這條服務路徑是 &lt;code>checkout-api -&amp;gt; order-db -&amp;gt; payment-callback -&amp;gt; reconciliation-job&lt;/code>。Checkout 建立訂單時先寫入訂單主檔與付款待確認狀態；payment callback 會更新付款結果；客服後台與對帳 job 會讀取同一筆訂單狀態來判斷是否需要補償、退款或人工處理。&lt;/p>
&lt;p>本篇示範的變更是把原本單一 &lt;code>status&lt;/code> 欄位中的付款語意拆到 &lt;code>payment_state&lt;/code>。這個欄位屬於正式狀態，會影響使用者看到的訂單結果、付款回呼的冪等更新、客服查詢與對帳流程，因此 rollout 的核心是讓新舊狀態語意在過渡期同時成立；DDL 只是其中一個執行動作。&lt;/p>
&lt;p>這條路徑的前置概念來自 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/schema-design/" data-link-title="1.2 Schema Design 與資料建模" data-link-desc="整理 table、index、key、partition、denormalization 與命名規則">1.2 schema design 與資料建模&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/01-database/transaction-boundary/" data-link-title="1.3 Transaction 與一致性邊界" data-link-desc="交易邊界、isolation level、retry 策略、distributed transaction（2PC、Saga）與跨 region 強一致取捨">1.3 transaction 與一致性邊界&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/database-migration-playbook/" data-link-title="1.6 資料庫轉換實作：雙寫、回填、切流與回滾" data-link-desc="同 DB 內 schema 演進與資料變更的可分段驗證流程、跟 1.12 cross-DB migration 分工">1.6 資料庫轉換實作&lt;/a>。1.2 定義欄位責任，1.3 定義哪些更新要在同一個交易邊界內成立，1.6 定義 expand、backfill、cutover 與 contract 的執行節奏。&lt;/p>
&lt;h2 id="rollout-階段">Rollout 階段&lt;/h2>
&lt;p>Migration rollout 的責任是把一次高風險資料變更切成多個可驗證階段。每個階段都要有輸入條件、完成訊號與停止條件，讓團隊能在資料漂移擴大前停下來。&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>Expand&lt;/td>
 &lt;td>新欄位與新程式碼能和舊版本共存&lt;/td>
 &lt;td>新舊程式可同時讀寫，舊欄位仍可支撐服務&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Backfill&lt;/td>
 &lt;td>歷史訂單補齊 &lt;code>payment_state&lt;/code>&lt;/td>
 &lt;td>checkpoint 穩定前進，mismatch 維持在門檻內&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Cutover&lt;/td>
 &lt;td>讀取路徑改以新欄位為主&lt;/td>
 &lt;td>新欄位讀取成功率與對帳結果達到放行條件&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Contract&lt;/td>
 &lt;td>移除舊語意與舊寫入路徑&lt;/td>
 &lt;td>舊欄位已無服務依賴，回寫與監控已更新&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這張表的重點是責任轉移。Expand 保護相容性，backfill 保護歷史資料，cutover 保護線上讀取，contract 保護長期維護成本；四者對應不同 evidence，也需要不同 release gate 判讀。&lt;/p>
&lt;h2 id="實作基準先寫出狀態契約">實作基準：先寫出狀態契約&lt;/h2>
&lt;p>狀態契約的責任是讓 migration 先有可驗證的語意邊界。這篇的範例把 &lt;code>orders.status&lt;/code> 裡混合的訂單生命週期與付款語意拆開：訂單仍用 &lt;code>status&lt;/code> 表示 &lt;code>created&lt;/code>、&lt;code>fulfilled&lt;/code>、&lt;code>cancelled&lt;/code> 這類流程狀態，付款結果則交給 &lt;code>payment_state&lt;/code> 表示 &lt;code>pending&lt;/code>、&lt;code>authorized&lt;/code>、&lt;code>captured&lt;/code>、&lt;code>failed&lt;/code> 與 &lt;code>refunded&lt;/code>。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>舊狀態&lt;/th>
 &lt;th>新欄位 &lt;code>payment_state&lt;/code>&lt;/th>
 &lt;th>判讀理由&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>pending_payment&lt;/code>&lt;/td>
 &lt;td>&lt;code>pending&lt;/code>&lt;/td>
 &lt;td>訂單已建立，付款結果仍未確認&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>paid&lt;/code>&lt;/td>
 &lt;td>&lt;code>captured&lt;/code>&lt;/td>
 &lt;td>付款已完成，可進入出貨或履約流程&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>payment_failed&lt;/code>&lt;/td>
 &lt;td>&lt;code>failed&lt;/code>&lt;/td>
 &lt;td>付款失敗，需要重試或取消路由&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>refunded&lt;/code>&lt;/td>
 &lt;td>&lt;code>refunded&lt;/code>&lt;/td>
 &lt;td>付款已逆向處理，客服與對帳要可查&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>cancelled_before_pay&lt;/code>&lt;/td>
 &lt;td>&lt;code>pending&lt;/code>&lt;/td>
 &lt;td>沒有付款成功事實，只保留流程取消&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>manual_review_required&lt;/code>&lt;/td>
 &lt;td>&lt;code>pending&lt;/code>&lt;/td>
 &lt;td>付款狀態未完成，等待人工判讀&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這張 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/mapping-table/" data-link-title="Mapping Table" data-link-desc="說明遷移或轉換期間如何把舊語意明確對應到新語意">mapping table&lt;/a> 是 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/validation-query/" data-link-title="Validation Query" data-link-desc="說明遷移、回填與修復期間如何用查詢證明資料語意是否一致">validation query&lt;/a>、backfill job 與 incident decision log 的共同語意來源。Mapping table 留在工程師腦中時，後續 mismatch 會變成「資料看起來怪」；mapping table 進入 artifact 後，gate 就能判斷錯誤集中在哪個付款語意，而不是停在總筆數。&lt;/p>
&lt;h2 id="expand先建立相容窗口">Expand：先建立相容窗口&lt;/h2>
&lt;p>Expand phase 的核心責任是讓新資料結構先進入 production，同時保留舊程式的可運作性。以 &lt;code>payment_state&lt;/code> 為例，常見起點是新增 nullable 欄位、補上必要索引，並讓寫入路徑可以在新欄位缺值時仍使用舊 &lt;code>status&lt;/code> 判讀付款狀態。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">ALTER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">orders&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">ADD&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">COLUMN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">payment_state&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">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">3&lt;/span>&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">INDEX&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">CONCURRENTLY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">idx_orders_payment_state&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">orders&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">payment_state&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="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">payment_state&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">IS&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>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這段 SQL 的用途是示範 artifact 形狀。Nullable 欄位保留舊資料的相容窗口；partial index 讓新讀取路徑能先被驗證，同時避免把尚未 backfill 的歷史資料全部推進新查詢模型。不同資料庫會有不同線上 DDL 能力，release gate 要把 lock 行為、index build 進度與 replication lag 納入 checks。&lt;/p></description><content:encoded><![CDATA[<p>Schema migration rollout 證據（Schema Migration Rollout Evidence）的核心責任是把正式狀態的演進拆成可觀測、可放行、可停止與可回寫的服務路徑。這篇以訂單資料表的付款狀態欄位演進為例，示範資料庫變更如何從 schema design、backfill、cutover 交接到 evidence package、release gate 與 incident decision log。</p>
<h2 id="服務路徑與狀態責任">服務路徑與狀態責任</h2>
<p>這條服務路徑是 <code>checkout-api -&gt; order-db -&gt; payment-callback -&gt; reconciliation-job</code>。Checkout 建立訂單時先寫入訂單主檔與付款待確認狀態；payment callback 會更新付款結果；客服後台與對帳 job 會讀取同一筆訂單狀態來判斷是否需要補償、退款或人工處理。</p>
<p>本篇示範的變更是把原本單一 <code>status</code> 欄位中的付款語意拆到 <code>payment_state</code>。這個欄位屬於正式狀態，會影響使用者看到的訂單結果、付款回呼的冪等更新、客服查詢與對帳流程，因此 rollout 的核心是讓新舊狀態語意在過渡期同時成立；DDL 只是其中一個執行動作。</p>
<p>這條路徑的前置概念來自 <a href="/blog/backend/01-database/schema-design/" data-link-title="1.2 Schema Design 與資料建模" data-link-desc="整理 table、index、key、partition、denormalization 與命名規則">1.2 schema design 與資料建模</a>、<a href="/blog/backend/01-database/transaction-boundary/" data-link-title="1.3 Transaction 與一致性邊界" data-link-desc="交易邊界、isolation level、retry 策略、distributed transaction（2PC、Saga）與跨 region 強一致取捨">1.3 transaction 與一致性邊界</a> 與 <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>。1.2 定義欄位責任，1.3 定義哪些更新要在同一個交易邊界內成立，1.6 定義 expand、backfill、cutover 與 contract 的執行節奏。</p>
<h2 id="rollout-階段">Rollout 階段</h2>
<p>Migration rollout 的責任是把一次高風險資料變更切成多個可驗證階段。每個階段都要有輸入條件、完成訊號與停止條件，讓團隊能在資料漂移擴大前停下來。</p>
<table>
  <thead>
      <tr>
          <th>階段</th>
          <th>服務責任</th>
          <th>完成訊號</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Expand</td>
          <td>新欄位與新程式碼能和舊版本共存</td>
          <td>新舊程式可同時讀寫，舊欄位仍可支撐服務</td>
      </tr>
      <tr>
          <td>Backfill</td>
          <td>歷史訂單補齊 <code>payment_state</code></td>
          <td>checkpoint 穩定前進，mismatch 維持在門檻內</td>
      </tr>
      <tr>
          <td>Cutover</td>
          <td>讀取路徑改以新欄位為主</td>
          <td>新欄位讀取成功率與對帳結果達到放行條件</td>
      </tr>
      <tr>
          <td>Contract</td>
          <td>移除舊語意與舊寫入路徑</td>
          <td>舊欄位已無服務依賴，回寫與監控已更新</td>
      </tr>
  </tbody>
</table>
<p>這張表的重點是責任轉移。Expand 保護相容性，backfill 保護歷史資料，cutover 保護線上讀取，contract 保護長期維護成本；四者對應不同 evidence，也需要不同 release gate 判讀。</p>
<h2 id="實作基準先寫出狀態契約">實作基準：先寫出狀態契約</h2>
<p>狀態契約的責任是讓 migration 先有可驗證的語意邊界。這篇的範例把 <code>orders.status</code> 裡混合的訂單生命週期與付款語意拆開：訂單仍用 <code>status</code> 表示 <code>created</code>、<code>fulfilled</code>、<code>cancelled</code> 這類流程狀態，付款結果則交給 <code>payment_state</code> 表示 <code>pending</code>、<code>authorized</code>、<code>captured</code>、<code>failed</code> 與 <code>refunded</code>。</p>
<table>
  <thead>
      <tr>
          <th>舊狀態</th>
          <th>新欄位 <code>payment_state</code></th>
          <th>判讀理由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>pending_payment</code></td>
          <td><code>pending</code></td>
          <td>訂單已建立，付款結果仍未確認</td>
      </tr>
      <tr>
          <td><code>paid</code></td>
          <td><code>captured</code></td>
          <td>付款已完成，可進入出貨或履約流程</td>
      </tr>
      <tr>
          <td><code>payment_failed</code></td>
          <td><code>failed</code></td>
          <td>付款失敗，需要重試或取消路由</td>
      </tr>
      <tr>
          <td><code>refunded</code></td>
          <td><code>refunded</code></td>
          <td>付款已逆向處理，客服與對帳要可查</td>
      </tr>
      <tr>
          <td><code>cancelled_before_pay</code></td>
          <td><code>pending</code></td>
          <td>沒有付款成功事實，只保留流程取消</td>
      </tr>
      <tr>
          <td><code>manual_review_required</code></td>
          <td><code>pending</code></td>
          <td>付款狀態未完成，等待人工判讀</td>
      </tr>
  </tbody>
</table>
<p>這張 <a href="/blog/backend/knowledge-cards/mapping-table/" data-link-title="Mapping Table" data-link-desc="說明遷移或轉換期間如何把舊語意明確對應到新語意">mapping table</a> 是 <a href="/blog/backend/knowledge-cards/validation-query/" data-link-title="Validation Query" data-link-desc="說明遷移、回填與修復期間如何用查詢證明資料語意是否一致">validation query</a>、backfill job 與 incident decision log 的共同語意來源。Mapping table 留在工程師腦中時，後續 mismatch 會變成「資料看起來怪」；mapping table 進入 artifact 後，gate 就能判斷錯誤集中在哪個付款語意，而不是停在總筆數。</p>
<h2 id="expand先建立相容窗口">Expand：先建立相容窗口</h2>
<p>Expand phase 的核心責任是讓新資料結構先進入 production，同時保留舊程式的可運作性。以 <code>payment_state</code> 為例，常見起點是新增 nullable 欄位、補上必要索引，並讓寫入路徑可以在新欄位缺值時仍使用舊 <code>status</code> 判讀付款狀態。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">orders</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w">  </span><span class="k">ADD</span><span class="w"> </span><span class="k">COLUMN</span><span class="w"> </span><span class="n">payment_state</span><span class="w"> </span><span class="nb">text</span><span class="w"> </span><span class="k">NULL</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">INDEX</span><span class="w"> </span><span class="n">CONCURRENTLY</span><span class="w"> </span><span class="n">idx_orders_payment_state</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">ON</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="p">(</span><span class="n">payment_state</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w">  </span><span class="k">WHERE</span><span class="w"> </span><span class="n">payment_state</span><span class="w"> </span><span class="k">IS</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="p">;</span></span></span></code></pre></div><p>這段 SQL 的用途是示範 artifact 形狀。Nullable 欄位保留舊資料的相容窗口；partial index 讓新讀取路徑能先被驗證，同時避免把尚未 backfill 的歷史資料全部推進新查詢模型。不同資料庫會有不同線上 DDL 能力，release gate 要把 lock 行為、index build 進度與 replication lag 納入 checks。</p>
<p>應用程式在 expand 階段要支援 <a href="/blog/backend/knowledge-cards/read-compatibility/" data-link-title="Read Compatibility" data-link-desc="說明資料或服務演進期間讀取路徑如何同時支援新舊語意">read compatibility</a>。相容性較高的寫法是讀取時優先使用 <code>payment_state</code>，缺值時 fallback 到舊 <code>status</code> 的付款語意；寫入時則依交易邊界同步更新舊欄位與新欄位，直到 cutover 前都保留一致性檢查。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">readPaymentState(order):
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  if order.payment_state is not null:
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    return order.payment_state
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  return mapLegacyStatusToPaymentState(order.status)
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">applyPaymentCallback(order, callback):
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  nextPaymentState = mapCallbackToPaymentState(callback)
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  update orders
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    set status = mapPaymentStateToLegacyStatus(nextPaymentState),
</span></span><span class="line"><span class="ln">10</span><span class="cl">        payment_state = nextPaymentState
</span></span><span class="line"><span class="ln">11</span><span class="cl">    where id = order.id</span></span></code></pre></div><p>這段相容讀寫的重點是「同一個 callback 只產生一個付款判讀」。舊欄位與新欄位可以同時存在，但它們要由同一份 mapping function 產生，否則 payment callback、客服修復與 reconciliation job 會各自形成一套隱性規則。</p>
<p>這裡要特別看 <a href="/blog/backend/knowledge-cards/dual-write/" data-link-title="Dual Write" data-link-desc="說明同一變更同時寫入兩個系統時的一致性風險">dual write</a> 的風險。雙寫只表示兩個欄位都有被寫入，仍要用 validation query 驗證兩者語意是否一致。若付款回呼、手動退款與對帳修復走不同程式路徑，雙寫函式也要被這些路徑共同使用。</p>
<h3 id="dual-write-divergence-schema">Dual-write divergence schema</h3>
<p>Dual-write 的責任不只是「兩邊都寫」、是「兩邊寫的結果一致」。要證明這件事、需要明確的 divergence schema、否則事故當下無法區分 mapping bug 跟 race condition。</p>
<p>最小 divergence 紀錄欄位：</p>
<table>
  <thead>
      <tr>
          <th>欄位</th>
          <th>用途</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>order_id</code></td>
          <td>哪一筆訂單</td>
      </tr>
      <tr>
          <td><code>legacy_value</code></td>
          <td>舊欄位寫入後的值</td>
      </tr>
      <tr>
          <td><code>new_value</code></td>
          <td>新欄位寫入後的值</td>
      </tr>
      <tr>
          <td><code>expected_new</code></td>
          <td>用 mapping function 從 <code>legacy_value</code> 推算的預期新值</td>
      </tr>
      <tr>
          <td><code>divergence_type</code></td>
          <td><code>mapping-mismatch</code> / <code>race-condition</code> / <code>manual-override</code></td>
      </tr>
      <tr>
          <td><code>write_path</code></td>
          <td>哪個程式路徑寫的（callback / refund / manual / reconciliation）</td>
      </tr>
      <tr>
          <td><code>detected_at</code></td>
          <td>偵測時間</td>
      </tr>
  </tbody>
</table>
<p><code>expected_new</code> 跟 <code>new_value</code> 對不上、表示 mapping function 在某些 path 沒被使用、是 mapping bug。<code>legacy_value</code> 跟 <code>new_value</code> 對不上、且 <code>expected_new == legacy_value</code> 對得上、是 dual-write 本身少寫一筆、可能是 race condition 或部分失敗。兩種情況的修法完全不同、不分類會在事故當下亂修。</p>
<p>Dual-write 失敗回退策略：寫舊欄位成功、寫新欄位失敗時、不能直接 retry 新欄位（會跟主寫入競爭）。實務做法是把 divergence 寫進 outbox / repair queue、由 backfill 同類流程補。對應 <a href="/blog/backend/09-performance-capacity/cases/seatgeek-virtual-waiting-room/" data-link-title="9.C16 SeatGeek：DynamoDB &#43; Lambda 打造的虛擬等候室" data-link-desc="SeatGeek 用 DynamoDB 4 張表 &#43; Lambda Bouncer 實作 flash-sale 限流排隊機制、取代第三方 waiting room 服務">9.C16 SeatGeek</a> 的 outbox-style 設計。</p>
<h3 id="線上-ddl-的-vendor-差異">線上 DDL 的 vendor 差異</h3>
<p>Expand 階段加欄位 / 加索引、不同資料庫的 <em>阻塞行為</em> 差異極大、選錯時機會直接讓 production 鎖表。</p>
<ul>
<li><strong>PostgreSQL</strong>：<code>ALTER TABLE ADD COLUMN ... NULL</code> 是 metadata-only、不重寫 table。<code>ADD COLUMN ... NOT NULL DEFAULT ...</code> 在 PG 11+ 才是 metadata-only。<code>CREATE INDEX CONCURRENTLY</code> 不阻塞寫入、但更慢、且 transaction 中不能用。<code>ALTER TABLE ALTER COLUMN TYPE</code> 通常會重寫整張表、要先評估規模。</li>
<li><strong>MySQL / Aurora MySQL</strong>：<code>ALTER TABLE ... ALGORITHM=INSTANT</code> 是 8.0+ 的 metadata-only、5.7 則靠 <code>ALGORITHM=INPLACE</code> / <code>LOCK=NONE</code>。Aurora MySQL 還有 fast DDL（部分變更秒級完成、不重寫）。判讀重點是 <em>explicitly 指定 ALGORITHM</em>、不要讓 MySQL 自己選（可能掉回 COPY 算法、整張表複製）。</li>
<li><strong>Spanner</strong>：schema change 預設非阻塞、後端 async 補欄位。新欄位 read 在 schema change 完成前可能讀不到、應用層要容忍。</li>
<li><strong>DynamoDB</strong>：表本身沒 schema、但 <em>GSI（Global Secondary Index）創建是 async</em>、可能跑數小時、且新 GSI 在 backfill 完成前查不到完整資料。判讀重點：cutover 不能假設新 GSI 立即可用、要等 <code>IndexStatus = ACTIVE</code>。</li>
<li><strong>Cosmos DB</strong>：document 級別無 schema、新 indexed path 加進 indexing policy 後、後端 <em>re-index</em> 整個 partition、期間 RU consumption 飆升。</li>
</ul>
<p>各 vendor 的線上 DDL evidence 都要包含：操作開始時間、預估完成時間、是否阻塞讀寫、實際 lock duration。expand gate 通過條件不能只看 DDL 跑完、要看 <em>所有副效應收斂</em>（index status active、re-indexing 完成、replica 同步）。</p>
<p>對應 vendor pages：<a href="/blog/backend/01-database/vendors/postgresql/" data-link-title="PostgreSQL" data-link-desc="多用途 OLTP 主流關聯式資料庫、MVCC、豐富 SQL 特性、是 Aurora / Cosmos DB / Spanner / CockroachDB / Aurora DSQL 的相容目標">PostgreSQL</a>、<a href="/blog/backend/01-database/vendors/mysql/" data-link-title="MySQL" data-link-desc="高併發網路服務常用關聯式資料庫、Vitess / PlanetScale 分片生態、GitHub / Shopify / Facebook 規模驗證">MySQL</a>、<a href="/blog/backend/01-database/vendors/aurora/" data-link-title="AWS Aurora" data-link-desc="AWS managed PostgreSQL / MySQL、storage / compute 分離、&#43;75% 效能改善的 production 證據">Aurora</a>、<a href="/blog/backend/01-database/vendors/spanner/" data-link-title="Google Cloud Spanner" data-link-desc="全球分散式 strong-consistency OLTP、TrueTime API、線性擴展到 10 億 req/sec">Spanner</a>、<a href="/blog/backend/01-database/vendors/dynamodb/" data-link-title="DynamoDB" data-link-desc="AWS managed key-value、partition-based scaling、9000 萬 RPS sustained 實戰證據">DynamoDB</a>、<a href="/blog/backend/01-database/vendors/cosmosdb/" data-link-title="Azure Cosmos DB" data-link-desc="全球分散式 multi-model DB、5 個 consistency levels、Microsoft 自家 dogfood 證據">Cosmos DB</a> 的線上 DDL 段。</p>
<h2 id="backfill把歷史資料變成可驗證進度">Backfill：把歷史資料變成可驗證進度</h2>
<p>Backfill phase 的核心責任是把歷史資料補齊成可追蹤、可暫停、可重試的進度。訂單表通常會同時承擔交易查詢、客服查詢與對帳查詢；backfill 若只追求速度，容易和線上流量競爭 I/O、放大 replication lag 或改變查詢計畫。</p>
<p>Backfill job 應以 checkpoint 管理進度。每批選取固定範圍的訂單，轉換 <code>status</code> 到 <code>payment_state</code>，寫入後立刻產生該批 validation query 結果。批次大小要能依延遲、鎖等待、replication lag 與線上錯誤率調整。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">checkpoint:
</span></span><span class="line"><span class="ln">2</span><span class="cl">  migration_id: orders-payment-state-2026-05
</span></span><span class="line"><span class="ln">3</span><span class="cl">  last_order_id: 18420000
</span></span><span class="line"><span class="ln">4</span><span class="cl">  batch_size: 5000
</span></span><span class="line"><span class="ln">5</span><span class="cl">  started_at: 2026-05-11T02:10:00Z
</span></span><span class="line"><span class="ln">6</span><span class="cl">  completed_at: 2026-05-11T02:12:40Z
</span></span><span class="line"><span class="ln">7</span><span class="cl">  rows_scanned: 5000
</span></span><span class="line"><span class="ln">8</span><span class="cl">  rows_updated: 4921
</span></span><span class="line"><span class="ln">9</span><span class="cl">  mismatch_count: 3</span></span></code></pre></div><p>Checkpoint 的角色是把 backfill 變成可恢復流程。<code>last_order_id</code> 告訴下一批從哪裡繼續，<code>rows_updated</code> 與 <code>mismatch_count</code> 告訴 gate 這批是否可以被納入放行證據，時間欄位則讓 replication lag、slow query 與錯誤率能回到同一個觀察窗口。</p>
<p><a href="/blog/backend/knowledge-cards/validation-query/" data-link-title="Validation Query" data-link-desc="說明遷移、回填與修復期間如何用查詢證明資料語意是否一致">Validation query</a> 的責任是證明語意一致。最小集合包含總筆數、已補筆數、缺值筆數、新舊語意不一致樣本、每批耗時、慢查詢與 replication lag。這些查詢要保留 <a href="/blog/backend/knowledge-cards/query-link/" data-link-title="Query Link" data-link-desc="說明證據包如何保存可重跑查詢入口，而不是只保留截圖或口頭結論">query link</a> 與 <a href="/blog/backend/knowledge-cards/time-range/" data-link-title="Time Range" data-link-desc="說明證據、查詢與事故判讀如何用時間窗保留可回放上下文">time range</a>，後續才能進入 <a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">4.20 Observability Evidence Package</a>。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w">  </span><span class="k">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">total_rows</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w">  </span><span class="k">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="n">FILTER</span><span class="w"> </span><span class="p">(</span><span class="k">WHERE</span><span class="w"> </span><span class="n">payment_state</span><span class="w"> </span><span class="k">IS</span><span class="w"> </span><span class="k">NULL</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">missing_payment_state</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="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="n">FILTER</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w">    </span><span class="k">WHERE</span><span class="w"> </span><span class="n">payment_state</span><span class="w"> </span><span class="k">IS</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w">      </span><span class="k">AND</span><span class="w"> </span><span class="n">payment_state</span><span class="w"> </span><span class="o">&lt;&gt;</span><span class="w"> </span><span class="n">map_legacy_status_to_payment_state</span><span class="p">(</span><span class="n">status</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="w">  </span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">mismatch_rows</span><span class="w">
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="n">orders</span><span class="w">
</span></span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="k">BETWEEN</span><span class="w"> </span><span class="mi">18415001</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="mi">18420000</span><span class="p">;</span></span></span></code></pre></div><p>Validation query 要和 mapping table 共用同一個語意。資料庫端缺少同一份 mapping function 時，查詢至少要把 mapping 規則展開成明確 CASE expression，並把 query version 保存在 evidence package；這樣事後才能知道 mismatch 是資料錯誤、mapping 規則改變，還是查詢本身落後。</p>
<h2 id="cutover先切讀取再收斂寫入">Cutover：先切讀取，再收斂寫入</h2>
<p>Cutover phase 的核心責任是把服務判讀權交給新欄位，同時保留可回退窗口。對訂單付款狀態來說，切換順序通常先從低風險讀取路徑開始，例如客服後台與內部對帳，再進入 checkout 查詢與使用者可見狀態；每一批切換都要有自己的 <a href="/blog/backend/knowledge-cards/cutover-window/" data-link-title="Cutover Window" data-link-desc="說明正式切換發生的觀察窗口、停止條件與回退判讀範圍">cutover window</a>。</p>
<p>讀取 cutover 的 <a href="/blog/backend/knowledge-cards/stop-condition/" data-link-title="Stop Condition" data-link-desc="說明變更、實驗或事故處理何時必須暫停、回退或改路線">stop condition</a> 要比寫入 cutover 更早觸發。新欄位讀取後出現 mismatch、客服查詢結果漂移、對帳 job 補償量異常時，先回到 <a href="/blog/backend/knowledge-cards/fallback-read/" data-link-title="Fallback Read" data-link-desc="說明讀取路徑切換失敗時如何暫時回到舊資料語意或舊讀取來源">fallback read</a>，讓錯誤限制在判讀層，再重新驗證寫入收斂條件。</p>
<p>寫入 cutover 要確認所有更新來源都已對齊。付款回呼、手動修復、退款、訂單取消與 reconciliation job 都可能更新付款狀態；只切主 checkout 寫入路徑會留下長尾漂移。完成 cutover 前，要用 audit query 確認仍在寫舊欄位的程式路徑已經歸零或被納入例外清單。</p>
<h3 id="shadow-read-patterncutover-前的讀取驗證">Shadow read pattern：cutover 前的讀取驗證</h3>
<p>Shadow read 的責任是讓新讀取路徑在 <em>真實流量</em> 下被驗證、但 <em>不影響使用者結果</em>。這跟 dual-write 是對偶機制：dual-write 證寫入收斂、<a href="/blog/backend/knowledge-cards/shadow-read/" data-link-title="Shadow Read" data-link-desc="說明正式讀取仍走舊路徑時如何暗中讀新路徑比對結果">shadow read</a> 證讀取分歧。</p>
<p>實作模式：</p>
<ol>
<li>每一筆讀取請求、同時用 <em>舊邏輯</em> 跟 <em>新邏輯</em> 查一次。</li>
<li>回給用戶的仍是舊邏輯結果（用戶體驗不變）。</li>
<li>在背景把兩個結果差異寫進 divergence log。</li>
<li>收集足夠樣本後、再決定切換 cutover。</li>
</ol>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">readPaymentStateWithShadow(order):
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  legacy = mapLegacyStatusToPaymentState(order.status)
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  new_result = order.payment_state ?? legacy
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  if legacy != new_result:
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    asyncLogDivergence({
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">      order_id: order.id,
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">      legacy: legacy,
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">      new: new_result,
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">      sample_at: now(),
</span></span><span class="line"><span class="ln">10</span><span class="cl">      caller: requestContext.caller,
</span></span><span class="line"><span class="ln">11</span><span class="cl">    })
</span></span><span class="line"><span class="ln">12</span><span class="cl">  return legacy  // 用戶仍拿舊邏輯結果</span></span></code></pre></div><p>Shadow read 的判讀重點：</p>
<ul>
<li><strong>抽樣率</strong>：1% / 10% / 100% — 高流量場景全量 shadow 會雙倍 DB 讀取、要先評估容量。Cosmos DB / DynamoDB 的 RU 成本要乘 2。</li>
<li><strong>分歧分類</strong>：跟 dual-write 一樣、divergence 要分類（mapping bug / race condition / <a href="/blog/backend/knowledge-cards/stale-read/" data-link-title="Stale Read" data-link-desc="讀取到落後於最新寫入版本的舊資料">stale read</a>）、不分類無法定位修法。</li>
<li><strong>覆蓋條件</strong>：要驗證所有 caller path（checkout / support / reconciliation / external API）都跑過 shadow、否則 cutover 後可能踩到沒測試過的 path。</li>
<li><strong>退場條件</strong>：shadow read 不該長期跑、會增加負載。設明確 sunset deadline、cutover 完成後一週內移除。</li>
</ul>
<p>對應 <a href="/blog/backend/09-performance-capacity/cases/zomato-tidb-to-dynamodb-migration/" data-link-title="9.C20 Zomato：從 TiDB 遷移到 DynamoDB、吞吐 4 倍、延遲降 90%、成本減 50%" data-link-desc="Zomato 帳單系統從 TiDB 遷移到 DynamoDB、吞吐 2K→8K RPM、延遲降 90%、成本減 50%">9.C20 Zomato TiDB → DynamoDB migration</a> — migration 期間用 shadow read 持續驗證 mapping 規則、抓到 mapping drift。</p>
<p>Dual-write 跟 shadow read 的選擇不是互斥、是依風險組合：</p>
<table>
  <thead>
      <tr>
          <th>風險場景</th>
          <th>建議組合</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>新邏輯只影響讀取（cache、index）</td>
          <td>shadow read 即可、不需要 dual-write</td>
      </tr>
      <tr>
          <td>新欄位是 source of truth</td>
          <td>dual-write 必要、cutover 前加 shadow read 驗證</td>
      </tr>
      <tr>
          <td>跨 service 共用欄位</td>
          <td>dual-write + shadow read + cross-service contract test</td>
      </tr>
      <tr>
          <td>跨 region migration</td>
          <td>dual-write + shadow read + 跨 region replication evidence</td>
      </tr>
  </tbody>
</table>
<h2 id="multi-region-與跨服務協調">Multi-region 與跨服務協調</h2>
<p>Migration 跨越 region 或多個 service 時、rollout 順序錯誤是最常見的失敗模式。Service A 切到新欄位、service B 還在讀舊欄位、結果整條業務流量看到不一致。</p>
<h3 id="multi-region-rollout-順序">Multi-region rollout 順序</h3>
<p>跨 region 的 schema migration 要從 <em>最後寫入點</em> 開始 expand、從 <em>最後讀取點</em> 開始 cutover。先 expand 寫端、再 expand 讀端；先 cutover 讀端、再 cutover 寫端。順序反了會在過渡期讀到沒被寫的新欄位、或寫了沒被讀的新欄位。</p>
<p>實務步驟：</p>
<ol>
<li><strong>Schema expand</strong>：所有 region 同步加新欄位（先寫端再讀端、不能跳）。確認跨 region replication lag 在新欄位上收斂、再進下一步。</li>
<li><strong>Backfill</strong>：可以平行跑、但每 region 各自 checkpoint、不共用。某 region backfill stuck 不應該卡住其他 region。</li>
<li><strong>Cutover read</strong>：region by region 切讀、用 canary region 先試 24-48 小時、再擴散。</li>
<li><strong>Cutover write</strong>：所有 region 都切完讀、再統一切寫。寫端切換比讀端更敏感、跨 region 寫差異會放大成跨 region inconsistency。</li>
</ol>
<p>對應 <a href="/blog/backend/01-database/global-distributed-oltp/" data-link-title="1.11 全球分散式 OLTP" data-link-desc="Spanner / Aurora DSQL / Cosmos DB multi-region write / CockroachDB / TiDB 的全球一致性取捨">1.11 全球分散式 OLTP</a> 的跨 region consistency 段。</p>
<h3 id="cross-service-migration-協調">Cross-service migration 協調</h3>
<p>當 schema 變更影響多個 service 時、API contract 是 <em>鬆耦合</em> 介面、不該讓所有 service 同步切換。</p>
<p>協調機制：</p>
<ul>
<li><strong>新欄位先在 API 是 optional</strong>：API contract 加新欄位、預設 nullable / optional。下游 service 可選擇何時讀。</li>
<li><strong>舊欄位保留至少一個版本週期</strong>：API 不能跟 DB schema 同步 contract、否則下游沒時間切。實務上保留 1-2 季、給下游充足 cutover 窗口。</li>
<li><strong>owner-by-owner cutover roster</strong>：明確列出每個下游 service 的 owner、預計 cutover 時間、目前狀態。常用工具是共享 dashboard、不是散落的 ticket。</li>
<li><strong>Contract test</strong>：每個下游 service 對新欄位都要有 contract test、在 CI gate 跑過。避免上游 cutover 後下游才發現沒讀對。</li>
</ul>
<p>對應案例：<a href="/blog/backend/09-performance-capacity/cases/zomato-tidb-to-dynamodb-migration/" data-link-title="9.C20 Zomato：從 TiDB 遷移到 DynamoDB、吞吐 4 倍、延遲降 90%、成本減 50%" data-link-desc="Zomato 帳單系統從 TiDB 遷移到 DynamoDB、吞吐 2K→8K RPM、延遲降 90%、成本減 50%">9.C20 Zomato TiDB → DynamoDB</a> — 跨多個 service 的 access pattern 變更、必須每個 service 各自驗證、不能假設「DB 切了就好」。</p>
<h2 id="evidence-package">Evidence Package</h2>
<p>資料庫 migration 的 evidence package 負責證明資料演進是否可判讀。這份 package 要把 validation query、時間窗、資料限制與 owner 包成後續放行與事故判斷可引用的證據，dashboard 只作為摘要入口。</p>
<table>
  <thead>
      <tr>
          <th>欄位</th>
          <th>訂單欄位演進中的內容</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Source</td>
          <td>validation query、DB metric、migration job log、audit log</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/time-range/" data-link-title="Time Range" data-link-desc="說明證據、查詢與事故判讀如何用時間窗保留可回放上下文">Time range</a></td>
          <td>expand、backfill、cutover 各階段的查詢窗口</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/query-link/" data-link-title="Query Link" data-link-desc="說明證據包如何保存可重跑查詢入口，而不是只保留截圖或口頭結論">Query link</a></td>
          <td>row count、mismatch sample、replication lag、slow query</td>
      </tr>
      <tr>
          <td>Owner</td>
          <td>database owner、checkout owner、reconciliation owner</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/data-quality/" data-link-title="Data Quality" data-link-desc="說明證據欄位如何標示 completeness、freshness、sampling 與資料限制">Data quality</a></td>
          <td>query 延遲、replica freshness、sample completeness</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/confidence/" data-link-title="Confidence" data-link-desc="說明證據包如何標示 confirmed、suspected 或 needs follow-up 的判讀信心">Confidence</a></td>
          <td>confirmed / suspected / needs follow-up</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/known-gap/" data-link-title="Known Gap" data-link-desc="說明證據包如何明確保存已知缺口，避免下游高估證據完整性">Known gap</a></td>
          <td>未覆蓋的手動修復路徑、低流量 tenant、延遲回呼</td>
      </tr>
  </tbody>
</table>
<p>Source 欄位要保留資料來源的能力邊界。Validation query 能證明欄位語意一致，DB metric 能看出 latency 與 lag，job log 能追進度，audit log 能判斷是否有高權限修復行為。把這些來源混在一起會讓下游誤判證據的用途。</p>
<p><a href="/blog/backend/knowledge-cards/data-quality/" data-link-title="Data Quality" data-link-desc="說明證據欄位如何標示 completeness、freshness、sampling 與資料限制">Data quality</a> 欄位要直接寫出限制。若查詢只跑 primary、replica lag 還在回復、某些 tenant 因資料遮罩未被抽樣，這些限制要跟 evidence 一起交給 release gate，讓 gate 能以證據完整度決定是否放行。</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">evidence_package</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">name</span><span class="p">:</span><span class="w"> </span><span class="l">orders-payment-state-cutover-batch-37</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">source</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">    </span>- <span class="nt">validation_query</span><span class="p">:</span><span class="w"> </span><span class="l">q_orders_payment_state_batch_37</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">db_metric</span><span class="p">:</span><span class="w"> </span><span class="l">replication_lag_orders_primary</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">    </span>- <span class="nt">job_log</span><span class="p">:</span><span class="w"> </span><span class="l">backfill_orders_payment_state_2026_05</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">  </span><span class="nt">time_range</span><span class="p">:</span><span class="w"> </span><span class="ld">2026-05-11T02:10:00Z</span><span class="l">/2026-05-11T02:20:00Z</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">  </span><span class="nt">owner</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">    </span><span class="nt">database</span><span class="p">:</span><span class="w"> </span><span class="l">data-platform-oncall</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">    </span><span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="l">checkout-oncall</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">    </span><span class="nt">reconciliation</span><span class="p">:</span><span class="w"> </span><span class="l">finance-ops-owner</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">  </span><span class="nt">data_quality</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">    </span><span class="nt">replica_freshness</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;primary only; replica lag still recovering&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w">    </span><span class="nt">sample_completeness</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;tenant tier enterprise covered; sandbox tenants excluded&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w">  </span><span class="nt">confidence</span><span class="p">:</span><span class="w"> </span><span class="l">suspected</span><span class="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w">  </span><span class="nt">known_gap</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="s2">&#34;manual refund repair path not yet sampled&#34;</span></span></span></code></pre></div><p>這份 package 故意把 <code>confidence</code> 標成 <code>suspected</code>。原因是 evidence 已能支持 backfill 繼續前進，但還不足以支持使用者可見讀取 cutover；這種中間狀態要被明確寫出，gate 才能做分階段決策。</p>
<h2 id="release-gate">Release Gate</h2>
<p>Schema migration 的 release gate 負責判斷下一階段是否可以放行。它接收 evidence package，但決策語言要回到 <a href="/blog/backend/06-reliability/release-gate/" data-link-title="6.8 Release Gate 與變更節奏" data-link-desc="把驗證、migration、相容性納入放行判準">6.8 Release Gate 與變更節奏</a>：<code>Gate decision</code>、<code>Checks</code>、<code>Stop condition</code>、<code>Rollback window</code>、<code>Owner</code>。</p>
<table>
  <thead>
      <tr>
          <th>Gate 欄位</th>
          <th>這條路徑的最小內容</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/gate-decision/" data-link-title="Gate Decision" data-link-desc="說明 release gate 如何把證據轉成放行、暫停、回退或補證據的決策">Gate decision</a></td>
          <td>放行下一批 backfill、暫停 cutover、回到 fallback read 或 fail-forward</td>
      </tr>
      <tr>
          <td>Checks</td>
          <td>compatibility result、mismatch rate、replication lag、slow query</td>
      </tr>
      <tr>
          <td>Stop condition</td>
          <td>mismatch 超門檻、交易錯誤率上升、lag 超窗口、客服查詢漂移</td>
      </tr>
      <tr>
          <td>Rollback window</td>
          <td>讀取 fallback 可用時間、舊欄位可支撐多久、contract 前最後回退點</td>
      </tr>
      <tr>
          <td>Owner</td>
          <td>migration owner、service owner、on-call owner</td>
      </tr>
  </tbody>
</table>
<p><a href="/blog/backend/knowledge-cards/gate-decision/" data-link-title="Gate Decision" data-link-desc="說明 release gate 如何把證據轉成放行、暫停、回退或補證據的決策">Gate decision</a> 要用服務語言書寫。<code>migration pass</code> 這種結論對下游不夠具體；<code>放行 10% 訂單 backfill</code>、<code>暫停使用者可見讀取 cutover</code>、<code>維持 fallback read 24 小時</code> 才能讓執行團隊知道下一步。</p>
<p><a href="/blog/backend/knowledge-cards/rollback-window/" data-link-title="Rollback Window" data-link-desc="說明變更進入 production 後還能用哪種方式回退或改路線的時間與條件">Rollback window</a> 是資料庫 migration 的關鍵欄位。Expand 與 backfill 階段通常能回到舊讀取；cutover 後仍可 fallback；contract 後舊語意被移除，回退會變成資料修復或 <a href="/blog/backend/knowledge-cards/fail-forward/" data-link-title="Fail-forward" data-link-desc="說明無法回到舊狀態時如何用受控前進完成修復">fail-forward</a>。gate 要在每階段說清楚目前還剩哪種退路。</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">release_gate</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">gate_decision</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;allow next 10% backfill; block customer-visible read cutover&#34;</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">checks</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">    </span><span class="nt">mismatch_rate</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;0.04%, below 0.1% batch threshold&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">    </span><span class="nt">replication_lag</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;p95 12s, below 30s stop condition&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">    </span><span class="nt">slow_query</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;no new support-admin slow query above 500ms&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">  </span><span class="nt">stop_condition</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="s2">&#34;mismatch_rate &gt;= 0.1% for two consecutive batches&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">    </span>- <span class="s2">&#34;replication_lag &gt;= 30s for 10 minutes&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">    </span>- <span class="s2">&#34;support-admin query drift confirmed by reconciliation owner&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">  </span><span class="nt">rollback_window</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;fallback read available until contract phase starts&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">  </span><span class="nt">owner</span><span class="p">:</span><span class="w"> </span><span class="l">checkout-oncall</span></span></span></code></pre></div><p>這份 gate record 把「繼續 backfill」和「暫緩讀取 cutover」拆成兩個決策。資料庫 migration 常見的判讀問題是 evidence 只支撐下一批資料修補，還支撐不了使用者可見行為切換。</p>
<h2 id="incident-decision-log">Incident Decision Log</h2>
<p>Migration 進入 production 後，pause、rollback 與 fail-forward 都是事故決策。這些決策要同步寫入 <a href="/blog/backend/08-incident-response/incident-decision-log/" data-link-title="8.19 Incident Decision Log" data-link-desc="把事中假設、決策、證據、回退條件與責任人留下可復盤紀錄">8.19 Incident Decision Log</a>，讓事中交班與事後復盤能回放當時的證據與限制。</p>
<p>常見決策包括暫停 backfill、降低 batch size、回到舊讀取、停止 contract、手動修補 mismatch、選擇 fail-forward。每筆都要保留 <code>Timestamp</code>、<code>Decision</code>、<code>Context</code>、<code>Evidence</code>、<code>Owner</code>、<code>Expected effect</code> 與 <a href="/blog/backend/knowledge-cards/rollback-condition/" data-link-title="Rollback Condition" data-link-desc="說明決策執行後出現哪些訊號時要撤回、回退或改路線">rollback condition</a>。</p>
<p>例如 cutover 後發現客服查詢 mismatch 升高，decision log 可以寫成：</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">incident_decision</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">timestamp</span><span class="p">:</span><span class="w"> </span><span class="ld">2026-05-11T03:05:00Z</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">decision</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;rollback support-admin read path to legacy status fallback&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">  </span><span class="nt">context</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;support-admin mismatch increased after internal read cutover&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">  </span><span class="nt">evidence</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">    </span>- <span class="nt">query</span><span class="p">:</span><span class="w"> </span><span class="l">q_orders_payment_state_support_mismatch</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">    </span>- <span class="nt">window</span><span class="p">:</span><span class="w"> </span><span class="ld">2026-05-11T02:35:00Z</span><span class="l">/2026-05-11T03:05:00Z</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">    </span>- <span class="nt">interpretation</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;suspected callback mapping drift&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">  </span><span class="nt">owner</span><span class="p">:</span><span class="w"> </span><span class="l">checkout-incident-commander</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">  </span><span class="nt">expected_effect</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;support ticket misclassification returns to baseline&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">  </span><span class="nt">rollback_condition</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;mismatch remains above threshold after 15 minutes&#34;</span></span></span></code></pre></div><p>這種記錄能避免事後只剩「當時有回退」的模糊敘事。後續 <a href="/blog/backend/08-incident-response/control-plane-decision-log-write-back/" data-link-title="8.23 Control Plane Decision Log and Write-back 實作示範" data-link-desc="以 rule/config rollout 事故示範 decision log 與 write-back 如何形成可回放閉環。">8.23 Control Plane Decision Log and Write-back 實作示範</a> 可承接同一組決策紀錄，把缺少 validation、owner 或 runbook 的地方回寫成改善項。</p>
<h2 id="判讀訊號">判讀訊號</h2>
<p>判讀訊號的責任是讓讀者知道何時該繼續、何時該停、何時該改路線。Migration 訊號要同時看資料正確性、線上健康度與回退窗口。</p>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>判讀重點</th>
          <th>對應動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>mismatch rate 持續低於門檻</td>
          <td>新舊欄位語意大致一致</td>
          <td>放行下一批 backfill 或低風險讀取 cutover</td>
      </tr>
      <tr>
          <td>mismatch 樣本集中在特定 callback</td>
          <td>轉換函式或特定付款路徑語意不一致</td>
          <td>暫停 cutover，修 mapping 後重跑該批</td>
      </tr>
      <tr>
          <td>dual-write divergence 分布偏向 mapping</td>
          <td>mapping function 在某 path 沒被使用</td>
          <td>找出該 path、強制走共用 mapping function</td>
      </tr>
      <tr>
          <td>dual-write divergence 偏向 race</td>
          <td>部分寫入失敗、寫順序問題</td>
          <td>切到 outbox-based dual-write、別直連</td>
      </tr>
      <tr>
          <td>shadow read 抽樣 RU 飆升</td>
          <td>shadow 讀取沒設抽樣率、雙倍負載</td>
          <td>降低抽樣率、或改成 off-peak shadow</td>
      </tr>
      <tr>
          <td>replication lag 在 backfill 升高</td>
          <td>migration 與線上查詢競爭資源</td>
          <td>降低 batch size，避開 peak，延長觀察窗口</td>
      </tr>
      <tr>
          <td>slow query 出現在客服查詢</td>
          <td>新欄位索引或查詢模型未對齊</td>
          <td>回到 fallback read，補 index 或改查詢條件</td>
      </tr>
      <tr>
          <td>DynamoDB GSI 仍在 building</td>
          <td>cutover 前依賴未 ACTIVE 的 GSI</td>
          <td>等 GSI ACTIVE 再切讀、別假設立即可用</td>
      </tr>
      <tr>
          <td>跨 region replica lag 在新欄位上漂移</td>
          <td>expand 階段沒等所有 region 收斂</td>
          <td>暫停 backfill、等 region 同步</td>
      </tr>
      <tr>
          <td>某下游 service 沒 cutover</td>
          <td>cross-service 協調沒做 contract test</td>
          <td>補 contract test、推遲 contract 階段</td>
      </tr>
      <tr>
          <td>contract 前仍有舊欄位寫入</td>
          <td>更新來源尚未完全收斂</td>
          <td>延後 contract，盤點寫入來源與 owner</td>
      </tr>
  </tbody>
</table>
<p>這些訊號要放回服務路徑判讀。Mismatch 要看集中在哪個業務入口；若 mismatch 只出現在延遲付款 callback，它代表外部 provider 回呼語意未對齊。Replication lag 要看是否和 backfill 批次對位；若它只在 backfill 批次出現，gate 應調整 migration 節奏，再判斷 schema 設計是否需要修正。</p>
<p>Dual-write 跟 shadow read 的 divergence 要分開看 — 兩者偵測不同層的問題。Dual-write divergence 偏向 mapping bug 或 race condition；shadow read divergence 偏向讀取邏輯漂移或 stale read。混在同一個 dashboard 會讓 reviewer 看不出問題真正在哪一層。</p>
<h2 id="常見誤區">常見誤區</h2>
<p>把 schema migration 寫成 DDL 任務，會讓風險集中在切換當下。穩定做法是先建立相容窗口，再用 evidence 證明資料語意已經跟上，最後才收斂舊路徑。</p>
<p>把 validation query 當成事後對帳，也會削弱 rollout 控制。Validation query 適合在 expand、backfill、cutover 每一階段都產生證據，讓 release gate 能在風險擴大前停下來。</p>
<p>把 rollback 寫成單一動作容易誤導團隊。資料庫 migration 的 rollback 會隨階段改變：expand 可回退 schema 使用，backfill 可暫停與重跑，cutover 可回到 fallback read，contract 後多半只能做資料修復或 fail-forward。</p>
<p>把 dual-write 跟 shadow read 當成同一個工具。兩者偵測不同層、結合使用可以互補、互相替代會留下盲點。Dual-write 不跑 shadow read、cutover 後可能踩到沒驗過的讀取 path；shadow read 不跑 dual-write、新欄位可能在某些寫路徑根本沒被寫進去。</p>
<p>把線上 DDL 當「一個 SQL 跑完就好」。各 vendor 的 DDL 語意差異大、PostgreSQL 的 <code>ADD COLUMN NOT NULL DEFAULT</code> 在 PG 10 重寫整張表、PG 11+ 是 metadata-only；MySQL 不指定 <code>ALGORITHM=INSTANT</code> 可能掉回 COPY。Expand evidence 要包含 <em>實際 lock duration</em>、不是只看 DDL 是否回傳成功。</p>
<p>只在主寫入路徑切 cutover、忘記補償流程跟 reconciliation job 也會寫舊欄位。這些長尾寫入會在 contract 階段才暴露、那時候已經沒有 fallback 可走。Cutover 前要 audit 所有寫舊欄位的程式路徑、不只看主流程。</p>
<h2 id="案例回寫">案例回寫</h2>
<p><a href="/blog/backend/00-service-selection/cases/post-scale-migration-language-tool-architecture/" data-link-title="營運後技術轉換：語言、工具與架構何時該換" data-link-desc="服務營運一段時間後，如何判讀何時該轉語言、工具或架構，並用案例說明轉換動機。">0.C4 營運後技術轉換</a> 可以回寫這篇的決策層。當服務營運後需要拆欄位、拆庫、分片或升級儲存引擎，先用 0.C4 判斷「為什麼要換」，再用本篇判斷「進入 production 後如何證明每一步成立」。</p>
<p><a href="/blog/backend/08-incident-response/cases/github/2018-oct21-mysql-topology-incident/" data-link-title="GitHub 2018 Oct21 MySQL Topology Incident" data-link-desc="2018-10-21 GitHub 因 network partition 觸發跨區資料庫拓撲異常的事故解析：資料一致性優先、fail-forward 決策與長時間恢復。">GitHub 2018 Oct21 MySQL Topology Incident</a> 可以回寫這篇的事故層。該事件顯示資料一致性優先時，團隊需要可回放的 fail-forward / fail-back 判準；本篇則把這個需求落到 migration rollout 的 evidence、gate 與 decision log。</p>
<p>這兩個案例共同支撐的是「資料狀態演進需要證據閉環」。0.C4 提供轉換動機與選型壓力，GitHub 事故提供資料一致性與恢復決策的代價；兩者都不直接替代 validation query、release gate 與 decision log 的實作細節。</p>
<h2 id="跨模組路由">跨模組路由</h2>
<ol>
<li>與 1.2 的交接：欄位責任、命名與查詢模型回到 <a href="/blog/backend/01-database/schema-design/" data-link-title="1.2 Schema Design 與資料建模" data-link-desc="整理 table、index、key、partition、denormalization 與命名規則">schema design</a>。</li>
<li>與 1.3 的交接：付款回呼、手動修復與對帳更新的交易邊界回到 <a href="/blog/backend/01-database/transaction-boundary/" data-link-title="1.3 Transaction 與一致性邊界" data-link-desc="交易邊界、isolation level、retry 策略、distributed transaction（2PC、Saga）與跨 region 強一致取捨">transaction boundary</a>。</li>
<li>與 1.6 的交接：expand、backfill、cutover 與 contract 的執行流程回到 <a href="/blog/backend/01-database/database-migration-playbook/" data-link-title="1.6 資料庫轉換實作：雙寫、回填、切流與回滾" data-link-desc="同 DB 內 schema 演進與資料變更的可分段驗證流程、跟 1.12 cross-DB migration 分工">資料庫轉換實作</a>。</li>
<li>與 4.20 / 4.22 的交接：validation query、row count、lag 與 slow query 進入 <a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">Observability Evidence Package</a> 與 <a href="/blog/backend/04-observability/checkout-api-evidence-package/" data-link-title="4.22 Checkout API Evidence Package 實作示範" data-link-desc="用 checkout 路徑示範 evidence package 如何交接給 release gate 與 incident decision。">Checkout API Evidence Package</a>。</li>
<li>與 6.11 / 6.8 / 6.25 的交接：migration 可逆性與放行條件進入 <a href="/blog/backend/06-reliability/migration-safety/" data-link-title="6.11 Migration Safety 與 DB Rollout" data-link-desc="把 schema migration 從一次性事件變成可逆、可漸進的 rollout 流程">Migration Safety</a>、<a href="/blog/backend/06-reliability/release-gate/" data-link-title="6.8 Release Gate 與變更節奏" data-link-desc="把驗證、migration、相容性納入放行判準">Release Gate</a> 與 <a href="/blog/backend/06-reliability/provider-dependency-release-gate/" data-link-title="6.25 Provider Dependency Release Gate 實作示範" data-link-desc="以 payment provider 變更示範 release gate 如何結合 evidence、stop condition 與 rollback window。">Provider Dependency Release Gate</a>。</li>
<li>與 8.19 / 8.23 的交接：pause、rollback、fail-forward 與 write-back 進入 <a href="/blog/backend/08-incident-response/incident-decision-log/" data-link-title="8.19 Incident Decision Log" data-link-desc="把事中假設、決策、證據、回退條件與責任人留下可復盤紀錄">Incident Decision Log</a> 與 <a href="/blog/backend/08-incident-response/control-plane-decision-log-write-back/" data-link-title="8.23 Control Plane Decision Log and Write-back 實作示範" data-link-desc="以 rule/config rollout 事故示範 decision log 與 write-back 如何形成可回放閉環。">Control Plane Decision Log and Write-back</a>。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<p>要把資料庫 migration 的 evidence 交給 release gate，接著讀 <a href="/blog/backend/06-reliability/provider-dependency-release-gate/" data-link-title="6.25 Provider Dependency Release Gate 實作示範" data-link-desc="以 payment provider 變更示範 release gate 如何結合 evidence、stop condition 與 rollback window。">6.25 Provider Dependency Release Gate 實作示範</a>，並把 provider 依賴示範中的 gate 欄位改寫成 migration gate 欄位。要看下一條分類服務路徑，接著進 <a href="/blog/backend/02-cache-redis/" data-link-title="模組二：快取與 Redis" data-link-desc="整理快取策略、Redis 資料型別與分散式狀態輔助能力">02 Cache / Redis 模組</a> 的 <code>Cache migration and stampede rollback</code> 服務路徑。</p>
<p>跨 vendor schema migration 深入：</p>
<ul>
<li><a href="/blog/backend/01-database/vendors/spanner/schema-migration-interleaved-tables/" data-link-title="Spanner Schema Migration Without Downtime &#43; Interleaved Tables" data-link-desc="Spanner DDL 是 long-running operation、用 TrueTime 給每次 schema change 分配 version timestamp、所有 read / write 對應自己 transaction timestamp 看到對應 schema。Interleaved table 是 storage-level parent-child 物理交錯、不是 logical FK。本文走 schema change lifecycle、interleaved layout 機制、backfill capacity 影響、5 production 踩雷、跟 PostgreSQL online schema change 對照">Spanner interleaved table 的 schema migration</a> — 全球分散式表結構變更的 evidence shape</li>
<li><a href="/blog/backend/01-database/vendors/aurora/migrate-from-self-managed-pg-mysql/" data-link-title="從自管 PostgreSQL / MySQL 遷到 Aurora：operational redesign migration playbook" data-link-desc="PostgreSQL / MySQL → Aurora 的 Type C operational redesign hybrid playbook、6 規格面（Driver / Diff audit / Phase plan / Evidence / Cutover / Cleanup）、Standard Chartered 合規 lead time 模型、Netflix 非 all-purpose store 邊界">Aurora 從自管 PostgreSQL / MySQL 遷入</a> — schema 比對與 dual-write 證據鏈</li>
<li><a href="/blog/backend/01-database/vendors/cosmosdb/mongodb-api-vs-sql-api/" data-link-title="Cosmos DB MongoDB API vs SQL API：遷移路徑、dogfood signal、multi-model、跨雲 hedging" data-link-desc="從『MongoDB API 跟 SQL API 哪個快』推進到 vendor selection 的四層問題：三型遷移路徑、dogfood signal 怎麼讀、multi-model 差異化、跨雲 hedging — 從 Microsoft 365 dogfood 案例切入">Cosmos DB MongoDB API vs SQL API</a> — multi-API document 在 rollout 階段的相容性 evidence</li>
</ul>
]]></content:encoded></item><item><title>3.8 Queue Consumer Retry 與 Replay Handoff（實作示範）</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/queue-consumer-retry-replay-handoff/</link><pubDate>Mon, 11 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/queue-consumer-retry-replay-handoff/</guid><description>&lt;p>Queue consumer retry 與 replay handoff 的核心責任是把 request 外副作用做成可重試、可去重、可隔離、可重播的服務流程。這篇以 &lt;code>order_created&lt;/code> consumer 為例，示範 delivery、processing、recovery 三層語意如何交接到 evidence package、release gate 與 incident decision log。&lt;/p>
&lt;h2 id="服務路徑與語意分層">服務路徑與語意分層&lt;/h2>
&lt;p>這條路徑是 &lt;code>order-service -&amp;gt; broker -&amp;gt; order-created-consumer -&amp;gt; invoice/email/search/webhook&lt;/code>。Producer 把事件交給 broker 後，真正的業務完成要看 consumer 是否正確提交副作用。&lt;/p>
&lt;p>這篇先固定三層語意：&lt;/p>
&lt;ol>
&lt;li>Delivery semantics：訊息是否投遞與確認。&lt;/li>
&lt;li>Processing semantics：副作用是否可承受重複與部分失敗。&lt;/li>
&lt;li>Recovery semantics：故障後是否可重播並恢復一致。&lt;/li>
&lt;/ol>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">ack/nack&lt;/a> 成功只代表 delivery 進度，不代表發票與通知已完成。&lt;/p>
&lt;h2 id="event-contract-與相容邊界">Event Contract 與相容邊界&lt;/h2>
&lt;p>Event contract 的責任是讓 producer 與 consumer 在版本演進時仍可互通，且可被觀測與回放。&lt;/p>
&lt;p>&lt;code>order_created&lt;/code> 最小欄位：&lt;/p>
&lt;ol>
&lt;li>&lt;code>event_id&lt;/code>：全域唯一識別。&lt;/li>
&lt;li>&lt;code>schema_version&lt;/code>：事件版本。&lt;/li>
&lt;li>&lt;code>occurred_at&lt;/code>：事件發生時間。&lt;/li>
&lt;li>&lt;code>order_id&lt;/code>、&lt;code>tenant_id&lt;/code>：業務定位。&lt;/li>
&lt;li>&lt;code>idempotency_key&lt;/code>：副作用去重鍵。&lt;/li>
&lt;li>&lt;code>pii_scope&lt;/code>：敏感欄位範圍。&lt;/li>
&lt;/ol>
&lt;p>版本演進採向後相容優先：新增欄位可選、舊欄位保留窗口。schema 演進前要先確認 consumer 端 fallback 解析邏輯存在，避免切版後整批進 DLQ。&lt;/p>
&lt;h2 id="retry--dlq--quarantine">Retry / DLQ / Quarantine&lt;/h2>
&lt;p>Retry 的責任是吸收暫時性故障，不把短暫抖動升級成事故。這條路徑使用有限重試 + backoff + jitter：&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>下游短暫 timeout 或限流&lt;/td>
 &lt;td>在主通道重試少量次數&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>延遲重試&lt;/td>
 &lt;td>故障持續但可恢復&lt;/td>
 &lt;td>延長 backoff，避免重試風暴&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>DLQ 隔離&lt;/td>
 &lt;td>payload 或版本異常、長時故障&lt;/td>
 &lt;td>轉入 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/dead-letter-queue/" data-link-title="Dead-Letter Queue" data-link-desc="說明 dead-letter queue 如何隔離多次處理失敗的訊息">dead-letter queue&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Quarantine&lt;/td>
 &lt;td>同型 poison message 連續爆發&lt;/td>
 &lt;td>停主通道回放，先分群診斷&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>DLQ 的責任是隔離與診斷，不是永久儲存。重點是把異常訊息分群後對應修法，修完再定向回放。&lt;/p>
&lt;h2 id="idempotency-與-ack-timing">Idempotency 與 Ack Timing&lt;/h2>
&lt;p>Idempotency 的責任是把 at-least-once 交付轉成可接受業務結果。副作用如發票、email、webhook 都要以 &lt;code>idempotency_key&lt;/code> 做去重。&lt;/p>
&lt;p>Ack timing 的原則是「核心副作用提交後再 ack」：&lt;/p>
&lt;ol>
&lt;li>先執行副作用或落地可追蹤結果。&lt;/li>
&lt;li>成功後寫去重紀錄或 checkpoint。&lt;/li>
&lt;li>最後 ack broker。&lt;/li>
&lt;/ol>
&lt;p>先 ack 再副作用會造成資料遺失；副作用成功但去重紀錄失敗，則要由 recovery 層補償。&lt;/p>
&lt;h2 id="replay-runbook">Replay Runbook&lt;/h2>
&lt;p>Replay 的責任是故障後在可控範圍內恢復，不把修復變成第二次事故。&lt;/p>
&lt;p>這條路徑的 replay runbook：&lt;/p>
&lt;ol>
&lt;li>選定 replay window：依 &lt;code>occurred_at&lt;/code> 與 &lt;code>schema_version&lt;/code> 分段。&lt;/li>
&lt;li>Dry run：先在影子通道跑去重與下游容量驗證。&lt;/li>
&lt;li>限速回放：按 tenant 或 partition 分批，監控下游錯誤率。&lt;/li>
&lt;li>Reconciliation：對帳發票、通知、索引結果。&lt;/li>
&lt;li>Stop condition：duplicate side-effect、downstream timeout、DLQ 再爆發即停。&lt;/li>
&lt;/ol>
&lt;p>replay window 要能被明確描述與回放，不可用「重播昨天全部」這種不可驗證句子。&lt;/p>
&lt;h2 id="job-queue-的拓樸分工">Job queue 的拓樸分工&lt;/h2>
&lt;p>當背景工作同時要 &lt;em>高吞吐&lt;/em> 跟 &lt;em>快速反應&lt;/em>、單一通道模型會變成瓶頸。job queue 的擴展通常是 &lt;em>拓樸重整&lt;/em>、把不同工作類型切到不同傳遞路徑、而非單點替換。&lt;/p>
&lt;p>對應 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/slack-job-queue-kafka-redis/" data-link-title="3.C5 Slack：Job Queue 演進到 Kafka &amp;#43; Redis" data-link-desc="背景工作通道在成長期如何從單一路徑演進成組合式架構。">3.C5 Slack Job Queue 演進到 Kafka + Redis&lt;/a> — Slack 在 job queue 擴展時把工作切到不同傳遞路徑、Kafka 跟 Redis 分別承擔持久性跟即時性目標、分開治理 lag、重試跟失敗重播。&lt;/p></description><content:encoded><![CDATA[<p>Queue consumer retry 與 replay handoff 的核心責任是把 request 外副作用做成可重試、可去重、可隔離、可重播的服務流程。這篇以 <code>order_created</code> consumer 為例，示範 delivery、processing、recovery 三層語意如何交接到 evidence package、release gate 與 incident decision log。</p>
<h2 id="服務路徑與語意分層">服務路徑與語意分層</h2>
<p>這條路徑是 <code>order-service -&gt; broker -&gt; order-created-consumer -&gt; invoice/email/search/webhook</code>。Producer 把事件交給 broker 後，真正的業務完成要看 consumer 是否正確提交副作用。</p>
<p>這篇先固定三層語意：</p>
<ol>
<li>Delivery semantics：訊息是否投遞與確認。</li>
<li>Processing semantics：副作用是否可承受重複與部分失敗。</li>
<li>Recovery semantics：故障後是否可重播並恢復一致。</li>
</ol>
<p><a href="/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">ack/nack</a> 成功只代表 delivery 進度，不代表發票與通知已完成。</p>
<h2 id="event-contract-與相容邊界">Event Contract 與相容邊界</h2>
<p>Event contract 的責任是讓 producer 與 consumer 在版本演進時仍可互通，且可被觀測與回放。</p>
<p><code>order_created</code> 最小欄位：</p>
<ol>
<li><code>event_id</code>：全域唯一識別。</li>
<li><code>schema_version</code>：事件版本。</li>
<li><code>occurred_at</code>：事件發生時間。</li>
<li><code>order_id</code>、<code>tenant_id</code>：業務定位。</li>
<li><code>idempotency_key</code>：副作用去重鍵。</li>
<li><code>pii_scope</code>：敏感欄位範圍。</li>
</ol>
<p>版本演進採向後相容優先：新增欄位可選、舊欄位保留窗口。schema 演進前要先確認 consumer 端 fallback 解析邏輯存在，避免切版後整批進 DLQ。</p>
<h2 id="retry--dlq--quarantine">Retry / DLQ / Quarantine</h2>
<p>Retry 的責任是吸收暫時性故障，不把短暫抖動升級成事故。這條路徑使用有限重試 + backoff + jitter：</p>
<table>
  <thead>
      <tr>
          <th>階段</th>
          <th>判讀重點</th>
          <th>動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>即時重試</td>
          <td>下游短暫 timeout 或限流</td>
          <td>在主通道重試少量次數</td>
      </tr>
      <tr>
          <td>延遲重試</td>
          <td>故障持續但可恢復</td>
          <td>延長 backoff，避免重試風暴</td>
      </tr>
      <tr>
          <td>DLQ 隔離</td>
          <td>payload 或版本異常、長時故障</td>
          <td>轉入 <a href="/blog/backend/knowledge-cards/dead-letter-queue/" data-link-title="Dead-Letter Queue" data-link-desc="說明 dead-letter queue 如何隔離多次處理失敗的訊息">dead-letter queue</a></td>
      </tr>
      <tr>
          <td>Quarantine</td>
          <td>同型 poison message 連續爆發</td>
          <td>停主通道回放，先分群診斷</td>
      </tr>
  </tbody>
</table>
<p>DLQ 的責任是隔離與診斷，不是永久儲存。重點是把異常訊息分群後對應修法，修完再定向回放。</p>
<h2 id="idempotency-與-ack-timing">Idempotency 與 Ack Timing</h2>
<p>Idempotency 的責任是把 at-least-once 交付轉成可接受業務結果。副作用如發票、email、webhook 都要以 <code>idempotency_key</code> 做去重。</p>
<p>Ack timing 的原則是「核心副作用提交後再 ack」：</p>
<ol>
<li>先執行副作用或落地可追蹤結果。</li>
<li>成功後寫去重紀錄或 checkpoint。</li>
<li>最後 ack broker。</li>
</ol>
<p>先 ack 再副作用會造成資料遺失；副作用成功但去重紀錄失敗，則要由 recovery 層補償。</p>
<h2 id="replay-runbook">Replay Runbook</h2>
<p>Replay 的責任是故障後在可控範圍內恢復，不把修復變成第二次事故。</p>
<p>這條路徑的 replay runbook：</p>
<ol>
<li>選定 replay window：依 <code>occurred_at</code> 與 <code>schema_version</code> 分段。</li>
<li>Dry run：先在影子通道跑去重與下游容量驗證。</li>
<li>限速回放：按 tenant 或 partition 分批，監控下游錯誤率。</li>
<li>Reconciliation：對帳發票、通知、索引結果。</li>
<li>Stop condition：duplicate side-effect、downstream timeout、DLQ 再爆發即停。</li>
</ol>
<p>replay window 要能被明確描述與回放，不可用「重播昨天全部」這種不可驗證句子。</p>
<h2 id="job-queue-的拓樸分工">Job queue 的拓樸分工</h2>
<p>當背景工作同時要 <em>高吞吐</em> 跟 <em>快速反應</em>、單一通道模型會變成瓶頸。job queue 的擴展通常是 <em>拓樸重整</em>、把不同工作類型切到不同傳遞路徑、而非單點替換。</p>
<p>對應 <a href="/blog/backend/03-message-queue/cases/slack-job-queue-kafka-redis/" data-link-title="3.C5 Slack：Job Queue 演進到 Kafka &#43; Redis" data-link-desc="背景工作通道在成長期如何從單一路徑演進成組合式架構。">3.C5 Slack Job Queue 演進到 Kafka + Redis</a> — Slack 在 job queue 擴展時把工作切到不同傳遞路徑、Kafka 跟 Redis 分別承擔持久性跟即時性目標、分開治理 lag、重試跟失敗重播。</p>
<p><strong>拓樸分工的判讀</strong>（基於 Slack case 揭露的雙通道分工方向）：</p>
<ul>
<li><strong>持久性主導的 job</strong>（發票、付款通知、合規記錄）→ Kafka / 持久 queue、保證 at-least-once</li>
<li><strong>即時性主導的 job</strong>（線上提醒、playback control、UI 更新）→ Redis / 輕量 queue、low latency</li>
</ul>
<p>設計含義：同一 consumer 應專注單一目標（高吞吐 / 即時 / 持久擇一）、其他目標拆到對應路徑。對應 <a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer-design 三個工程議題鐵三角</a> — idempotency / 重播流程 / 下游承載能力是 consumer 內部設計、拓樸分工是 <em>跨 consumer</em> 的責任拆分、兩者互補。</p>
<h2 id="job-queue-規模差異的治理重點">Job queue 規模差異的治理重點</h2>
<p>不同規模服務的 job queue 治理問題差異大、SSoT 在本章。對應 <a href="/blog/backend/03-message-queue/cases/contrast-queue-model-by-scale/" data-link-title="3.C10 對照：規模差異下的佇列模型" data-link-desc="同一 queue 模型在不同規模下的治理與失敗邊界差異。">3.C10 對照：規模差異下的佇列模型</a>：</p>
<ul>
<li><strong>小型服務</strong>：優先用 managed queue（SQS / Pub/Sub）、運維成本最低。最容易忽略的是語意邊界（重試次數、死信規則、重播責任）、規模一上來會出現資料重複與漏處理。<strong>升級訊號</strong>：team 數超 3-5 個、各自寫 consumer 開始出現 idempotency 不一致、進中型階段</li>
<li><strong>中型服務</strong>：常見問題是 lag 與 DLQ 長期累積。原因是 consumer idempotency + 重播流程 + 下游承載能力沒一起設計。對應前段 Job queue 拓樸分工。<strong>升級訊號</strong>：DLQ 累積速度高於排空速度連續 7 天、單一 tenant 流量尖峰拖垮其他 tenant、進大型階段</li>
<li><strong>大型服務</strong>：需要處理跨租戶跟跨區壓力。單叢集思維會讓任何一類流量尖峰拖垮整體。對應 <a href="/blog/backend/03-message-queue/cases/linkedin-kafka-tiered-clusters/" data-link-title="3.C4 LinkedIn：Kafka 分層叢集治理" data-link-desc="Kafka 從單叢集走向 tiered clusters 的轉換案例。">3.C4 LinkedIn Tiered Clusters</a> 跟 <a href="/blog/backend/03-message-queue/broker-basics/" data-link-title="3.1 broker 基礎與投遞模型" data-link-desc="先理解 broker、queue、consumer 與 delivery semantics">3.1 broker-basics 分層治理平台</a>、重點從「怎麼送訊息」轉成「怎麼隔離失敗」</li>
</ul>
<p>判讀重點：當前服務規模決定要處理的 <em>主要</em> 問題。規模尚小的服務硬上 multi-tenant 隔離治理屬過度設計、規模化服務應同時考慮 broker 容量是否充足跟隔離邊界是否完整。判斷自己在哪個階段、看 <em>升級訊號</em> 對應的指標。</p>
<h2 id="evidence-package">Evidence Package</h2>
<p>Queue evidence 的責任是證明「投遞可達」與「處理可恢復」兩者同時成立。</p>
<table>
  <thead>
      <tr>
          <th>欄位</th>
          <th>內容</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Source</td>
          <td>broker metric、consumer metric、DLQ log、reconciliation query</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/time-range/" data-link-title="Time Range" data-link-desc="說明證據、查詢與事故判讀如何用時間窗保留可回放上下文">Time range</a></td>
          <td>retry/replay 批次窗口</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/query-link/" data-link-title="Query Link" data-link-desc="說明證據包如何保存可重跑查詢入口，而不是只保留截圖或口頭結論">Query link</a></td>
          <td>lag、retry count、DLQ count、duplicate side-effect、throughput</td>
      </tr>
      <tr>
          <td>Owner</td>
          <td>queue owner、consumer owner、downstream owner</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/data-quality/" data-link-title="Data Quality" data-link-desc="說明證據欄位如何標示 completeness、freshness、sampling 與資料限制">Data quality</a></td>
          <td>指標延遲、抽樣缺口、對帳覆蓋率</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/confidence/" data-link-title="Confidence" data-link-desc="說明證據包如何標示 confirmed、suspected 或 needs follow-up 的判讀信心">Confidence</a></td>
          <td>confirmed / suspected / needs follow-up</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/known-gap/" data-link-title="Known Gap" data-link-desc="說明證據包如何明確保存已知缺口，避免下游高估證據完整性">Known gap</a></td>
          <td>尚未驗證之下游 webhook 供應商、低流量 tenant replay</td>
      </tr>
  </tbody>
</table>
<p>這份 evidence 要對齊 <a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">4.20 Observability Evidence Package</a> 與 <a href="/blog/backend/06-reliability/verification-evidence-handoff/" data-link-title="6.23 Verification Evidence Handoff" data-link-desc="把 SLO、load、chaos、DR 與 readiness 結果包成 release / incident 可用證據">6.23 Verification Evidence Handoff</a>。</p>
<h2 id="release-gate">Release Gate</h2>
<p>Queue release gate 的責任是決定是否擴大回放或恢復主通道，而不是只看單一 lag 指標。</p>
<table>
  <thead>
      <tr>
          <th>Gate 欄位</th>
          <th>最小內容</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/gate-decision/" data-link-title="Gate Decision" data-link-desc="說明 release gate 如何把證據轉成放行、暫停、回退或補證據的決策">Gate decision</a></td>
          <td>放行下一批 replay、維持觀察、暫停 consumer</td>
      </tr>
      <tr>
          <td>Checks</td>
          <td>idempotency proof、DLQ drain 結果、下游容量、duplicate 比例</td>
      </tr>
      <tr>
          <td>Stop condition</td>
          <td>retry storm、DLQ 再爆發、下游錯誤率超門檻</td>
      </tr>
      <tr>
          <td>Rollback window</td>
          <td>replay 可中止窗口、主通道可回切時間</td>
      </tr>
      <tr>
          <td>Owner</td>
          <td>queue on-call、business owner</td>
      </tr>
  </tbody>
</table>
<p>這組欄位對齊 <a href="/blog/backend/06-reliability/idempotency-replay/" data-link-title="6.12 Idempotency 與 Replay 驗證" data-link-desc="把重試、重播與冪等性從口頭約定變成可驗證屬性">6.12 Idempotency 與 Replay 驗證</a> 與 <a href="/blog/backend/06-reliability/release-gate/" data-link-title="6.8 Release Gate 與變更節奏" data-link-desc="把驗證、migration、相容性納入放行判準">6.8 Release Gate</a>。</p>
<h2 id="incident-decision-log">Incident Decision Log</h2>
<p>pause consumer、drain DLQ、啟動 replay、停止 replay、執行補償都屬事故決策，需寫入 <a href="/blog/backend/08-incident-response/incident-decision-log/" data-link-title="8.19 Incident Decision Log" data-link-desc="把事中假設、決策、證據、回退條件與責任人留下可復盤紀錄">8.19 Incident Decision Log</a>。</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">incident_decision</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">timestamp</span><span class="p">:</span><span class="w"> </span><span class="ld">2026-05-11T13:18:00Z</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">decision</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;pause invoice consumer and start scoped replay for tenant A&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">  </span><span class="nt">context</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;duplicate invoices increased after consumer version rollout&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">  </span><span class="nt">evidence</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">    </span>- <span class="nt">query</span><span class="p">:</span><span class="w"> </span><span class="l">duplicate_invoice_ratio_tenant_a</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">    </span>- <span class="nt">query</span><span class="p">:</span><span class="w"> </span><span class="l">dlq_events_by_schema_version</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">  </span><span class="nt">owner</span><span class="p">:</span><span class="w"> </span><span class="l">queue-incident-commander</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">  </span><span class="nt">expected_effect</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;stop duplicate side effects and restore invoice consistency&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">  </span><span class="nt">rollback_condition</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;duplicate ratio does not decrease within two replay batches&#34;</span></span></span></code></pre></div><h2 id="case-write-back-與邊界">Case Write-back 與邊界</h2>
<p>這篇回寫對齊 <a href="/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9 反例</a>，重點是切換時語意分層混淆導致 delivery 成功但業務結果失真。</p>
<p>這篇不處理同步 API latency、cache TTL 或 deployment drain。若風險在同步交易壓力、快取失效或流量切換，路由到 <a href="/blog/backend/04-observability/checkout-api-evidence-package/" data-link-title="4.22 Checkout API Evidence Package 實作示範" data-link-desc="用 checkout 路徑示範 evidence package 如何交接給 release gate 與 incident decision。">4.22 Checkout API Evidence Package</a>、<a href="/blog/backend/02-cache-redis/cache-migration-stampede-rollback/" data-link-title="2.9 Cache Migration 與 Stampede Rollback（實作示範）" data-link-desc="以商品詳情與價格快取示範 cache migration 如何交付 evidence package、release gate 與 incident decision log。">2.9 Cache Migration 與 Stampede Rollback</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 with Drain and Rollback</a>。</p>
]]></content:encoded></item><item><title>5.8 Deployment Rollout with Drain and Rollback（實作示範）</title><link>https://tarrragon.github.io/blog/backend/05-deployment-platform/deployment-rollout-drain-rollback/</link><pubDate>Mon, 11 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/05-deployment-platform/deployment-rollout-drain-rollback/</guid><description>&lt;p>Deployment rollout with drain and rollback 的核心責任是把版本、流量、連線、設定與回退條件拆成可驗證批次。這篇以 checkout service 為例，示範平台切換如何從 preflight、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/canary-release/" data-link-title="Canary Release" data-link-desc="分批把流量導向新版本、用 stop condition 控制 blast radius 的部署策略">canary&lt;/a>、drain 到事故回退都保留一致證據。&lt;/p>
&lt;p>本篇以 &lt;a href="https://tarrragon.github.io/blog/backend/05-deployment-platform/kubernetes-deployment/" data-link-title="5.2 Kubernetes 部署策略" data-link-desc="整理 deployment、probe 與 rolling update">5.2 Kubernetes 部署策略&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/05-deployment-platform/load-balancer-contract/" data-link-title="5.3 load balancer 合約" data-link-desc="整理 idle timeout、draining 與 health check">5.3 load balancer 合約&lt;/a> 為前置知識——rollout 批次、probe 對齊、drain contract 等概念在該兩篇定義，本篇直接操作化。lifecycle 狀態的完整定義見 &lt;a href="https://tarrragon.github.io/blog/backend/05-deployment-platform/platform-lifecycle-contract/" data-link-title="5.6 Platform Lifecycle Contract" data-link-desc="說明 runtime、startup、readiness、liveness、shutdown 與 drain 如何組成平台生命週期合約。">5.6 Platform Lifecycle Contract&lt;/a>。&lt;/p>
&lt;h2 id="服務路徑與切換責任">服務路徑與切換責任&lt;/h2>
&lt;p>這條路徑是 &lt;code>client -&amp;gt; load balancer -&amp;gt; checkout-api -&amp;gt; payment provider/order db/order event&lt;/code>。部署期間新舊版本會同時承接流量，核心風險在流量生命週期是否可收斂，image 替換本身反而是最可預測的部分。&lt;/p>
&lt;p>切換責任分三層：&lt;/p>
&lt;ol>
&lt;li>版本可啟動：container/runtime/config 可用。&lt;/li>
&lt;li>版本可接流量：readiness 與依賴狀態對齊。&lt;/li>
&lt;li>版本可退場：drain 與在途請求可收束。&lt;/li>
&lt;/ol>
&lt;h2 id="preflight先驗證可服務基線">Preflight：先驗證可服務基線&lt;/h2>
&lt;p>Preflight 的責任是把「可啟動」與「可服務」拆開驗證。最小檢查包含：&lt;/p>
&lt;ol>
&lt;li>image 與 runtime config 版本對齊。&lt;/li>
&lt;li>secret 已注入且權限正確。&lt;/li>
&lt;li>startup/readiness probe 能反映真實依賴狀態。&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/load-balancer-contract/" data-link-title="Load Balancer Contract" data-link-desc="說明服務與負載平衡器之間的流量與健康檢查約定">load balancer contract&lt;/a> 參數與服務期望一致。&lt;/li>
&lt;li>service discovery 註冊與摘除路徑可用。&lt;/li>
&lt;/ol>
&lt;p>Preflight 失敗時不進 canary。先把失敗收斂在控制面，避免切流後才發現版本不可服務。&lt;/p>
&lt;h3 id="preflight-自動化">Preflight 自動化&lt;/h3>
&lt;p>手動 preflight 在低頻部署時可行，部署頻率上升後會成為瓶頸或被跳過。穩定做法是把 preflight 檢查嵌入 CI/CD pipeline 的 pre-deploy stage：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>image 與 config 版本對齊檢查&lt;/strong>：pipeline 比對即將部署的 image tag 與 ConfigMap / Secret 版本是否在相容矩陣內。版本矩陣可維護在 git（如 &lt;code>deploy/compat-matrix.yaml&lt;/code>），CI 自動比對。&lt;/li>
&lt;li>&lt;strong>infra drift detection&lt;/strong>：部署前用 IaC 工具（Terraform plan、Crossplane drift check）掃描目標環境的實際狀態是否跟宣告狀態一致。drift 存在時暫停部署——在已漂移的環境上部署新版本，會把漂移與版本變更的影響混在一起，事故時無法分辨根因。&lt;/li>
&lt;li>&lt;strong>probe 語意驗證&lt;/strong>：在 staging 環境對新版本觸發 startup → readiness → liveness 全流程，確認 probe 回應與依賴就緒條件吻合。這步抓的是 probe 設定退化（如 readiness endpoint 被改成永遠回 200）。&lt;/li>
&lt;li>&lt;strong>rollback 可行性驗證&lt;/strong>：確認舊版本 image 仍在 registry 且可拉取、舊版本 config 仍相容。rollback 能力在 preflight 階段驗證，比事故時才發現「舊版拉不到」代價低得多。&lt;/li>
&lt;/ol>
&lt;p>Preflight 自動化的產出是一份 go/no-go 報告，進入 &lt;a href="https://tarrragon.github.io/blog/backend/06-reliability/release-gate/" data-link-title="6.8 Release Gate 與變更節奏" data-link-desc="把驗證、migration、相容性納入放行判準">6.8 Release Gate&lt;/a> 作為放行依據。pipeline 中的 preflight stage 失敗應阻擋部署而非產生警告——可忽略的 preflight 等於沒有 preflight。&lt;/p>
&lt;h2 id="canary-batch-與-stop-condition">Canary Batch 與 Stop Condition&lt;/h2>
&lt;p>小流量先驗證新版本行為，再決定是否擴批——Canary 回答的是「這個版本值不值得擴大」。&lt;/p></description><content:encoded><![CDATA[<p>Deployment rollout with drain and rollback 的核心責任是把版本、流量、連線、設定與回退條件拆成可驗證批次。這篇以 checkout service 為例，示範平台切換如何從 preflight、<a href="/blog/backend/knowledge-cards/canary-release/" data-link-title="Canary Release" data-link-desc="分批把流量導向新版本、用 stop condition 控制 blast radius 的部署策略">canary</a>、drain 到事故回退都保留一致證據。</p>
<p>本篇以 <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> 與 <a href="/blog/backend/05-deployment-platform/load-balancer-contract/" data-link-title="5.3 load balancer 合約" data-link-desc="整理 idle timeout、draining 與 health check">5.3 load balancer 合約</a> 為前置知識——rollout 批次、probe 對齊、drain contract 等概念在該兩篇定義，本篇直接操作化。lifecycle 狀態的完整定義見 <a href="/blog/backend/05-deployment-platform/platform-lifecycle-contract/" data-link-title="5.6 Platform Lifecycle Contract" data-link-desc="說明 runtime、startup、readiness、liveness、shutdown 與 drain 如何組成平台生命週期合約。">5.6 Platform Lifecycle Contract</a>。</p>
<h2 id="服務路徑與切換責任">服務路徑與切換責任</h2>
<p>這條路徑是 <code>client -&gt; load balancer -&gt; checkout-api -&gt; payment provider/order db/order event</code>。部署期間新舊版本會同時承接流量，核心風險在流量生命週期是否可收斂，image 替換本身反而是最可預測的部分。</p>
<p>切換責任分三層：</p>
<ol>
<li>版本可啟動：container/runtime/config 可用。</li>
<li>版本可接流量：readiness 與依賴狀態對齊。</li>
<li>版本可退場：drain 與在途請求可收束。</li>
</ol>
<h2 id="preflight先驗證可服務基線">Preflight：先驗證可服務基線</h2>
<p>Preflight 的責任是把「可啟動」與「可服務」拆開驗證。最小檢查包含：</p>
<ol>
<li>image 與 runtime config 版本對齊。</li>
<li>secret 已注入且權限正確。</li>
<li>startup/readiness probe 能反映真實依賴狀態。</li>
<li><a href="/blog/backend/knowledge-cards/load-balancer-contract/" data-link-title="Load Balancer Contract" data-link-desc="說明服務與負載平衡器之間的流量與健康檢查約定">load balancer contract</a> 參數與服務期望一致。</li>
<li>service discovery 註冊與摘除路徑可用。</li>
</ol>
<p>Preflight 失敗時不進 canary。先把失敗收斂在控制面，避免切流後才發現版本不可服務。</p>
<h3 id="preflight-自動化">Preflight 自動化</h3>
<p>手動 preflight 在低頻部署時可行，部署頻率上升後會成為瓶頸或被跳過。穩定做法是把 preflight 檢查嵌入 CI/CD pipeline 的 pre-deploy stage：</p>
<ol>
<li><strong>image 與 config 版本對齊檢查</strong>：pipeline 比對即將部署的 image tag 與 ConfigMap / Secret 版本是否在相容矩陣內。版本矩陣可維護在 git（如 <code>deploy/compat-matrix.yaml</code>），CI 自動比對。</li>
<li><strong>infra drift detection</strong>：部署前用 IaC 工具（Terraform plan、Crossplane drift check）掃描目標環境的實際狀態是否跟宣告狀態一致。drift 存在時暫停部署——在已漂移的環境上部署新版本，會把漂移與版本變更的影響混在一起，事故時無法分辨根因。</li>
<li><strong>probe 語意驗證</strong>：在 staging 環境對新版本觸發 startup → readiness → liveness 全流程，確認 probe 回應與依賴就緒條件吻合。這步抓的是 probe 設定退化（如 readiness endpoint 被改成永遠回 200）。</li>
<li><strong>rollback 可行性驗證</strong>：確認舊版本 image 仍在 registry 且可拉取、舊版本 config 仍相容。rollback 能力在 preflight 階段驗證，比事故時才發現「舊版拉不到」代價低得多。</li>
</ol>
<p>Preflight 自動化的產出是一份 go/no-go 報告，進入 <a href="/blog/backend/06-reliability/release-gate/" data-link-title="6.8 Release Gate 與變更節奏" data-link-desc="把驗證、migration、相容性納入放行判準">6.8 Release Gate</a> 作為放行依據。pipeline 中的 preflight stage 失敗應阻擋部署而非產生警告——可忽略的 preflight 等於沒有 preflight。</p>
<h2 id="canary-batch-與-stop-condition">Canary Batch 與 Stop Condition</h2>
<p>小流量先驗證新版本行為，再決定是否擴批——Canary 回答的是「這個版本值不值得擴大」。</p>
<table>
  <thead>
      <tr>
          <th>批次階段</th>
          <th>判讀重點</th>
          <th>停損條件</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1-5%</td>
          <td>per-version error rate、p95/p99 latency</td>
          <td>錯誤率高於基線、延遲持續惡化</td>
      </tr>
      <tr>
          <td>10-25%</td>
          <td>payment dependency timeout、fallback 比例</td>
          <td>依賴 timeout 連續超門檻</td>
      </tr>
      <tr>
          <td>50%</td>
          <td>drain 成功率、reconnect 波形、下游事件完整性</td>
          <td>drain 未完成或 reconnect storm</td>
      </tr>
      <tr>
          <td>100% 前</td>
          <td>新舊版本差異是否收斂、rollback 可行性</td>
          <td>仍需依賴舊版本特殊路徑</td>
      </tr>
  </tbody>
</table>
<p>canary 判讀要維持 per-version 視角。只看整體服務平均值會掩蓋新版本局部退化。</p>
<h2 id="traffic--drain把退場變成可驗證流程">Traffic / Drain：把退場變成可驗證流程</h2>
<p>Drain 的責任是讓舊版本在下線前完成在途請求，不讓 rollout 把短暫切換放大成用戶錯誤。</p>
<p>退場順序：</p>
<ol>
<li>舊實例 readiness 先轉 <code>not-ready</code> 停接新流量。</li>
<li>保留 drain 窗口完成 <a href="/blog/backend/knowledge-cards/in-flight/" data-link-title="In-Flight Work" data-link-desc="目前已接收但尚未完成處理的工作量">in-flight</a> request。</li>
<li>確認連線數下降到門檻後再終止進程。</li>
<li>驗證無異常 reconnect 尖峰再進下一批。</li>
</ol>
<p>Drain 條件的完整 workload 分類回到 <a href="/blog/backend/05-deployment-platform/platform-lifecycle-contract/" data-link-title="5.6 Platform Lifecycle Contract" data-link-desc="說明 runtime、startup、readiness、liveness、shutdown 與 drain 如何組成平台生命週期合約。">5.6 Platform Lifecycle Contract</a>，本段以 checkout service 為例：短 API 的 <a href="/blog/backend/knowledge-cards/draining/" data-link-title="Draining" data-link-desc="說明服務如何先停止接收新流量，再讓既有工作完成">draining</a> 窗口可短，長輪詢與 webhook callback 要更保守。</p>
<h2 id="rollback-compatibility">Rollback Compatibility</h2>
<p>舊版本回來時仍可運作，是 rollback 能成立的前提——回退如果變成第二次故障，就失去了回退的工程價值。</p>
<p>要先驗證四個相容面：</p>
<ol>
<li>config 相容：新設定不會讓舊版啟動失敗。</li>
<li>schema 相容：資料結構仍可被舊版讀取。</li>
<li>cache key 相容：舊版可讀新快取或有 fallback。</li>
<li>event schema 相容：舊版 consumer 不會因新事件欄位崩潰。</li>
</ol>
<p>若這四項未完成，所謂 rollback 只會停在「版本回切」，無法恢復服務正確性。</p>
<h2 id="evidence-package">Evidence Package</h2>
<p>每一批切換要可被判讀、可被追責、可被回放——部署 evidence 支撐這三個條件。</p>
<table>
  <thead>
      <tr>
          <th>欄位</th>
          <th>內容</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Source</td>
          <td>deployment logs、LB metrics、service metrics、dependency logs</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/time-range/" data-link-title="Time Range" data-link-desc="說明證據、查詢與事故判讀如何用時間窗保留可回放上下文">Time range</a></td>
          <td>每批 rollout/drain 觀察窗口</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/query-link/" data-link-title="Query Link" data-link-desc="說明證據包如何保存可重跑查詢入口，而不是只保留截圖或口頭結論">Query link</a></td>
          <td>per-version error、latency、5xx、timeout、drain completion</td>
      </tr>
      <tr>
          <td>Owner</td>
          <td>platform owner、checkout owner、SRE on-call</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/data-quality/" data-link-title="Data Quality" data-link-desc="說明證據欄位如何標示 completeness、freshness、sampling 與資料限制">Data quality</a></td>
          <td>指標延遲、分區覆蓋、log 掉點</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/confidence/" data-link-title="Confidence" data-link-desc="說明證據包如何標示 confirmed、suspected 或 needs follow-up 的判讀信心">Confidence</a></td>
          <td>confirmed / suspected / needs follow-up</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/known-gap/" data-link-title="Known Gap" data-link-desc="說明證據包如何明確保存已知缺口，避免下游高估證據完整性">Known gap</a></td>
          <td>尚未覆蓋長連線場景、低流量區域樣本不足</td>
      </tr>
  </tbody>
</table>
<p>這份 evidence 要對齊 <a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">4.20 Observability Evidence Package</a>。</p>
<h2 id="release-gate">Release Gate</h2>
<p>Release gate 的責任是決定下一批切換與是否凍結 rollout，不是報告「目前看起來正常」。</p>
<table>
  <thead>
      <tr>
          <th>Gate 欄位</th>
          <th>最小內容</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/gate-decision/" data-link-title="Gate Decision" data-link-desc="說明 release gate 如何把證據轉成放行、暫停、回退或補證據的決策">Gate decision</a></td>
          <td>放行下一批、維持 canary、freeze rollout、rollback version</td>
      </tr>
      <tr>
          <td>Checks</td>
          <td>per-version SLI、dependency timeout、drain completion</td>
      </tr>
      <tr>
          <td>Stop condition</td>
          <td>error <a href="/blog/backend/knowledge-cards/burn-rate/" data-link-title="Burn Rate" data-link-desc="說明 error budget 消耗速度如何支援告警與事故分級">burn rate</a>、reconnect storm、drain 逾時</td>
      </tr>
      <tr>
          <td>Rollback window</td>
          <td>可回切時間、舊版可服務窗口、config 回退窗口</td>
      </tr>
      <tr>
          <td>Owner</td>
          <td>release owner、platform on-call</td>
      </tr>
  </tbody>
</table>
<p>這組欄位要對齊 <a href="/blog/backend/06-reliability/release-gate/" data-link-title="6.8 Release Gate 與變更節奏" data-link-desc="把驗證、migration、相容性納入放行判準">6.8 Release Gate</a>。</p>
<h2 id="incident-decision-log">Incident Decision Log</h2>
<p>freeze rollout、rollback version、隔離 region、延長 drain 都屬事故決策，需寫入 <a href="/blog/backend/08-incident-response/incident-decision-log/" data-link-title="8.19 Incident Decision Log" data-link-desc="把事中假設、決策、證據、回退條件與責任人留下可復盤紀錄">8.19 Incident Decision Log</a>。涉及流量規則 / <a href="/blog/backend/knowledge-cards/control-plane/" data-link-title="Control Plane" data-link-desc="負責下發策略、配置與路由決策的控制層">control plane</a> 設定推送的決策、見 <a href="/blog/backend/05-deployment-platform/traffic-config-control-plane-boundary/" data-link-title="5.7 Traffic、Config 與 Control Plane Boundary" data-link-desc="說明流量、設定、secret、service discovery 與管理面如何分責任與回退。">5.7</a> 跟 <a href="/blog/backend/08-incident-response/control-plane-decision-log-write-back/" data-link-title="8.23 Control Plane Decision Log and Write-back 實作示範" data-link-desc="以 rule/config rollout 事故示範 decision log 與 write-back 如何形成可回放閉環。">8.23 Control Plane Decision Log</a>。</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">incident_decision</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">timestamp</span><span class="p">:</span><span class="w"> </span><span class="ld">2026-05-11T15:06:00Z</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">decision</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;freeze rollout at 25% and rollback one region&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">  </span><span class="nt">context</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;new version timeout to payment provider increased in ap-northeast&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">  </span><span class="nt">evidence</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">    </span>- <span class="nt">query</span><span class="p">:</span><span class="w"> </span><span class="l">checkout_error_rate_by_version_region</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">    </span>- <span class="nt">query</span><span class="p">:</span><span class="w"> </span><span class="l">payment_timeout_ratio_by_region</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">  </span><span class="nt">owner</span><span class="p">:</span><span class="w"> </span><span class="l">release-incident-commander</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">  </span><span class="nt">expected_effect</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;contain customer impact and restore baseline success rate&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">  </span><span class="nt">rollback_condition</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;timeout ratio does not recover after rollback batch completes&#34;</span></span></span></code></pre></div><h2 id="case-write-back-與邊界">Case Write-back 與邊界</h2>
<p>這篇回寫對齊 <a href="/blog/backend/05-deployment-platform/cases/failure-platform-cutover-without-drain/" data-link-title="5.C9 反例：平台切流未先 Draining" data-link-desc="切流時忽略連線清退造成請求錯誤與重試風暴。">5.C9 反例</a>、<a href="/blog/backend/05-deployment-platform/cases/tradeshift-self-managed-k8s-to-eks/" data-link-title="5.C1 Tradeshift：self-managed Kubernetes 遷移到 EKS" data-link-desc="零停機平台遷移的分段策略案例。">5.C1 Tradeshift</a> 與 <a href="/blog/backend/05-deployment-platform/cases/orbitera-managed-kubernetes-migration/" data-link-title="5.C3 Orbitera：遷移到 Managed Kubernetes" data-link-desc="平台重置時如何讓產品不中斷地完成編排層轉換。">5.C3 Orbitera</a>：前者看切換失序，後兩者看遷移路徑與回退策略。preflight / canary / drain 各階段的生命週期定義回到 <a href="/blog/backend/05-deployment-platform/platform-lifecycle-contract/" data-link-title="5.6 Platform Lifecycle Contract" data-link-desc="說明 runtime、startup、readiness、liveness、shutdown 與 drain 如何組成平台生命週期合約。">5.6 Platform Lifecycle Contract</a>。</p>
<p>這篇不處理 schema migration 本身、cache stampede 或 queue replay。若核心風險在資料正式狀態、快取回源或事件恢復，路由到 <a href="/blog/backend/01-database/schema-migration-rollout-evidence/" data-link-title="1.7 Schema Migration Rollout 證據（Schema Migration Rollout Evidence）實作示範" data-link-desc="以訂單付款狀態欄位演進示範 schema migration 如何產出 evidence、release gate 與 incident decision log。">1.7 Schema Migration Rollout 證據</a>、<a href="/blog/backend/02-cache-redis/cache-migration-stampede-rollback/" data-link-title="2.9 Cache Migration 與 Stampede Rollback（實作示範）" data-link-desc="以商品詳情與價格快取示範 cache migration 如何交付 evidence package、release gate 與 incident decision log。">2.9 Cache Migration 與 Stampede Rollback</a> 或 <a href="/blog/backend/03-message-queue/queue-consumer-retry-replay-handoff/" data-link-title="3.8 Queue Consumer Retry 與 Replay Handoff（實作示範）" data-link-desc="以 order_created consumer 示範 queue 路徑如何交付 idempotency evidence、DLQ handling、replay runbook 與 incident decision log。">3.8 Queue Consumer Retry 與 Replay Handoff</a>。</p>
]]></content:encoded></item><item><title>2.9 Cache Migration 與 Stampede Rollback（實作示範）</title><link>https://tarrragon.github.io/blog/backend/02-cache-redis/cache-migration-stampede-rollback/</link><pubDate>Mon, 11 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/02-cache-redis/cache-migration-stampede-rollback/</guid><description>&lt;p>Cache migration 與 stampede rollback 的核心責任是讓快取副本在格式、鍵名與覆蓋範圍演進時，仍能保護 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">source of truth&lt;/a> 不被回源流量打穿。這篇以商品詳情與價格快取為例，示範如何把 key schema 演進、freshness 控制、warmup、放行與停損交給可交接 artifact。&lt;/p>
&lt;h2 id="服務路徑與失敗代價">服務路徑與失敗代價&lt;/h2>
&lt;p>這條路徑是 &lt;code>product-page -&amp;gt; cache -&amp;gt; product-db/pricing-service&lt;/code>。商品頁會同時讀取描述、價格、庫存與促銷標籤，快取需要在低延遲與正確性間平衡。&lt;/p>
&lt;p>這篇示範的變更是把舊 key &lt;code>product:{id}&lt;/code> 演進到版本化 key &lt;code>product:v2:{region}:{id}&lt;/code>。演進動機是支援區域價格與促銷欄位拆分，避免舊序列化格式在多區域路徑下持續膨脹。&lt;/p>
&lt;p>失敗代價分三層：描述欄位 stale 主要影響體驗，價格 stale 直接影響交易正確性，回源尖峰會擠壓正式狀態查詢容量。這三層要分別設 freshness、gate 與 rollback 條件。&lt;/p>
&lt;h2 id="key-schema-與相容窗口">Key Schema 與相容窗口&lt;/h2>
&lt;p>Key schema 的責任是讓新舊值可共存，不讓切換變成一次性替換。這條路徑採 &lt;code>dual-read&lt;/code> 再 &lt;code>dual-write&lt;/code> 再 &lt;code>single-read-v2&lt;/code>：&lt;/p>
&lt;ol>
&lt;li>讀取先查 &lt;code>v2&lt;/code>，miss 再查舊 key，最後才回源。&lt;/li>
&lt;li>回填期間新舊 key 同時寫入，保留可回退窗口。&lt;/li>
&lt;li>&lt;code>v2&lt;/code> 命中穩定後，關閉舊 key 寫入，保留舊 key 讀 fallback 一段時間。&lt;/li>
&lt;/ol>
&lt;p>相容窗口的重點是讀語意一致。舊 key 與新 key 的值結構不同時，要先有轉換層，避免同一商品在不同 API path 回傳不同語意。&lt;/p>
&lt;h2 id="freshness-window-與資料分級">Freshness Window 與資料分級&lt;/h2>
&lt;p>Freshness window 的責任是把 stale 代價寫成可執行規則，而不是只寫全域 TTL。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>資料欄位&lt;/th>
 &lt;th>freshness window&lt;/th>
 &lt;th>原因&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>商品描述&lt;/td>
 &lt;td>5-15 分鐘&lt;/td>
 &lt;td>體驗導向，短時間 stale 可接受&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>促銷標籤&lt;/td>
 &lt;td>1-3 分鐘&lt;/td>
 &lt;td>促銷切換頻繁，錯誤會影響轉換率&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>庫存可售狀態&lt;/td>
 &lt;td>10-30 秒&lt;/td>
 &lt;td>超賣風險高，需接近即時&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>價格與幣別&lt;/td>
 &lt;td>5-15 秒&lt;/td>
 &lt;td>交易正確性高風險，需短 TTL 並搭配事件失效&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>失敗回源保護值&lt;/td>
 &lt;td>3-10 秒&lt;/td>
 &lt;td>下游暫時異常時保護來源，避免反覆 miss 放大回源壓力&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/ttl/" data-link-title="TTL" data-link-desc="說明資料過期時間如何影響快取新鮮度、成本與一致性">TTL&lt;/a> 與事件失效要同時存在。TTL 控上限，事件失效控即時性；只用其一都會造成隱性風險。&lt;/p>
&lt;h2 id="warmup-與回源保護">Warmup 與回源保護&lt;/h2>
&lt;p>Warmup 的責任是先建立新 key 的可服務覆蓋率，再擴大流量。這條路徑採分批 warmup：&lt;code>region -&amp;gt; category -&amp;gt; hot key list -&amp;gt; 全量&lt;/code>。&lt;/p>
&lt;p>Warmup completion 的判讀訊號：&lt;/p>
&lt;ol>
&lt;li>&lt;code>v2&lt;/code> 命中率在目標區間連續穩定。&lt;/li>
&lt;li>origin QPS 未突破上限。&lt;/li>
&lt;li>熱門 key 的 miss 尖峰已被抹平。&lt;/li>
&lt;/ol>
&lt;p>回源保護策略：&lt;/p>
&lt;ol>
&lt;li>以 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/singleflight/" data-link-title="Singleflight" data-link-desc="說明相同工作同時發生時如何合併成一次下游請求">singleflight&lt;/a> 合併同 key 同時 miss。&lt;/li>
&lt;li>對回源查詢設 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/rate-limit/" data-link-title="Rate Limit" data-link-desc="說明限流如何保護服務入口、下游依賴與租戶公平性">rate limit&lt;/a> 與超時。&lt;/li>
&lt;li>回源失敗時寫入短 TTL 降級值，避免瞬時重試風暴。&lt;/li>
&lt;li>針對熱門 key 在切換前做預熱與分散過期。&lt;/li>
&lt;/ol>
&lt;h3 id="cache-切換引發-stampede-的真實事故結構">Cache 切換引發 stampede 的真實事故結構&lt;/h3>
&lt;p>對應 &lt;a href="https://tarrragon.github.io/blog/backend/02-cache-redis/cases/failure-cache-stampede-rollout-regression/" data-link-title="2.C9 反例：快取切換引發 Stampede 回歸" data-link-desc="快取策略切換若缺乏保護，會導致回源壓力與錯誤率連鎖上升。">2.C9 反例：Cache Stampede Rollout Regression&lt;/a> — 看似低風險的 cache key 或 TTL 切換、若回源保護不足、會讓熱門資料同時 miss。事故結構屬「讀取路徑同時失去緩衝」的系統性失敗、不只是單一 key 問題。&lt;/p>
&lt;p>切換引發 stampede 的三個放大機制會 &lt;em>疊加&lt;/em>、不是獨立失效。在 read-heavy 規模化服務（如 Tinder 47M MAU、Tubi feature store）這類場景、典型疊加順序：重試放大先觸發 → 下游放大跟進 → 應用層放大終結：&lt;/p></description><content:encoded><![CDATA[<p>Cache migration 與 stampede rollback 的核心責任是讓快取副本在格式、鍵名與覆蓋範圍演進時，仍能保護 <a href="/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">source of truth</a> 不被回源流量打穿。這篇以商品詳情與價格快取為例，示範如何把 key schema 演進、freshness 控制、warmup、放行與停損交給可交接 artifact。</p>
<h2 id="服務路徑與失敗代價">服務路徑與失敗代價</h2>
<p>這條路徑是 <code>product-page -&gt; cache -&gt; product-db/pricing-service</code>。商品頁會同時讀取描述、價格、庫存與促銷標籤，快取需要在低延遲與正確性間平衡。</p>
<p>這篇示範的變更是把舊 key <code>product:{id}</code> 演進到版本化 key <code>product:v2:{region}:{id}</code>。演進動機是支援區域價格與促銷欄位拆分，避免舊序列化格式在多區域路徑下持續膨脹。</p>
<p>失敗代價分三層：描述欄位 stale 主要影響體驗，價格 stale 直接影響交易正確性，回源尖峰會擠壓正式狀態查詢容量。這三層要分別設 freshness、gate 與 rollback 條件。</p>
<h2 id="key-schema-與相容窗口">Key Schema 與相容窗口</h2>
<p>Key schema 的責任是讓新舊值可共存，不讓切換變成一次性替換。這條路徑採 <code>dual-read</code> 再 <code>dual-write</code> 再 <code>single-read-v2</code>：</p>
<ol>
<li>讀取先查 <code>v2</code>，miss 再查舊 key，最後才回源。</li>
<li>回填期間新舊 key 同時寫入，保留可回退窗口。</li>
<li><code>v2</code> 命中穩定後，關閉舊 key 寫入，保留舊 key 讀 fallback 一段時間。</li>
</ol>
<p>相容窗口的重點是讀語意一致。舊 key 與新 key 的值結構不同時，要先有轉換層，避免同一商品在不同 API path 回傳不同語意。</p>
<h2 id="freshness-window-與資料分級">Freshness Window 與資料分級</h2>
<p>Freshness window 的責任是把 stale 代價寫成可執行規則，而不是只寫全域 TTL。</p>
<table>
  <thead>
      <tr>
          <th>資料欄位</th>
          <th>freshness window</th>
          <th>原因</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>商品描述</td>
          <td>5-15 分鐘</td>
          <td>體驗導向，短時間 stale 可接受</td>
      </tr>
      <tr>
          <td>促銷標籤</td>
          <td>1-3 分鐘</td>
          <td>促銷切換頻繁，錯誤會影響轉換率</td>
      </tr>
      <tr>
          <td>庫存可售狀態</td>
          <td>10-30 秒</td>
          <td>超賣風險高，需接近即時</td>
      </tr>
      <tr>
          <td>價格與幣別</td>
          <td>5-15 秒</td>
          <td>交易正確性高風險，需短 TTL 並搭配事件失效</td>
      </tr>
      <tr>
          <td>失敗回源保護值</td>
          <td>3-10 秒</td>
          <td>下游暫時異常時保護來源，避免反覆 miss 放大回源壓力</td>
      </tr>
  </tbody>
</table>
<p><a href="/blog/backend/knowledge-cards/ttl/" data-link-title="TTL" data-link-desc="說明資料過期時間如何影響快取新鮮度、成本與一致性">TTL</a> 與事件失效要同時存在。TTL 控上限，事件失效控即時性；只用其一都會造成隱性風險。</p>
<h2 id="warmup-與回源保護">Warmup 與回源保護</h2>
<p>Warmup 的責任是先建立新 key 的可服務覆蓋率，再擴大流量。這條路徑採分批 warmup：<code>region -&gt; category -&gt; hot key list -&gt; 全量</code>。</p>
<p>Warmup completion 的判讀訊號：</p>
<ol>
<li><code>v2</code> 命中率在目標區間連續穩定。</li>
<li>origin QPS 未突破上限。</li>
<li>熱門 key 的 miss 尖峰已被抹平。</li>
</ol>
<p>回源保護策略：</p>
<ol>
<li>以 <a href="/blog/backend/knowledge-cards/singleflight/" data-link-title="Singleflight" data-link-desc="說明相同工作同時發生時如何合併成一次下游請求">singleflight</a> 合併同 key 同時 miss。</li>
<li>對回源查詢設 <a href="/blog/backend/knowledge-cards/rate-limit/" data-link-title="Rate Limit" data-link-desc="說明限流如何保護服務入口、下游依賴與租戶公平性">rate limit</a> 與超時。</li>
<li>回源失敗時寫入短 TTL 降級值，避免瞬時重試風暴。</li>
<li>針對熱門 key 在切換前做預熱與分散過期。</li>
</ol>
<h3 id="cache-切換引發-stampede-的真實事故結構">Cache 切換引發 stampede 的真實事故結構</h3>
<p>對應 <a href="/blog/backend/02-cache-redis/cases/failure-cache-stampede-rollout-regression/" data-link-title="2.C9 反例：快取切換引發 Stampede 回歸" data-link-desc="快取策略切換若缺乏保護，會導致回源壓力與錯誤率連鎖上升。">2.C9 反例：Cache Stampede Rollout Regression</a> — 看似低風險的 cache key 或 TTL 切換、若回源保護不足、會讓熱門資料同時 miss。事故結構屬「讀取路徑同時失去緩衝」的系統性失敗、不只是單一 key 問題。</p>
<p>切換引發 stampede 的三個放大機制會 <em>疊加</em>、不是獨立失效。在 read-heavy 規模化服務（如 Tinder 47M MAU、Tubi feature store）這類場景、典型疊加順序：重試放大先觸發 → 下游放大跟進 → 應用層放大終結：</p>
<ul>
<li><strong>重試放大</strong>：用戶請求 miss、應用層或 client SDK 內建重試、每次重試又 miss、單一用戶請求變多次 origin QPS</li>
<li><strong>下游放大</strong>：cache miss 同時打到 DB、DB 變慢、應用對 cache 設的 timeout 又觸發新 miss、回到 DB 更慢、形成正向循環</li>
<li><strong>應用層放大</strong>：等待 cache 的 request 堆積、application thread / connection pool 滿、新請求被拒、被拒的請求觸發更多重試</li>
</ul>
<p>判讀重點：stampede 的早期訊號通常出現在下游 origin（DB QPS 突然超 baseline 數倍）跟 application（latency p99 拉高、request queue length 增加）、不一定先在 cache 層看到。cache hit rate 顯示異常時、事故通常已在中後段。</p>
<h3 id="切換順序決定-stampede-風險">切換順序決定 stampede 風險</h3>
<p>對應 <a href="/blog/backend/02-cache-redis/cases/contrast-cache-strategy-by-scale/" data-link-title="2.C10 對照：規模差異下的快取策略" data-link-desc="同一快取策略在小中大型服務下會產生不同風險。">2.C10 對照：規模差異下的快取策略</a> — 切換順序（先改 key 結構 vs 先改 TTL）會決定是否出現 stampede 連鎖反應、特別在中型服務同時承受活動流量跟版本切換時。</p>
<p><strong>安全切換順序</strong>（dual-read 模式、每步停損點不同）：</p>
<ol>
<li><strong>新 key 寫入啟用</strong>：應用層同時寫舊 key + 新 key、讀路徑不變。停損點是「寫入失敗率」、若雙寫失敗率超基線、回退停止啟用。</li>
<li><strong>新 key 命中觀察</strong>：讀路徑加入 v2 first / fallback to v1 邏輯、v2 命中率隨自然回填爬升。停損點是「v2 hit rate 爬升曲線」、若曲線停滯、表示 warmup 沒擴散到熱資料、要先 manual warmup。</li>
<li><strong>舊 key 命中率穩定下降</strong>：表示新 key 自然 warmup 完成、可進入下一階段。停損點是「舊 key hit rate 是否真的降到目標」、不能只看 v2 hit rate。</li>
<li><strong>舊 key 寫入停止</strong>：只寫 v2、舊 key 自然 TTL 過期。停損點是「v2 唯一寫入是否穩定」、若出現 v2 寫入失敗、回退到雙寫。</li>
<li><strong>舊 key 讀 fallback 移除</strong>：完全切到 v2 only。停損點是「v2 hit rate 是否已達切換前舊 key 水位」、否則 fallback 移除後直接回源。</li>
</ol>
<p><strong>應該注意的反模式</strong>（會引發 stampede）：</p>
<ul>
<li>應先 warmup 新 key 再刪除舊 key、避免所有讀立即 miss</li>
<li>應拆維度切換（key OR TTL OR 序列化各自獨立）、避免多變化疊加讓 debug 困難</li>
<li>應先在低流量 region 試跑、再擴大到全量、避免事故時無回退時間</li>
</ul>
<p>判讀順序：每次切換只動 <em>一個維度</em>（key OR TTL OR 序列化）、先在低流量 region / tenant 試跑、命中率穩定後再擴大。在 Shopify 序列化遷移（<a href="/blog/backend/02-cache-redis/cases/shopify-cache-serialization-migration/" data-link-title="2.C3 Shopify：快取序列化格式遷移" data-link-desc="快取 payload 從 Marshal 轉 MessagePack 的遷移策略。">2.C3</a>）類場景、停損 KPI 是「新格式編碼成功率」+「舊格式 fallback 觸發率」；在 Tinder 類 schema 變化頻繁場景、停損 KPI 是「v2 cache hit rate 是否在預估 warmup 時間內達標」。對應 <a href="/blog/backend/09-performance-capacity/cases/zomato-tidb-to-dynamodb-migration/" data-link-title="9.C20 Zomato：從 TiDB 遷移到 DynamoDB、吞吐 4 倍、延遲降 90%、成本減 50%" data-link-desc="Zomato 帳單系統從 TiDB 遷移到 DynamoDB、吞吐 2K→8K RPM、延遲降 90%、成本減 50%">9.C20 Zomato</a> 跟 <a href="/blog/backend/01-database/schema-migration-rollout-evidence/" data-link-title="1.7 Schema Migration Rollout 證據（Schema Migration Rollout Evidence）實作示範" data-link-desc="以訂單付款狀態欄位演進示範 schema migration 如何產出 evidence、release gate 與 incident decision log。">1.7 Schema Migration Rollout Evidence</a> 的同類 expand-contract 思維。</p>
<h3 id="schema-變更引發的隱性-cache-invalidation路由見-27">Schema 變更引發的隱性 cache invalidation（路由：見 2.7）</h3>
<p>Cache invalidation <em>模型</em> 主寫於 <a href="/blog/backend/02-cache-redis/cache-copy-freshness-boundary/" data-link-title="2.7 Cache Copy Boundary 與 Freshness" data-link-desc="說明快取何時只是可重建副本，何時會影響交易、權限或配額正確性。">2.7 cache copy boundary 的 Invalidation 段</a>；本章從 migration <em>實作步驟</em> 角度補充：schema migration 是 cache stampede 的隱藏觸發點。<a href="/blog/backend/09-performance-capacity/cases/tinder-elasticache-valkey-matching/" data-link-title="9.C6 Tinder：ElastiCache for Valkey 撐 4700 萬月活的配對引擎" data-link-desc="Tinder 用 Amazon ElastiCache for Valkey 提供配對引擎所需的次毫秒延遲快取層">9.C6 Tinder</a> 案例的警惕段提出 <em>風險推測</em>：「configurable matching」業務邏輯複雜、快取資料的 schema 變化頻繁、一個 schema 變更可能引發 cache invalidation 風險。</p>
<p>Schema 變化讓 cache 失效的三種模式（屬工程實踐推導、非案例直接揭露）：</p>
<ul>
<li><strong>欄位重命名 / 刪除</strong>：舊 cache value 反序列化失敗、application 視為 miss、全部回源</li>
<li><strong>type 變更</strong>（int → string、enum 增 case）：反序列化可能成功但語意錯、業務邏輯踩錯</li>
<li><strong>序列化格式換</strong>（Marshal → MessagePack）：舊格式無法用新 decoder 讀、對應 <a href="/blog/backend/02-cache-redis/cases/shopify-cache-serialization-migration/" data-link-title="2.C3 Shopify：快取序列化格式遷移" data-link-desc="快取 payload 從 Marshal 轉 MessagePack 的遷移策略。">2.C3 Shopify</a> 的雙軌策略</li>
</ul>
<p><strong>Migration 實作步驟</strong>（按優先序）：</p>
<ol>
<li><strong>Schema migration 前盤點 cache key</strong>（最先）：哪些 cache 包含這個 schema 的資料、估算 invalid 範圍。沒這步無法估算 warmup 計畫規模。</li>
<li><strong>大規模 schema migration 配 cache warmup 計畫</strong>：預先 warmup、避免用戶觸發 cache miss。warmup 計畫主寫於本章的「Warmup 與回源保護」段。</li>
<li><strong>新欄位用 versioned key</strong>（同步進行）：<code>product:v2:{id}</code> 跟 <code>product:v1:{id}</code> 並存、避免雙寫干擾。對應 <a href="/blog/backend/02-cache-redis/cases/shopify-cache-serialization-migration/" data-link-title="2.C3 Shopify：快取序列化格式遷移" data-link-desc="快取 payload 從 Marshal 轉 MessagePack 的遷移策略。">2.C3 Shopify 雙軌策略</a>。</li>
<li><strong>降級 fallback</strong>（最後保險）：cache miss 後 origin 也準備好被打、避免假設「cache hit rate 永遠維持高水位」。對應本章「回源保護策略」段。</li>
</ol>
<p>判讀重點：四步應同步落地、缺一個就會在 migration 期間踩 stampede。一致性 invalidation 模型回到 <a href="/blog/backend/02-cache-redis/cache-copy-freshness-boundary/" data-link-title="2.7 Cache Copy Boundary 與 Freshness" data-link-desc="說明快取何時只是可重建副本，何時會影響交易、權限或配額正確性。">2.7</a>。</p>
<h2 id="rollout--cutover--rollback">Rollout / Cutover / Rollback</h2>
<p>Rollout 的責任是把快取切換拆成可停損批次，不把風險一次放大。</p>
<table>
  <thead>
      <tr>
          <th>階段</th>
          <th>判讀重點</th>
          <th>停損動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Dual read</td>
          <td><code>v2</code> miss 是否快速收斂</td>
          <td>維持舊 key 讀 fallback，暫停擴批</td>
      </tr>
      <tr>
          <td>Dual write</td>
          <td>新舊值語意是否一致</td>
          <td>停新格式寫入，保留舊格式</td>
      </tr>
      <tr>
          <td>Single read on <code>v2</code></td>
          <td>origin QPS 是否受控、價格 stale 是否達門檻</td>
          <td>回退到 dual read，恢復舊 key 讀路徑</td>
      </tr>
      <tr>
          <td>Contract old key</td>
          <td>舊 key 是否仍被依賴</td>
          <td>停 contract，延長相容窗口</td>
      </tr>
  </tbody>
</table>
<p>Rollback 不是只「切回舊 key」。若新格式已經被下游依賴，回退時要同時保留新舊讀寫相容，避免第二次不一致。</p>
<h2 id="evidence-package">Evidence Package</h2>
<p>快取 migration evidence 的責任是證明「效能提升」沒有交換成「來源壓力失控」或「交易資料錯誤」。</p>
<table>
  <thead>
      <tr>
          <th>欄位</th>
          <th>內容</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Source</td>
          <td>cache metrics、origin metrics、query logs、warmup job logs</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/time-range/" data-link-title="Time Range" data-link-desc="說明證據、查詢與事故判讀如何用時間窗保留可回放上下文">Time range</a></td>
          <td>每個 rollout batch 的觀察窗口</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/query-link/" data-link-title="Query Link" data-link-desc="說明證據包如何保存可重跑查詢入口，而不是只保留截圖或口頭結論">Query link</a></td>
          <td>hit/miss、origin QPS、<a href="/blog/backend/knowledge-cards/stale-read/" data-link-title="Stale Read" data-link-desc="讀取到落後於最新寫入版本的舊資料">stale read</a>、eviction、latency 分布</td>
      </tr>
      <tr>
          <td>Owner</td>
          <td>cache owner、product owner、pricing owner</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/data-quality/" data-link-title="Data Quality" data-link-desc="說明證據欄位如何標示 completeness、freshness、sampling 與資料限制">Data quality</a></td>
          <td>指標延遲、抽樣覆蓋率、分區漏報</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/confidence/" data-link-title="Confidence" data-link-desc="說明證據包如何標示 confirmed、suspected 或 needs follow-up 的判讀信心">Confidence</a></td>
          <td>confirmed / suspected / needs follow-up</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/known-gap/" data-link-title="Known Gap" data-link-desc="說明證據包如何明確保存已知缺口，避免下游高估證據完整性">Known gap</a></td>
          <td>未涵蓋低流量區域、尚未演練的促銷尖峰窗口</td>
      </tr>
  </tbody>
</table>
<p>這份 evidence 要對齊 <a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">4.20 Observability Evidence Package</a>。</p>
<h2 id="release-gate">Release Gate</h2>
<p>Release gate 的責任是決定是否放行下一批切換，而不是只報告觀測結果。</p>
<table>
  <thead>
      <tr>
          <th>Gate 欄位</th>
          <th>最小內容</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/gate-decision/" data-link-title="Gate Decision" data-link-desc="說明 release gate 如何把證據轉成放行、暫停、回退或補證據的決策">Gate decision</a></td>
          <td>放行下一批、維持當前批、回退到 dual read</td>
      </tr>
      <tr>
          <td>Checks</td>
          <td><code>v2</code> 命中率、origin QPS ceiling、stale price ratio</td>
      </tr>
      <tr>
          <td>Stop condition</td>
          <td>回源尖峰、價格 stale 超門檻、熱門 key miss 反彈</td>
      </tr>
      <tr>
          <td>Rollback window</td>
          <td>舊 key fallback 可維持時間、舊格式寫入可恢復時間</td>
      </tr>
      <tr>
          <td>Owner</td>
          <td>cache on-call、pricing on-call</td>
      </tr>
  </tbody>
</table>
<p>這組欄位要對齊 <a href="/blog/backend/06-reliability/release-gate/" data-link-title="6.8 Release Gate 與變更節奏" data-link-desc="把驗證、migration、相容性納入放行判準">6.8 Release Gate</a> 與 <a href="/blog/backend/06-reliability/experiment-safety-boundary/" data-link-title="6.20 Experiment Safety Boundary" data-link-desc="定義 chaos、load test、DR drill 的 [blast radius](/backend/knowledge-cards/blast-radius/)、停止條件與權限約束">6.20 Experiment Safety Boundary</a>。</p>
<h2 id="incident-decision-log">Incident Decision Log</h2>
<p>切換過程中的停用新 key、延長 TTL、凍結 invalidation、回退讀路徑都屬於事故決策。每筆決策都要留在 <a href="/blog/backend/08-incident-response/incident-decision-log/" data-link-title="8.19 Incident Decision Log" data-link-desc="把事中假設、決策、證據、回退條件與責任人留下可復盤紀錄">8.19 Incident Decision Log</a>。</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">incident_decision</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">timestamp</span><span class="p">:</span><span class="w"> </span><span class="ld">2026-05-11T11:42:00Z</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">decision</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;rollback to dual-read and freeze v2-only rollout&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">  </span><span class="nt">context</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;origin QPS exceeded ceiling and stale price ratio increased in TW region&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">  </span><span class="nt">evidence</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">    </span>- <span class="nt">query</span><span class="p">:</span><span class="w"> </span><span class="l">cache_v2_origin_qps_region_tw</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">    </span>- <span class="nt">query</span><span class="p">:</span><span class="w"> </span><span class="l">stale_price_ratio_by_region</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">  </span><span class="nt">owner</span><span class="p">:</span><span class="w"> </span><span class="l">cache-incident-commander</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">  </span><span class="nt">expected_effect</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;reduce origin pressure and restore price freshness baseline&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">  </span><span class="nt">rollback_condition</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;origin qps or stale ratio does not recover within 15 minutes&#34;</span></span></span></code></pre></div><h2 id="case-write-back-與邊界">Case Write-back 與邊界</h2>
<p>這篇回寫重點對齊 <a href="/blog/backend/02-cache-redis/cases/shopify-cache-serialization-migration/" data-link-title="2.C3 Shopify：快取序列化格式遷移" data-link-desc="快取 payload 從 Marshal 轉 MessagePack 的遷移策略。">2.C3 Shopify：Cache Serialization Migration</a> 與 <a href="/blog/backend/02-cache-redis/cases/failure-cache-stampede-rollout-regression/" data-link-title="2.C9 反例：快取切換引發 Stampede 回歸" data-link-desc="快取策略切換若缺乏保護，會導致回源壓力與錯誤率連鎖上升。">2.C9 反例</a>：前者看格式演進與相容窗口，後者看回源尖峰與停損節奏。</p>
<p>這篇不處理分散式鎖正確性、queue replay 或資料庫正式狀態切換。若核心風險在互斥語意、事件重播或資料 schema，路由到 <a href="/blog/backend/02-cache-redis/distributed-lock/" data-link-title="2.4 distributed lock 與租約" data-link-desc="整理鎖語意、租約風險與適用場景">2.4 distributed lock</a>、<a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計與去重</a> 或 <a href="/blog/backend/01-database/schema-migration-rollout-evidence/" data-link-title="1.7 Schema Migration Rollout 證據（Schema Migration Rollout Evidence）實作示範" data-link-desc="以訂單付款狀態欄位演進示範 schema migration 如何產出 evidence、release gate 與 incident decision log。">1.7 Schema Migration Rollout 證據</a>。</p>
]]></content:encoded></item><item><title>4.22 Checkout API Evidence Package 實作示範</title><link>https://tarrragon.github.io/blog/backend/04-observability/checkout-api-evidence-package/</link><pubDate>Fri, 08 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/04-observability/checkout-api-evidence-package/</guid><description>&lt;p>Checkout API evidence package 的核心責任是把同一條交易路徑的訊號整理成可交接證據，讓放行與事故判斷用到同一組事實。&lt;/p>
&lt;h2 id="服務路徑與邊界">服務路徑與邊界&lt;/h2>
&lt;p>本篇服務路徑是 &lt;code>client -&amp;gt; checkout-api -&amp;gt; payment-adapter -&amp;gt; order-db&lt;/code>。觀測邊界只處理「這條路徑目前是否可判讀」，不處理重試策略與回退決策本身；後者交給 06 與 08。&lt;/p>
&lt;p>要先定義 evidence package 的最小欄位：&lt;code>Source&lt;/code>、&lt;code>Time range&lt;/code>、&lt;code>Query link&lt;/code>、&lt;code>Owner&lt;/code>、&lt;code>Data quality&lt;/code>、&lt;code>Confidence&lt;/code>、&lt;code>Known gap&lt;/code>。這些欄位在事故期與放行期共用，避免兩套語言。&lt;/p>
&lt;h2 id="實作步驟">實作步驟&lt;/h2>
&lt;ol>
&lt;li>固定交易路徑的觀測主鍵：&lt;code>trace_id&lt;/code>、&lt;code>order_id&lt;/code>、&lt;code>tenant_id&lt;/code>、&lt;code>region&lt;/code>。&lt;/li>
&lt;li>建立三組查詢入口：延遲分布（p50/p95/p99）、錯誤率與錯誤類別、下游 payment dependency timeout。&lt;/li>
&lt;li>為每組查詢補欄位：時間窗、資料延遲、採樣比例、目前 owner。&lt;/li>
&lt;li>在 deploy 前把同一份 evidence package 連到 &lt;a href="https://tarrragon.github.io/blog/backend/06-reliability/release-gate/" data-link-title="6.8 Release Gate 與變更節奏" data-link-desc="把驗證、migration、相容性納入放行判準">6.8 Release Gate&lt;/a>。&lt;/li>
&lt;li>事故期間把同一份 evidence package 連到 &lt;a href="https://tarrragon.github.io/blog/backend/08-incident-response/incident-decision-log/" data-link-title="8.19 Incident Decision Log" data-link-desc="把事中假設、決策、證據、回退條件與責任人留下可復盤紀錄">8.19 Incident Decision Log&lt;/a>。&lt;/li>
&lt;/ol>
&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>p95 latency 升高但 error rate 無明顯變化&lt;/td>
 &lt;td>可能是下游慢查詢或連線池飽和&lt;/td>
 &lt;td>先查 dependency span 與 DB wait&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>payment timeout 增加且 trace 斷在 adapter&lt;/td>
 &lt;td>下游依賴退化，不是本地 CPU 飽和&lt;/td>
 &lt;td>進 6.8 依賴風險 gate，限制放行&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>log 有錯誤但 metric 沒反映&lt;/td>
 &lt;td>訊號覆蓋不一致或聚合粒度不對&lt;/td>
 &lt;td>回寫 data quality，補 query 與聚合維度&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>dashboard 正常但客訴增加&lt;/td>
 &lt;td>可觀測性盲區或取樣偏差&lt;/td>
 &lt;td>提升 client-side signal 權重並標示 known gap&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>同版不同區域行為差異大&lt;/td>
 &lt;td>區域配置或依賴拓樸差異，非單點程式回歸&lt;/td>
 &lt;td>補 region 維度 evidence，進 8.18 分流 triage&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="常見誤區">常見誤區&lt;/h2>
&lt;p>把 evidence package 寫成 dashboard 截圖集合，會失去可重跑性。沒有 query link 與時間窗，事故交班時很難重建判讀脈絡。&lt;/p>
&lt;p>把 confidence 省略也會導致誤判。事故前期資料常不完整，若不標示 &lt;code>suspected&lt;/code> 與 &lt;code>known gap&lt;/code>，下游決策容易把猜測當成結論。&lt;/p>
&lt;h2 id="案例回寫">案例回寫&lt;/h2>
&lt;p>這條路徑可用 &lt;a href="https://tarrragon.github.io/blog/backend/08-incident-response/cases/gcp/2019-us-network-congestion-multi-service-incident/" data-link-title="GCP 2019 US Network Congestion Multi-service Incident" data-link-desc="2019-06-02 Google Cloud 因美國區域網路壅塞造成多服務退化的事故解析：跨產品依賴、流量控制與區域隔離判讀。">GCP 2019 Network Incident&lt;/a> 回寫。先看跨服務訊號如何失真，再回到本章檢查欄位是否能支撐「先分流、再判斷」。&lt;/p>
&lt;p>這個案例主要支撐的是「證據欄位完整度」判讀，不直接支撐 release gate 停損門檻設計；停損規則要回到 6.8。&lt;/p>
&lt;h2 id="跨模組路由">跨模組路由&lt;/h2>
&lt;ol>
&lt;li>與 4.17 的交接：資料限制與偏差回到 &lt;a href="https://tarrragon.github.io/blog/backend/04-observability/telemetry-data-quality/" data-link-title="4.17 Telemetry Data Quality" data-link-desc="把 missing signal、schema drift、sampling bias 與 timestamp skew 變成資料品質問題">Telemetry Data Quality&lt;/a>。&lt;/li>
&lt;li>與 6.8 的交接：放行判斷使用同一份 evidence package。&lt;/li>
&lt;li>與 6.23 的交接：驗證證據欄位對齊 &lt;a href="https://tarrragon.github.io/blog/backend/06-reliability/verification-evidence-handoff/" data-link-title="6.23 Verification Evidence Handoff" data-link-desc="把 SLO、load、chaos、DR 與 readiness 結果包成 release / incident 可用證據">Verification Evidence Handoff&lt;/a>。&lt;/li>
&lt;li>與 8.19 的交接：事故決策直接引用 evidence link 與 confidence。&lt;/li>
&lt;/ol>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>要把證據轉成放行條件，接著讀 &lt;a href="https://tarrragon.github.io/blog/backend/06-reliability/provider-dependency-release-gate/" data-link-title="6.25 Provider Dependency Release Gate 實作示範" data-link-desc="以 payment provider 變更示範 release gate 如何結合 evidence、stop condition 與 rollback window。">6.25 Provider Dependency Release Gate 實作示範&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>Checkout API evidence package 的核心責任是把同一條交易路徑的訊號整理成可交接證據，讓放行與事故判斷用到同一組事實。</p>
<h2 id="服務路徑與邊界">服務路徑與邊界</h2>
<p>本篇服務路徑是 <code>client -&gt; checkout-api -&gt; payment-adapter -&gt; order-db</code>。觀測邊界只處理「這條路徑目前是否可判讀」，不處理重試策略與回退決策本身；後者交給 06 與 08。</p>
<p>要先定義 evidence package 的最小欄位：<code>Source</code>、<code>Time range</code>、<code>Query link</code>、<code>Owner</code>、<code>Data quality</code>、<code>Confidence</code>、<code>Known gap</code>。這些欄位在事故期與放行期共用，避免兩套語言。</p>
<h2 id="實作步驟">實作步驟</h2>
<ol>
<li>固定交易路徑的觀測主鍵：<code>trace_id</code>、<code>order_id</code>、<code>tenant_id</code>、<code>region</code>。</li>
<li>建立三組查詢入口：延遲分布（p50/p95/p99）、錯誤率與錯誤類別、下游 payment dependency timeout。</li>
<li>為每組查詢補欄位：時間窗、資料延遲、採樣比例、目前 owner。</li>
<li>在 deploy 前把同一份 evidence package 連到 <a href="/blog/backend/06-reliability/release-gate/" data-link-title="6.8 Release Gate 與變更節奏" data-link-desc="把驗證、migration、相容性納入放行判準">6.8 Release Gate</a>。</li>
<li>事故期間把同一份 evidence package 連到 <a href="/blog/backend/08-incident-response/incident-decision-log/" data-link-title="8.19 Incident Decision Log" data-link-desc="把事中假設、決策、證據、回退條件與責任人留下可復盤紀錄">8.19 Incident Decision Log</a>。</li>
</ol>
<h2 id="判讀訊號">判讀訊號</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>判讀重點</th>
          <th>對應動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>p95 latency 升高但 error rate 無明顯變化</td>
          <td>可能是下游慢查詢或連線池飽和</td>
          <td>先查 dependency span 與 DB wait</td>
      </tr>
      <tr>
          <td>payment timeout 增加且 trace 斷在 adapter</td>
          <td>下游依賴退化，不是本地 CPU 飽和</td>
          <td>進 6.8 依賴風險 gate，限制放行</td>
      </tr>
      <tr>
          <td>log 有錯誤但 metric 沒反映</td>
          <td>訊號覆蓋不一致或聚合粒度不對</td>
          <td>回寫 data quality，補 query 與聚合維度</td>
      </tr>
      <tr>
          <td>dashboard 正常但客訴增加</td>
          <td>可觀測性盲區或取樣偏差</td>
          <td>提升 client-side signal 權重並標示 known gap</td>
      </tr>
      <tr>
          <td>同版不同區域行為差異大</td>
          <td>區域配置或依賴拓樸差異，非單點程式回歸</td>
          <td>補 region 維度 evidence，進 8.18 分流 triage</td>
      </tr>
  </tbody>
</table>
<h2 id="常見誤區">常見誤區</h2>
<p>把 evidence package 寫成 dashboard 截圖集合，會失去可重跑性。沒有 query link 與時間窗，事故交班時很難重建判讀脈絡。</p>
<p>把 confidence 省略也會導致誤判。事故前期資料常不完整，若不標示 <code>suspected</code> 與 <code>known gap</code>，下游決策容易把猜測當成結論。</p>
<h2 id="案例回寫">案例回寫</h2>
<p>這條路徑可用 <a href="/blog/backend/08-incident-response/cases/gcp/2019-us-network-congestion-multi-service-incident/" data-link-title="GCP 2019 US Network Congestion Multi-service Incident" data-link-desc="2019-06-02 Google Cloud 因美國區域網路壅塞造成多服務退化的事故解析：跨產品依賴、流量控制與區域隔離判讀。">GCP 2019 Network Incident</a> 回寫。先看跨服務訊號如何失真，再回到本章檢查欄位是否能支撐「先分流、再判斷」。</p>
<p>這個案例主要支撐的是「證據欄位完整度」判讀，不直接支撐 release gate 停損門檻設計；停損規則要回到 6.8。</p>
<h2 id="跨模組路由">跨模組路由</h2>
<ol>
<li>與 4.17 的交接：資料限制與偏差回到 <a href="/blog/backend/04-observability/telemetry-data-quality/" data-link-title="4.17 Telemetry Data Quality" data-link-desc="把 missing signal、schema drift、sampling bias 與 timestamp skew 變成資料品質問題">Telemetry Data Quality</a>。</li>
<li>與 6.8 的交接：放行判斷使用同一份 evidence package。</li>
<li>與 6.23 的交接：驗證證據欄位對齊 <a href="/blog/backend/06-reliability/verification-evidence-handoff/" data-link-title="6.23 Verification Evidence Handoff" data-link-desc="把 SLO、load、chaos、DR 與 readiness 結果包成 release / incident 可用證據">Verification Evidence Handoff</a>。</li>
<li>與 8.19 的交接：事故決策直接引用 evidence link 與 confidence。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<p>要把證據轉成放行條件，接著讀 <a href="/blog/backend/06-reliability/provider-dependency-release-gate/" data-link-title="6.25 Provider Dependency Release Gate 實作示範" data-link-desc="以 payment provider 變更示範 release gate 如何結合 evidence、stop condition 與 rollback window。">6.25 Provider Dependency Release Gate 實作示範</a>。</p>
]]></content:encoded></item><item><title>8.23 Control Plane Decision Log and Write-back 實作示範</title><link>https://tarrragon.github.io/blog/backend/08-incident-response/control-plane-decision-log-write-back/</link><pubDate>Fri, 08 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/08-incident-response/control-plane-decision-log-write-back/</guid><description>&lt;p>Control plane decision log and write-back 的核心責任是讓規則或配置事故的事中判斷可回放、事後修正可追蹤。&lt;/p>
&lt;h2 id="服務路徑與事件邊界">服務路徑與事件邊界&lt;/h2>
&lt;p>示範事件是全域 rule rollout 後 CPU 激增與錯誤率上升。這類事故的難點在決策序列是否清楚、偵測本身反而容易：先限流、先回退、還是先分區隔離。&lt;/p>
&lt;p>事中決策欄位固定用 &lt;code>Timestamp&lt;/code>、&lt;code>Decision&lt;/code>、&lt;code>Context&lt;/code>、&lt;code>Evidence&lt;/code>、&lt;code>Owner&lt;/code>、&lt;code>Expected effect&lt;/code>、&lt;code>Rollback condition&lt;/code>。write-back 再補 &lt;code>target artifact&lt;/code>、&lt;code>closure signal&lt;/code>、&lt;code>review date&lt;/code>。&lt;/p>
&lt;h2 id="實作步驟">實作步驟&lt;/h2>
&lt;ol>
&lt;li>建立 incident intake：彙整告警、dashboard、客訴與 deploy event。&lt;/li>
&lt;li>啟動 decision log：每個會改變路由的動作都記錄欄位。&lt;/li>
&lt;li>每 10-15 分鐘更新一次 expected effect 是否達成。&lt;/li>
&lt;li>事故收斂後建立 write-back 條目：對應到 runbook、gate、signal 或 ownership 缺口。&lt;/li>
&lt;li>在下一次 readiness review 檢查 closure signal 是否達成。&lt;/li>
&lt;/ol>
&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;/td>
 &lt;td>已決事項與討論事項混在一起&lt;/td>
 &lt;td>強制 decision log 欄位化&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>回退後暫時恢復但再次抖動&lt;/td>
 &lt;td>rollback condition 不完整&lt;/td>
 &lt;td>補充次級門檻與觀察窗&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>通訊內容與內部判斷不一致&lt;/td>
 &lt;td>evidence 版本不同步&lt;/td>
 &lt;td>以 decision log 為唯一對外事實來源&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>write-back 列很多但無人關閉&lt;/td>
 &lt;td>owner 與 review date 缺失&lt;/td>
 &lt;td>補責任人與 closure signal&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>同類事故重複發生&lt;/td>
 &lt;td>回寫只寫故事，沒進入上游控制面&lt;/td>
 &lt;td>把項目映射到 4.20/6.8/6.23&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="常見誤區">常見誤區&lt;/h2>
&lt;p>把 decision log 當成事後整理會失去事故價值。事故當下不記，事後只能用記憶補洞，容易產生 hindsight 偏差。&lt;/p>
&lt;p>把 write-back 當成待辦清單也會失效。沒有 &lt;code>closure signal&lt;/code> 的改善項目很快會退化成長期債務。&lt;/p>
&lt;h2 id="案例回寫">案例回寫&lt;/h2>
&lt;p>這條路徑可用 &lt;a href="https://tarrragon.github.io/blog/backend/08-incident-response/cases/cloudflare/2023-workers-kv-deployment-tool-misconfiguration/" data-link-title="Cloudflare 2023 Workers KV Deployment Tool Misconfiguration" data-link-desc="2023-10-30 Cloudflare 控制面事故：deployment tool 設定錯誤造成 Workers KV 連鎖影響，重點在變更範圍限制與決策回寫。">Cloudflare 2023 Workers KV Deployment Tool Misconfiguration&lt;/a> 回寫。先看控制面變更如何擴散，再回到本章檢查決策欄位與回寫欄位是否能完整重放事故節奏。&lt;/p>
&lt;p>這個案例主要支撐的是「控制面決策可回放」判讀，不直接支撐 provider dependency gate 門檻；放行策略回到 6.25/6.8。&lt;/p>
&lt;h2 id="跨模組路由">跨模組路由&lt;/h2>
&lt;ol>
&lt;li>與 8.19 的交接：欄位語言與 &lt;a href="https://tarrragon.github.io/blog/backend/08-incident-response/incident-decision-log/" data-link-title="8.19 Incident Decision Log" data-link-desc="把事中假設、決策、證據、回退條件與責任人留下可復盤紀錄">Incident Decision Log&lt;/a> 對齊。&lt;/li>
&lt;li>與 8.22 的交接：回寫欄位與 &lt;a href="https://tarrragon.github.io/blog/backend/08-incident-response/incident-evidence-write-back/" data-link-title="8.22 Incident Evidence Write-back" data-link-desc="把事故證據、決策與復盤結論回寫到 observability、reliability 與 runbook">Incident Evidence Write-back&lt;/a> 對齊。&lt;/li>
&lt;li>與 6.24 的交接：控制面事故停損條件回到 &lt;a href="https://tarrragon.github.io/blog/backend/06-reliability/rule-rollout-safety-gate/" data-link-title="6.24 規則推送安全閘門" data-link-desc="把規則、策略與控制面配置推送從部署步驟升級為可靠性 gate，避免小變更在秒級擴散成全網事故。">Rule Rollout Safety Gate&lt;/a>。&lt;/li>
&lt;li>與 4.20 的交接：證據來源統一到 observability evidence package。&lt;/li>
&lt;/ol>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>要把控制面事故前移到資安治理，接著讀 &lt;a href="https://tarrragon.github.io/blog/backend/07-security-data-protection/credential-rotation-scoped-evidence/" data-link-title="7.27 Credential Rotation with Scoped Evidence 實作示範" data-link-desc="以 webhook/API credential 輪替示範 scope map、證據欄位與回退窗口如何一起設計。">7.27 Credential Rotation with Scoped Evidence 實作示範&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>Control plane decision log and write-back 的核心責任是讓規則或配置事故的事中判斷可回放、事後修正可追蹤。</p>
<h2 id="服務路徑與事件邊界">服務路徑與事件邊界</h2>
<p>示範事件是全域 rule rollout 後 CPU 激增與錯誤率上升。這類事故的難點在決策序列是否清楚、偵測本身反而容易：先限流、先回退、還是先分區隔離。</p>
<p>事中決策欄位固定用 <code>Timestamp</code>、<code>Decision</code>、<code>Context</code>、<code>Evidence</code>、<code>Owner</code>、<code>Expected effect</code>、<code>Rollback condition</code>。write-back 再補 <code>target artifact</code>、<code>closure signal</code>、<code>review date</code>。</p>
<h2 id="實作步驟">實作步驟</h2>
<ol>
<li>建立 incident intake：彙整告警、dashboard、客訴與 deploy event。</li>
<li>啟動 decision log：每個會改變路由的動作都記錄欄位。</li>
<li>每 10-15 分鐘更新一次 expected effect 是否達成。</li>
<li>事故收斂後建立 write-back 條目：對應到 runbook、gate、signal 或 ownership 缺口。</li>
<li>在下一次 readiness review 檢查 closure signal 是否達成。</li>
</ol>
<h2 id="判讀訊號">判讀訊號</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>判讀重點</th>
          <th>對應動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>事故頻道討論很多但決策記錄很少</td>
          <td>已決事項與討論事項混在一起</td>
          <td>強制 decision log 欄位化</td>
      </tr>
      <tr>
          <td>回退後暫時恢復但再次抖動</td>
          <td>rollback condition 不完整</td>
          <td>補充次級門檻與觀察窗</td>
      </tr>
      <tr>
          <td>通訊內容與內部判斷不一致</td>
          <td>evidence 版本不同步</td>
          <td>以 decision log 為唯一對外事實來源</td>
      </tr>
      <tr>
          <td>write-back 列很多但無人關閉</td>
          <td>owner 與 review date 缺失</td>
          <td>補責任人與 closure signal</td>
      </tr>
      <tr>
          <td>同類事故重複發生</td>
          <td>回寫只寫故事，沒進入上游控制面</td>
          <td>把項目映射到 4.20/6.8/6.23</td>
      </tr>
  </tbody>
</table>
<h2 id="常見誤區">常見誤區</h2>
<p>把 decision log 當成事後整理會失去事故價值。事故當下不記，事後只能用記憶補洞，容易產生 hindsight 偏差。</p>
<p>把 write-back 當成待辦清單也會失效。沒有 <code>closure signal</code> 的改善項目很快會退化成長期債務。</p>
<h2 id="案例回寫">案例回寫</h2>
<p>這條路徑可用 <a href="/blog/backend/08-incident-response/cases/cloudflare/2023-workers-kv-deployment-tool-misconfiguration/" data-link-title="Cloudflare 2023 Workers KV Deployment Tool Misconfiguration" data-link-desc="2023-10-30 Cloudflare 控制面事故：deployment tool 設定錯誤造成 Workers KV 連鎖影響，重點在變更範圍限制與決策回寫。">Cloudflare 2023 Workers KV Deployment Tool Misconfiguration</a> 回寫。先看控制面變更如何擴散，再回到本章檢查決策欄位與回寫欄位是否能完整重放事故節奏。</p>
<p>這個案例主要支撐的是「控制面決策可回放」判讀，不直接支撐 provider dependency gate 門檻；放行策略回到 6.25/6.8。</p>
<h2 id="跨模組路由">跨模組路由</h2>
<ol>
<li>與 8.19 的交接：欄位語言與 <a href="/blog/backend/08-incident-response/incident-decision-log/" data-link-title="8.19 Incident Decision Log" data-link-desc="把事中假設、決策、證據、回退條件與責任人留下可復盤紀錄">Incident Decision Log</a> 對齊。</li>
<li>與 8.22 的交接：回寫欄位與 <a href="/blog/backend/08-incident-response/incident-evidence-write-back/" data-link-title="8.22 Incident Evidence Write-back" data-link-desc="把事故證據、決策與復盤結論回寫到 observability、reliability 與 runbook">Incident Evidence Write-back</a> 對齊。</li>
<li>與 6.24 的交接：控制面事故停損條件回到 <a href="/blog/backend/06-reliability/rule-rollout-safety-gate/" data-link-title="6.24 規則推送安全閘門" data-link-desc="把規則、策略與控制面配置推送從部署步驟升級為可靠性 gate，避免小變更在秒級擴散成全網事故。">Rule Rollout Safety Gate</a>。</li>
<li>與 4.20 的交接：證據來源統一到 observability evidence package。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<p>要把控制面事故前移到資安治理，接著讀 <a href="/blog/backend/07-security-data-protection/credential-rotation-scoped-evidence/" data-link-title="7.27 Credential Rotation with Scoped Evidence 實作示範" data-link-desc="以 webhook/API credential 輪替示範 scope map、證據欄位與回退窗口如何一起設計。">7.27 Credential Rotation with Scoped Evidence 實作示範</a>。</p>
]]></content:encoded></item><item><title>6.25 Provider Dependency Release Gate 實作示範</title><link>https://tarrragon.github.io/blog/backend/06-reliability/provider-dependency-release-gate/</link><pubDate>Fri, 08 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/06-reliability/provider-dependency-release-gate/</guid><description>&lt;p>Provider dependency release gate 的核心責任是把第三方依賴風險轉成可驗證放行條件，避免變更在高不確定性下直接擴散。&lt;/p>
&lt;h2 id="服務路徑與風險模型">服務路徑與風險模型&lt;/h2>
&lt;p>示範路徑是 checkout API 切換 payment provider timeout/retry 設定。這類變更看起來只改 config，但會直接影響交易成功率、延遲與重試風暴。&lt;/p>
&lt;p>gate 應固定五欄：&lt;code>Gate decision&lt;/code>、&lt;code>Checks&lt;/code>、&lt;code>Stop condition&lt;/code>、&lt;code>Rollback window&lt;/code>、&lt;code>Owner&lt;/code>。欄位先成立，再討論工具落地。&lt;/p>
&lt;p>以 payment provider timeout 調整為例，五欄的具體內容：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>欄位&lt;/th>
 &lt;th>範例值&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Gate decision&lt;/td>
 &lt;td>proceed / hold / rollback — 每批 canary 結束時做一次判定&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Checks&lt;/td>
 &lt;td>checkout success rate &amp;gt; 99.5%、provider timeout ratio &amp;lt; 2%、duplicate charge = 0、error budget remaining &amp;gt; 30%&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Stop condition&lt;/td>
 &lt;td>error rate 超門檻、latency p99 超過基線 2 倍、provider timeout ratio &amp;gt; 5%，任一觸發即停止擴批&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Rollback window&lt;/td>
 &lt;td>15 min — config-only 變更無 schema 衝突，超過 15 min 後交易資料可能依賴新設定&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Owner&lt;/td>
 &lt;td>checkout team lead，負責每批 go/no-go 與 rollback 決策&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Checks 欄位的數值來自歷史 baseline，每次變更前從 production 最近 7 天取值。baseline 偏移超過 10% 時，先校準再啟動 canary。&lt;/p>
&lt;h2 id="實作步驟">實作步驟&lt;/h2>
&lt;ol>
&lt;li>定義放行前檢查：checkout 成功率、provider timeout 比率、duplicate charge 監控、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/error-budget/" data-link-title="Error Budget" data-link-desc="說明 SLO 允許的失敗額度如何影響發版與可靠性投入">error budget&lt;/a> 餘量。&lt;/li>
&lt;li>設定 canary 節奏：1% -&amp;gt; 5% -&amp;gt; 25% -&amp;gt; 100%，每批觀察固定時間窗。&lt;/li>
&lt;li>為每批設定 stop condition：error rate、latency、provider timeout 任一超門檻即停止擴大。&lt;/li>
&lt;li>設定 rollback window：例如 15 分鐘內可無資料格式衝突地回退設定。&lt;/li>
&lt;li>把每批結果寫入 &lt;a href="https://tarrragon.github.io/blog/backend/06-reliability/verification-evidence-handoff/" data-link-title="6.23 Verification Evidence Handoff" data-link-desc="把 SLO、load、chaos、DR 與 readiness 結果包成 release / incident 可用證據">6.23 Verification Evidence Handoff&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/08-incident-response/incident-decision-log/" data-link-title="8.19 Incident Decision Log" data-link-desc="把事中假設、決策、證據、回退條件與責任人留下可復盤紀錄">8.19 Incident Decision Log&lt;/a>。&lt;/li>
&lt;/ol>
&lt;h3 id="canary-節奏與觀察窗">Canary 節奏與觀察窗&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>批次&lt;/th>
 &lt;th>流量比例&lt;/th>
 &lt;th>觀察窗&lt;/th>
 &lt;th>Go/no-go 判斷依據&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>B1&lt;/td>
 &lt;td>1%&lt;/td>
 &lt;td>30 min&lt;/td>
 &lt;td>checks 全過、stop condition 未觸發&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>B2&lt;/td>
 &lt;td>5%&lt;/td>
 &lt;td>1 h&lt;/td>
 &lt;td>B1 指標持平、無 duplicate charge、無客訴&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>B3&lt;/td>
 &lt;td>25%&lt;/td>
 &lt;td>2 h&lt;/td>
 &lt;td>B2 指標持平、error budget 消耗速度未加快&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>B4&lt;/td>
 &lt;td>100%&lt;/td>
 &lt;td>持續觀測&lt;/td>
 &lt;td>B3 指標持平、跨區結果一致，進入持續觀測而非一次性放行&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Payment 類變更的觀察窗比一般 config 變更長，原因有兩個。第一，交易確認有延遲 — provider 回傳 settlement 結果可能在數分鐘到數小時後，短觀察窗無法看到完整的交易結果分佈。第二，退款與爭議申請通常在交易後數小時甚至數天才出現，B3 階段需要持續追蹤退款率趨勢，確認新設定沒有引發 provider 層的異常判定。&lt;/p></description><content:encoded><![CDATA[<p>Provider dependency release gate 的核心責任是把第三方依賴風險轉成可驗證放行條件，避免變更在高不確定性下直接擴散。</p>
<h2 id="服務路徑與風險模型">服務路徑與風險模型</h2>
<p>示範路徑是 checkout API 切換 payment provider timeout/retry 設定。這類變更看起來只改 config，但會直接影響交易成功率、延遲與重試風暴。</p>
<p>gate 應固定五欄：<code>Gate decision</code>、<code>Checks</code>、<code>Stop condition</code>、<code>Rollback window</code>、<code>Owner</code>。欄位先成立，再討論工具落地。</p>
<p>以 payment provider timeout 調整為例，五欄的具體內容：</p>
<table>
  <thead>
      <tr>
          <th>欄位</th>
          <th>範例值</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Gate decision</td>
          <td>proceed / hold / rollback — 每批 canary 結束時做一次判定</td>
      </tr>
      <tr>
          <td>Checks</td>
          <td>checkout success rate &gt; 99.5%、provider timeout ratio &lt; 2%、duplicate charge = 0、error budget remaining &gt; 30%</td>
      </tr>
      <tr>
          <td>Stop condition</td>
          <td>error rate 超門檻、latency p99 超過基線 2 倍、provider timeout ratio &gt; 5%，任一觸發即停止擴批</td>
      </tr>
      <tr>
          <td>Rollback window</td>
          <td>15 min — config-only 變更無 schema 衝突，超過 15 min 後交易資料可能依賴新設定</td>
      </tr>
      <tr>
          <td>Owner</td>
          <td>checkout team lead，負責每批 go/no-go 與 rollback 決策</td>
      </tr>
  </tbody>
</table>
<p>Checks 欄位的數值來自歷史 baseline，每次變更前從 production 最近 7 天取值。baseline 偏移超過 10% 時，先校準再啟動 canary。</p>
<h2 id="實作步驟">實作步驟</h2>
<ol>
<li>定義放行前檢查：checkout 成功率、provider timeout 比率、duplicate charge 監控、<a href="/blog/backend/knowledge-cards/error-budget/" data-link-title="Error Budget" data-link-desc="說明 SLO 允許的失敗額度如何影響發版與可靠性投入">error budget</a> 餘量。</li>
<li>設定 canary 節奏：1% -&gt; 5% -&gt; 25% -&gt; 100%，每批觀察固定時間窗。</li>
<li>為每批設定 stop condition：error rate、latency、provider timeout 任一超門檻即停止擴大。</li>
<li>設定 rollback window：例如 15 分鐘內可無資料格式衝突地回退設定。</li>
<li>把每批結果寫入 <a href="/blog/backend/06-reliability/verification-evidence-handoff/" data-link-title="6.23 Verification Evidence Handoff" data-link-desc="把 SLO、load、chaos、DR 與 readiness 結果包成 release / incident 可用證據">6.23 Verification Evidence Handoff</a> 與 <a href="/blog/backend/08-incident-response/incident-decision-log/" data-link-title="8.19 Incident Decision Log" data-link-desc="把事中假設、決策、證據、回退條件與責任人留下可復盤紀錄">8.19 Incident Decision Log</a>。</li>
</ol>
<h3 id="canary-節奏與觀察窗">Canary 節奏與觀察窗</h3>
<table>
  <thead>
      <tr>
          <th>批次</th>
          <th>流量比例</th>
          <th>觀察窗</th>
          <th>Go/no-go 判斷依據</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>B1</td>
          <td>1%</td>
          <td>30 min</td>
          <td>checks 全過、stop condition 未觸發</td>
      </tr>
      <tr>
          <td>B2</td>
          <td>5%</td>
          <td>1 h</td>
          <td>B1 指標持平、無 duplicate charge、無客訴</td>
      </tr>
      <tr>
          <td>B3</td>
          <td>25%</td>
          <td>2 h</td>
          <td>B2 指標持平、error budget 消耗速度未加快</td>
      </tr>
      <tr>
          <td>B4</td>
          <td>100%</td>
          <td>持續觀測</td>
          <td>B3 指標持平、跨區結果一致，進入持續觀測而非一次性放行</td>
      </tr>
  </tbody>
</table>
<p>Payment 類變更的觀察窗比一般 config 變更長，原因有兩個。第一，交易確認有延遲 — provider 回傳 settlement 結果可能在數分鐘到數小時後，短觀察窗無法看到完整的交易結果分佈。第二，退款與爭議申請通常在交易後數小時甚至數天才出現，B3 階段需要持續追蹤退款率趨勢，確認新設定沒有引發 provider 層的異常判定。</p>
<h3 id="證據留存格式">證據留存格式</h3>
<p>每批 canary 結束時留存一筆結構化證據，供 <a href="/blog/backend/06-reliability/verification-evidence-handoff/" data-link-title="6.23 Verification Evidence Handoff" data-link-desc="把 SLO、load、chaos、DR 與 readiness 結果包成 release / incident 可用證據">6.23</a> 與 <a href="/blog/backend/08-incident-response/incident-decision-log/" data-link-title="8.19 Incident Decision Log" data-link-desc="把事中假設、決策、證據、回退條件與責任人留下可復盤紀錄">8.19</a> 調用。</p>
<table>
  <thead>
      <tr>
          <th>欄位</th>
          <th>內容</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>batch</td>
          <td>B1 / B2 / B3 / B4</td>
      </tr>
      <tr>
          <td>timestamp</td>
          <td>批次開始與結束時間</td>
      </tr>
      <tr>
          <td>traffic %</td>
          <td>該批實際流量比例</td>
      </tr>
      <tr>
          <td>metrics snapshot</td>
          <td>checkout success rate、latency p99、provider timeout ratio</td>
      </tr>
      <tr>
          <td>decision</td>
          <td>proceed / hold / rollback</td>
      </tr>
      <tr>
          <td>decider</td>
          <td>做出該決策的人與角色</td>
      </tr>
  </tbody>
</table>
<p>這個格式讓事故發生時，<a href="/blog/backend/08-incident-response/incident-decision-log/" data-link-title="8.19 Incident Decision Log" data-link-desc="把事中假設、決策、證據、回退條件與責任人留下可復盤紀錄">8.19 Incident Decision Log</a> 可以直接調用每批的 metrics 與決策紀錄，不需要回溯 dashboard 截圖。</p>
<h2 id="判讀訊號">判讀訊號</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>判讀重點</th>
          <th>對應動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>canary 成功率正常但 timeout 升高</td>
          <td>交易完成但成本與延遲風險在累積</td>
          <td>暫停擴批，先調 provider timeout 策略</td>
      </tr>
      <tr>
          <td>error budget 快速消耗</td>
          <td>變更風險超過目前可承受範圍</td>
          <td>觸發 freeze，回到上一批設定</td>
      </tr>
      <tr>
          <td>rollback 成功但客訴仍上升</td>
          <td>影響可能來自非同步補償或下游延遲</td>
          <td>補 replay/對帳證據，再決定是否二次回退</td>
      </tr>
      <tr>
          <td>不同區域結果分歧</td>
          <td>provider 區域品質差異或路由策略不一致</td>
          <td>分區 gate，禁止全域同批放行</td>
      </tr>
      <tr>
          <td>告警只反映症狀無法定位變更關聯</td>
          <td>evidence 與 deploy event 沒對位</td>
          <td>補 deployment event link 與 owner 欄位</td>
      </tr>
  </tbody>
</table>
<h2 id="常見誤區">常見誤區</h2>
<p>把 gate 當成 CI 綠燈會漏掉依賴風險。依賴類變更需要觀測窗與停損條件，單靠測試通過不足以放行。</p>
<p>把 rollback window 寫成「可回退」但沒有時限也會失效。沒有時間邊界的回退通常意味著資料與行為已經漂移。</p>
<h2 id="案例回寫">案例回寫</h2>
<p>這條路徑可用 <a href="/blog/backend/06-reliability/cases/stripe/idempotency-and-zero-downtime-migration/" data-link-title="Stripe：Idempotency 與零停機遷移的交易安全設計" data-link-desc="把 API 重試與資料遷移放在同一套安全模型，維持支付交易的一致結果。">Stripe Idempotency and Zero-downtime Migration</a> 回寫。先看交易正確性與變更節奏如何綁定，再回到本章對齊 gate 欄位與停損邏輯。</p>
<p>這個案例主要支撐的是「交易依賴變更放行節奏」判讀，不直接支撐 incident 通訊節奏；對外更新要回到 8.4。</p>
<h2 id="跨模組路由">跨模組路由</h2>
<ol>
<li>與 4.22 的交接：證據來源使用 <a href="/blog/backend/04-observability/checkout-api-evidence-package/" data-link-title="4.22 Checkout API Evidence Package 實作示範" data-link-desc="用 checkout 路徑示範 evidence package 如何交接給 release gate 與 incident decision。">Checkout API Evidence Package</a>。</li>
<li>與 6.8 的交接：策略與制度回到 <a href="/blog/backend/06-reliability/release-gate/" data-link-title="6.8 Release Gate 與變更節奏" data-link-desc="把驗證、migration、相容性納入放行判準">Release Gate 與變更節奏</a>。</li>
<li>與 6.23 的交接：每批驗證證據進 <a href="/blog/backend/06-reliability/verification-evidence-handoff/" data-link-title="6.23 Verification Evidence Handoff" data-link-desc="把 SLO、load、chaos、DR 與 readiness 結果包成 release / incident 可用證據">Verification Evidence Handoff</a>。</li>
<li>與 8.19 的交接：停損與回退決策同步到 incident decision log。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<p>要看控制面事故如何用 decision log 與 write-back 關閉迴圈，接著讀 <a href="/blog/backend/08-incident-response/control-plane-decision-log-write-back/" data-link-title="8.23 Control Plane Decision Log and Write-back 實作示範" data-link-desc="以 rule/config rollout 事故示範 decision log 與 write-back 如何形成可回放閉環。">8.23 Control Plane Decision Log and Write-back 實作示範</a>。</p>
]]></content:encoded></item><item><title>7.27 Credential Rotation with Scoped Evidence 實作示範</title><link>https://tarrragon.github.io/blog/backend/07-security-data-protection/credential-rotation-scoped-evidence/</link><pubDate>Fri, 08 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/07-security-data-protection/credential-rotation-scoped-evidence/</guid><description>&lt;p>Credential rotation with scoped evidence 的核心責任是把憑證輪替從一次性操作改成分域、可驗證、可回退的控制流程。&lt;/p>
&lt;h2 id="服務路徑與控制範圍">服務路徑與控制範圍&lt;/h2>
&lt;p>示範路徑是 webhook secret 與 service-to-service API token 輪替。這類變更常見錯誤是全域同批切換，導致無法快速定位失效範圍。&lt;/p>
&lt;p>第一步先建 &lt;code>scope map&lt;/code>：哪些服務、哪些環境、哪些第三方端點共用同一組 credential。再定義證據欄位：輪替前健康度、輪替中錯誤率、輪替後驗證結果與撤銷狀態。&lt;/p>
&lt;h2 id="實作步驟">實作步驟&lt;/h2>
&lt;ol>
&lt;li>盤點 scope map：服務、環境、憑證用途、到期日、owner。&lt;/li>
&lt;li>設計輪替批次：先低風險租戶與非核心流量，再核心路徑。&lt;/li>
&lt;li>建立雙軌驗證窗口：新舊 credential 並行期間記錄命中比例。&lt;/li>
&lt;li>設定 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/rollback-window/" data-link-title="Rollback Window" data-link-desc="說明變更進入 production 後還能用哪種方式回退或改路線的時間與條件">rollback window&lt;/a>：若驗證失敗可在時限內回退到舊憑證。&lt;/li>
&lt;li>輪替後執行撤銷與稽核：確認舊 credential 不再可用並保留 audit evidence。&lt;/li>
&lt;/ol>
&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>輪替後 webhook 驗簽失敗集中在單區域&lt;/td>
 &lt;td>scope map 與部署批次不一致&lt;/td>
 &lt;td>暫停擴批，先修區域映射&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>新舊 credential 命中比例長期雙高&lt;/td>
 &lt;td>撤銷步驟未完成或有隱藏呼叫方&lt;/td>
 &lt;td>延長觀察並追來源，禁止結案&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>輪替成功率高但稽核鏈缺欄位&lt;/td>
 &lt;td>證據不完整，事後不可追蹤&lt;/td>
 &lt;td>補 audit 欄位再進 release gate&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>回退後仍有驗簽錯誤&lt;/td>
 &lt;td>客戶端快取或第三方同步延遲&lt;/td>
 &lt;td>補回退窗口策略與客戶端同步公告&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>同一 key 在多服務超範圍使用&lt;/td>
 &lt;td>credential scope 漂移&lt;/td>
 &lt;td>重新分域並建立到期輪替節奏&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="常見誤區">常見誤區&lt;/h2>
&lt;p>把輪替看成單次安全動作，會忽略它其實是跨服務變更管理。沒有 scope map 的輪替，出問題時只能全域停損。&lt;/p>
&lt;p>把撤銷延後也會累積風險。舊 credential 長時間保留，會讓攻擊面與誤用窗口同時存在。&lt;/p>
&lt;h2 id="案例回寫">案例回寫&lt;/h2>
&lt;p>這條路徑可用 &lt;a href="https://tarrragon.github.io/blog/backend/07-security-data-protection/cases/failure-credential-rotation-without-scope/" data-link-title="7.C9 反例：憑證輪替未分 Scope" data-link-desc="憑證輪替若未分域分批，容易造成跨系統連鎖中斷。">7.C9 反例：憑證輪替失敗&lt;/a> 回寫。先看失敗是發生在分域、驗證還是撤銷，再回到本章補齊 scope map 與回退窗口。&lt;/p>
&lt;p>這個案例主要支撐的是「輪替分域與證據鏈完整度」判讀，不直接支撐 incident 通訊節奏；外部通報回到 8.4/8.20。&lt;/p>
&lt;h2 id="控制面-token-事件的分域輪替壓力">控制面 token 事件的分域輪替壓力&lt;/h2>
&lt;p>控制面 token 事件的分域輪替壓力是 scope map 的最強壓測場景。當高權限 token 跨多個服務、多個 tenant、多個第三方端點共用、事件期間要回答「哪些必須先輪、哪些可以後輪、哪些必須同步輪」、缺 scope map 時這個排序只能靠 ad-hoc 判斷。&lt;/p>
&lt;p>對應 &lt;a href="https://tarrragon.github.io/blog/backend/07-security-data-protection/cases/cloudflare-control-plane-token-2023/" data-link-title="7.C2 Cloudflare：2023 Control-plane Token 事件" data-link-desc="控制面 token 事件如何回寫 secrets 與機器憑證治理。">7.C2 Cloudflare 控制面 token 2023&lt;/a> 跟 &lt;a href="https://tarrragon.github.io/blog/backend/07-security-data-protection/red-team/cases/identity-access/cloudflare-2023-okta-token-follow-through/" data-link-title="7.R7.1.6 Cloudflare 2023：供應商事件後的身分收斂" data-link-desc="同一條供應商事件鏈，如何在客戶端變成 session 與 token 的收斂壓力">Cloudflare 2023 follow-through&lt;/a>：揭露控制面 token 事件的處置壓力 — 主 case 揭露三個策略方向（工作負載身份替代長期共享 token、強制 rotation 與細粒度 scope、把憑證事件寫入 release gate）、紅隊 case 補的具體 mechanism 是「分批恢復必要權限、前提是事先有 token 範圍 inventory」。&lt;/p>
&lt;p>以下基於通用工程知識補充：分批恢復的工程意義是讓事件期間的可用性風險可控 — 用三個維度排序：業務優先序（核心交易 vs 內部工具）、依賴方向（上游 service 先恢復 / 下游後恢復）、權限等級（低權先恢復 / 高權後恢復）。三維度衝突時、業務優先序勝過權限等級、是常見的工程取捨點。粗粒度的「全部凍結再全部解封」是 fallback 選項、會把可用性代價拉滿。&lt;/p>
&lt;h2 id="ci-平台事件的輪替壓力">CI 平台事件的輪替壓力&lt;/h2>
&lt;p>CI 平台事件的輪替壓力跟控制面 token 不同 — 範圍 &lt;em>已知&lt;/em> 但 &lt;em>量大&lt;/em>。CI 平台被入侵時、所有客戶端 secrets 都進入 &lt;em>可能洩漏&lt;/em> 狀態、實際是否被使用要靠後續行為佐證；scoped rotation 要在「全部輪太貴」跟「分層輪會漏」之間找平衡。&lt;/p>
&lt;p>CircleCI 2023 案例的範圍量級壓力 governance frame 在 [7.6 § CI secrets 集中化跟 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/blast-radius/" data-link-title="Blast Radius" data-link-desc="說明事故影響面如何估算與隔離">blast radius&lt;/a>](/backend/07-security-data-protection/secrets-and-machine-credential-governance/#ci-secrets-集中化跟-blast-radius)；本節聚焦 scoped rotation 視角下的實作示範 — 拿到 inventory 後如何排序 batch、用什麼 metadata 支撐分批決策。&lt;/p></description><content:encoded><![CDATA[<p>Credential rotation with scoped evidence 的核心責任是把憑證輪替從一次性操作改成分域、可驗證、可回退的控制流程。</p>
<h2 id="服務路徑與控制範圍">服務路徑與控制範圍</h2>
<p>示範路徑是 webhook secret 與 service-to-service API token 輪替。這類變更常見錯誤是全域同批切換，導致無法快速定位失效範圍。</p>
<p>第一步先建 <code>scope map</code>：哪些服務、哪些環境、哪些第三方端點共用同一組 credential。再定義證據欄位：輪替前健康度、輪替中錯誤率、輪替後驗證結果與撤銷狀態。</p>
<h2 id="實作步驟">實作步驟</h2>
<ol>
<li>盤點 scope map：服務、環境、憑證用途、到期日、owner。</li>
<li>設計輪替批次：先低風險租戶與非核心流量，再核心路徑。</li>
<li>建立雙軌驗證窗口：新舊 credential 並行期間記錄命中比例。</li>
<li>設定 <a href="/blog/backend/knowledge-cards/rollback-window/" data-link-title="Rollback Window" data-link-desc="說明變更進入 production 後還能用哪種方式回退或改路線的時間與條件">rollback window</a>：若驗證失敗可在時限內回退到舊憑證。</li>
<li>輪替後執行撤銷與稽核：確認舊 credential 不再可用並保留 audit evidence。</li>
</ol>
<h2 id="判讀訊號">判讀訊號</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>判讀重點</th>
          <th>對應動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>輪替後 webhook 驗簽失敗集中在單區域</td>
          <td>scope map 與部署批次不一致</td>
          <td>暫停擴批，先修區域映射</td>
      </tr>
      <tr>
          <td>新舊 credential 命中比例長期雙高</td>
          <td>撤銷步驟未完成或有隱藏呼叫方</td>
          <td>延長觀察並追來源，禁止結案</td>
      </tr>
      <tr>
          <td>輪替成功率高但稽核鏈缺欄位</td>
          <td>證據不完整，事後不可追蹤</td>
          <td>補 audit 欄位再進 release gate</td>
      </tr>
      <tr>
          <td>回退後仍有驗簽錯誤</td>
          <td>客戶端快取或第三方同步延遲</td>
          <td>補回退窗口策略與客戶端同步公告</td>
      </tr>
      <tr>
          <td>同一 key 在多服務超範圍使用</td>
          <td>credential scope 漂移</td>
          <td>重新分域並建立到期輪替節奏</td>
      </tr>
  </tbody>
</table>
<h2 id="常見誤區">常見誤區</h2>
<p>把輪替看成單次安全動作，會忽略它其實是跨服務變更管理。沒有 scope map 的輪替，出問題時只能全域停損。</p>
<p>把撤銷延後也會累積風險。舊 credential 長時間保留，會讓攻擊面與誤用窗口同時存在。</p>
<h2 id="案例回寫">案例回寫</h2>
<p>這條路徑可用 <a href="/blog/backend/07-security-data-protection/cases/failure-credential-rotation-without-scope/" data-link-title="7.C9 反例：憑證輪替未分 Scope" data-link-desc="憑證輪替若未分域分批，容易造成跨系統連鎖中斷。">7.C9 反例：憑證輪替失敗</a> 回寫。先看失敗是發生在分域、驗證還是撤銷，再回到本章補齊 scope map 與回退窗口。</p>
<p>這個案例主要支撐的是「輪替分域與證據鏈完整度」判讀，不直接支撐 incident 通訊節奏；外部通報回到 8.4/8.20。</p>
<h2 id="控制面-token-事件的分域輪替壓力">控制面 token 事件的分域輪替壓力</h2>
<p>控制面 token 事件的分域輪替壓力是 scope map 的最強壓測場景。當高權限 token 跨多個服務、多個 tenant、多個第三方端點共用、事件期間要回答「哪些必須先輪、哪些可以後輪、哪些必須同步輪」、缺 scope map 時這個排序只能靠 ad-hoc 判斷。</p>
<p>對應 <a href="/blog/backend/07-security-data-protection/cases/cloudflare-control-plane-token-2023/" data-link-title="7.C2 Cloudflare：2023 Control-plane Token 事件" data-link-desc="控制面 token 事件如何回寫 secrets 與機器憑證治理。">7.C2 Cloudflare 控制面 token 2023</a> 跟 <a href="/blog/backend/07-security-data-protection/red-team/cases/identity-access/cloudflare-2023-okta-token-follow-through/" data-link-title="7.R7.1.6 Cloudflare 2023：供應商事件後的身分收斂" data-link-desc="同一條供應商事件鏈，如何在客戶端變成 session 與 token 的收斂壓力">Cloudflare 2023 follow-through</a>：揭露控制面 token 事件的處置壓力 — 主 case 揭露三個策略方向（工作負載身份替代長期共享 token、強制 rotation 與細粒度 scope、把憑證事件寫入 release gate）、紅隊 case 補的具體 mechanism 是「分批恢復必要權限、前提是事先有 token 範圍 inventory」。</p>
<p>以下基於通用工程知識補充：分批恢復的工程意義是讓事件期間的可用性風險可控 — 用三個維度排序：業務優先序（核心交易 vs 內部工具）、依賴方向（上游 service 先恢復 / 下游後恢復）、權限等級（低權先恢復 / 高權後恢復）。三維度衝突時、業務優先序勝過權限等級、是常見的工程取捨點。粗粒度的「全部凍結再全部解封」是 fallback 選項、會把可用性代價拉滿。</p>
<h2 id="ci-平台事件的輪替壓力">CI 平台事件的輪替壓力</h2>
<p>CI 平台事件的輪替壓力跟控制面 token 不同 — 範圍 <em>已知</em> 但 <em>量大</em>。CI 平台被入侵時、所有客戶端 secrets 都進入 <em>可能洩漏</em> 狀態、實際是否被使用要靠後續行為佐證；scoped rotation 要在「全部輪太貴」跟「分層輪會漏」之間找平衡。</p>
<p>CircleCI 2023 案例的範圍量級壓力 governance frame 在 [7.6 § CI secrets 集中化跟 <a href="/blog/backend/knowledge-cards/blast-radius/" data-link-title="Blast Radius" data-link-desc="說明事故影響面如何估算與隔離">blast radius</a>](/backend/07-security-data-protection/secrets-and-machine-credential-governance/#ci-secrets-集中化跟-blast-radius)；本節聚焦 scoped rotation 視角下的實作示範 — 拿到 inventory 後如何排序 batch、用什麼 metadata 支撐分批決策。</p>
<p><a href="/blog/backend/07-security-data-protection/red-team/cases/supply-chain/circleci-2023-secrets-rotation/" data-link-title="7.R7.2.3 CircleCI 2023：CI secrets 輪替壓力" data-link-desc="工程端點入侵後，CI 平台 secrets 如何成為高風險擴散點">CircleCI 2023</a> 案例「可落地檢查點」標明事故中 mechanism 為「按分級快速輪替、並記錄 MTTR」，前提是「事先有 secrets inventory 跟 owner mapping」。實作示範視角的補充是：分級要落到具體 metadata schema、不只是規範性說法。</p>
<p>以下基於通用工程知識補充：tag 是事件期間的輪替排序前提 — metadata 完整時可從「high blast radius + critical tier」直接抽 subset 優先輪、再依資源展開。每個 secret 在 vault 裡帶 blast radius tag（local / shared / global）、business tier（critical / standard / experimental）、rotation cost（low / high）三維度。metadata 不足時排序退回全域輪替（成本高）或部分輪替（覆蓋風險）兩個 fallback。</p>
<h2 id="跨模組路由">跨模組路由</h2>
<ol>
<li>與 7.6 的交接：治理原則回到 <a href="/blog/backend/07-security-data-protection/secrets-and-machine-credential-governance/" data-link-title="7.6 秘密管理與機器憑證治理" data-link-desc="以問題驅動方式整理 secret、token、key 與機器身份治理">Secrets and Machine Credential Governance</a>。</li>
<li>與 7.7 的交接：稽核欄位與責任鏈回到 <a href="/blog/backend/07-security-data-protection/audit-trail-and-accountability-boundary/" data-link-title="7.7 稽核追蹤與責任邊界" data-link-desc="以問題驅動方式整理高風險操作追蹤、可回查與責任切分">Audit Trail and Accountability Boundary</a>。</li>
<li>與 <a href="/blog/backend/06-reliability/release-gate/" data-link-title="6.8 Release Gate 與變更節奏" data-link-desc="把驗證、migration、相容性納入放行判準">6.8 Release Gate</a> 的交接：高風險輪替變更進 release gate。</li>
<li>與 <a href="/blog/backend/08-incident-response/incident-decision-log/" data-link-title="8.19 Incident Decision Log" data-link-desc="把事中假設、決策、證據、回退條件與責任人留下可復盤紀錄">8.19 Incident Decision Log</a> 的交接：輪替中止與回退判斷進 incident decision log。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<p>要回到全模組實作串接，接著讀 <a href="/blog/backend/#%e5%ad%b8%e7%bf%92%e8%b7%af%e7%b7%9a" data-link-title="Backend 服務實務指南" data-link-desc="用跨語言教學路線整理資料庫、快取、訊息佇列、觀測、部署、可靠性、資安、事故與容量等後端服務能力">Backend 學習路線</a> 進入下一條服務路徑。</p>
]]></content:encoded></item></channel></rss>